Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Beginner7 min readUpdated
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.
  • RUNNABLE in a thread dump covers both "running" and "blocked on I/O" — the JVM cannot tell.
  • Interruption is cooperative. Never swallow InterruptedException.

The six states

Thread.State as the JVM reports it. Note there is no separate RUNNING state — RUNNABLE covers both scheduled and executing.

The distinction that matters when reading a thread dump:

StateMeansTypical cause
RUNNABLEExecuting or ready or blocked on I/OReal work, or a socket read
BLOCKEDWaiting to enter a synchronized blockLock contention
WAITINGWaiting indefinitely for another threadwait(), join(), LockSupport.park()
TIMED_WAITINGSame, with a deadlinesleep, 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

four ways, in increasing order of preference
// 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.

start() versus run()
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 restarted

What 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.

the practical ceiling
// 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.

the correct pattern
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

daemon
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.

uncaught exceptions
// 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

name every thread
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()?
start() asks the JVM to create a new OS thread and invoke run() on it, returning immediately. Calling run() directly just executes the method on the current thread, like any other method call — no new thread exists and nothing runs in parallel. Calling start() twice throws IllegalThreadStateException, because a thread object cannot be restarted once it has terminated.
What actually happens when you interrupt a thread?
Nothing is forced. Interrupting sets a boolean flag on the thread. If the thread is blocked in sleep, wait or join, that method throws InterruptedException and clears the flag; otherwise the thread keeps running until it checks Thread.currentThread().isInterrupted(). Interruption is a cooperative request to stop, which is why swallowing InterruptedException without restoring the flag breaks shutdown throughout an application.
What is a daemon thread?
A thread that does not prevent the JVM from exiting. When the last non-daemon thread finishes, the JVM shuts down and kills every daemon thread abruptly, without unwinding stacks or running finally blocks. Use daemon threads for background housekeeping whose loss is harmless, and never for anything that writes data — a daemon thread mid-write when the JVM exits leaves a corrupt file.

Related tutorials