Threads: Lifecycle, Creation and What One Costs
The six thread states and what moves between them, why calling run() directly is a no-op, what a platform thread actually costs in memory and switching, and the interrupt protocol done properly.
On this page
Threads are the foundation everything else in this phase sits on, and the questions about them are
deceptively basic. "What is the difference between start() and run()?" filters out a surprising
number of candidates, and the follow-up about what a thread costs is what separates people who have
tuned a server from people who have written a tutorial.
Key Takeaways
- Six states:
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED. start()creates an OS thread;run()is an ordinary method call on the current thread.- A platform thread costs roughly 1MB of reserved stack and about a microsecond to create.
RUNNABLEin a thread dump covers both "running" and "blocked on I/O" — the JVM cannot tell.- Interruption is cooperative. Never swallow
InterruptedException.
The six states
The distinction that matters when reading a thread dump:
| State | Means | Typical cause |
|---|---|---|
RUNNABLE | Executing or ready or blocked on I/O | Real work, or a socket read |
BLOCKED | Waiting to enter a synchronized block | Lock contention |
WAITING | Waiting indefinitely for another thread | wait(), join(), LockSupport.park() |
TIMED_WAITING | Same, with a deadline | sleep, poll(timeout), await(timeout) |
Note also that BLOCKED applies only to synchronized monitors. A thread waiting on a
ReentrantLock shows as WAITING at LockSupport.park, which is why lock contention looks different
in a dump depending on which lock type you used.
Creating one
// 1. Extend Thread — couples your logic to the threading mechanism. Avoid.
class Worker extends Thread {
@Override public void run() { doWork(); }
}
new Worker().start();
// 2. Implement Runnable — separates what to run from how to run it.
new Thread(() -> doWork(), "worker-1").start();
// 3. An executor — reuses threads, bounds concurrency, handles lifecycle.
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Result> f = pool.submit(() -> compute()); // Callable: returns a value, may throw
// 4. Virtual threads (Java 21) — one per task, cheap enough not to pool.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
exec.submit(() -> handleRequest(req));
}Prefer Runnable/Callable over extending Thread: it leaves your class free to extend something
meaningful, it separates the task from the execution policy, and it is what every executor API
accepts. Callable<V> differs from Runnable in two ways worth naming — it returns a value and it
may throw a checked exception.
Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));
t.run(); // prints "main" — an ordinary method call, no new thread
t.start(); // prints "Thread-0" — a real thread
t.start(); // IllegalThreadStateException — a thread cannot be restartedWhat a thread costs
Stack memory. Each platform thread reserves a stack, defaulting to 1MB on 64-bit HotSpot
(-Xss changes it). This is reserved virtual address space, committed lazily page by page, so a
thousand idle threads use far less than 1GB of physical memory — but the reservation still counts
against the address space and against a container's memory accounting.
Creation time. Roughly 50–100 microseconds: an OS thread, a stack allocation, and JVM bookkeeping. Creating a thread per request at a thousand requests per second spends real CPU on nothing but setup, which is the entire reason thread pools exist.
Context switching. Roughly 1–10 microseconds per switch, plus the invisible cost of the CPU caches being refilled for the newly scheduled thread. With far more runnable threads than cores, the machine spends an increasing share of its time switching rather than working.
// Comfortable on a normal server: hundreds of platform threads
// Painful: a few thousand
// OutOfMemoryError: unable to create native thread — typically 5,000-15,000
// depending on -Xss and OS limits
// Virtual threads change the arithmetic entirely: ~a few hundred bytes each,
// millions are routine, because they are JVM objects rather than OS threads.This cost model is why the request-per-thread architecture had a hard ceiling before Java 21, why reactive frameworks existed, and why virtual threads matter — see Virtual threads.
Interruption, done properly
There is no safe way to stop a thread from outside. Thread.stop() was deprecated in Java 1.2 and
removed in Java 20 because it threw an exception at an arbitrary bytecode, leaving objects
half-updated and locks released mid-mutation. What replaced it is a cooperative protocol.
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
Task task = queue.take(); // throws InterruptedException when interrupted
process(task);
}
} catch (InterruptedException e) {
// take() cleared the flag when it threw. Restore it so that
// whoever called us can also see that we were interrupted.
Thread.currentThread().interrupt();
} finally {
cleanup();
}
}Three rules:
Never swallow it. catch (InterruptedException e) { } destroys the only signal that shutdown was
requested. The thread keeps running, the executor's shutdownNow() appears to do nothing, and the
JVM hangs on exit.
Restore the flag. A blocking method that throws InterruptedException clears the interrupt
status as it throws. If you catch it and do not rethrow, call Thread.currentThread().interrupt() so
callers further up can still observe it.
Check it in long loops. A CPU-bound loop with no blocking call never throws, so it must test
isInterrupted() itself. Note that Thread.interrupted() is the static version and clears the
flag as a side effect, while isInterrupted() does not — mixing them up is a subtle bug.
Daemon threads and uncaught exceptions
Thread cleaner = new Thread(this::sweep, "cache-cleaner");
cleaner.setDaemon(true); // must be set BEFORE start()
cleaner.start();When the last non-daemon thread terminates, the JVM exits and every daemon thread is killed where it
stands — no finally blocks, no shutdown hooks of its own, no flush. That makes daemon threads right
for pure housekeeping and wrong for anything holding unwritten state.
// An exception escaping run() kills that thread silently unless you handle it.
Thread.setDefaultUncaughtExceptionHandler(
(thread, throwable) -> log.error("uncaught in {}", thread.getName(), throwable));
// Executors are different: an exception from submit() is captured in the Future
// and only surfaces when you call get(). If nobody calls get(), it vanishes.
Future<?> f = pool.submit(() -> { throw new IllegalStateException("boom"); });
// ... f.get() throws ExecutionException wrapping the IllegalStateException
// execute() has no Future, so the exception reaches the uncaught handler instead.
pool.execute(() -> { throw new IllegalStateException("boom"); });The submit versus execute difference is a genuinely useful interview point: a task submitted with
submit() whose Future is discarded swallows its exception completely. Scheduled tasks are worse —
an exception from a scheduleAtFixedRate task cancels all future executions with no log line.
Naming and thread dumps
ExecutorService pool = Executors.newFixedThreadPool(8,
r -> {
Thread t = new Thread(r, "order-worker-" + counter.incrementAndGet());
t.setDaemon(false);
t.setUncaughtExceptionHandler(handler);
return t;
});pool-3-thread-7 tells you nothing at 3am. order-worker-7 tells you which subsystem is stuck. Given
that reading thread dumps is the primary diagnostic tool for every incident in
Phase 6, naming threads is one of the
cheapest operational improvements available.
What gets asked
start() versus run() is nearly guaranteed. Then the state diagram, then interruption, then
something about cost. The strongest single addition you can make is the observation that RUNNABLE
in a thread dump includes threads blocked on I/O — it shows you have read a dump rather than a
tutorial.
Frequently Asked Questions
What is the difference between start() and run()?
What actually happens when you interrupt a thread?
What is a daemon thread?
Related tutorials
- synchronized, Locks and the Java Memory ModelWhat the Java Memory Model guarantees, why reordering and visibility are separate problems, what synchronized actually does beyond mutual exclusion, and when ReentrantLock earns its extra complexity.
- volatile, Atomics and the Visibility ProblemWhat volatile guarantees and what it does not, why count++ is broken even when volatile, how compare-and-swap works, when LongAdder beats AtomicLong, and the double-checked locking idiom.
- ExecutorService and Thread-Pool SizingHow ThreadPoolExecutor decides whether to queue or grow, why newFixedThreadPool can exhaust the heap, sizing pools from measurements with Little law, and shutting down without losing work.
- CompletableFuture and Async CompositionComposing async work without blocking: thenApply versus thenCompose, which thread runs each stage, combining with allOf and anyOf, timeouts, and how exceptions propagate through a chain.