Multithreading in Java
Learn Java concurrency — creating threads, the Runnable interface, thread lifecycle, synchronization, the Executor framework, and CompletableFuture.
Multithreading lets your program do multiple things at once — serving HTTP requests while writing to a database, downloading files in parallel, or keeping a UI responsive while computing in the background. Java has first-class concurrency support built in, from low-level threads to high-level async utilities. The key challenge is coordinating shared state safely, which is why understanding synchronization is as important as knowing how to create threads.
Threads
Extending Thread
The simplest way to create a thread is to extend Thread and override run(). The thread starts executing run() asynchronously when you call start() — never call run() directly, as that executes it on the current thread, defeating the purpose.
public class DownloadThread extends Thread {
private final String url;
public DownloadThread(String url) {
this.url = url;
setName("Downloader-" + url.substring(url.lastIndexOf('/') + 1));
}
@Override
public void run() {
System.out.println(getName() + " started");
// ... download logic
try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
System.out.println(getName() + " finished");
}
}
DownloadThread t = new DownloadThread("https://example.com/file.zip");
t.start(); // starts a new OS thread — do NOT call run() directly
Implementing Runnable (Preferred)
Runnable separates the task from the thread that runs it. This matters because you might want to run the same task in a thread pool, a scheduled executor, or a plain thread — none of that requires changing the Runnable itself. It also avoids the single-inheritance limitation of extending Thread.
public class PrintTask implements Runnable {
private final String message;
private final int count;
public PrintTask(String message, int count) {
this.message = message;
this.count = count;
}
@Override
public void run() {
for (int i = 0; i < count; i++) {
System.out.println(Thread.currentThread().getName() + ": " + message + " #" + i);
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
}
Thread t1 = new Thread(new PrintTask("Hello", 3), "Thread-A");
Thread t2 = new Thread(new PrintTask("World", 3), "Thread-B");
t1.start();
t2.start(); // both run concurrently — output order is non-deterministic
// Or with a lambda — Runnable is a functional interface
Thread t3 = new Thread(() -> System.out.println("Lambda task"), "Thread-C");
t3.start();
Thread Lifecycle
A thread moves through five states. Understanding this helps you reason about what join(), sleep(), and interrupt() actually do.
NEW → RUNNABLE → (BLOCKED/WAITING/TIMED_WAITING) → TERMINATED
Thread t = new Thread(() -> {
try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
System.out.println(t.getState()); // NEW — created but not started
t.start();
System.out.println(t.getState()); // RUNNABLE — running or ready to run
Thread.sleep(500);
System.out.println(t.getState()); // TIMED_WAITING — sleeping for 2s
t.join(); // current thread waits for t to finish
System.out.println(t.getState()); // TERMINATED — run() has returned
Synchronization
When multiple threads read and write the same variable, the result depends on scheduling — a race condition. The count++ operation looks atomic but is actually three steps: read, increment, write. Two threads interleaving those steps produce an incorrect result. Synchronization solves this by guaranteeing that only one thread executes a critical section at a time.
// UNSAFE — count++ is not atomic; two threads can lose increments
public class Counter {
private int count = 0;
public void increment() { count++; } // read-modify-write: NOT atomic
public int get() { return count; }
}
// SAFE — synchronized keyword creates a mutual exclusion lock on the instance
public class SafeCounter {
private int count = 0;
public synchronized void increment() { count++; } // only one thread at a time
public synchronized int get() { return count; }
}
// SAFE — AtomicInteger uses hardware compare-and-swap; faster than synchronized for single variables
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); } // atomic, no lock needed
public int get() { return count.get(); }
}
synchronized Block
Synchronizing an entire method locks the whole object. A synchronized block lets you lock only the lines that actually access shared state, reducing contention and improving throughput when the method does other non-shared work.
public class SharedBuffer {
private final List<String> buffer = new ArrayList<>();
private final Object lock = new Object(); // explicit lock object — more granular than 'this'
public void add(String item) {
synchronized (lock) {
buffer.add(item); // only this section is protected
}
}
public String remove() {
synchronized (lock) {
return buffer.isEmpty() ? null : buffer.remove(0);
}
}
}
volatile
volatile guarantees that reads and writes to a variable go directly to main memory rather than a thread-local CPU cache. This solves visibility problems — one thread writing a flag that another thread never sees — but it does not make compound operations like i++ atomic. Use it for simple flags and state variables that are written by one thread and read by others.
public class StopFlag {
// Without volatile, the worker thread might cache 'running' and never see the update
private volatile boolean running = true;
public void stop() { running = false; }
public void doWork() {
while (running) {
// processes work items until stop() is called from another thread
}
}
}
Executor Framework
Creating a raw Thread for every task is expensive — thread creation and teardown has significant overhead, and spawning thousands of threads can exhaust system resources. Thread pools reuse a fixed set of threads, queuing tasks until a thread is available. The ExecutorService API is the standard way to manage thread pools in Java.
import java.util.concurrent.*;
// Fixed pool — at most 4 threads run concurrently; extra tasks queue up
ExecutorService pool = Executors.newFixedThreadPool(4);
// Submit tasks — returns immediately; task runs asynchronously
for (int i = 0; i < 10; i++) {
final int taskId = i;
pool.submit(() -> {
System.out.println("Task " + taskId + " on " + Thread.currentThread().getName());
Thread.sleep(500);
return "result-" + taskId;
});
}
// Shutdown — stop accepting new tasks; existing tasks finish
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS); // block until all done or timeout
Callable and Future
Runnable cannot return a value or throw checked exceptions. Callable<T> solves both — it returns a value and can throw. Future<T> is the handle to a pending computation; calling get() blocks until the result is ready.
Callable<Integer> task = () -> {
Thread.sleep(1000); // simulate work
return 42; // Callable can return a value, unlike Runnable
};
ExecutorService pool = Executors.newSingleThreadExecutor();
Future<Integer> future = pool.submit(task);
// Do other work while the task runs in the background
System.out.println("Task submitted, doing other work");
// get() blocks until the result is available or the timeout expires
Integer result = future.get(5, TimeUnit.SECONDS);
System.out.println("Result: " + result); // 42
pool.shutdown();
Multiple Tasks
invokeAll submits a batch of tasks and returns a list of Futures in the same order, making it easy to collect all results after all tasks complete.
List<Callable<String>> tasks = List.of(
() -> { Thread.sleep(300); return "fast"; },
() -> { Thread.sleep(1000); return "slow"; },
() -> { Thread.sleep(500); return "medium"; }
);
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<String>> futures = pool.invokeAll(tasks); // all three start concurrently
for (Future<String> f : futures) {
System.out.println(f.get()); // fast, slow, medium — in submission order
}
pool.shutdown();
CompletableFuture (Java 8+)
Future.get() blocks the calling thread. CompletableFuture enables non-blocking async programming: you attach callbacks that run when the result is ready, and chain transformations without ever blocking. This is the foundation of reactive-style code in modern Java.
import java.util.concurrent.CompletableFuture;
// supplyAsync runs on the common ForkJoinPool by default
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
Thread.sleep(1000);
return "Hello";
});
// Chain transformations — these register callbacks, they don't block
CompletableFuture<String> result = cf
.thenApply(s -> s + ", World") // transform result when ready
.thenApply(String::toUpperCase); // chain another transform
System.out.println("Doing other work...");
System.out.println(result.get()); // blocks only here — HELLO, WORLD
// thenAccept for side effects (no return value)
cf.thenAccept(s -> System.out.println("Got: " + s));
// Combine two independent async operations — both run in parallel
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(1));
CompletableFuture<String> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrders(1));
CompletableFuture<String> combined = userFuture.thenCombine(orderFuture,
(user, orders) -> "User: " + user + ", Orders: " + orders);
System.out.println(combined.get());
// Wait for all to complete before continuing
CompletableFuture<Void> all = CompletableFuture.allOf(
CompletableFuture.runAsync(() -> System.out.println("Task 1")),
CompletableFuture.runAsync(() -> System.out.println("Task 2")),
CompletableFuture.runAsync(() -> System.out.println("Task 3"))
);
all.join(); // join() is like get() but throws unchecked exceptions
// Error handling — exceptionally provides a fallback value on failure
CompletableFuture<Integer> safe = CompletableFuture
.supplyAsync(() -> Integer.parseInt("bad")) // throws NumberFormatException
.exceptionally(ex -> {
System.err.println("Failed: " + ex.getMessage());
return -1; // fallback value
});
System.out.println(safe.get()); // -1
Thread-Safe Collections
Standard collections like HashMap and ArrayList are not thread-safe. Java provides thread-safe alternatives in java.util.concurrent that are optimised for concurrent access — far better than wrapping everything in synchronized blocks.
import java.util.concurrent.*;
// ConcurrentHashMap — thread-safe HashMap; reads never lock, writes lock only one segment
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
map.computeIfAbsent("b", k -> 2);
map.merge("a", 1, Integer::sum); // atomic read-modify-write: a becomes 2
// CopyOnWriteArrayList — iteration is always safe; writes create a new copy of the array
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("item");
// BlockingQueue — the backbone of producer-consumer patterns
// put() blocks if full; take() blocks if empty — no busy-waiting needed
BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);
Thread producer = new Thread(() -> {
try {
queue.put("item1"); // blocks if queue is full
queue.put("item2");
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread consumer = new Thread(() -> {
try {
System.out.println(queue.take()); // blocks until an item is available
System.out.println(queue.take());
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
producer.start();
consumer.start();
Project: Multi-threaded File Downloader
This project demonstrates the thread pool pattern in practice. A fixed pool of 8 threads processes all downloads concurrently. Using Future lets the main thread collect results in submission order after all work is done, and ExecutionException wraps any per-task failures without stopping the other downloads.
import java.util.concurrent.*;
import java.util.*;
public class MultiDownloader {
record DownloadTask(String url, String destination) {}
record DownloadResult(String url, boolean success, long bytesDownloaded, long durationMs) {}
public static List<DownloadResult> downloadAll(List<DownloadTask> tasks) throws InterruptedException {
// Cap threads at 8 — more threads don't help if bandwidth is the bottleneck
ExecutorService pool = Executors.newFixedThreadPool(Math.min(tasks.size(), 8));
List<Future<DownloadResult>> futures = new ArrayList<>();
for (DownloadTask task : tasks) {
futures.add(pool.submit(() -> simulate(task)));
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.MINUTES);
List<DownloadResult> results = new ArrayList<>();
for (Future<DownloadResult> f : futures) {
try {
results.add(f.get());
} catch (ExecutionException e) {
// One failure doesn't stop the others
System.err.println("Download failed: " + e.getCause().getMessage());
}
}
return results;
}
private static DownloadResult simulate(DownloadTask task) throws InterruptedException {
long start = System.currentTimeMillis();
long bytes = (long)(Math.random() * 5_000_000) + 100_000;
Thread.sleep(bytes / 100_000 * 50); // simulate variable network delay
long duration = System.currentTimeMillis() - start;
System.out.printf("Downloaded %s (%.1f KB) in %dms%n",
task.url().substring(task.url().lastIndexOf('/') + 1),
bytes / 1024.0, duration);
return new DownloadResult(task.url(), true, bytes, duration);
}
public static void main(String[] args) throws InterruptedException {
List<DownloadTask> tasks = List.of(
new DownloadTask("https://example.com/file1.zip", "/tmp/file1.zip"),
new DownloadTask("https://example.com/file2.zip", "/tmp/file2.zip"),
new DownloadTask("https://example.com/file3.zip", "/tmp/file3.zip"),
new DownloadTask("https://example.com/file4.zip", "/tmp/file4.zip")
);
long start = System.currentTimeMillis();
List<DownloadResult> results = downloadAll(tasks);
long total = System.currentTimeMillis() - start;
long totalBytes = results.stream().mapToLong(DownloadResult::bytesDownloaded).sum();
System.out.printf("%nAll done in %dms. Total: %.1f MB%n",
total, totalBytes / 1_048_576.0);
}
}