JAVA / THREADS AND CONCURRENCY
Threads, Runnable and the lifecycle of a thread
Start real threads with a Runnable, tell start() apart from run(), and follow a thread from NEW through RUNNABLE to TERMINATED.
What you will learn
- Run a task on a second thread by passing a Runnable to new Thread(...).start()
- Explain why calling run() directly executes on the caller's thread and starts nothing
- Use join(), isAlive() and getState() to see where a thread is in its lifecycle
- Know that TERMINATED is final: restarting a Thread throws IllegalThreadStateException
Understanding Threads, Runnable and the lifecycle of a thread
A Thread instance is an ordinary heap object; it is a handle, not the flow of execution itself. The flow only comes into being when you call start(), which asks the JVM for a fresh call stack (backed by an OS thread) and arranges for run() to be invoked on that stack. Calling run() yourself skips all of that and is a plain virtual method call, which is why the body finishes before your next line and never overlaps with anything. Keeping the work in a Runnable and the worker in a Thread makes the split visible: the Runnable says what to do, the Thread decides where it runs.
The lifecycle is a small state machine that, taken as a whole, only moves one way. A freshly constructed thread is NEW; start() makes it RUNNABLE, meaning eligible to execute, while whether a core is free right now is the scheduler's business. From RUNNABLE it can dip into BLOCKED, WAITING or TIMED_WAITING while it waits for a lock, a notification or a timed sleep, then come back; when run() returns or throws, it lands in TERMINATED and stays there. Because that final state is absorbing and each Thread object records exactly one lifetime, a second start() throws IllegalThreadStateException rather than rerunning the task.
start() returns immediately, so from that instant two stacks are advancing and nothing orders their printlns except the scheduler. join() is how you buy ordering back: it blocks the caller until the target thread is TERMINATED, which is why output printed after a join is reliable while output printed between start() and join() is not. Whether the JVM waits for a thread at all is a separate decision, controlled by the daemon flag: the JVM shuts down once the last non-daemon thread finishes, abandoning daemon threads wherever they happen to be.
public class ThreadLifecycleDemo {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
Thread self = Thread.currentThread();
System.out.println("running on " + self.getName() + ", state " + self.getState());
try {
Thread.sleep(30);
} catch (InterruptedException e) {
self.interrupt();
}
System.out.println("task body returning");
};
Thread worker = new Thread(task, "worker-1");
System.out.println("created: " + worker.getState() + ", alive=" + worker.isAlive());
worker.start();
worker.join();
System.out.println("joined: " + worker.getState() + ", alive=" + worker.isAlive());
System.out.println("still on: " + Thread.currentThread().getName());
}
}A Thread object is a one-shot handle to a separate call stack: start() creates that stack and pushes the thread through a one-way lifecycle, while run() is only a method call.
Worked examples
start() versus run(), and the one-shot rule
Shows which thread actually executes the Runnable body, and what happens when a finished thread is started again.
public class StartVersusRun {
public static void main(String[] args) throws InterruptedException {
Runnable body = () -> System.out.println("body ran on " + Thread.currentThread().getName());
Thread t = new Thread(body, "worker");
t.run();
t.start();
t.join();
try {
t.start();
} catch (IllegalThreadStateException e) {
System.out.println("second start failed, state is " + t.getState());
}
}
}Example explained
Line 1t.run() is an ordinary call, so the body executes on main's stack and no second thread exists.
Line 2t.start() hands the same Runnable to a new thread, which is why the second line reports worker.
Line 3join() is what makes the ordering of these three lines predictable; without it the worker's line could appear later.
Line 4The final start() finds a thread past NEW and throws IllegalThreadStateException, because a Thread object cannot be rewound.
Catching a thread in TIMED_WAITING
Polls getState() from main to observe a sleeping thread, then uses interrupt() to end the sleep early.
public class ObserveSleepingThread {
public static void main(String[] args) throws InterruptedException {
Thread sleeper = new Thread(() -> {
try {
Thread.sleep(5000);
System.out.println("slept the full 5 seconds");
} catch (InterruptedException e) {
System.out.println("sleep cut short by interrupt");
}
}, "sleeper");
sleeper.start();
while (sleeper.getState() != Thread.State.TIMED_WAITING) {
Thread.onSpinWait();
}
System.out.println("observed state: " + sleeper.getState());
sleeper.interrupt();
sleeper.join();
System.out.println("after join: " + sleeper.getState());
}
}Example explained
Line 1The spin loop runs on main and exits only once sleeper has entered Thread.sleep, so the printed state is guaranteed to be TIMED_WAITING.
Line 2interrupt() does not stop the thread; it makes the pending sleep throw InterruptedException so the thread can decide what to do.
Line 3The catch block prints and then run() returns, moving the thread to TERMINATED and letting join() return.
Line 4The 5000 ms sleep exists only to give main a wide window in which to observe the waiting state.
A daemon thread does not keep the JVM alive
Demonstrates that JVM shutdown is tied to non-daemon threads, so a daemon thread can be abandoned mid-task.
public class DaemonLifetime {
public static void main(String[] args) {
Thread background = new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
return;
}
System.out.println("this never prints");
}, "background");
background.setDaemon(true);
background.start();
System.out.println("daemon=" + background.isDaemon());
System.out.println("main returns now, JVM does not wait");
}
}Example explained
Line 1setDaemon(true) must be called while the thread is still NEW; on a started thread it throws IllegalThreadStateException.
Line 2main is the only non-daemon thread here, so the JVM shuts down as soon as main returns.
Line 3The 5-second sleep never completes and the println after it is dropped, without any error being reported.
Line 4Nothing is unwound on shutdown, so a daemon thread is the wrong place to flush a file or close a connection.
Important notes
RUNNABLE covers both 'executing now' and 'ready but not scheduled', and a thread blocked in native I/O also reports RUNNABLE, so getState() is a debugging hint rather than a precise fact.
An exception escaping run() ends only that thread: the default handler prints the trace to stderr, the code that called start() is never told, and the thread just becomes TERMINATED early.
Common mistakes
Calling worker.run() instead of worker.start(): the body executes on the calling thread, the program stays single threaded, and the plausible-looking output hides the bug.
Calling start() again on a thread that already finished: it throws IllegalThreadStateException instead of rerunning the task, so you must construct a new Thread around the same Runnable.
Putting join() immediately after start() inside a loop: each worker must terminate before the next is created, so you pay for N threads and get the throughput of one.
Try it yourself
Change, predict, then run
Write a main method that creates two threads named a and b, each printing its own name five times with Thread.sleep(10) between prints, starts both, joins both, then prints done. Run it several times and note that the a/b interleaving changes while done is always last.
Open the Java workspaceCheck your understanding
A loop creates ten Thread objects and, inside each iteration, calls start() and then join() on that thread. What actually happens?
- All ten tasks overlap, since start() is what creates the concurrent execution
- Ten threads are created but run one after another, because join() blocks the loop until each one terminates
- The second iteration throws IllegalThreadStateException, since a thread cannot be started while another is running
- The loop deadlocks, because a thread may not join a thread that started it
Show answer
join() blocks the calling thread (main) until the target reaches TERMINATED, so each worker is finished before the next one is even constructed; you get the cost of ten threads with no overlap. Option 0 is tempting because start() really does create a concurrent flow, but overlap only appears if all the starts happen before any join, so collect the threads first, start them all, then join them in a second loop. Option 2 is wrong because each iteration starts a distinct, still-NEW Thread object.