Diving Deeper into Threads
A thread is a schedulable stream of instructions: the OS scheduler hands a core to one thread at a time, and the only state it must save and restore on a context switch is that thread's program counter (which instruction is next), its register set (live working values), and its stack pointer (the top of its private call stack) — everything else (heap, code, open files) is shared with sibling threads in the same process, which is exactly why a thread switch is cheap and a process switch is not.
That sharing is the whole point and the whole danger: threads run concurrently (their executions overlap in time), but they run in parallel only when there are enough free cores to give two of them a CPU at the same instant. On a single core, ten threads still make progress — the scheduler time-slices between them — but never two at once. Mixing up "concurrent" and "parallel" is the first mistake to unlearn here.
Concurrent vs. parallel: same threads, different hardware
Two threads, A and B, each running a 30 ms CPU-bound loop. The only thing that changes between the two runs below is the core count. Notice that wall-clock time is what differs — not the code.
| Wall-clock | 1 core (time-sliced) | 2 cores (true parallel) |
|---|---|---|
| 0–10 ms | A runs (B ready, waiting) | A runs on core 0, B runs on core 1 |
| 10–20 ms | scheduler preempts A; B runs | A on core 0, B on core 1 |
| 20–30 ms | A runs | A on core 0, B on core 1 |
| 30–40 ms | B runs | both already finished at 30 ms |
| 40–60 ms | A then B finish | — |
| Total | ~60 ms (concurrent, not parallel) | ~30 ms (parallel) |
Both runs are concurrent — A and B are both "in flight" the whole time. Only the right column is parallel. The thread code does not know or care which it got; the scheduler and the available cores decide.
Java: extend Thread, then trace start() and join()
The original page extended Thread and stopped — it never explained what start() and join() actually do, which is where the learning is. The two calls are not symmetric: start() creates a new OS thread and returns immediately; join() blocks the calling thread until the target finishes. The comment in the original even said "executes in parallel" — true only if a second core is free.
public class Worker extends Thread {
private final int id;
Worker(int id) { this.id = id; }
@Override
public void run() { // runs ON the new thread
System.out.println("thread " + id + " on " + Thread.currentThread().getName());
}
public static void main(String[] args) throws InterruptedException {
Worker w = new Worker(1);
w.start(); // (1) spawn a NEW OS thread; main keeps going
System.out.println("main keeps running after start()");
w.join(); // (2) main BLOCKS here until w.run() returns
System.out.println("main resumes after w finished");
}
}Trace of the four interleavings that matter, with the key fact at each step:
| # | Thread | Event |
|---|---|---|
| 1 | main | w.start() returns at once; the JVM has asked the OS to create thread Thread-0 and schedule run(). |
| 2 | main | prints "main keeps running…" — this may print before, after, or interleaved with thread-1's line. The order is not defined. |
| 3 | Thread-0 | run() executes, prints "thread 1 on Thread-0", then returns. The thread is now dead. |
| 4 | main | w.join(): if Thread-0 already finished, returns immediately; otherwise main parks until step 3 completes, then prints "main resumes…". This line is guaranteed last. |
Why the naive version is wrong
Two real bugs hide in the original snippet's pattern. First, calling w.run() instead of w.start() compiles and prints the same text — but it runs the body on the main thread, with zero new threads created. No concurrency at all; a silent no-op of threading. Second, dropping the join() means main can reach the end and the JVM can begin shutdown while run() is still mid-execution; you observe "completed" messages out of order or work that never finishes. join() is the only thing that establishes happens-before between the worker's writes and main's reads after it.
Go: the same logic with a goroutine and a channel
Go does not give you OS threads directly. A go f() launches a goroutine — a few-KB stack that the Go runtime multiplexes onto a small pool of OS threads (the M:N or "GMP" scheduler). You can have a million goroutines on a handful of OS threads; you cannot have a million Java Thread objects, because each maps to one OS thread with a ~1 MB stack.
package main
import "fmt"
func worker(id int, done chan<- int) {
fmt.Printf("goroutine %d running\n", id)
done <- id // send result; this is the synchronization point
}
func main() {
done := make(chan int) // unbuffered channel
go worker(1, done) // (1) spawn goroutine; main keeps going
fmt.Println("main keeps running after go")
id := <-done // (2) main BLOCKS until the goroutine sends — Go's join
fmt.Printf("main resumes; goroutine %d finished\n", id)
}The channel receive <-done plays the exact role of Java's join(): it blocks main until the goroutine reaches its send, and it establishes happens-before so the printed result is safely visible. Where Java threads coordinate by sharing memory and guarding it (locks, wait/notify), idiomatic Go coordinates by passing the value through a channel — "don't communicate by sharing memory; share memory by communicating."
Java threads vs. Go goroutines: where the runtimes differ
Java Thread | Go goroutine | |
|---|---|---|
| Maps to | 1:1 with an OS thread (pre-virtual-threads) | M:N — many goroutines onto few OS threads |
| Initial stack | ~512 KB–1 MB, fixed | ~2–8 KB, grows/shrinks dynamically |
| Practical count | thousands | millions |
| "Wait for done" | t.join() / Future.get() | channel receive / sync.WaitGroup |
| Coordinate by | shared memory + locks, wait/notify | channels (CSP); locks via sync when needed |
| Blocking I/O | blocks the OS thread it holds | runtime parks the goroutine, reuses the OS thread |
Note: Java 21+ adds virtual threads (Project Loom), which move Java much closer to the goroutine model — cheap, M:N-scheduled threads on a carrier pool. Classic Thread remains 1:1.
Pitfalls
- Calling
run()instead ofstart(). Compiles, runs the body on the current thread, spawns nothing. The bug is invisible until you wonder why there's no speedup and your "threads" share one stack. - Assuming threads run in parallel. On one core, or with more runnable threads than cores, they time-slice. A CPU-bound program with 100 threads on 4 cores runs ~4 at a time and adds context-switch overhead — often slower than 4 threads.
- Forgetting to
join()(Java) or read the channel/WaitGroup(Go). Main exits, results are lost, writes done by the worker may not be visible. Without the join/receive there is no happens-before edge. - User-level (green) threads block the whole process. If threads are scheduled purely in user space over one kernel thread, a single blocking syscall freezes every thread — the OS sees one runnable entity. This is why Go's runtime hands a blocking goroutine off and why pure user-level threading fell out of favor.
- Oversubscribing stacks. Each Java thread reserves ~1 MB of stack; 10,000 threads ≈ 10 GB of reserved address space and you hit
OutOfMemoryError: unable to create new native threadlong before CPU is the limit. Goroutines (KB stacks) and virtual threads sidestep this.
Takeaways
- A thread's private state is just stack + program counter + registers; everything else is shared with its process — that asymmetry is both the speed and the hazard.
- Concurrent means overlapping in time; parallel means literally simultaneous on separate cores. Concurrency is a structure; parallelism is a hardware outcome.
start()spawns and returns immediately;join()blocks the caller until the target dies and makes its results visible. In Go a channel receive (orWaitGroup.Wait) is the equivalent join.- Java
Threadis 1:1 with an OS thread (heavy, thousands); Go goroutines are M:N (light, millions). Pick threads per core for CPU-bound work; lean on goroutines/virtual threads for massive I/O concurrency.
Re-authored and deepened for this guide. Sources: Silberschatz, Galvin & Gagne, Operating System Concepts, 10th ed. (threads, user- vs kernel-level scheduling); Brian Goetz et al., Java Concurrency in Practice (thread lifecycle, join and happens-before); the Oracle Java SE documentation for java.lang.Thread and JEP 444 (virtual threads); Donovan & Kernighan, The Go Programming Language, ch. 8–9 (goroutines and channels); and the Go runtime scheduler design (GMP / M:N). The original page's claim that a thread "executes in parallel with other threads" and its untraced extends Thread snippet were corrected here.
Side-by-side output — run() vs start()
The silent non-concurrency of run() is the interview trap. Print thread names so the bug is undeniable.
// Bug: run() — still on main
Worker w = new Worker(1);
w.run(); // does NOT spawn
// Printed (always sequential, one thread):
// thread 1 on main
// main keeps running after run()
// main resumes after w finished // "join" is a no-op conceptually
// Correct: start() — new OS thread
Worker w2 = new Worker(2);
w2.start();
w2.join();
// Possible print (order of first two lines nondeterministic):
// main keeps running after start()
// thread 2 on Thread-0
// main resumes after w finished // always last after join
| Call | Thread.currentThread() inside body | New OS thread? | Needs join for HB? |
|---|---|---|---|
w.run() | main | No | No — already same thread |
w.start() | Thread-N | Yes | Yes — without join, main may exit early |
When not
Don't extend Thread in production code either — implement Runnable/Callable and submit to an ExecutorService. Extending couples work with threading policy.
Failure / ops
"We added threads but p99 unchanged and thread count stayed 1" → search for .run() on Thread/Runnable or for synchronous executor.execute wrappers that re-enter on the caller.
Drill ladder
- L1: What does
start()return beforerun()finishes? - L2: Show printed thread names that prove
run()never left main. - L3: Where does happens-before come from after
join()? - L4: Map
start/jointo Gogo+ channel receive.
🤖 Don't fully get this? Learn it with Claude
Stuck on Diving Deeper into Threads? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Diving Deeper into Threads** (Concurrency) and want to truly understand it. Explain Diving Deeper into Threads from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Diving Deeper into Threads** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Diving Deeper into Threads** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Diving Deeper into Threads** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.