On this page
Description
A web service handles many requests at the same time. A batch job splits a big file across workers. A phone app downloads in the background while you scroll. All of this runs on threads: separate paths of execution inside one program, each doing its own work while sharing the same memory.
Most Java code today doesn't create threads by hand; executors, web servers and frameworks do it for you. But every one of those tools is built on the same few rules about threads, and when something goes wrong (a service that won't shut down, a worker that silently died, one user's data showing up in another user's request), those rules are where the answer is. They're also a favorite topic in interviews for backend roles.
Here's the path, from easiest to most technical:
- What a thread is, and how to start one (
startversusrun). - Waiting for threads, and the states a thread moves through.
- Stopping a thread politely: interruption.
- What happens when a thread throws, and what "daemon" threads are.
ThreadLocal: data that belongs to one thread, and its trap.
What a thread is
When your program starts, it has one thread, called main. You can start more, and they all run at the same time (on separate CPU cores, or taking turns on one). They share the same objects in memory, which is what makes threads powerful and also what makes them tricky: two threads changing the same data at once can corrupt it. The next skill, on synchronized and volatile, deals with that. This one is about the threads themselves.
Starting a thread
You describe the work as a Runnable (a task with a run() method), give it to a Thread, and call start():
Runnable task = () -> System.out.println("running on " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker");
t.run(); // prints "running on main": just a normal method call, no new thread
t.start(); // prints "running on worker": a new thread runs the task
t.join(); // wait for the worker to finish
t.start(); // IllegalThreadStateException: a thread can only be started once
The first line trips up a lot of people. Calling run() yourself works without any error, but it runs the task on your current thread, so nothing happens in parallel.
A few habits:
- Pass a
Runnableinstead of extendingThread. The task stays separate from how it's run, so the same task can later go to a thread pool. - In real code, prefer executors (thread pools) over
new Thread. They're covered in the Concurrency module. join()waits until a thread finishes. Afterwards, you're guaranteed to see everything that thread wrote.Thread.sleep(ms)pauses the current thread. It doesn't release any lock the thread is holding.
A thread's states
A thread is always in one of six states, which t.getState() reports:
| State | What it means |
|---|---|
NEW | created, but start() hasn't been called |
RUNNABLE | running, or ready to run (this also covers waiting on network I/O) |
BLOCKED | waiting to enter a synchronized block that another thread holds |
WAITING | waiting with no time limit: join(), wait() |
TIMED_WAITING | waiting with a time limit: sleep(ms), join(ms), wait(ms) |
TERMINATED | finished, normally or by throwing |
You'll meet these states in a thread dump: a snapshot of every thread in a running program, with its state and what it's doing. When a service hangs, it's the first thing to collect (jstack <pid>). Lots of BLOCKED threads point at a lock everyone is fighting over; lots of WAITING ones at something they're all waiting for that never arrives.
Stopping a thread: interruption
Java has no safe way to force a thread to stop. Instead, you ask it: t.interrupt() sets a flag on the thread. What happens next depends on what the thread is doing:
- If it's waiting in
sleep,wait,joinor a blocking queue'stake, that call throws anInterruptedException, and the flag is cleared at the same moment. - Otherwise nothing happens until the thread checks
Thread.currentThread().isInterrupted().
That "cleared" detail causes a classic bug. This worker never stops when a shutdown interrupts it:
while (!Thread.currentThread().isInterrupted()) {
try {
pollQueue();
Thread.sleep(50);
} catch (InterruptedException e) {
// ignored: "we'll check the flag at the top of the loop"
}
}
The interrupt arrives during sleep, sleep throws and clears the flag, the catch block ignores it, and the loop's check finds the flag false. The signal is lost. There are two correct ways to handle InterruptedException:
// 1. Don't catch it: declare `throws InterruptedException` and let the caller decide.
// 2. Catch it, put the flag back, and stop:
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag, so callers and loops can see it
return;
}
Thread pools rely on this: shutting down a pool (shutdownNow()) or cancelling a task interrupts the threads running them. Swallowing the interrupt breaks both.
One more detail: the static Thread.interrupted() reads the flag and clears it, while isInterrupted() only reads it.
When a thread throws, and daemon threads
If an exception escapes a thread's run() method, only that thread dies. Java prints Exception in thread "worker" ... with the stack trace, and every other thread, main included, keeps going. So a service can lose a worker and carry on at reduced capacity without anyone noticing. Long-running worker loops should catch and log exceptions for each item they process, so one bad item doesn't kill the worker.
A program keeps running as long as any non-daemon thread is alive. A daemon thread (marked with setDaemon(true) before start()) is for background work that shouldn't keep the program alive. The catch: when the last non-daemon thread ends, daemon threads are simply abandoned, and their finally blocks don't run. Never give a daemon thread work that must finish, like writing a file.
ThreadLocal
A ThreadLocal holds a separate value for each thread:
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
currentUser.set("alice"); // only this thread sees "alice"
currentUser.get(); // "alice" here; null on every other thread
Frameworks use it to carry per-request information, such as the logged-in user, without passing it through every method. The trap is thread pools. A pool reuses the same thread for request after request, so a value one request set is still there for the next request on that thread, unless you remove it:
currentUser.set(user);
try {
handle(request);
} finally {
currentUser.remove(); // always clean up
}
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Calling run() instead of start() | the task runs on your own thread | start() |
| Starting a thread twice | IllegalThreadStateException | a new Thread, or a pool |
Catching InterruptedException and carrying on | shutdown and cancel stop working | restore the flag and stop, or rethrow |
No try/catch per item in a worker loop | one bad item kills the worker | catch and log each item's failure |
| Important work on a daemon thread | abandoned at exit, finally skipped | a normal thread, and a clean shutdown |
ThreadLocal on a pool without remove() | one request sees another's data | remove() in finally |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. What's the difference between t.run() and t.start()?
Show answer
start() creates a new thread that runs the task. run() is an ordinary method call: the task runs on your current thread, and nothing happens in parallel.
2. A worker catches InterruptedException from sleep, ignores it, and loops while !isInterrupted(). Why does it never stop?
Show answer
sleep clears the interrupt flag when it throws, so the loop's check sees false. Restore it with Thread.currentThread().interrupt() in the catch block (and stop), or let the exception propagate.
3. A worker thread throws an exception nobody catches. What happens to the rest of the program?
Show answer
Only that thread dies. The exception and stack trace are printed to standard error, and main and every other thread keep running.
4. Why can a ThreadLocal set during one request show up in a different request?
Show answer
Both requests ran on the same pool thread, and nothing removed the value after the first one. Call remove() in a finally block around the work.
Key takeaways
- A thread is a separate path of execution sharing the same memory.
start()creates one;run()doesn't. join()waits for a thread; the six states are what you'll read in a thread dump.- Interruption is a polite request. Blocking calls throw and clear the flag, so restore it and stop, or rethrow.
- An uncaught exception kills one thread only. Daemon threads die at exit without cleanup.
- On a pool, always
remove()aThreadLocalinfinally.