> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Thread Join in Java: What join() Locks and Never Tells You
- URL: https://www.ggorantala.dev/java-thread-join/
- Published: 2026-10-14T02:31:00.000Z
- Updated: 2026-11-05T03:32:02.000Z
- Description: Thread join in Java waits on the Thread's own monitor, can't tell a timeout from success, and ignores failure. What join() promises, and what it hides.
- Author: Gopi Gorantala
- Tags: java-threads, java-concurrency, java-memory-model

`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):

```log
"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:

1. When it returns normally, one of three things is true: the thread terminated, the timeout you passed (to the `long` overloads) ran out, or the thread was never started. The `long` versions return `void`, so you can't tell which from the return value.
2. 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 `join` is 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 names `isAlive()` and `join()` as ways to detect it. 17.4.5 says all actions in a thread happen-before any other thread successfully returns from `join()` on it. (I covered what these edges mean in [the memory model article](https://ggorantala.dev/java-memory-model-happens-before/?ref=ggorantala.dev).)

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.

```log
$ java -version
openjdk version "21.0.12.1" 2026-08-18

```

**File 1: `Lock.java`.** One thread locks another thread's Thread object.

```java
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.

```java
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.

```java
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.**

```log
$ 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.**

```log
$ 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.**

```log
$ 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.**

```log
$ 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:

```log
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](https://ggorantala.dev/java-wait-notify-notifyall/?ref=ggorantala.dev), 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`: if `isAlive()`, convert to nanos and call `VirtualThread.joinNanos`. No `synchronized`, no `wait`. That's why the virtual case didn't care about the held monitor.
- Otherwise, `synchronized (this)`. With a positive timeout it records `System.nanoTime()`, then loops on `wait(remaining)` while `isAlive()` and time is left. With zero it's `while (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.

```log
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](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev): 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](https://ggorantala.dev/java-safe-publication/?ref=ggorantala.dev) 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](https://ggorantala.dev/java-executorservice/?ref=ggorantala.dev)             |
| 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](https://ggorantala.dev/java-structured-concurrency/?ref=ggorantala.dev) |

If you stay with raw threads, share the budget. `JoinAll.java`:

```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
    }
}

```

```log
$ 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

- `join` is `synchronized (this)` plus a `wait` loop on the Thread. Don't lock Thread objects; `start()` and thread exit need them too.
- `join(long)` returns `void`. Use `join(Duration)` (JDK 19) or check `isAlive()`.
- A returned join tells you the thread ended, not that it worked.
- After `isAlive()` is false or `join(Duration)` is `true`, 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.