Lesson 05 / 11
Threads and Lifecycle
The same body runs on the caller with run(), on a thread created with start(); the difference is a single method name. Of three interruption setups, only one stops the body — the other two reach the same next step whether the interrupt call arrives or not.
Published
In this lesson
- Runs the same body with run() and start() and counts which thread ran it by label
- Observes a thread's deterministic life states with isAlive and join
- Measures in three separate setups whether an interruption request stops the body
Contents
The Just-in-Time Compilation lesson ended with this result: the party that decided on an optimization not present in the class file was not the program itself, but the program was still the one that created the threads that ran the code. This lesson starts from there. Who loads the class file, who makes the decision, who produces the profile — every one of those questions assumed a body was already running. Now the lesson takes a step back: how does a thread come to exist, and which party runs the body that was just written for it?
The answer does not show up in the call syntax. A Runnable body is defined in exactly one
place and can be triggered by two different methods; both run the same line, and both are
valid calls as far as the compiler is concerned. The first difference this lesson measures —
also the first two lines of the course’s shared core — sits exactly here.
The course’s question has two parts: which thread runs a given step, and who created that
thread? This lesson looks only at the second part, at the act of creating itself. While a
program runs, at least one thread already exists — the thread running the main method — and
if the program never asks for another thread, that one is enough on its own. Asking for a new
thread moves the body to a separate flow; that flow is no longer the caller’s flow, and it
starts and ends on its own. The two sections below measure this process of creation and
completion, together with a third question in between: what does telling an unfinished thread
“stop now” actually make the body do.
Same Body, Two Ways to Run It
- CC1 — The label that says who is running is read not from the thread itself but from a
separate registry; if a thread never registered with
Run.announce, it counts asunknown. - CC2 — The thread that runs
Bodies.mainis registered as"caller"from the start; for the rest of this lesson, “the caller” always means this thread.
// Run.java — labels which thread runs a given body
import java.util.*;
public class Run {
static final Map<Thread, String> labels =
Collections.synchronizedMap(new IdentityHashMap<>());
static void announce(String label) { labels.put(Thread.currentThread(), label); }
static String who() {
String l = labels.get(Thread.currentThread());
return l == null ? "unknown" : l;
}
static void observe(String point) {
System.out.printf("%-32s %s%n", point, who());
}
}
Run never reads a thread’s name, identity hash, or priority — all of that is
environment-dependent data. The only thing it reads is a label written in advance by
announce; the question of identity is reduced to the name we ourselves gave it.
// Bodies.java — the same body runs on the caller via run(), on the created thread via start()
public class Bodies {
public static void main(String[] args) throws InterruptedException {
Run.announce("caller");
Runnable body = () -> Run.observe("body");
// 1. direct call: run() does not bind another thread, the caller runs it
new Thread(body).run();
// 2. start(): the created thread runs the body
Thread t = new Thread(() -> { Run.announce("created"); body.run(); });
t.start();
t.join();
}
}
body caller body created
Both lines run the same body reference, and both call Runnable.run(). The difference is who
says run(): on the first line, run() is called directly on the new Thread(body) object —
this is an ordinary method call, it does not create a new thread, it only runs the body on
the caller’s own stack. On the second line, start() is called; this sends a request to the
runtime for a new thread, and the body runs on that thread. Without t.join(), nothing would
guarantee that main waits for the body to finish — this bridge is treated separately in the
limiting measurement below.
The expression new Thread(body) itself has not run anything yet. In both ways of running
it, a Thread object is built first; the moment the object is built, it carries only a
record — there is yet no commitment about who will run the body. On the first line, that
object is called with run() without ever being start()ed — the object exists but a thread
was never requested, the body enters the caller’s own code flow directly. On the second line,
the start() call makes that commitment: it asks the runtime for a new thread, and the body now
runs on the thread that answers that request. The difference is between a call that creates
a thread and one that does not — and that difference stays hidden behind a run() call that
looks identical in both lines.
Lifecycle: Deterministic Observations
- CC3 —
isAlive()reads no identity data from the thread itself; it only answers “was it started and has it not finished yet” withtrue/false, and this does not depend on the environment.
A Thread object is new before start() is called, is running after start() and
before it finishes, and is definitively finished the moment join() returns. All three
states are observed deterministically with isAlive(); the transitions between them depend not
on timing but on which method was called. isAlive() itself also reads no environment-dependent
data from the thread — the only thing it returns is a boolean, and that value is determined by
which methods were called before it, not by how busy the machine happens to be at that moment.
// Lifecycle.java — states observed deterministically: before start, while running, after finish
import java.util.concurrent.CountDownLatch;
public class Lifecycle {
public static void main(String[] args) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
Thread t = new Thread(() -> {
try { start.await(); } catch (InterruptedException e) { }
});
System.out.println("before start isAlive: " + t.isAlive());
t.start();
System.out.println("after start isAlive: " + t.isAlive());
start.countDown();
t.join();
System.out.println("after join isAlive: " + t.isAlive());
try {
t.start();
System.out.println("second start : succeeded");
} catch (IllegalThreadStateException e) {
System.out.println("second start : " + e.getClass().getSimpleName());
}
}
}
before start isAlive: false after start isAlive: true after join isAlive: false second start : IllegalThreadStateException
start holds the body on a CountDownLatch; this guarantees, without leaving it to a race,
that the body has not yet finished once start() returns. The join() call does two things at
once: it stops the caller until the body finishes, and it guarantees that isAlive() will be
false after it returns — join is this lesson’s ordering tool. The last line shows a separate
rule: a Thread object can be started only once; a second start() call always throws
IllegalThreadStateException, regardless of the object’s state.
The reason for this restriction is the lifecycle itself. start() moves a Thread object out
of the new state and hands it to the runtime exactly once; after that handoff, the object
never returns to the new state again, and once it finishes it is considered permanently
over. Allowing an object to be started a second time would mean reusing the same thread identity
across two separate runs; the framework instead requires a fresh Thread object to be built for
every run. Code that wants to rerun a finished body does so by wrapping the same Runnable body
in a new Thread object — the object changes, the body does not.
Interruption: One Request, the Answer Lives in the Body
- CC4 — Interruption is not a command;
Thread.interrupt()only sets a flag or drops an exception in a blocking call. What the body does in response to that request is written in the body’s own code — it is not something the framework decides.
Three setups share the same pattern: a thread starts, registers itself with CountDownLatch,
then an interruption request is (optionally) sent, and finally its completion is awaited with
join(). The only thing that changes is how the body responds to the interruption.
The first body’s loop condition calls Thread.currentThread().isInterrupted(); this is an
instance method and it does not clear the flag after reading it. There is also a static
counterpart that asks the same question but clears the flag as soon as it reads it; that
counterpart is deliberately not used here, because the result.append line after leaving the
loop might still need the information “this thread was asked to interrupt” — the clearing form
would wipe the flag on the first read and would not carry that information past the loop. Which
form to use is also the body’s decision — the framework offers both, and afterward the choice
falls to the programmer depending on whether the flag is still needed.
// Interruption.java — interruption is a request: the body can see it but its own code decides whether to stop
import java.util.concurrent.CountDownLatch;
public class Interruption {
static String stopsCheckingFlag(boolean doInterrupt) throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
StringBuilder result = new StringBuilder();
Thread t = new Thread(() -> {
started.countDown();
while (!Thread.currentThread().isInterrupted()) { }
result.append("left the loop on seeing the flag");
});
t.start();
started.await();
if (doInterrupt) t.interrupt();
t.join();
return result.toString();
}
static String catchesInSleep(boolean doInterrupt) throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
StringBuilder result = new StringBuilder();
Thread t = new Thread(() -> {
started.countDown();
try { Thread.sleep(80); result.append("sleep completed, "); }
catch (InterruptedException e) { result.append("exception caught, "); }
result.append("moved to the next step regardless");
});
t.start();
started.await();
if (doInterrupt) t.interrupt();
t.join();
return result.toString();
}
static String neverAsksFlag(boolean doInterrupt) throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
StringBuilder result = new StringBuilder();
Thread t = new Thread(() -> {
started.countDown();
long total = 0;
for (long i = 0; i < 20_000_000L; i++) total += i;
result.append("loop completed (total greater than zero: " + (total > 0) + ")");
});
t.start();
started.await();
if (doInterrupt) t.interrupt();
t.join();
return result.toString();
}
public static void main(String[] args) throws InterruptedException {
System.out.println("1. body checking the flag, with interruption:");
System.out.println(" " + stopsCheckingFlag(true));
System.out.println();
System.out.println("2. body catching in sleep, with interruption:");
System.out.println(" " + catchesInSleep(true));
System.out.println("2. same body, WITHOUT interruption:");
System.out.println(" " + catchesInSleep(false));
System.out.println();
System.out.println("3. body never asking the flag, with interruption:");
System.out.println(" " + neverAsksFlag(true));
System.out.println("3. same body, WITHOUT interruption:");
System.out.println(" " + neverAsksFlag(false));
}
}
1. body checking the flag, with interruption: left the loop on seeing the flag 2. body catching in sleep, with interruption: exception caught, moved to the next step regardless 2. same body, WITHOUT interruption: sleep completed, moved to the next step regardless 3. body never asking the flag, with interruption: loop completed (total greater than zero: true) 3. same body, WITHOUT interruption: loop completed (total greater than zero: true)
The first setup’s loop only looks at the interruption flag; if the interrupt call never
came, this loop had no other way out — the code itself shows this, which is why that setup is
never run without an interruption. The second and third setups were each run twice, once
with the interrupt call and once without, and both reached the same result either way: the catch
block inside Thread.sleep swallows the exception and moves to the next line, while the
counting loop never reads the flag at all and completes unaware that an interruption ever
happened. Of the three setups, only one never finishes its body without an interruption
request; the other two reach the same next step whether they see the interruption request or
not. Interruption itself does not impose a behavior — the body’s author chooses the behavior.
The distinction is not specific to these three examples; it generalizes into a rule.
Standard-library blocking calls — Thread.sleep, the wait method called while holding an
object lock, join, the waiting calls in the concurrency package — check the interruption flag
themselves and, when the flag is set (or the moment it becomes set), throw
InterruptedException and clear the flag. Every other kind of code — a computation loop,
writing to a file, reading a network socket — does not check the flag on its own; a body that
wants to check it has to write that check by hand, as in the first setup. The second setup
sits in the middle of this distinction: sleep sees the flag and throws the exception, but the
body catches that exception and continues on its way without doing anything about it. The
exception being thrown is a guarantee; responding to it is the body’s responsibility, and
that responsibility is lost in an empty catch block more often than not.
Limiting Measurement: start() Returning Gives No Order
When the t.start() call in Bodies.main returns, nothing guarantees that the body’s first
line has already run. start()‘s job is to request a new thread; when that request will be
fulfilled is outside the caller’s knowledge. This is why, if a line were added inside
Bodies.main to read the body’s output before t.join() was called, whether that line ran
before or after the body’s output would be an observation with no order fixed by the setup
and would not count as a measurement — much like this lesson’s shared definition of the blind
spot, where “did not happen” gets confused with “the order was never established.” Every setup
in the Lifecycle and Interruption lessons therefore opens with a CountDownLatch: the body
registers itself first, and only after that registration does the caller move to the next step.
The order is not left to the scheduler; it is built by hand with await, join, and
registration points.
This limit also explains why the measurement always carries the same skeleton.
Interruption.stopsCheckingFlag’s body registers itself with started.countDown() before the
main thread waits on started.await(); only after that registration is the interruption request
sent. If the registration line were skipped and t.interrupt() were called right after
t.start(), whether the request arrived before or after the body entered the while loop would
be undecidable — the result would not change (the loop would sooner or later see the flag and
exit), but which turn it exited on could not be measured. This lesson never claims a turn
count; the claim is limited to the question did the output appear or not, because that is
exactly what the boundary is: what can be measured is only as much as the setup makes
deterministic.
Summary
- The same
Runnablebody runs on the caller if called withrun(), and on a created thread if called withstart(); the difference is a single method name in the call syntax. isAlive()deterministically observes a thread’s state before starting, while running, and afterjoin(); a secondstart()call always throwsIllegalThreadStateException, regardless of the object’s state.- Interruption is a request, not a command: of three setups, only one (the one checking the flag in its loop condition) never finishes its body without an interruption request; the other two reach the same next step whether they see the request or not.
start()returning does not mean the body’s first line has run; no order is established between the two events, and this cannot be observed without a setup — the order is built by hand withCountDownLatchandjoin.
Next Step
This lesson answered the question “who is running” and showed that the answer to an
interruption request is written in the body’s own code. But in the Interruption setups, every
setup read a StringBuilder and a counter written by the body without using any synchronization
at all, because the same thread always both wrote and read the value — started.await() and
t.join() only established order, and the real mechanism that provided visibility was a
guarantee each of them already carried internally, never named in this lesson. The next lesson
removes this comfort: when the thread writing a value and the thread reading it are different,
what is guaranteed, and which side — only the writer, only the reader, or both — is required to
establish that guarantee?
To keep your progress and take notes, Log in
My notes
Log in to take notes.