Java · lesson 35 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Multithreading in Java
Learn Java multithreading by running it: Runnable and join, race conditions, synchronized and atomics, executors, CompletableFuture, virtual threads.
By SkillByExample · Updated
A thread is an independent path of execution inside one program. Threads share the heap, so they
can cooperate on the same objects — and corrupt them if they are not coordinated. Thread scheduling
is not deterministic, so every example here collects results and prints them in a fixed order after
the threads finish. Each is a single file run with the JDK 21 source launcher (java File.java).
Starting a thread with a Runnable
A Runnable is the task; a Thread runs it. join() waits for the thread to finish.
public class FirstThread {
public static void main(String[] args) throws InterruptedException {
String[] results = new String[2];
Runnable task0 = () -> results[0] = "computed by " + Thread.currentThread().getName();
Runnable task1 = () -> results[1] = "computed by " + Thread.currentThread().getName();
Thread t0 = new Thread(task0, "worker-0");
Thread t1 = new Thread(task1, "worker-1");
t0.start(); // run() executes on a new thread
t1.start();
t0.join(); // wait for both before reading results
t1.join();
for (String r : results) System.out.println(r);
System.out.println("main ran on " + Thread.currentThread().getName());
}
}
$ java FirstThread.java
computed by worker-0
computed by worker-1
main ran on main
start() returns immediately and the two workers run concurrently with main. Calling
task0.run() instead would run the code on main — no new thread at all. join() also guarantees
that the workers’ writes to results are visible to main afterwards.
The thread lifecycle
A thread moves through NEW, RUNNABLE, blocked or waiting states, and TERMINATED.
import java.util.concurrent.CountDownLatch;
public class Lifecycle {
public static void main(String[] args) throws InterruptedException {
CountDownLatch go = new CountDownLatch(1);
Thread t = new Thread(() -> {
try { go.await(); } catch (InterruptedException e) { return; }
});
System.out.println(t.getState());
t.start();
while (t.getState() != Thread.State.WAITING) Thread.onSpinWait();
System.out.println(t.getState()); // parked inside await()
go.countDown();
t.join();
System.out.println(t.getState());
}
}
$ java Lifecycle.java
NEW
WAITING
TERMINATED
The loop waits for the observable state rather than sleeping for a guessed time. A thread blocked
in await(), join() or wait() is WAITING; with a timeout it would be TIMED_WAITING, and one
queuing for a synchronized lock is BLOCKED.
A race condition
count++ reads, adds and writes. Four threads doing it a million times each lose updates.
public class Race {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1_000_000; j++) count++;
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("expected 4000000");
System.out.println("lost updates? " + (count < 4_000_000));
}
}
$ java Race.java
expected 4000000
lost updates? true
The exact total changes from run to run — that unpredictability is the bug — so the program prints only whether updates were lost. Two threads read the same old value, both add one, and one write overwrites the other.
Fixing it: synchronized and AtomicInteger
A synchronized block lets one thread at a time through; an AtomicInteger does the
read-add-write as one hardware operation.
import java.util.concurrent.atomic.AtomicInteger;
public class NoRace {
static int locked = 0;
static final Object LOCK = new Object();
static final AtomicInteger atomic = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1_000_000; j++) {
synchronized (LOCK) { locked++; }
atomic.incrementAndGet();
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("synchronized: " + locked);
System.out.println("atomic: " + atomic.get());
}
}
$ java NoRace.java
synchronized: 4000000
atomic: 4000000
Both counters are exact every run. Every thread must lock on the same object — synchronizing on a different object per thread would protect nothing. For a single counter, the atomic is simpler and usually faster.
volatile for a stop flag
volatile makes a write by one thread visible to others promptly. It suits a simple flag.
public class StopFlag {
static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long spins = 0;
while (running) spins++; // re-reads the field each time
System.out.println("worker saw running=false, spun at least once? " + (spins > 0));
});
worker.start();
Thread.sleep(50);
running = false;
worker.join();
System.out.println("worker state: " + worker.getState());
}
}
$ java StopFlag.java
worker saw running=false, spun at least once? true
worker state: TERMINATED
Without volatile, the JIT may hoist the read out of the loop and the worker can spin forever.
volatile fixes visibility but not atomicity: volatile int count would still lose updates in
Race.
ExecutorService and Future
An executor owns a pool of threads. submit takes a Callable and returns a Future for its
result.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class Pool {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<Long>> futures = new ArrayList<>();
for (int n = 1; n <= 5; n++) {
final int limit = n * 1_000_000;
futures.add(pool.submit(() -> {
long sum = 0;
for (int i = 1; i <= limit; i++) sum += i;
return sum;
}));
}
for (int i = 0; i < futures.size(); i++) {
System.out.println("task " + (i + 1) + " -> " + futures.get(i).get());
}
pool.shutdown();
System.out.println("terminated: " + pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
$ java Pool.java
task 1 -> 500000500000
task 2 -> 2000001000000
task 3 -> 4500001500000
task 4 -> 8000002000000
task 5 -> 12500002500000
terminated: true
Five tasks share three threads; the rest wait in the pool’s queue. Reading the futures in submission
order keeps the output stable no matter which task finished first. get() blocks until that result
is ready and rethrows any task exception wrapped in ExecutionException.
CompletableFuture pipelines
CompletableFuture chains asynchronous steps without blocking between them.
import java.util.concurrent.CompletableFuture;
public class Pipeline {
static int fetchPrice(String item) { return item.length() * 100; }
public static void main(String[] args) {
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> fetchPrice("book"));
CompletableFuture<Double> rate = CompletableFuture.supplyAsync(() -> 1.2);
CompletableFuture<String> total = price
.thenCombine(rate, (p, r) -> p * r) // when both are done
.thenApply(t -> String.format("total %.2f", t));
CompletableFuture<Integer> failing = CompletableFuture
.supplyAsync(() -> 10 / 0)
.exceptionally(ex -> -1); // recover with a default
System.out.println(total.join());
System.out.println("failing -> " + failing.join());
}
}
$ java Pipeline.java
total 480.00
failing -> -1
supplyAsync runs on the common ForkJoinPool. thenCombine waits for two independent futures,
thenApply transforms a result, and exceptionally turns a failure into a value. join() is the
one blocking call, at the end.
Thread-safe collections
ConcurrentHashMap.merge updates a key atomically, so threads can count into one shared map.
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.*;
public class WordCount {
public static void main(String[] args) throws InterruptedException {
String[] docs = {"a b a", "b c", "a c c", "b b"};
Map<String, Integer> counts = new ConcurrentHashMap<>();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (String doc : docs) {
pool.submit(() -> {
for (String w : doc.split(" ")) counts.merge(w, 1, Integer::sum);
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(new TreeMap<>(counts));
}
}
$ java WordCount.java
{a=3, b=4, c=3}
A plain HashMap here could lose counts or corrupt its internal table. Copying into a TreeMap
gives a sorted, stable print.
Virtual threads (Java 21)
Virtual threads are cheap enough to create one per task, even ten thousand blocking tasks.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class Virtual {
public static void main(String[] args) throws Exception {
long start = System.nanoTime();
List<Future<Integer>> results = new ArrayList<>();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int n = i;
results.add(exec.submit(() -> {
Thread.sleep(100); // blocking call: frees the carrier
return n % 2;
}));
}
} // close() waits for all tasks
int odd = 0;
for (Future<Integer> f : results) odd += f.get();
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("odd tasks: " + odd);
System.out.println("finished in under 10 s? " + (ms < 10_000));
}
}
$ java Virtual.java
odd tasks: 5000
finished in under 10 s? true
Ten thousand tasks that each sleep 100 ms finish in seconds rather than 1,000, because a sleeping virtual thread is unmounted from its carrier platform thread. On a fixed pool of a few platform threads the same work would take minutes.
Practice
- Make
Racecorrect by changing the counter to ajava.util.concurrent.atomic.LongAdder, which scales better thanAtomicIntegerunder heavy contention.
Solution
import java.util.concurrent.atomic.LongAdder;
public class Adder {
static final LongAdder count = new LongAdder();
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1_000_000; j++) count.increment();
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println(count.sum());
}
}
$ java Adder.java
4000000
LongAdder keeps per-thread cells and sums them on demand, so threads rarely contend on one
memory location.
- Use
invokeAllto run threeCallables that return"A","B"and"C"on a two-thread pool, and print the results joined in order.
Solution
import java.util.List;
import java.util.concurrent.*;
public class InvokeAll {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
List<Callable<String>> tasks = List.of(() -> "A", () -> "B", () -> "C");
StringBuilder sb = new StringBuilder();
for (Future<String> f : pool.invokeAll(tasks)) sb.append(f.get());
pool.shutdown();
System.out.println(sb);
}
}
$ java InvokeAll.java
ABC
invokeAll blocks until every task is done and returns the futures in the same order as the
input list.
- Start a thread that sleeps for 10 seconds, interrupt it from
main, and have it print that it was interrupted. Print its state afterjoin.
Solution
public class Interrupt {
public static void main(String[] args) throws InterruptedException {
Thread sleeper = new Thread(() -> {
try {
Thread.sleep(10_000);
System.out.println("woke normally");
} catch (InterruptedException e) {
System.out.println("interrupted while sleeping");
}
});
sleeper.start();
while (sleeper.getState() != Thread.State.TIMED_WAITING) Thread.onSpinWait();
sleeper.interrupt();
sleeper.join();
System.out.println(sleeper.getState());
}
}
$ java Interrupt.java
interrupted while sleeping
TERMINATED
Interruption is cooperative: blocking methods such as sleep respond by throwing
InterruptedException, and the thread decides how to wind down.
- Chain a
CompletableFuturethat parses"42"to anint, doubles it, and prints the result withthenAccept, then make the input"x"and recover withhandle.
Solution
import java.util.concurrent.CompletableFuture;
public class Chain {
static CompletableFuture<Void> run(String input) {
return CompletableFuture.supplyAsync(() -> Integer.parseInt(input))
.thenApply(n -> n * 2)
.handle((n, ex) -> ex == null ? "result " + n : "failed: " + ex.getCause().getClass().getSimpleName())
.thenAccept(System.out::println);
}
public static void main(String[] args) {
run("42").join();
run("x").join();
}
}
$ java Chain.java
result 84
failed: NumberFormatException
handle sees either the value or the exception (wrapped in CompletionException), so it can
recover in both cases with one function.