Multithreading
Multithreading lets a single program run multiple paths of execution concurrently, exploiting multi-core CPUs and keeping applications responsive during blocking work. Java has first-class thread support built into the language and runtime — along with the hazards (race conditions, deadlocks) that come with shared mutable state.
Thread vs Runnable
There are two ways to define work for a thread: extend Thread, or implement Runnable and hand it to a thread. Prefer Runnable — it decouples the task from the threading mechanism and works with executors and lambdas.
// Implementing Runnable (preferred)
Runnable task = () -> System.out.println("running on " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker-1");
t.start();
// Extending Thread (rarely needed)
class Printer extends Thread {
public void run() {
System.out.println("printer thread");
}
}
new Printer().start();
Tip: A class can implement multiple interfaces but extend only one class. Implementing
Runnablekeeps your inheritance slot free and matches theExecutorServiceAPI.
start vs run
This is the most common beginner mistake. Calling run() directly executes the body on the current thread — no new thread is created. Only start() spawns a new thread and then invokes run() on it.
Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.run(); // prints "main" — no new thread
t.start(); // prints "Thread-0" — new thread
Output:
main
Thread-0
The Thread Lifecycle
A thread moves through a fixed set of states, exposed by Thread.State.
| State | Meaning |
|---|---|
NEW | Created but start() not yet called |
RUNNABLE | Eligible to run (running or ready) |
BLOCKED | Waiting to acquire a monitor lock |
WAITING | Waiting indefinitely (join(), wait()) |
TIMED_WAITING | Waiting with a timeout (sleep, timed join) |
TERMINATED | run() has completed |
sleep and join
Thread.sleep pauses the current thread for a duration without releasing locks. join makes one thread wait for another to finish.
Thread worker = new Thread(() -> {
try {
Thread.sleep(100); // TIMED_WAITING for 100ms
System.out.println("work done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
}
});
worker.start();
worker.join(); // main waits here until worker terminates
System.out.println("main continues");
Output:
work done
main continues
Warning: When you catch
InterruptedException, restore the interrupt status withThread.currentThread().interrupt()(or rethrow). Swallowing it hides cancellation requests from upstream code.
Race Conditions and synchronized
When multiple threads read and write shared mutable state without coordination, the result depends on timing — a race condition. The classic example is a non-atomic increment.
class Counter {
private int count = 0;
public void increment() { count++; } // NOT atomic: read-modify-write
public synchronized void safeIncrement() { count++; }
public int get() { return count; }
}
count++ is three operations (read, add, write). Two threads can interleave and lose updates. The synchronized keyword serializes access by acquiring the object’s monitor, ensuring only one thread executes the block at a time and that changes are visible to others.
Counter c = new Counter();
Runnable job = () -> { for (int i = 0; i < 10_000; i++) c.safeIncrement(); };
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
System.out.println(c.get()); // reliably 20000 with synchronized
Output:
20000
Without synchronized, the final count would be unpredictably less than 20000.
Virtual Threads (Java 21)
Java 21 made virtual threads a standard feature (Project Loom). They are lightweight threads managed by the JVM, not the OS — you can run millions of them. Blocking a virtual thread (e.g. on I/O) does not block an OS thread, making the simple thread-per-request model scale.
// Java 21+
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(java.time.Duration.ofSeconds(1));
return null;
});
}
}
Note: Virtual threads shine for I/O-bound workloads. For CPU-bound work, the number of useful threads is still bounded by your cores — use a bounded pool of platform threads.
Best Practices
- Prefer
Runnable/ExecutorServiceover manually managingThreadobjects. - Always call
start(), neverrun(), to actually create a thread. - Minimize shared mutable state; favor immutability and message passing.
- Guard every read and write of shared state, not just writes.
- Restore the interrupt flag when catching
InterruptedException. - Use virtual threads for high-concurrency I/O; bounded pools for CPU-bound work.
Interview Questions
Q: What is the difference between start() and run()?
start() creates a new thread and schedules run() to execute on it. Calling run() directly executes the body synchronously on the current thread with no concurrency.
Q: Why prefer implementing Runnable over extending Thread?
Runnable separates the task from the execution mechanism, leaves the single inheritance slot free, and works with executors, thread pools, and lambdas. Extending Thread couples your logic to a specific threading approach.
Q: What is a race condition and how do you prevent it?
A race condition occurs when the outcome depends on the non-deterministic interleaving of threads accessing shared mutable state. Prevent it with synchronization (synchronized, locks), atomic classes, or by eliminating shared state through immutability.
Q: How do virtual threads differ from platform threads? Virtual threads are JVM-managed and extremely cheap, so millions can exist. Blocking one does not tie up an OS thread, so a simple blocking thread-per-task style scales for I/O-bound work. Platform threads map 1:1 to OS threads and are a limited resource.