Java Thread and Runnable Tutorial
Published Updated Java 13 min read
start() against run(), why implementing Runnable beats extending Thread, the six states a thread can be in, interruption as a request rather than a kill, and what virtual threads change about all of it.
Creating a thread in Java is two lines, and the first mistake is available immediately: calling
run() instead of start() executes the code on the current thread and creates nothing. The
compiler accepts it, the output looks plausible, and there is no concurrency at all.
This is the thread API itself, construction, lifecycle, interruption and shutdown. The failure modes that concurrency causes are in the concurrency basics. Written against Java 17, with notes on 21.
Two ways to define the work
// 1. Implement Runnable — preferred
Runnable task = () -> System.out.println("Running on " + Thread.currentThread().getName());
Thread thread = new Thread(task, "worker-1");
thread.start();
// 2. Extend Thread — rarely right
class Worker extends Thread {
@Override
public void run() {
System.out.println("Running on " + getName());
}
}
new Worker().start();
Runnable is better for three concrete reasons rather than as a style preference. A class can
implement several interfaces and extend only one class, so extending Thread spends the single
inheritance slot on a detail. It separates what runs from how it runs, so the same Runnable can
be handed to an ExecutorService instead. And it is a functional interface, so the task is a lambda.
Extending Thread is justified only when overriding something other than run(), which is almost
never.
start() against run()
thread.run(); // executes on the CURRENT thread; no new thread exists
thread.start(); // creates a thread, which then calls run()
start() asks the JVM for a new thread and returns immediately. run() is an ordinary method call.
The tell in output is the thread name: a program printing main everywhere is calling run().
start() can be called once. A second call throws IllegalThreadStateException, and a finished
thread cannot be restarted, the object is not reusable. That constraint is one of the reasons pools
exist.
Naming, and why it matters before it matters
Thread thread = new Thread(task, "poll-worker-1");
The default is Thread-0, Thread-1 and so on. A thread dump full of those tells you nothing, and a
thread dump is the main tool for diagnosing a hang. Naming costs one argument.
Thread.currentThread().getName() in a log line is worth having for the same reason. Most logging
frameworks include it by default; check the pattern.
The six states
Thread.State state = thread.getState();
| State | Meaning |
|---|---|
NEW | created, start() not yet called |
RUNNABLE | running, or ready and waiting for a core |
BLOCKED | waiting to acquire a monitor |
WAITING | in wait(), join() or park() with no timeout |
TIMED_WAITING | the same with a timeout, or in sleep() |
TERMINATED | run() has returned |
RUNNABLE covers both “executing” and “ready to execute”, so a thread dump showing RUNNABLE does
not mean it is using CPU. It also covers a thread blocked on I/O, because the JVM cannot
distinguish that from running — which is why a stack trace matters more than the state.
BLOCKED is specifically monitor contention. Several threads BLOCKED on the same lock is the
signature of a bottleneck; BLOCKED in a cycle is a deadlock.
Waiting for a thread
Thread worker = new Thread(task);
worker.start();
worker.join(); // block until it finishes
worker.join(5_000); // or give up after 5 seconds
join() returns when the thread terminates, and — importantly — everything the thread did
happens-before join() returns, so results it wrote are visible without any other synchronisation.
The timeout form does not stop the thread. It stops waiting. Distinguishing the two cases needs a check afterwards:
worker.join(5_000);
if (worker.isAlive()) {
worker.interrupt(); // ask it to stop
}
Interruption is a request
There is no way to kill a thread. Thread.stop() exists, has been deprecated since Java 1.2 and
throws UnsupportedOperationException since Java 20 — it released locks mid-update and left objects
half-written.
interrupt() sets a flag. Cooperative code checks it:
Runnable task = () -> {
while (!Thread.currentThread().isInterrupted()) {
doWork();
}
cleanup();
};
A thread inside sleep(), wait() or join() gets an InterruptedException instead, and — the
detail that causes the bug — throwing it clears the flag:
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
return; // and stop
}
Swallowing InterruptedException with an empty catch or a log line makes the thread uninterruptible:
the flag is gone and the loop condition never sees it. Either restore the flag and exit, or propagate
the exception. Never just log it.
Daemon threads
Thread background = new Thread(task);
background.setDaemon(true); // must be set BEFORE start()
background.start();
The JVM exits when the last non-daemon thread finishes; daemon threads are killed at that point
without running finally blocks. That makes them right for a housekeeping loop and wrong for
anything holding a resource or mid-write.
Setting it after start() throws IllegalThreadStateException.
Uncaught exceptions
An exception escaping run() terminates that thread only, silently by default — the main thread
carries on and nothing is logged. For a pool this is worse: the worker is replaced and the failure
disappears.
thread.setUncaughtExceptionHandler((t, e) ->
log.error("Thread {} died", t.getName(), e));
Thread.setDefaultUncaughtExceptionHandler((t, e) ->
log.error("Uncaught in {}", t.getName(), e));
Set the default at startup. A silently dead worker is one of the harder production problems to notice.
sleep, yield and onSpinWait
Thread.sleep(1000); // give up the CPU for a duration; holds any locks it owns
Thread.yield(); // a hint to the scheduler; may do nothing
Thread.onSpinWait(); // Java 9+, a hint inside a busy-wait loop
sleep does not release monitors. A thread sleeping inside a synchronized block keeps the lock
for the whole duration, which is a straightforward way to stall every other thread that needs it —
and a common accidental one, because sleep inside a critical section reads as harmless.
yield is a suggestion the JVM is free to ignore, and code whose correctness depends on it is
already broken. Its only defensible use is in a benchmark or a stress test, to widen the window in
which a race can occur.
onSpinWait is different in kind: it emits a CPU pause instruction that makes a short busy-wait less
expensive. It belongs in a loop that is genuinely expected to exit within a handful of iterations,
and nowhere else — a spin loop waiting on something slow burns a core.
Sleeping as a synchronisation mechanism — “wait 100 ms for the other thread to finish” — is the
underlying anti-pattern all three share. Use join, a CountDownLatch, or a Future, all of which
carry a happens-before guarantee that a sleep does not.
Do not create threads directly
Everything above describes the mechanism; application code should rarely use it. A thread per task costs about a megabyte of stack and tens of microseconds to create, and there is no bound on how many you make.
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
Future<String> result = pool.submit(() -> compute());
System.out.println(result.get());
}
An ExecutorService reuses threads, bounds
concurrency, and gives back a Future — which a raw Thread cannot, because Runnable.run()
returns nothing and cannot throw a checked exception. That is what
Callable is for.
Java 21’s virtual threads change the arithmetic rather than the API:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task);
}
A virtual thread is scheduled by the JVM onto a small pool of carrier threads, costs a few hundred bytes, and blocks cheaply — so “a thread per request” becomes reasonable at a scale where platform threads would not fit. They do not make anything thread-safe, and pooling them is pointless: create one per task.
Frequently asked questions
What is the difference between start() and run()?
start() creates a thread that then calls
run(). Calling run() directly is an ordinary method call on the current thread — no concurrency
at all.
Runnable or extending Thread?
Runnable. It leaves the inheritance slot free, separates the task
from the execution mechanism, and is a lambda.
Can a thread be started twice?
No. A second start() throws IllegalThreadStateException, and a
terminated thread cannot be restarted.
How do I stop a thread?
You ask. interrupt() sets a flag that cooperative code checks;
Thread.stop() is removed. A thread that ignores interruption cannot be stopped.
Why does my thread ignore interrupt()?
The InterruptedException was caught and swallowed, which
clears the flag. Restore it with Thread.currentThread().interrupt() and return.
What does RUNNABLE mean in a thread dump?
Running, ready to run, or blocked on I/O — the JVM cannot tell those apart. Read the stack trace, not the state.
What is a daemon thread?
One that does not keep the JVM alive. It is killed at exit without
running finally blocks, so use it only for work that can be abandoned. setDaemon must precede
start().
Where do exceptions thrown inside run() go?
Nowhere, by default — the thread dies silently. Set a
default UncaughtExceptionHandler at startup.
Does join() make the thread’s results visible?
Yes. Everything the thread did happens-before
join() returns, so no extra synchronisation is needed to read what it wrote.
Should I still create threads directly?
Rarely. Use an ExecutorService for pooling and
Futures, or a virtual-thread executor on Java 21 where one thread per task is now affordable.