Java Multithreading
Multithreading lets a Java program execute multiple sequences of instructions concurrently. The JVM maps Java threads onto operating system threads, so they run in parallel on multi-core machines. Multithreading is how you keep a UI responsive while doing work, how you serve thousands of HTTP requests per second on one server, and how you keep long batch jobs bounded.
This tutorial covers thread creation, the Runnable interface, the modern ExecutorService API, thread-safe collections, the synchronized and volatile keywords, and Java 21's virtual threads.
1. The Thread Class and Runnable
The classic way to start a thread is to subclass Thread or, better, to pass a Runnable to a Thread constructor:
class=class="tok-str">"tok-cmt">// class="tok-num">1. implement Runnable (preferred)
Runnable task = () -> {
System.out.println("running in " + Thread.currentThread().getName());
};
Thread t = new Thread(task);
t.start(); class=class="tok-str">"tok-cmt">// schedules the thread to run
t.join(); class=class="tok-str">"tok-cmt">// wait for it to finish
class=class="tok-str">"tok-cmt">// class="tok-num">2. subclass Thread (rarely needed)
class MyThread extends Thread {
public void run() {
System.out.println("from MyThread");
}
}
new MyThread().start();
class=class="tok-str">"tok-cmt">// class="tok-num">3. lambda - because Runnable is functional
new Thread(() -> System.out.println("lambda thread")).start();
Always prefer Runnable over subclassing Thread — it separates the task from the execution mechanism.
2. The Modern API: ExecutorService
Creating raw threads is rarely the right choice in production code. Use an ExecutorService, which manages a pool of worker threads and lets you submit many tasks:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
class=class="tok-str">"tok-cmt">// fixed pool of class="tok-num">4 threads
ExecutorService pool = Executors.newFixedThreadPool(class="tok-num">4);
class=class="tok-str">"tok-cmt">// submit tasks
for (int i = class="tok-num">0; i < class="tok-num">20; i++) {
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " working");
try { Thread.sleep(class="tok-num">500); } catch (InterruptedException e) {}
});
}
class=class="tok-str">"tok-cmt">// graceful shutdown
pool.shutdown(); class=class="tok-str">"tok-cmt">// no new tasks accepted
pool.awaitTermination(class="tok-num">60, TimeUnit.SECONDS); class=class="tok-str">"tok-cmt">// wait
class=class="tok-str">"tok-cmt">// cached pool - grows as needed, good for short tasks
ExecutorService cached = Executors.newCachedThreadPool();
class=class="tok-str">"tok-cmt">// single-threaded - tasks run in order
ExecutorService single = Executors.newSingleThreadExecutor();
If you forget shutdown(), the JVM will not exit because the non-daemon worker threads keep it alive. shutdown() stops accepting new tasks; shutdownNow() also interrupts running tasks.
3. Callable and Future
Runnable returns nothing and cannot throw a checked exception. Callable<V> does both:
import java.util.concurrent.*;
Callable<Integer> task = () -> {
Thread.sleep(class="tok-num">1000);
return class="tok-num">42;
};
ExecutorService pool = Executors.newSingleThreadExecutor();
Future<Integer> future = pool.submit(task);
class=class="tok-str">"tok-cmt">// do other work in parallel here
Integer result = future.get(); class=class="tok-str">"tok-cmt">// blocks until the task completes
System.out.println(result); class=class="tok-str">"tok-cmt">// class="tok-num">42
class=class="tok-str">"tok-cmt">// with timeout
Integer result2 = future.get(class="tok-num">2, TimeUnit.SECONDS);
class=class="tok-str">"tok-cmt">// handle exceptions
try {
Integer r = future.get();
} catch (ExecutionException e) {
class=class="tok-str">"tok-cmt">// the task threw an exception - it's wrapped here
Throwable cause = e.getCause();
}
4. CompletableFuture for Composition
For composing asynchronous pipelines, CompletableFuture is the modern tool. It supports then-apply, then-combine, error handling, and combining multiple futures:
import java.util.concurrent.CompletableFuture;
class=class="tok-str">"tok-cmt">// async computation
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
return computeExpensiveValue();
});
class=class="tok-str">"tok-cmt">// chain transformations
CompletableFuture<String> transformed = future
.thenApply(n -> n * class="tok-num">2)
.thenApply(Object::toString)
.thenApply(s -> "result=" + s);
class=class="tok-str">"tok-cmt">// combine two futures
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> class="tok-num">10);
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> class="tok-num">20);
CompletableFuture<Integer> sum = a.thenCombine(b, Integer::sum);
class=class="tok-str">"tok-cmt">// error handling
CompletableFuture<String> safe = CompletableFuture
.supplyAsync(() -> riskyCall())
.exceptionally(ex -> "fallback");
class=class="tok-str">"tok-cmt">// wait for all
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.join(); class=class="tok-str">"tok-cmt">// blocks until all three complete
5. synchronized and volatile
When multiple threads access shared mutable state, you must protect it from data races:
class=class="tok-str">"tok-cmt">// synchronized method - lock is `this`
public class Counter {
private int count;
public synchronized void inc() { count++; }
public synchronized int get() { return count; }
}
class=class="tok-str">"tok-cmt">// synchronized block - explicit lock object
public class Counter2 {
private int count;
private final Object lock = new Object();
public void inc() {
synchronized (lock) {
count++;
}
}
}
class=class="tok-str">"tok-cmt">// volatile - visibility guarantee, NOT atomicity
private volatile boolean running = true;
public void stop() { running = false; }
public void run() { while (running) { class=class="tok-str">"tok-cmt">/* ... */ } }
class=class="tok-str">"tok-cmt">// atomic types for lock-free thread-safe counters
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger count = new AtomicInteger();
public void inc() { count.incrementAndGet(); }
public int get() { return count.get(); }
volatile guarantees visibility — writes by one thread are seen by other threads — but it does not provide atomicity for compound operations. For atomic counters, use AtomicInteger; for complex state, use synchronized or Lock.
6. Thread-Safe Collections
The java.util.concurrent package provides collections designed for concurrent access:
import java.util.concurrent.*;
class=class="tok-str">"tok-cmt">// thread-safe HashMap
ConcurrentMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", class="tok-num">1);
map.putIfAbsent("a", class="tok-num">2); class=class="tok-str">"tok-cmt">// no-op, key present
class=class="tok-str">"tok-cmt">// thread-safe ArrayList
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("x"); class=class="tok-str">"tok-cmt">// creates a new internal array - good for read-heavy
class=class="tok-str">"tok-cmt">// blocking queues for producer-consumer
BlockingQueue<String> queue = new ArrayBlockingQueue<>(class="tok-num">100);
queue.put("item"); class=class="tok-str">"tok-cmt">// blocks if full
String item = queue.take(); class=class="tok-str">"tok-cmt">// blocks if empty
class=class="tok-str">"tok-cmt">// atomic counters
AtomicLong counter = new AtomicLong();
counter.incrementAndGet();
long n = counter.addAndGet(class="tok-num">5);
class=class="tok-str">"tok-cmt">// high-throughput counters - LongAdder
LongAdder bigCounter = new LongAdder();
bigCounter.increment();
bigCounter.sum();
7. Virtual Threads (Java 21+)
Java 21 added virtual threads — lightweight threads that the JVM schedules onto a small number of platform threads. You can create millions of them:
import java.util.concurrent.Executors;
class=class="tok-str">"tok-cmt">// spawn a single virtual thread
Thread.startVirtual(() -> {
System.out.println("hello from a virtual thread");
});
class=class="tok-str">"tok-cmt">// spawn many virtual threads via newVirtualThreadPerTaskExecutor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = class="tok-num">0; i < class="tok-num">10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(class="tok-num">100));
return null;
});
}
} class=class="tok-str">"tok-cmt">// close() waits for all tasks to finish
class=class="tok-str">"tok-cmt">// class="tok-num">10,class="tok-num">000 virtual threads sleeping 100ms should finish in ~100ms,
class=class="tok-str">"tok-cmt">// because each virtual thread is cheap (a few KB, no OS thread).
Virtual threads shine for I/O-bound work: HTTP handlers, database calls, file operations. They are not a win for CPU-bound work — for that, use parallel streams or a fixed-size platform thread pool sized to the number of cores.
8. Pitfalls
Two threads incrementing a shared int counter can lose updates because counter++ is read-modify-write, not atomic. Use AtomicInteger or synchronized.
Two threads each holding a lock the other needs will wait forever. Always acquire locks in a consistent global order.
Starting a thread inside a constructor lets the thread see a partially-constructed this. Use a factory method instead, or start the thread in a separate start() call after construction.
Exercises
- Submit 10 tasks to a fixed thread pool of 4, each sleeping 1 second and printing its thread name. Verify they run in parallel.
- Use
Callableto compute the factorial of 10 in another thread and retrieve the result viaFuture.get(). - Make a shared
intcounter safe withAtomicInteger; run 1000 increments from 100 threads and confirm the result. - Spawn 10,000 virtual threads, each sleeping 100ms, and time how long the program takes (should be ~100ms, not 1000s).