threads
starting threads, their lifecycle, and the six states.
- chapter
- chapter 09.01
- reading time
- 9 min read
a thread is an independent path of execution: its own stack, its own program counter, sharing one heap with every other thread. you create one with a task, and nothing happens until you call start().
from then on it moves through six states, from NEW to TERMINATED. this chapter is those six states and the handful of methods that move a thread between them.
the picture
think of a kitchen. the recipe book is your code. each cook is a thread, with their own notepad (the stack: where they are in the recipe, their local scribbles). the fridge and the counter are shared (the heap). two cooks can follow the same recipe at the same time, and that's fine, right up until they both reach for the same bowl.
your program is never single-threaded, by the way. a JVM that runs nothing but Thread.sleep(8000) had 18 threads in its thread dump on my machine (jdk 25, G1): main, plus Reference Handler, Finalizer, Signal Dispatcher, two JIT compiler threads, a VM Thread, a handful of G1 and GC Threads and a few more. you only wrote one of them.
start() vs run()
a Thread is just an object until you call start(). here are the ways to make one:
Runnable task = () -> IO.println("working on " + Thread.currentThread().getName());
Thread a = new Thread(task); // classic
Thread b = Thread.ofPlatform().name("worker-1").start(task); // builder, java 21+, starts it too
Thread c = Thread.ofVirtual().start(task); // a virtual thread, java 21+
a.start();the classic mistake is calling run() instead of start(). it compiles, it runs, it prints, and it's single-threaded:
void main() {
Thread t = new Thread(() ->
IO.println("hi from " + Thread.currentThread().getName()));
t.run();
t.start();
}hi from main hi from Thread-0
a Thread object exists, on the heap, like any other object. no OS thread yet. nothing is running in that lane.
run() never touches the second lane. only start() creates a thread, and the program only ends when its last non-daemon thread does.Thread t = new Thread(() -> IO.println(Thread.currentThread().getName()));
t.run();the six states
Thread.State is an enum with exactly six values. every thread is in exactly one of them at any moment, and t.getState() tells you which (a snapshot, already stale by the time you read it).
t.start();
start() asks the OS for a real thread and schedules run() on it. call start() a second time and you get IllegalThreadStateException.
you can watch a thread visit all six. this program pins t in each state on purpose (holding the lock it wants, then waiting on it, then sleeping) and spins until getState() agrees:
void waitFor(Thread t, Thread.State s) {
while (t.getState() != s) Thread.onSpinWait();
IO.println(s);
}
void main() throws Exception {
Object lock = new Object();
Thread t = new Thread(() -> {
IO.println(Thread.currentThread().getState()); // its own state, while running
synchronized (lock) { // main holds it: BLOCKED
try {
lock.wait(); // WAITING until notified
Thread.sleep(50); // TIMED_WAITING
} catch (InterruptedException e) { }
}
});
IO.println(t.getState());
synchronized (lock) {
t.start();
waitFor(t, Thread.State.BLOCKED);
}
waitFor(t, Thread.State.WAITING);
synchronized (lock) { lock.notify(); }
waitFor(t, Thread.State.TIMED_WAITING);
t.join();
IO.println(t.getState());
}NEW RUNNABLE BLOCKED WAITING TIMED_WAITING TERMINATED
this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.
the monitor and wait/notify parts of that get their own chapters. for now, notice that the states are about why a thread isn't making progress, not about the CPU.
join, daemons, and when the JVM exits
t.join() parks the calling thread until t is TERMINATED. it's the simplest way to wait for a result:
void main() throws InterruptedException {
int[] result = new int[1];
Thread worker = Thread.ofPlatform().name("worker").start(() -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
result[0] = sum;
});
worker.join();
IO.println(result[0]);
}5050
one int on the heap, reachable from both threads.
join() returns, main is guaranteed to see the worker's write.that guarantee is not a given. without join() (or some other synchronization), main could read result[0] and see 0 even after the worker wrote 5050. the rule that makes join() safe is called happens-before, and it's the whole java memory model chapter.
the JVM exits when the last non-daemon thread ends. main is non-daemon, threads you create inherit the flag from their creator, and daemon threads (the JVM's own helpers, or yours with .daemon()) don't keep the process alive. when the JVM leaves, it doesn't stop them politely. it just leaves:
void main() throws InterruptedException {
Thread t = Thread.ofPlatform().daemon().start(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
} finally {
IO.println("cleanup");
}
});
Thread.sleep(100);
IO.println("main done");
}main done
a daemon thread is a background helper. same kind of thread, one flag different.
finally is skipped, which is why daemon threads must never own anything that needs closing (files, transactions, half-written output).stopping a thread: interrupt, politely
there's no safe way to kill a thread from outside. Thread.stop() could kill a thread halfway through updating an object (leaving it broken for everyone else), so it was deprecated in java 1.2, and on java 25 it just throws UnsupportedOperationException.
what you get instead is interruption: a boolean flag on the thread that means "please stop when convenient". t.interrupt() sets it. blocking methods like sleep, wait and join notice it, clear it, and throw InterruptedException. long loops check Thread.currentThread().isInterrupted() themselves.
void main() throws InterruptedException {
Thread sleeper = Thread.ofPlatform().name("sleeper").start(() -> {
try {
Thread.sleep(60_000);
IO.println("woke up normally");
} catch (InterruptedException e) {
IO.println("interrupted! flag is now " + Thread.currentThread().isInterrupted());
}
});
Thread.sleep(100);
sleeper.interrupt();
sleeper.join();
IO.println(sleeper.getName() + " is " + sleeper.getState());
}interrupted! flag is now false sleeper is TERMINATED
an uncaught exception, on the other hand, kills only the thread it was thrown in. the JVM prints Exception in thread "Thread-0" ... to stderr and the other threads carry on like nothing happened, which is exactly how background failures go unnoticed in prod. if it matters, set an UncaughtExceptionHandler or (better) run the task on an executor and check its Future.
why you won't create many of these
every thread in this chapter (except c up there) is a platform thread: a thin wrapper around one OS thread, 1:1. the OS reserves a stack for each (2 MB by default on jdk 25 on an arm mac, -Xss), schedules it, and charges a context switch every time it swaps one out. a few thousand are fine. a hundred thousand is not.
so real code mostly does one of two things: reuse a small, fixed set of platform threads through a thread pool, or use virtual threads, which are cheap enough to create one per task. the six states, join, interrupts and daemons all work the same way for both.
- a thread is a stack plus a program counter. the heap is shared by all of them.
- nothing runs until
start().run()is just a method call. - six states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. BLOCKED is only for
synchronized. join()waits for a thread and makes its writes visible to you.- the JVM exits when the last non-daemon thread ends, and daemons die mid-sentence.
- you can't kill a thread. you can only interrupt it and hope it listens.