On This Page
Thread.join() is a synchronized (this) block with a wait() loop inside it, and this is the Thread object you're waiting for. Almost everything odd about it follows from that one fact, including a worker that has finished all its work and is sitting in BLOCKED.
A worker that finished and is still BLOCKED
If you've only ever used thread join in Java as "wait for that thread", this dump will look wrong. It's from the program in the lab section, taken with jcmd <pid> Thread.print about 2.5 seconds in (trimmed to the interesting frames, OpenJDK 21.0.12.1):
"main" #1 [646] prio=5 ... waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
at java.lang.Thread.join(java.base@21.0.12.1/Thread.java:2068)
- waiting to lock <0x00000000a7bb2420> (a java.lang.Thread)
at java.lang.Thread.join(java.base@21.0.12.1/Thread.java:2155)
at Lock.main(Lock.java:21)
"worker" #19 [664] prio=5 ... waiting for monitor entry [0x0000000000000000]
java.lang.Thread.State: BLOCKED (on object monitor)
"holder" #20 [665] prio=5 ... waiting on condition
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(java.base@21.0.12.1/Thread.java:509)
at Lock.lambda$main$1(Lock.java:7)
- locked <0x00000000a7bb2420> (a java.lang.Thread)
worker's run() returned about 200 ms into the program. It has no frames left and it's BLOCKED. main is BLOCKED too, not WAITING, which is what you'd expect from a join. Both want the same address, 0xa7bb2420, and the one thread that owns it is holder, which locked the worker's Thread object and went to sleep for three seconds.
Nothing here is a deadlock. Three seconds later it all unwinds. But a thread that has finished its work can't finish dying until somebody else lets go of a lock on the Thread object, and that's a sentence I'd never have predicted I'd write about join.
What join promises, and what it leaves out
I'd always pictured join as a thin wrapper over the operating system waiting for a thread handle to signal. It isn't. It's plain Java monitor code, and it makes two promises:
- When it returns normally, one of three things is true: the thread terminated, the timeout you passed (to the
longoverloads) ran out, or the thread was never started. Thelongversions returnvoid, so you can't tell which from the return value. - If the thread did terminate, everything it did is visible to you afterwards. That's the Java Memory Model part, and it's the reason
joinis more than a delay. JLS 17.4.4 says the final action of a thread synchronizes-with any action in another thread that detects the termination, and namesisAlive()andjoin()as ways to detect it. 17.4.5 says all actions in a thread happen-before any other thread successfully returns fromjoin()on it. (I covered what these edges mean in the memory model article.)
It's also interruptible: InterruptedException comes out, and the interrupt flag is cleared on the way.
What it doesn't promise matters more in practice. It doesn't tell you whether the thread succeeded. An exception that escapes run() goes to the uncaught-exception handler (stderr, by default) and join returns as if nothing happened. It doesn't hand you a result or share one time budget across several joins. And it doesn't give you any edge at all for a timed join that came back with the thread still running; if you read that thread's data anyway, you're reading racily.
Three files and a dump, about five minutes
What you need. A JDK 21 or newer. I ran OpenJDK 21.0.12.1 (the Ubuntu 24.04 package) in a container with 2 vCPUs, x86_64. With SDKMAN, pick a Temurin 21 from sdk list java. No Maven, no JMH; these are single-file programs. Nothing here is core-count sensitive because every thread is sleeping, which is also why I can't tell you anything about ARM from this lab.
$ java -version
openjdk version "21.0.12.1" 2026-08-18
File 1: Lock.java. One thread locks another thread's Thread object.
public class Lock {
public static void main(String[] a) throws Exception {
boolean startFirst = a.length > 0 && a[0].equals("start-first");
Thread worker = new Thread(() -> { try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "worker");
Thread holder = new Thread(() -> {
synchronized (worker) {
try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}, "holder");
long t0 = System.nanoTime();
if (startFirst) {
worker.start();
holder.start();
Thread.sleep(50);
} else {
holder.start();
Thread.sleep(50);
worker.start(); // start() is synchronized on the Thread object too
System.out.println("start() returned at " + (System.nanoTime()-t0)/1_000_000 + " ms");
}
worker.join();
System.out.println("join() returned at " + (System.nanoTime()-t0)/1_000_000 + " ms; worker body needs ~200 ms");
}
}
File 2: Probes.java. Four small probes of join's edge cases.
import java.time.Duration;
public class Probes {
public static void main(String[] args) throws Exception {
switch (args[0]) {
case "never" -> {
Thread t = new Thread(() -> {});
t.join(); // never started: returns at once
System.out.println("join() on NEW thread returned, state=" + t.getState());
try { t.join(Duration.ofSeconds(1)); }
catch (IllegalThreadStateException e) { System.out.println("join(Duration) on NEW: " + e); }
}
case "timeout" -> {
Thread t = napper(300);
t.start();
t.join(100); // void: finished, or just gave up?
System.out.println("after join(100): alive=" + t.isAlive());
System.out.println("join(Duration 100ms) -> " + t.join(Duration.ofMillis(100)));
System.out.println("join(Duration 1s) -> " + t.join(Duration.ofSeconds(1)));
Thread z = napper(300);
z.start();
long s = System.nanoTime();
z.join(0); // 0 means forever
System.out.println("join(0) waited " + (System.nanoTime() - s) / 1_000_000 + " ms");
Thread d = napper(300);
d.start();
System.out.println("join(Duration.ZERO) -> " + d.join(Duration.ZERO));
d.join();
}
case "interrupt" -> {
Thread main = Thread.currentThread();
Thread t = napper(1000);
t.start();
Thread.ofPlatform().start(() -> { sleep(100); main.interrupt(); });
try { t.join(); }
catch (InterruptedException e) {
System.out.println("join interrupted; flag=" + main.isInterrupted() + " workerAlive=" + t.isAlive());
}
t.join();
}
case "vt" -> {
Thread v = Thread.ofVirtual().unstarted(() -> {});
synchronized (v) { // hold the virtual thread's own monitor
v.start();
sleep(200);
long s = System.nanoTime();
v.join();
System.out.println("virtual join under its monitor: " + (System.nanoTime() - s) / 1_000_000 + " ms");
}
Thread p = new Thread(() -> {});
synchronized (p) { // same thing, platform thread
p.start();
sleep(200);
System.out.println("platform: state=" + p.getState() + " alive=" + p.isAlive());
System.out.println("join(Duration 200ms) -> " + p.join(Duration.ofMillis(200)));
}
}
}
}
static Thread napper(long ms) { return new Thread(() -> sleep(ms)); }
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
File 3: Shards.java. A pretend settlement batch with four shards. Shard 2 is slow and shard 3 throws.
import java.time.Duration;
import java.util.*;
public class Shards {
static final long[] totals = new long[4]; // plain array, one slot per shard
static final long[] ms = {20, 40, 600, 60};
public static void main(String[] args) throws Exception {
boolean fixed = args.length > 0 && args[0].equals("fixed");
Thread[] shards = new Thread[4];
for (int i = 0; i < 4; i++) {
final int k = i;
shards[i] = new Thread(() -> {
try { Thread.sleep(ms[k]); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
if (k == 3) throw new IllegalStateException("shard 3: bad ledger row");
totals[k] = 1000L * (k + 1);
}, "shard-" + i);
shards[i].start();
}
List<String> late = new ArrayList<>();
for (Thread t : shards) {
if (!fixed) {
t.join(150); // void: timeout and success look the same
} else if (!t.join(Duration.ofMillis(150))) { // boolean: false = still running
late.add(t.getName());
}
}
System.out.println("late=" + late + " totals=" + Arrays.toString(totals) + " sum=" + Arrays.stream(totals).sum());
for (Thread t : shards) System.out.println(t.getName() + " " + t.getState());
}
}
Step 1: let someone else hold the worker's monitor.
$ java Lock.java
start() returned at 3001 ms
join() returned at 3202 ms; worker body needs ~200 ms
Look at the first line. worker.start() itself sat there for three seconds. Thread.start() is synchronized on the Thread object too (I confirmed the monitorenter at the top of start() with javap -c -p java.lang.Thread).
That cost me a wrong turn. My first version of this lab printed only the join time, measured after start() returned, and it said 200 ms. I spent a while deciding the lock theory was dead before I noticed start() had eaten the three seconds. The version above prints both.
Step 2: start the worker first, so only join and the thread's own exit are affected.
$ for i in 1 2 3 4 5; do java Lock.java start-first; done
join() returned at 3002 ms; worker body needs ~200 ms
join() returned at 3003 ms; worker body needs ~200 ms
join() returned at 3000 ms; worker body needs ~200 ms
join() returned at 3000 ms; worker body needs ~200 ms
join() returned at 3001 ms; worker body needs ~200 ms
5 of 5, within 3 ms of the hold time. A deterministic sleep makes this a demo of the mechanism, not a model of production timing.
Step 3: take the dump from section one. Run java Lock.java start-first &, wait about 2.5 seconds, then use jcmd -l to find the PID. The java Lock.java launcher shows up as jdk.compiler/...launcher.Main Lock.java start-first. (My first attempt used pgrep -f and grabbed the wrong process, so jcmd Thread.print printed nothing.) Then jcmd <pid> Thread.print.
Step 4: the edge-case probes.
$ java Probes.java never
join() on NEW thread returned, state=NEW
join(Duration) on NEW: java.lang.IllegalThreadStateException: Thread not started
$ java Probes.java timeout
after join(100): alive=true
join(Duration 100ms) -> false
join(Duration 1s) -> true
join(0) waited 300 ms
join(Duration.ZERO) -> false
$ java Probes.java interrupt
join interrupted; flag=false workerAlive=true
$ java Probes.java vt
virtual join under its monitor: 0 ms
platform: state=BLOCKED alive=true
join(Duration 200ms) -> true
Step 5: the shards.
$ java Shards.java
Exception in thread "shard-3" java.lang.IllegalStateException: shard 3: bad ledger row
at Shards.lambda$main$0(Shards.java:15)
at java.base/java.lang.Thread.run(Thread.java:1583)
late=[] totals=[1000, 2000, 0, 0] sum=3000
shard-0 TERMINATED
shard-1 TERMINATED
shard-2 TIMED_WAITING
shard-3 TERMINATED
$ java Shards.java fixed
... same stack trace ...
late=[shard-2] totals=[1000, 2000, 0, 0] sum=3000
Confirmation signal. For the lock lab, main shows BLOCKED (on object monitor) at Thread.join, with a waiting to lock ... (a java.lang.Thread) line. If instead you see WAITING (on object monitor) inside Object.wait below Thread.join, that's the normal case, a join on a live worker, and nobody holds anything. The lab's hold is 3 seconds, so the BLOCKED picture only exists for those 3 seconds.
Environment differences. Nothing in these probes depends on core count. On a virtual thread the join path is different (more below), which is why Probes.java vt prints 0 ms for the virtual case and the platform case is the one that needs the monitor. I didn't run JDK 22 or newer. The timing is sleep-based, so results are stable from run to run; I saw no variance beyond 3 ms across the 5 runs.
Cleanup. jps or jcmd -l, then kill any leftover Lock.java. Delete the scratch directory. Nothing else is written.
Who wakes whom when a thread dies
Here's the sequence in the start-first run, which is the one that matches the dump:
t=0 main worker.start(); holder.start()
t~0 holder takes the worker's monitor, sleeps 3000 ms
t=50 main worker.join() -> enters Thread.join, tries synchronized(worker) BLOCKED
t=200 worker run() returns. Dying thread needs worker's monitor to flip to
"terminated" and wake joiners -> BLOCKED, no frames
t=3000 holder releases the monitor
t~3000 main (or worker) gets it; main re-checks isAlive(), still true, calls wait()
t~3000 worker gets monitor, becomes not-alive, notifyAll() on itself
t~3003 main wakes, loop re-checks isAlive() == false, join returns
I should say what I know and what I inferred. I saw the BLOCKED worker with no frames in the dump, and the 3000 ms result. The Javadoc says the VM does the notifyAll on termination, and the join code I disassembled has no notifyAll of its own. I didn't read HotSpot's thread-exit code this run, so "the dying thread needs the monitor to do that" is my reading of the dump and the timing, which together fit it exactly.
The loop matters. join calls wait and then checks isAlive() again before leaving. So a stray notifyAll from some other code on the worker's Thread object doesn't cut the wait short. I tried it: 300 ms worker, a second thread calls t.notifyAll() inside synchronized (t), and main still waited the full 300 ms. That's the guarded-wait idiom from the wait/notify article, applied by the JDK to itself.
One more thing the dump explains. In the vt probe the platform case printed state=BLOCKED alive=true while main held the thread's monitor, and then join(Duration 200ms) returned true in about 2 ms. That looks like it should block, since main owns the monitor the dying thread wants. It doesn't, because join runs wait, and wait releases all of the caller's holds on that monitor, including the outer synchronized (p) I wrapped around it. The dying thread gets in, finishes, notifies. Reentrancy hid the hold from join, and wait gave it away.
I lost most of a day and a half on this one, mostly to the start() detour and to the lab that kept "disproving" the monitor. It's written up so you don't have to.
Reading Thread.java: one loop, two paths
src.zip was missing in my container and GitHub's raw host was blocked from the shell, so I read the bytecode of java.lang.Thread with javap -c -p. A fetch agent's summary of the source for jdk-21-ga and jdk-25-ga was truncated before join, so I'm treating that as a pointer and trusting the disassembly for JDK 21. Here's what join(long) does on 21.0.12.1:
- A negative timeout throws
IllegalArgumentException("timeout value is negative")first. - If
this instanceof VirtualThread: ifisAlive(), convert to nanos and callVirtualThread.joinNanos. Nosynchronized, nowait. That's why the virtual case didn't care about the held monitor. - Otherwise,
synchronized (this). With a positive timeout it recordsSystem.nanoTime(), then loops onwait(remaining)whileisAlive()and time is left. With zero it'swhile (isAlive()) wait(0).
Zero means forever, which is why join(0) waited the full 300 ms in the probe. And join() is join(0).
Thread.start() also starts with monitorenter, and throws IllegalThreadStateException if the thread was already started.
Java 5 JSR 133 memory model (the join/isAlive edges are in JLS ch.17 today; checked on SE 21)
JDK 19 join(Duration): returns boolean, throws IllegalThreadStateException if
the thread was never started (verified on 21; "since 19" from the Javadoc)
JDK 21 virtual threads final; join has a separate joinNanos path (verified)
Feb 2026 core-libs RFR for JDK-8378268: platform and virtual join share one
wait-on-Thread loop, skip notifyAll at exit when nobody joined
JDK 25 I could not read join's body; not run
On that last item: the JBS issue says unresolved with no fix version when my agent read it, and the mailing-list thread (Alan Bateman reviewing a Viktor Klang PR) says the special virtual-thread path was needed because Object.wait used to pin a carrier, and isn't any more. That's the JEP 491 change, which I believe shipped in JDK 24; I didn't re-verify that number today. If that change lands, join on a virtual thread will take the same monitor as on a platform thread. Code that does synchronized (someVirtualThread) and joins it from the same thread would start behaving like the platform probe above. Rare, but you now know what to retest.
There's also JDK-6347010, an old spec bug about this very hazard: because join waits on the Thread object and the VM notifies it at termination, code that uses wait/notify on a Thread can be woken by a death it never asked about. I couldn't read its resolution from the page I got, so I'm only citing the title.
Where thread join in Java lies by omission
The timeout that looks like success. This is the Shards.java result. Four shards, join(150) each, and the first run prints late=[] totals=[1000, 2000, 0, 0] sum=3000. Shard 2 was still sleeping (TIMED_WAITING), shard 3 threw, and the only evidence of that is a stack trace on stderr. The total is short by 7,000, and nothing in the control flow noticed. Picture a nightly settlement job that logs sum=3000 and moves on. That's an illustration, not an incident I'm reporting.
There are two bugs stacked here, and they're different. join(long) can't say whether it timed out. And join has no channel for "it failed", so a thread that died with an exception looks the same as one that completed.
Budgets that multiply. Four sequential join(150) calls give a worst case of 600 ms, not 150. Every slow shard gets its own fresh 150. If you're guarding an SLA, that's the part that bites.
Interrupt clears the flag and doesn't stop the worker. In the probe, main got InterruptedException, the flag read false afterwards, and the worker was still alive. The classic bug is catching it, printing, and carrying on as if join had finished. Rule is in the interruption article: restore the flag or propagate.
Joining yourself. Thread.currentThread().join() never returns, because you're alive for as long as you're waiting. The dump is WAITING (on object monitor) at Thread.join, and it looks like an ordinary join. If a "stuck join" has your own thread's name on the monitor, that's it.
Using Thread objects as locks. synchronized (thread) or thread.notifyAll(). Both legal, and all of them share a monitor with join and start, and with the thread's exit path. Anything slow inside synchronized (worker) stalls those three, as in section one. The join Javadoc's implementation note steers you away from it (that's from a summary of the page I read, not the exact wording), which I found after I'd done it.
The thing I'd call cargo-cult: t.join(1000) followed by reading t's results with no isAlive() check. It works in tests because the worker is fast. In production, with a slow dependency, you read half-written state and, per the spec, you have no happens-before edge to promise otherwise.
I also wanted a lab where a missing join produces a stale read. I couldn't make one on x86 in this container, same as my earlier safe-publication attempt that showed 0 violations. The argument for the edge rests on the JLS, not on a jcstress outcome, because I didn't run jcstress.
What I'd write now, and what each choice costs
A raw join is fine for one or two threads you started and care about, if you check the result after. Past that, you want something that can carry results and failures.
| Option | You get | It costs |
|---|---|---|
join(Duration) plus isAlive() |
Timeout you can see, edge once the thread is not alive | You collect results and failures yourself |
ExecutorService + Future.get(timeout) |
Result and exception come back to you | A pool to manage and shut down; see the executor article |
CountDownLatch |
Waits for N events, not N threads | No exception; edge is countDown to await, not thread death |
StructuredTaskScope |
One scope: join, failure, cancel | Preview API on 21; check your JDK, see structured concurrency |
If you stay with raw threads, share the budget. JoinAll.java:
import java.time.Duration;
import java.util.*;
public class JoinAll {
/** Waits for all threads within ONE shared budget. Returns the names of threads still running. */
static List<String> joinAll(List<Thread> threads, Duration budget) throws InterruptedException {
long deadline = System.nanoTime() + budget.toNanos();
List<String> stillRunning = new ArrayList<>();
for (Thread t : threads) {
long left = deadline - System.nanoTime();
if (left > 0) t.join(Duration.ofNanos(left));
if (t.isAlive()) stillRunning.add(t.getName()); // false here also gives the happens-before edge
}
return stillRunning;
}
public static void main(String[] args) throws Exception {
long[] ms = {20, 40, 600, 60};
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < ms.length; i++) {
long d = ms[i];
Thread t = new Thread(() -> {
try { Thread.sleep(d); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "shard-" + i);
t.start();
ts.add(t);
}
long s = System.nanoTime();
List<String> late = joinAll(ts, Duration.ofMillis(150));
System.out.println("late=" + late + " after " + (System.nanoTime() - s) / 1_000_000 + " ms (budget 150)");
for (Thread t : ts) t.join(); // let the slow one finish before exit
}
}
$ java JoinAll.java
late=[shard-2] after 151 ms (budget 150)
Same on 3 of 3 runs, 150 to 151 ms. A shared deadline means total time is bounded; checking isAlive() after the join means you only read the results of threads that are provably finished. One sentence on why: isAlive() returning false is itself a way of detecting termination under JLS 17.4.4, so it carries the edge.
Capture failures on purpose. Give each thread an UncaughtExceptionHandler that stores the exception somewhere you check, or let a Future do it for you. Either way, "joined" and "succeeded" are two separate questions.
Dumps, tests, and the upgrade you're about to do
In a thread dump, WAITING (on object monitor) with Object.wait under Thread.join is a healthy join on a live worker. BLOCKED (on object monitor) at Thread.join with waiting to lock ... (a java.lang.Thread) means someone else holds that Thread object's monitor; search the dump for locked <same address>. Take two dumps a few seconds apart so you can tell a stuck join from a slow worker. A worker BLOCKED with no frames, as in section one, is a thread that finished and can't exit.
For a test: after the join, assertFalse(worker.isAlive()), and make the test fail on a timeout rather than letting it report a pass. For batch code, a test where one worker throws and the batch must report failure would have caught the Shards.java problem on day one.
What to watch on upgrade: the 21 behaviour I measured is that a virtual thread join ignores the thread's monitor (0 ms), and that a platform join takes it. If JDK-8378268 lands in your target release, retest anything that locks a Thread object. Don't depend on notifyAll at termination for any logic of yours; the RFR says the JDK may skip it when no one has joined. I ran none of this on JDK 22 to 25, or on ARM.
The short list
joinissynchronized (this)plus awaitloop on the Thread. Don't lock Thread objects;start()and thread exit need them too.join(long)returnsvoid. Usejoin(Duration)(JDK 19) or checkisAlive().- A returned join tells you the thread ended, not that it worked.
- After
isAlive()is false orjoin(Duration)istrue, the thread's writes are visible. After a timed-out join, they aren't promised. - Sequential timeouts add up. Use one deadline.
Questions that come up in review
Does Thread.join() make a thread's writes visible to me?
Yes, once the thread has terminated and you've detected it by join() or isAlive() returning false (JLS 17.4.4, 17.4.5). After a timed join that left the thread running, no.
What does join() do on a thread that was never started?
On JDK 21 the long overloads return immediately (my probe: state stays NEW). join(Duration) throws IllegalThreadStateException("Thread not started").
How do I tell if join(timeout) timed out?
join(long) can't tell you. Check isAlive() afterwards, or use join(Duration) from JDK 19, which returns true when the thread terminated.
Can join() block forever?
Yes. join() and join(0) wait without limit: a worker that never ends or a monitor someone else holds for good. Joining the current thread does it too.