CMD Guide
HomeConcurrencyConcurrency Foundations

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-clock1 core (time-sliced)2 cores (true parallel)
0–10 msA runs (B ready, waiting)A runs on core 0, B runs on core 1
10–20 msscheduler preempts A; B runsA on core 0, B on core 1
20–30 msA runsA on core 0, B on core 1
30–40 msB runsboth already finished at 30 ms
40–60 msA 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.

diagram
diagram

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:

#ThreadEvent
1mainw.start() returns at once; the JVM has asked the OS to create thread Thread-0 and schedule run().
2mainprints "main keeps running…" — this may print before, after, or interleaved with thread-1's line. The order is not defined.
3Thread-0run() executes, prints "thread 1 on Thread-0", then returns. The thread is now dead.
4mainw.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 ThreadGo goroutine
Maps to1: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 countthousandsmillions
"Wait for done"t.join() / Future.get()channel receive / sync.WaitGroup
Coordinate byshared memory + locks, wait/notifychannels (CSP); locks via sync when needed
Blocking I/Oblocks the OS thread it holdsruntime 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

Takeaways


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
CallThread.currentThread() inside bodyNew OS thread?Needs join for HB?
w.run()mainNoNo — already same thread
w.start()Thread-NYesYes — 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

🤖 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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes