Java Multithreading

ADVANCED ~8 min read Tutorial

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:

java
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:

java
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();
Always shut down your executor

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:

java
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:

java
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:

java
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:

java
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:

java
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

Race conditions

Two threads incrementing a shared int counter can lose updates because counter++ is read-modify-write, not atomic. Use AtomicInteger or synchronized.

Deadlock

Two threads each holding a lock the other needs will wait forever. Always acquire locks in a consistent global order.

Publishing objects before construction

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

  1. Submit 10 tasks to a fixed thread pool of 4, each sleeping 1 second and printing its thread name. Verify they run in parallel.
  2. Use Callable to compute the factorial of 10 in another thread and retrieve the result via Future.get().
  3. Make a shared int counter safe with AtomicInteger; run 1000 increments from 100 threads and confirm the result.
  4. Spawn 10,000 virtual threads, each sleeping 100ms, and time how long the program takes (should be ~100ms, not 1000s).