Java Concurrency & Multithreading — Complete Notes

Threads, locks, visibility, the memory model, executors, CompletableFuture and virtual threads — built up from why shared memory is dangerous, through to the tools that make it safe. This is where backend interviews get serious.

00. Why any of this exists

Concurrency is structuring a program so several things can be in progress at once. Parallelism is actually executing them at the same instant on different cores. You can have concurrency on a single core; parallelism needs real hardware.

Concurrency is one cook juggling three pans — chopping while the water boils, switching between tasks so nothing sits idle. Parallelism is three cooks, one pan each. Concurrency is about dealing with many things at once; parallelism is about doing many things at once.

You reach for threads for exactly two reasons:

Goal The problem What helps
Throughput (I/O-bound) A thread waiting on the network is a wasted CPU Many threads, so someone always has work
Speed (CPU-bound) One core is doing all the work; the other 7 idle Split the work across cores

Process vs thread

Process Thread
Memory Its own, isolated Shared with sibling threads
Cost to create Heavy Lighter (~1MB stack each, still not free)
Communication IPC, sockets, pipes — explicit Just read the same variable — implicit
A crash… Kills only itself Can take the whole process down
The one sentence that explains every bug in this document

Threads share memory by default. That's what makes them fast, and it's what makes them dangerous — two threads touching the same variable with no coordination is the root of every race condition, visibility bug and deadlock you will ever write.

Coming from Go

Go's motto is "share memory by communicating" — goroutines pass data over channels. Java's default is the opposite: "communicate by sharing memory", and it's your job to guard it. The mapping is roughly: goroutine → thread (or virtual thread, which is much closer) · channel → BlockingQueue · sync.Mutexsynchronized/ReentrantLock · sync/atomicjava.util.concurrent.atomic · WaitGroupCountDownLatch · go f()executor.submit(f). Java's race detector equivalent is… discipline. There is no -race flag.

01. Threads — creating and controlling

A thread is an independent path of execution with its own call stack, running inside your process and sharing its heap.

Three ways to start one — prefer the third
// 1. Extend Thread — DON'T. Burns your single inheritance slot, couples task to mechanism.
class MyThread extends Thread {
    public void run() { System.out.println("running"); }
}
new MyThread().start();

// 2. Implement Runnable — the task is a separate thing from the thread running it
class MyTask implements Runnable {
    public void run() { System.out.println("running"); }
}
new Thread(new MyTask()).start();

// 3. A lambda + an executor — what you should actually write (see section 08)
executor.submit(() -> System.out.println("running"));
start() vs run() — the classic trick question

start() asks the JVM for a new thread, which then calls run(). Calling run() directly is just an ordinary method call on the current thread — no concurrency at all, and no error to tell you. Also: calling start() twice throws IllegalThreadStateException. A thread is not reusable.

Proof
Runnable task = () -> System.out.println(Thread.currentThread().getName());

new Thread(task).start();   // prints "Thread-0"  — a real new thread
new Thread(task).run();     // prints "main"      — no thread was created!

The six thread states

Thread.State — and what moves you between them
     NEW                          created, start() not yet called
      |  start()
      v
   RUNNABLE  <-------------+     runnable or running (Java doesn't distinguish)
      |                    |
      |  wants a lock      | lock acquired
      v                    |
   BLOCKED  --------------+      waiting to enter a synchronized block
      |
      |  wait() / join() / park()
      v
   WAITING  ---------------+     waiting indefinitely for another thread
      |  notify()/notifyAll()/interrupt()
      |
      |  wait(t) / sleep(t) / join(t)
      v
 TIMED_WAITING ----------+       same, but with a deadline
      |
      v
  TERMINATED                     run() finished (or threw)
State Meaning
NEW Object exists, start() not called
RUNNABLE Eligible to run — may or may not be on a CPU right now
BLOCKED Waiting for a monitor lock (i.e. to enter synchronized)
WAITING wait(), join(), LockSupport.park() — no timeout
TIMED_WAITING sleep(n), wait(n), join(n), tryLock(n)
TERMINATED Finished. Cannot restart.
BLOCKED vs WAITING

BLOCKED = "I want a lock someone else holds" — nobody has to do anything, I'll get in when they leave. WAITING = "I've been told to wait and I need someone to actively wake me" (notify, signal, unpark). A WAITING thread with nobody to wake it waits forever. This distinction is the first thing you look for in a thread dump.

Essential thread methods

What you'll actually call
Thread.sleep(1000);            // pause THIS thread ~1s. Holds any locks it owns! (see gotchas)
t.join();                      // block until t finishes
t.join(500);                   // ...or 500ms, whichever comes first
t.interrupt();                 // politely ASK t to stop — see below
Thread.currentThread();        // who am I
t.setDaemon(true);             // JVM won't wait for this thread to exit (MUST be before start())
t.setName("worker-1");         // do this — it makes thread dumps readable
Thread.yield();                // "I'd share the CPU" — a hint, usually useless
Interruption is a request, not a command

Java has no way to kill a thread. t.interrupt() just sets a flag. If the thread is blocked in sleep/wait/join, it throws InterruptedException and clears the flag. Otherwise the thread must check Thread.interrupted() itself. Cooperative cancellation is the only cancellation there is. (Thread.stop() exists, is deeply broken, and was removed.)

Handling interruption correctly — the two valid options
// Option A — let it propagate (best, if your signature allows it)
void work() throws InterruptedException {
    Thread.sleep(1000);
}

// Option B — you can't rethrow (e.g. inside Runnable.run) -> RESTORE THE FLAG
public void run() {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();   // put the flag back!
        return;                               // and actually stop
    }
}

// WRONG — swallows the request. Your app now won't shut down.
try { Thread.sleep(1000); } catch (InterruptedException e) { }   // never do this
A cancellable loop
public void run() {
    while (!Thread.currentThread().isInterrupted()) {
        doChunkOfWork();
    }
    cleanup();
}

02. The problem — shared mutable state

Every concurrency bug traces back to one of three things: atomicity (an operation isn't as indivisible as it looks), visibility (a thread never sees another's write), and ordering (things didn't happen in the order you wrote them).

Problem 1: atomicity — i++ is a lie

One character of source, three operations in bytecode
count++;

// is really:
//   1. READ  count from memory        (getfield)
//   2. ADD   1 to the value           (iadd)
//   3. WRITE the result back          (putfield)
//
// Another thread can interleave at ANY point between them.
The lost update — watch two increments produce one
// count = 0

// Thread A            | Thread B
// read count  -> 0    |
//                     | read count  -> 0      <- B reads before A wrote
// add 1       -> 1    |
//                     | add 1       -> 1
// write 1             |
//                     | write 1               <- overwrites A's work
//
// count == 1. Two increments happened. One vanished.
Run it yourself — it fails every time
class Counter {
    private int count = 0;
    public void increment() { count++; }
    public int get() { return count; }
}

Counter c = new Counter();
Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.increment(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.increment(); });
t1.start(); t2.start();
t1.join();  t2.join();

System.out.println(c.get());   // Expected 200000. Actual: 137482. Or 189301. Never the same twice.
Race condition

A race condition is when the result depends on the timing of threads. The section of code that touches shared state is the critical section. Fixing a race means making the critical section atomic — indivisible from any other thread's point of view.

The other classic: check-then-act

if (map.get(k) == null) map.put(k, v); is a race even with a thread-safe map. Both operations are individually atomic, but another thread can slip in between them. Lazy initialisation (if (instance == null) instance = new …) is the same bug. You need one atomic operation: map.putIfAbsent(k, v) or map.computeIfAbsent(k, …).

Problem 2: visibility — the write that never arrives

This one is genuinely counter-intuitive. Even with no interleaving at all, a thread may never see another thread's write. Not "later" — never.

An infinite loop that shouldn't be possible
class Task {
    private boolean running = true;      // no volatile!

    public void stop() { running = false; }

    public void run() {
        while (running) { }              // may spin FOREVER, even after stop() is called
        System.out.println("stopped");
    }
}
// Main thread calls stop(). The worker thread may never notice — and often doesn't
// once the JIT warms up. Adding `volatile` to `running` fixes it completely.
Why the write is invisible

Each CPU core has its own cache. A write may sit in Core A's cache and never reach Core B. On top of that, the JIT compiler sees a loop that never modifies running and legally hoists the read out: if (running) while (true) { }. Both are allowed, because without synchronisation the JVM makes no promise at all that one thread's write is visible to another.

You and a colleague each have a private notepad copy of the shared whiteboard. You cross out "running" on your pad. Unless someone walks to the whiteboard and syncs, your colleague keeps reading "running" off their own pad forever. volatile and locks are the rule that says "always read and write the actual whiteboard."

Problem 3: reordering

The compiler and CPU may legally reorder your code
// You wrote:              // Another thread might observe:
a = 1;                     //   flag = true;     <- reordered ahead!
flag = true;               //   a = 1;

// So a reader that sees flag == true can still read a == 0.
// Single-threaded semantics are preserved; cross-thread ones are not.
The rule that permits all this

The JVM only guarantees as-if-serial semantics — the result must look correct to the thread doing it. Any reordering invisible to that single thread is fair game, and the hardware does it aggressively. Other threads get no guarantees whatsoever unless you establish a happens-before relationship (section 05).

03. synchronized — the built-in lock

synchronized guarantees that only one thread at a time runs the block, and that everything the previous holder did is visible to the next. It fixes atomicity and visibility together.

The counter, fixed
class Counter {
    private int count = 0;
    public synchronized void increment() { count++; }   // one thread at a time
    public synchronized int get() { return count; }     // MUST also be synchronized — see below
}
// Now always prints exactly 200000.
The reader must synchronize too

Synchronising only the writer is a classic half-fix. Locking gives visibility only between threads that use the same lock. An unsynchronised get() has no happens-before edge with increment(), so it may read a stale value forever. Every access to shared mutable state — reads included — must be synchronised.

Every object has a lock

Every Java object carries a hidden monitor (or intrinsic lock). synchronized acquires it on entry and releases it on exit — including when an exception is thrown, so you can never leak an intrinsic lock.

What each form actually locks
class Example {

    public synchronized void a() { }        // locks `this`

    public static synchronized void b() { } // locks Example.class — a DIFFERENT lock from `this`!

    public void c() {
        synchronized (this) { }             // identical to a()
    }

    private final Object lock = new Object();
    public void d() {
        synchronized (lock) { }             // locks a private object — usually the best choice
    }
}
Instance and static locks are unrelated

A synchronized instance method locks this; a static synchronized method locks the Class object. They are two completely separate locks — so an instance method and a static method can run at the same time and happily corrupt the same static field. This trips people up constantly.

Prefer a private lock object
// Risky: `this` is public, so ANY outside code can lock your object and stall you
class Bad {
    public synchronized void work() { }
}
Bad b = new Bad();
synchronized (b) { Thread.sleep(60_000); }   // outsider just froze every call to work()

// Safe: nobody outside can reach the lock
class Good {
    private final Object lock = new Object();   // final! reassigning a lock breaks everything
    public void work() { synchronized (lock) { } }
}

Synchronized is reentrant

A thread can re-enter a lock it already holds
class Reentrant {
    public synchronized void outer() {
        inner();          // already hold the lock — walk straight in. No deadlock.
    }
    public synchronized void inner() { }
}
// The JVM keeps a hold COUNT: +1 on entry, -1 on exit. Released at zero.
// Without reentrancy, any object calling its own synchronized method would deadlock with itself.
Keep critical sections small

A lock serialises threads — everything inside it is single-threaded, so it's a direct cap on throughput. Never do I/O, call unknown code, or sleep while holding a lock. Calling a callback or an overridable method inside a lock is an "alien method" and a classic deadlock source: you have no idea what locks it will grab.

Lock the update, not the work
// BAD — the network call holds the lock for 200ms
public synchronized void update(String id) {
    Data d = httpClient.fetch(id);       // slow! Everyone else is blocked.
    cache.put(id, d);
}

// GOOD — do the slow part outside, lock only the shared bit
public void update(String id) {
    Data d = httpClient.fetch(id);       // no lock held
    synchronized (lock) {
        cache.put(id, d);                // microseconds
    }
}

04. volatile — visibility only

volatile makes a field's writes immediately visible to every thread, and forbids reordering around it. It does not make anything atomic.

The correct use — a stop flag
class Worker implements Runnable {
    private volatile boolean running = true;   // now the write is guaranteed to be seen

    public void stop() { running = false; }

    public void run() {
        while (running) { doWork(); }          // terminates promptly. Guaranteed.
    }
}
What volatile guarantees — and what it doesn't

Yes: reads/writes go to main memory, never a stale cache · reads/writes are atomic even for long/double · no reordering across it (it's a memory barrier). No: it does not make count++ atomic — that's still read-modify-write, three operations, each individually visible but collectively racy.

The trap — volatile does not fix a counter
private volatile int count = 0;
public void increment() { count++; }   // STILL BROKEN. Still loses updates.

// Because it's read, add, write — and volatile only makes each STEP visible,
// not the three of them indivisible.

// Fixes: synchronized, or AtomicInteger:
private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }   // correct
volatile synchronized
Visibility Yes Yes
Atomicity of compound ops No Yes
Blocks threads No — never blocks Yes
Applies to A single field A block or method
Cost Cheap (a memory barrier) More expensive (may park threads)
When volatile is enough

Use it when exactly one of these holds: the write doesn't depend on the current value (a flag, a reference swap), or only one thread ever writes. If the new value is computed from the old one, you need atomics or a lock. The long/double point matters too: on 32-bit JVMs, non-volatile 64-bit reads/writes may be torn into two halves — volatile forbids that.

05. The Java Memory Model — happens-before

The Java Memory Model (JMM) is the contract that says exactly when one thread's writes must be visible to another. Its entire vocabulary is one relation: happens-before.

What happens-before means

If action A happens-before B, then everything A did is guaranteed visible to B, and cannot be reordered after it. If there is no happens-before edge between two actions on shared data, and at least one is a write, you have a data race — and the JMM promises you nothing at all. Not "usually works". Nothing.

The rules that create an edge

Rule A happens-before B
Program order Earlier statement → later statement, within one thread
Monitor lock unlock on M → a later lock on the same M
Volatile A write to v → every later read of v
Thread start Everything before t.start() → everything inside t
Thread join Everything in t → whatever follows t.join()
Final fields Constructor finishing → any thread seeing a correctly published reference
Transitivity A → B and B → C implies A → C
How volatile publishes more than just itself
class Publisher {
    private int data;                 // plain field — NOT volatile
    private volatile boolean ready;   // volatile flag

    void writer() {
        data = 42;              // (1)
        ready = true;           // (2) volatile WRITE
    }

    void reader() {
        if (ready) {            // (3) volatile READ
            System.out.println(data);   // (4) GUARANTEED to print 42 — never 0
        }
    }
}
// Why: (1) happens-before (2) by program order.
//      (2) happens-before (3) by the volatile rule.
//      (3) happens-before (4) by program order.
//      Transitivity => (1) happens-before (4). `data` is visible even though it's not volatile.
// This is the "volatile piggyback": the barrier flushes EVERYTHING written before it.
Safe publication via start() and join()
int x = 10;                              // written before start()
Thread t = new Thread(() -> System.out.println(x));   // sees 10, guaranteed
t.start();

// --- and the other direction ---
Thread t2 = new Thread(() -> result = compute());
t2.start();
t2.join();                 // everything t2 did happens-before here
System.out.println(result);   // guaranteed to see it

Final fields and safe publication

Why final is a concurrency tool, not just a style choice
class Safe {
    private final int x;
    Safe(int x) { this.x = x; }
}
// The JMM guarantees: any thread that sees a Safe reference sees a fully-constructed
// object with x already set. No synchronisation needed. This is why immutable objects
// are automatically thread-safe.

class Unsafe {
    private int x;             // NOT final
    Unsafe(int x) { this.x = x; }
}
// Another thread can see a non-null Unsafe reference but still read x == 0,
// because the constructor's write can be reordered with the reference publication.
Never let this escape a constructor

Registering a listener, starting a thread, or storing this anywhere inside a constructor publishes a half-built object. Other threads can observe fields that aren't assigned yet — including final ones. Use a static factory that constructs first and registers after.

The three ways to be thread-safe

1. Don't share — thread confinement, locals, ThreadLocal. 2. Don't mutate — immutable objects with final fields need no synchronisation at all. 3. Synchronise — if you must share mutable state, guard every access with the same lock. In that order of preference.

06. Atomics and CAS

The java.util.concurrent.atomic classes give you lock-free, atomic read-modify-write on a single variable — built on one CPU instruction: compare-and-swap.

AtomicInteger — the counter, done right and fast
AtomicInteger count = new AtomicInteger(0);

count.incrementAndGet();         // ++count  -> returns the NEW value
count.getAndIncrement();         // count++  -> returns the OLD value
count.addAndGet(5);
count.get();
count.set(10);
count.compareAndSet(10, 20);     // if it's 10, make it 20. Returns whether it worked.
count.updateAndGet(x -> x * 2);  // arbitrary function, applied atomically
count.accumulateAndGet(5, Integer::sum);

How CAS works

Compare-and-swap — optimistic, not pessimistic
// CAS(memoryLocation, expectedValue, newValue):
//   atomically, in ONE uninterruptible CPU instruction:
//   "if the value here is still `expected`, replace it with `new` and report success;
//    otherwise change nothing and report failure."

// incrementAndGet() is essentially:
int current;
do {
    current = get();                              // read
} while (!compareAndSet(current, current + 1));   // try to swap; if someone beat us, LOOP and retry
return current + 1;
Optimistic vs pessimistic

A lock is pessimistic: "assume conflict, so block everyone else first." CAS is optimistic: "assume no conflict, do the work, and check at the last instant — if I was beaten, just try again." Nobody blocks, no thread ever parks, and there's no context switch. Under low contention this is dramatically faster.

Editing a shared doc. Locking = check the doc out so nobody else can touch it. CAS = edit your copy, then try to save: "was it still version 7 when I saved? Yes → saved. No → someone else committed; discard my attempt and redo it on version 8."

CAS is not free under contention

Every failed CAS means a wasted loop iteration. With many threads hammering one variable, threads spin burning CPU and a lock can actually win. That's what LongAdder is for: it keeps multiple internal cells, so threads update different memory and only sum() combines them. For a hot counter, prefer it over AtomicLong.

The atomic family
AtomicInteger / AtomicLong / AtomicBoolean
AtomicReference<T>                     // CAS on an object reference
AtomicIntegerArray / AtomicLongArray    // per-element atomicity
LongAdder / LongAccumulator             // high-contention counters — much faster
AtomicStampedReference<T>              // reference + version stamp -> defeats the ABA problem

// AtomicReference: swap an immutable object atomically
AtomicReference<Config> cfg = new AtomicReference<>(initial);
cfg.updateAndGet(c -> c.withTimeout(30));
The ABA problem

CAS only asks "is the value the same?", not "was it never changed?". If a value goes A → B → A, your CAS succeeds even though the world moved underneath you — which matters for lock-free stacks and linked structures. AtomicStampedReference attaches a version counter so A-with-stamp-1 and A-with-stamp-3 are distinguishable.

07. Explicit locks

ReentrantLock does everything synchronized does, plus the things it can't: timeouts, interruptible waits, fairness, and multiple wait-sets.

The mandatory shape — unlock in finally, always
private final ReentrantLock lock = new ReentrantLock();

public void work() {
    lock.lock();
    try {
        // critical section
    } finally {
        lock.unlock();     // MUST be in finally — an exception would leak the lock forever
    }
}
This is the one real downside of explicit locks

synchronized releases automatically on any exit, including a thrown exception. With ReentrantLock, forgetting finally means the lock is never released and every other thread blocks forever. Never put lock() inside the try — if it throws, you'd unlock a lock you never acquired (IllegalMonitorStateException).

What you get that synchronized cannot do
// 1. Try, and give up immediately rather than blocking
if (lock.tryLock()) {
    try { work(); } finally { lock.unlock(); }
} else {
    doSomethingElse();          // no waiting around
}

// 2. Try with a timeout — the single best deadlock defence there is
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try { work(); } finally { lock.unlock(); }
} else {
    log.warn("could not acquire lock in 500ms — backing off");
}

// 3. Interruptible acquisition (synchronized blocks are NOT interruptible)
lock.lockInterruptibly();

// 4. Fairness — longest waiter goes first (slower, but no starvation)
ReentrantLock fair = new ReentrantLock(true);

ReadWriteLock — many readers, one writer

When reads massively outnumber writes
private final ReadWriteLock rw = new ReentrantReadWriteLock();
private final Map<String,String> cache = new HashMap<>();

public String get(String k) {
    rw.readLock().lock();          // MANY threads can hold this at once
    try { return cache.get(k); }
    finally { rw.readLock().unlock(); }
}

public void put(String k, String v) {
    rw.writeLock().lock();         // EXCLUSIVE — blocks all readers and writers
    try { cache.put(k, v); }
    finally { rw.writeLock().unlock(); }
}
Measure before reaching for it

A ReadWriteLock only pays off when reads are frequent and long. Its bookkeeping is heavier than a plain lock, so for short reads a ReentrantLock often wins — and ConcurrentHashMap beats both for the cache case above. Note you cannot upgrade a read lock to a write lock (that deadlocks); you must release and re-acquire.

Condition — wait/notify, done properly

A bounded buffer with two separate wait-sets
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull  = lock.newCondition();   // separate queues — this is the win
private final Condition notEmpty = lock.newCondition();
private final Queue<T> q = new ArrayDeque<>();
private final int cap = 10;

public void put(T item) throws InterruptedException {
    lock.lock();
    try {
        while (q.size() == cap) notFull.await();   // ALWAYS in a while, never an if
        q.add(item);
        notEmpty.signal();                          // wake only a consumer
    } finally { lock.unlock(); }
}

public T take() throws InterruptedException {
    lock.lock();
    try {
        while (q.isEmpty()) notEmpty.await();
        T item = q.poll();
        notFull.signal();                           // wake only a producer
        return item;
    } finally { lock.unlock(); }
}
Always wait in a while loop, never an if

Two reasons. Spurious wakeups: the JVM is permitted to wake a waiting thread for no reason at all. Stolen conditions: by the time you reacquire the lock, another thread may have already consumed what you were woken for. The condition must be re-checked after waking — which is exactly what while does.

The old intrinsic equivalent — same rules
synchronized (lock) {
    while (!condition) {
        lock.wait();          // releases the monitor and waits. MUST hold it to call this.
    }
    // ... use the resource
    lock.notifyAll();         // prefer notifyAll: notify() wakes ONE arbitrary thread,
}                             // which may be the wrong one -> lost signal -> hang.
synchronized ReentrantLock
Release Automatic Manual — finally or bust
Timeout / try No Yes
Interruptible No Yes
Fairness option No Yes
Multiple wait-sets No — one per object Yes — many Conditions
Verdict Default choice — simpler, safer When you need a feature above

The coordination toolkit

Four you should recognise instantly
// CountDownLatch — wait for N things to finish. ONE SHOT, cannot be reset. (Go: WaitGroup)
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
    executor.submit(() -> { try { work(); } finally { latch.countDown(); } });
}
latch.await();                     // blocks until the count hits 0

// CyclicBarrier — N threads wait for EACH OTHER, then all proceed. Reusable.
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("all arrived"));
barrier.await();                   // each thread blocks here until the 3rd arrives

// Semaphore — limit concurrent access to N permits
Semaphore sem = new Semaphore(5);  // at most 5 at a time
sem.acquire();
try { useResource(); } finally { sem.release(); }   // release in finally!

// Phaser — like a reusable barrier with a dynamic party count (rarely needed)

08. ExecutorService & thread pools

Creating a thread per task is a mistake: each costs ~1MB of stack and a syscall, and unbounded thread creation will kill your JVM. An executor separates what to run from which thread runs it, and reuses a fixed pool.

The basic loop
ExecutorService pool = Executors.newFixedThreadPool(4);

pool.execute(() -> System.out.println("fire and forget"));   // Runnable, returns nothing
Future<String> f = pool.submit(() -> "I return a value");    // Callable, returns a Future

pool.shutdown();                                    // no new tasks; finish what's queued
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { // wait for them
    pool.shutdownNow();                             // interrupt the stragglers
}
Always shut down, ideally in a finally

Pool threads are non-daemon by default, so a forgotten shutdown() keeps your JVM alive forever. Since Java 19, ExecutorService implements AutoCloseable, so try (var pool = Executors.newFixedThreadPool(4)) { … } closes and awaits termination for you.

The factory methods — and why you probably shouldn't use them

Factory Behaviour The catch
newFixedThreadPool(n) n threads, unbounded queue Queue grows forever → OOM under overload
newCachedThreadPool() Unbounded threads, reused for 60s Unbounded threads → OOM under a burst
newSingleThreadExecutor() One thread, unbounded queue Sequential; queue can still explode
newScheduledThreadPool(n) Delayed / periodic tasks One task throwing silently kills the schedule
newWorkStealingPool() ForkJoinPool, one deque per thread Doesn't preserve submission order
newVirtualThreadPerTaskExecutor() Java 21: a new virtual thread per task Not a pool — don't try to size it
In production, build the pool yourself

Every Executors factory is unbounded somewhere — in the queue or in the thread count. Under load that turns backpressure into an OutOfMemoryError. Construct a ThreadPoolExecutor with a bounded queue and an explicit rejection policy so overload degrades instead of exploding.

ThreadPoolExecutor — every knob explained
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    4,                                    // corePoolSize — kept alive even when idle
    8,                                    // maximumPoolSize — only grown when the QUEUE IS FULL
    60, TimeUnit.SECONDS,                 // keepAliveTime — how long extra (non-core) threads linger
    new ArrayBlockingQueue<>(100),        // BOUNDED work queue — this is the important bit
    new ThreadFactoryBuilder().setNameFormat("worker-%d").build(),   // named threads = readable dumps
    new ThreadPoolExecutor.CallerRunsPolicy()   // what to do when full (see below)
);
The sizing rule that surprises everyone

A ThreadPoolExecutor grows past corePoolSize only when the queue is full — not when tasks are waiting. So with an unbounded queue, maximumPoolSize is never reached and is completely ignored. That's precisely why newFixedThreadPool passes the same number for both.

The four rejection policies
new ThreadPoolExecutor.AbortPolicy()          // default: throw RejectedExecutionException
new ThreadPoolExecutor.CallerRunsPolicy()     // the SUBMITTING thread runs it -> natural backpressure
new ThreadPoolExecutor.DiscardPolicy()        // silently drop it (dangerous)
new ThreadPoolExecutor.DiscardOldestPolicy()  // drop the oldest queued task, then retry
CallerRunsPolicy is usually the right answer

When the pool is saturated, the submitter is forced to execute the task itself. That slows the producer down — it can't submit again until it finishes — which is exactly the backpressure you want. The queue stops growing, nothing is dropped, and the system degrades gracefully instead of falling over.

How big should the pool be?

The two rules of thumb
int cores = Runtime.getRuntime().availableProcessors();

// CPU-bound work: more threads than cores just adds context switching
int cpuBound = cores;         // (or cores + 1 to cover the odd page fault)

// I/O-bound work: threads are mostly parked waiting, so you want many more
int ioBound = cores * (1 + waitTime / computeTime);
// e.g. 8 cores, 100ms waiting per 5ms computing  ->  8 * (1 + 20) = 168 threads

// This formula is exactly why virtual threads exist — see section 12.

Callable and Future

Runnable vs Callable
Runnable r = () -> System.out.println("hi");         // void, cannot throw checked exceptions
Callable<String> c = () -> { return "result"; };     // returns a value, CAN throw checked

Future<String> f = pool.submit(c);
f.get();                        // BLOCKS until done. Throws ExecutionException wrapping any failure.
f.get(1, TimeUnit.SECONDS);     // ...or gives up. TimeoutException.
f.isDone();
f.cancel(true);                 // true = interrupt it if it's already running
Future.get() blocks — that's its whole problem

Submitting ten tasks then calling get() on each in turn is barely better than doing them in sequence: you're blocked at the first one while the rest finish invisibly. You can't chain, combine, or react to a Future without blocking a thread. That limitation is exactly what CompletableFuture was invented to remove.

Exceptions in submitted tasks disappear

execute() sends a thrown exception to the thread's UncaughtExceptionHandler. But submit() captures it in the Future — so if you never call get(), the exception is silently swallowed and your task just… doesn't work. Always get(), or handle failures explicitly.

09. CompletableFuture

CompletableFuture is a Future you can chain. Instead of blocking to ask "are you done?", you attach "when you're done, do this next" — and no thread waits.

Starting one
// async + returns a value
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> fetchUser());

// async + returns nothing
CompletableFuture<Void> g = CompletableFuture.runAsync(() -> log());

// already-finished
CompletableFuture<String> done = CompletableFuture.completedFuture("value");

// with your own pool — ALWAYS do this in production (the default is the common FJ pool)
CompletableFuture.supplyAsync(() -> fetchUser(), myExecutor);
Chaining — the three that matter
CompletableFuture.supplyAsync(() -> fetchUserId())
    .thenApply(id -> loadUser(id))          // map:     T -> U        (sync-ish, same thread)
    .thenCompose(user -> loadOrdersAsync(user))  // flatMap: T -> CompletableFuture<U>
    .thenAccept(orders -> render(orders))   // consume: T -> void
    .thenRun(() -> log.info("done"));       // ignore the value entirely
thenApply vs thenCompose — it's map vs flatMap

If your function returns a plain value, use thenApply. If it returns another CompletableFuture, use thenCompose — otherwise you end up holding a CompletableFuture<CompletableFuture<T>>, which is exactly as awkward as it looks.

The nesting mistake
// WRONG — a future inside a future
CompletableFuture<CompletableFuture<User>> bad =
    CompletableFuture.supplyAsync(() -> id).thenApply(id -> loadUserAsync(id));

// RIGHT — flattened
CompletableFuture<User> good =
    CompletableFuture.supplyAsync(() -> id).thenCompose(id -> loadUserAsync(id));
Combining — run things in parallel and join the results
CompletableFuture<String> user  = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> prefs = CompletableFuture.supplyAsync(() -> fetchPrefs());

// both run CONCURRENTLY; combine when both finish
CompletableFuture<String> page = user.thenCombine(prefs, (u, p) -> render(u, p));

// wait for many
CompletableFuture.allOf(f1, f2, f3).join();     // Void — collect results from each yourself
CompletableFuture.anyOf(f1, f2, f3).join();     // the FIRST one to finish wins

// A useful idiom: allOf + collect
List<CompletableFuture<String>> futures = ids.stream()
    .map(id -> CompletableFuture.supplyAsync(() -> load(id), pool))
    .toList();
List<String> results = CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
    .thenApply(v -> futures.stream().map(CompletableFuture::join).toList())
    .join();
Error handling — three flavours
cf.exceptionally(ex -> "fallback")            // only on failure -> supply a default
  .handle((result, ex) -> ex != null ? "fallback" : result)   // ALWAYS runs; sees both
  .whenComplete((result, ex) -> log(result, ex));             // ALWAYS runs; changes nothing

// Timeouts (Java 9+)
cf.orTimeout(2, TimeUnit.SECONDS);                       // fail with TimeoutException
cf.completeOnTimeout("default", 2, TimeUnit.SECONDS);    // ...or quietly substitute a value
An exception mid-chain skips everything downstream

If supplyAsync throws, every thenApply after it is skipped and the exception travels to the first exceptionally/handle. If you never attach one and never call join(), the failure vanishes silently — no log, no crash. Always terminate a chain with error handling.

join() vs get()

Same blocking behaviour, different exceptions. get() throws checked InterruptedException/ExecutionException; join() throws unchecked CompletionException — which is why join() works inside lambdas and get() doesn't.

The *Async suffix

thenApply may run on whichever thread completed the previous stage (or even the calling thread). thenApplyAsync always hands the work to a pool. Use the Async variants — with your own executor — whenever a stage does anything slow, or you'll accidentally tie up a completing thread.

10. Concurrent collections

Never wrap a HashMap in a lock and call it a day. java.util.concurrent ships collections designed for concurrency from the ground up.

Instead of Use How it works
HashMap ConcurrentHashMap Per-bucket locking + CAS — reads are lock-free
ArrayList (read-heavy) CopyOnWriteArrayList Every write copies the whole array
TreeMap ConcurrentSkipListMap Lock-free sorted skip list
A hand-rolled queue ArrayBlockingQueue / LinkedBlockingQueue Blocking producer/consumer handoff
A non-blocking queue ConcurrentLinkedQueue Lock-free (CAS) — never blocks
Collections.synchronizedMap Basically nothing One global lock — a bottleneck, and still not safe for compound ops
ConcurrentHashMap — the atomic methods are the point
ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap<>();

// BROKEN — two atomic calls are not one atomic operation
if (!map.containsKey(k)) map.put(k, 1);       // another thread can slip in between

// CORRECT — single atomic operations
map.putIfAbsent(k, 1);
map.computeIfAbsent(k, key -> expensiveLoad(key));   // computed at most once per key
map.compute(k, (key, v) -> v == null ? 1 : v + 1);
map.merge(k, 1, Integer::sum);                        // the cleanest counter you'll write
map.getOrDefault(k, 0);
Why ConcurrentHashMap is fast

Reads never lock — nodes hold volatile fields, so a get is a plain read. Writes lock only one bucket (Java 8+ synchronises on the first node of that bin, after trying CAS for an empty bin). Two threads writing different keys almost never contend. Compare that to Collections.synchronizedMap, where one giant lock serialises every single operation.

ConcurrentHashMap forbids null keys and values

HashMap allows them; ConcurrentHashMap throws NPE. The reason is genuinely subtle: with a plain map you'd disambiguate with containsKey(), but concurrently that answer can change between the two calls — so get() returning null would be irreducibly ambiguous ("absent" vs "mapped to null"). Banning null removes the ambiguity entirely.

Weakly consistent iterators

Concurrent collections never throw ConcurrentModificationException. Their iterators are weakly consistent: they reflect the state at some point since creation and may or may not show later changes. Similarly, size() is an estimate that may be stale the instant it returns — never use it in a for (int i = 0; i < map.size(); i++).

BlockingQueue — the producer/consumer backbone (Java's channel)
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);   // BOUNDED = backpressure

// Producer — blocks when full, which throttles it naturally
queue.put(task);

// Consumer — blocks when empty, no busy-waiting
Task t = queue.take();

// The full method set — four behaviours for each operation:
//            Throws           Returns special   Blocks     Times out
// insert     add(e)           offer(e)          put(e)     offer(e, t, unit)
// remove     remove()         poll()            take()     poll(t, unit)
// examine    element()        peek()            —          —

// SynchronousQueue — capacity ZERO: a direct handoff, each put waits for a take.
// This is the closest thing Java has to an unbuffered Go channel.
CopyOnWriteArrayList copies the entire array on every write

That's O(n) per mutation. It's excellent for listener lists — write once at startup, read constantly, iterate without locking or CME. It is catastrophic for anything write-heavy.

11. Deadlock, livelock, starvation

A deadlock is two threads each holding the lock the other needs, both waiting forever. Nothing throws, nothing logs — the program simply stops.

The classic — lock ordering inversion
final Object lockA = new Object();
final Object lockB = new Object();

Thread t1 = new Thread(() -> {
    synchronized (lockA) {              // gets A
        Thread.sleep(50);
        synchronized (lockB) { }        // wants B — held by t2. Waits forever.
    }
});

Thread t2 = new Thread(() -> {
    synchronized (lockB) {              // gets B
        Thread.sleep(50);
        synchronized (lockA) { }        // wants A — held by t1. Waits forever.
    }
});
// Both threads BLOCKED. No exception, no log, no CPU usage. The app just hangs.
The four Coffman conditions — all must hold

1. Mutual exclusion — a resource can't be shared. 2. Hold and wait — you hold one while requesting another. 3. No preemption — locks can't be forcibly taken. 4. Circular wait — a cycle in the "waits for" graph. Break any one and deadlock is impossible. In practice you break #4.

Fix 1 — global lock ordering (the standard answer)
// Rule: EVERY thread acquires locks in the same defined order. No cycle can form.
void transfer(Account from, Account to, int amount) {
    Account first  = from.id < to.id ? from : to;    // order by a stable, unique key
    Account second = from.id < to.id ? to : from;

    synchronized (first) {
        synchronized (second) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}
// Now transfer(A,B) and transfer(B,A) both lock A then B. Circular wait is impossible.
Fix 2 — tryLock with a timeout (break "hold and wait")
boolean transfer(Account from, Account to, int amount) throws InterruptedException {
    if (from.lock.tryLock(1, TimeUnit.SECONDS)) {
        try {
            if (to.lock.tryLock(1, TimeUnit.SECONDS)) {
                try {
                    from.debit(amount);
                    to.credit(amount);
                    return true;
                } finally { to.lock.unlock(); }
            }
        } finally { from.lock.unlock(); }   // couldn't get the second -> RELEASE the first and retry
    }
    return false;
}

The neighbours

Problem What's happening CPU
Deadlock Everyone blocked in a cycle, forever Idle
Livelock Threads keep responding to each other and never progress Busy — looks healthy!
Starvation One thread never gets the lock; others hog it Busy

Deadlock: two people in a corridor, each waiting for the other to move first — frozen. Livelock: both politely step aside, at the same time, again and again — lots of motion, zero progress. Starvation: one person keeps getting shoved to the back of the queue while everyone else walks through.

Diagnosing a hang in production
jps                    # find the pid
jstack <pid>           # dump every thread's stack

# jstack DETECTS deadlocks for you and prints:
#
# Found one Java-level deadlock:
# =============================
# "Thread-1":
#   waiting to lock monitor 0x00007f... (object 0x000000076ab, a java.lang.Object),
#   which is held by "Thread-0"
# "Thread-0":
#   waiting to lock monitor 0x00007f... (object 0x000000076ac, a java.lang.Object),
#   which is held by "Thread-1"

# This is why naming your threads matters — "pool-1-thread-7" tells you nothing.

12. Virtual threads (Java 21)

A virtual thread is a thread the JVM manages itself instead of the OS. They cost a few hundred bytes instead of a megabyte, so you can have millions — and the "thread per request" model becomes viable again.

The problem they solve

A platform thread maps 1:1 to an OS thread: ~1MB of stack, a syscall to create, and a costly context switch. So you can afford maybe a few thousand — which is why we invented thread pools, then async callbacks, then reactive streams, all to avoid blocking a precious thread. Virtual threads make blocking cheap again, which deletes that entire tower of complexity.

Using them
// One-off
Thread.startVirtualThread(() -> System.out.println("hi"));

Thread t = Thread.ofVirtual().name("worker").start(runnable);
t.join();

// The real way — an executor that makes a NEW virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            Thread.sleep(1000);      // blocks the VIRTUAL thread — the carrier is freed!
            return fetchSomething();
        });
    }
}   // close() waits for all of them

// A million concurrent tasks. With platform threads this would OOM instantly.

How they work

Mounting and unmounting
// Virtual threads run on a small pool of PLATFORM threads called "carriers"
// (a ForkJoinPool sized to your core count).
//
//   1. Virtual thread MOUNTS onto a carrier and runs.
//   2. It hits a blocking call (I/O, sleep, lock).
//   3. The JVM UNMOUNTS it: its stack is copied to the heap, the carrier is released.
//   4. The carrier immediately runs another virtual thread.
//   5. When the I/O completes, the virtual thread is remounted (on any carrier) and continues.
//
// Blocking a virtual thread costs a heap copy, not an OS context switch.

Platform threads are hired staff — expensive, so you keep a small pool and never let one sit idle. Virtual threads are sticky notes: write one per task and stick it on the board. A handful of real workers (carriers) pick up whatever note is actionable. A note waiting on someone else just sits there costing nothing.

Do NOT pool virtual threads

Pooling exists to amortise expensive creation. Virtual threads are cheap to create, so a pool only reintroduces the limit you were escaping. Never write newFixedThreadPool of virtual threads — use newVirtualThreadPerTaskExecutor(). Similarly, don't cache anything in ThreadLocal on them: with a million threads that's a million copies.

Pinning — the one real gotcha

If a virtual thread blocks inside a synchronized block (or in a native/JNI call), it cannot unmount — it's pinned to its carrier, and that carrier is stuck too. Enough pinned threads and you starve the carrier pool. The fix in Java 21 is to use ReentrantLock instead of synchronized around blocking calls. (JDK 24 largely removes this limitation, but on 21 it's real.) Diagnose with -Djdk.tracePinnedThreads=full.

Platform thread Virtual thread
Backed by An OS thread The JVM (runs on a carrier)
Cost ~1MB stack ~a few hundred bytes, grows on demand
Practical count Thousands Millions
Pool them? Yes No — never
Good for CPU-bound work I/O-bound work
Daemon? Configurable Always daemon; priority is ignored
They don't make CPU-bound code faster

You still only have N cores. Virtual threads solve waiting, not computing. For CPU-bound work, a platform-thread pool sized to your cores is still exactly right.

13. Gotchas — where Java surprises you

1. Thread.sleep() does not release locks.

wait() releases the monitor; sleep() keeps everything it holds. So sleeping inside synchronized blocks every other thread for the full duration. wait() also requires you to hold the monitor — calling it without one throws IllegalMonitorStateException.

2. Double-checked locking is broken without volatile.

instance = new Singleton() is three steps (allocate, construct, assign), and the JIT may reorder the assignment before the constructor finishes. Another thread then sees a non-null reference to a half-built object. Marking the field volatile forbids that reordering and makes the idiom correct. Better still: use the static holder idiom or an enum.

3. Collections.synchronizedList doesn't make compound actions safe.

Each call is atomic, but if (!list.contains(x)) list.add(x); is two calls with a gap. Iteration isn't safe either — you must manually synchronized (list) around the whole loop, or you'll get a ConcurrentModificationException.

4. SimpleDateFormat is not thread-safe.

It holds mutable state in a field, so a shared static instance silently produces wrong dates under load — no exception, just garbage. Use java.time (DateTimeFormatter is immutable and thread-safe). Same trap: Random (use ThreadLocalRandom) and StringBuilder.

5. Exceptions in submit() vanish.

submit() stores the throwable in the Future instead of surfacing it. If you never call get(), nothing is ever logged. execute() at least routes it to the uncaught-exception handler. The same applies to a CompletableFuture chain with no exceptionally/handle.

6. A ScheduledExecutorService task that throws stops rescheduling.

scheduleAtFixedRate cancels the whole schedule if the task ever throws — silently. Your periodic job just stops running and nobody finds out for a week. Always wrap the body in try/catch(Throwable).

7. ThreadLocal leaks memory in a thread pool.

Pool threads live forever, so anything you put in a ThreadLocal is never collected — and the next task on that thread sees the previous task's value. Always remove() in a finally.

8. notify() can lose a signal; prefer notifyAll().

notify() wakes one arbitrary waiter. If multiple threads wait on different conditions on the same monitor, it may wake one whose condition is still false — which goes back to sleep, and the signal is gone forever. Use notifyAll(), or use separate Condition objects.

9. Non-volatile long/double can tear.

The JMM permits a 64-bit read/write to be split into two 32-bit halves, so another thread can observe a value that was never written — half old, half new. Rare on 64-bit JVMs, but specified and legal. volatile or AtomicLong forbids it.

10. Making every method synchronized is not thread safety.

It makes each call atomic, not each use case. if (!list.isEmpty()) list.remove(0); still races. Thread safety is about invariants, not methods — the whole compound action needs one lock.

14. Interview Q&A

Q: start() vs run()?

start() creates a new thread which then invokes run(). Calling run() directly executes it on the current thread — a plain method call, no concurrency. Calling start() twice throws IllegalThreadStateException.

Q: volatile vs synchronized?

volatile gives visibility and ordering for one field; it never blocks and gives no atomicity for compound operations. synchronized gives visibility and mutual exclusion over a block. Use volatile for a flag or a reference swap; anything read-modify-write needs a lock or an atomic.

Q: Why isn't volatile int i; i++; thread-safe?

i++ is read-modify-write. volatile makes each individual read and write visible, but another thread can interleave between them, so updates are still lost. Use AtomicInteger.incrementAndGet() or a lock.

Q: What is happens-before?

The JMM's visibility guarantee: if A happens-before B, A's writes are visible to B and can't be reordered past it. Created by program order, unlock→lock on the same monitor, volatile write→read, start(), join(), and final-field publication — and it's transitive. No edge + concurrent access with a write = data race = no guarantees at all.

Q: BLOCKED vs WAITING?

BLOCKED is waiting to acquire a monitor — it resumes automatically when the holder releases. WAITING is waiting for an explicit signal (notify/signal/unpark/thread termination) — someone must actively wake it.

Q: synchronized vs ReentrantLock?

ReentrantLock adds tryLock, timeouts, interruptible acquisition, fairness, and multiple Conditions — at the cost of manual unlock() in a finally. synchronized is simpler, releases automatically, and is the default. Both are reentrant.

Q: How does CAS work, and what's the ABA problem?

CAS atomically writes a new value only if the current value equals the expected one; failure means retry in a loop. It's optimistic and lock-free, so no thread blocks. ABA: a value changes A→B→A, so CAS succeeds despite intervening modification. AtomicStampedReference adds a version to distinguish them.

Q: What causes deadlock and how do you prevent it?

All four Coffman conditions holding at once: mutual exclusion, hold-and-wait, no preemption, and circular wait. Break one — usually circular wait, by imposing a global lock ordering. Other tools: tryLock with timeout, and holding fewer locks (ideally one).

Q: Why is ConcurrentHashMap better than Hashtable?

Hashtable and synchronizedMap lock the entire map on every operation. ConcurrentHashMap never locks reads and locks only a single bucket on write, so unrelated keys don't contend. It also offers atomic compound ops (computeIfAbsent, merge) which a lock-wrapped map can't express safely.

Q: How do you size a thread pool?

CPU-bound: roughly the number of cores. I/O-bound: cores × (1 + waitTime/computeTime) — often hundreds. And in production build a ThreadPoolExecutor with a bounded queue and CallerRunsPolicy, because every Executors factory is unbounded somewhere and will OOM under load.

Q: What are virtual threads and when do they help?

JVM-managed threads that unmount from their carrier when they block, so a blocking call costs a heap copy instead of an OS context switch. Millions are practical, which makes thread-per-request viable and removes the need for reactive plumbing. They help I/O-bound work only; don't pool them; watch out for pinning inside synchronized.

Q: How do you make a class thread-safe?

Best: don't share (confine it to one thread). Next best: don't mutate (immutable + final fields is automatically safe). Last resort: synchronise every access — reads included — with the same lock, keep the critical section small, and never call unknown code while holding it.

15. Cheat sheet

  • Three problems: atomicity (i++ is 3 ops) · visibility (caches + JIT) · ordering (reordering is legal).
  • States: NEW → RUNNABLE → (BLOCKED = wants a lock | WAITING = needs a signal | TIMED_WAITING) → TERMINATED.
  • start() = new thread · run() = plain method call. Interrupt is a request; restore the flag if you swallow it.
  • volatile: visibility + ordering, no atomicity, never blocks. Good for flags.
  • synchronized: visibility + mutual exclusion. Reentrant. Instance locks this; static locks Classdifferent locks. Lock a private final object.
  • happens-before: program order · unlock→lock (same monitor) · volatile write→read · start() · join() · final fields · transitive.
  • Atomics: CAS = optimistic retry loop, lock-free. LongAdder under high contention. ABA → AtomicStampedReference.
  • ReentrantLock: tryLock · timeout · lockInterruptibly · fairness · Condition. Always unlock() in finally.
  • Wait: always in a while loop (spurious wakeups) · prefer notifyAll() · wait() releases the lock, sleep() does not.
  • Pools: grows past core only when the queue is full → unbounded queue means max is ignored. Use a bounded queue + CallerRunsPolicy. Size: cores (CPU) · cores×(1+wait/compute) (I/O).
  • CompletableFuture: thenApply (map) · thenCompose (flatMap) · thenCombine (join two) · allOf/anyOf · exceptionally/handle. Pass your own executor.
  • Collections: ConcurrentHashMap (no nulls, atomic merge/computeIfAbsent) · CopyOnWriteArrayList (read-heavy only) · BlockingQueue (bounded = backpressure).
  • Deadlock: 4 Coffman conditions · fix with global lock ordering or tryLock timeout · diagnose with jstack.
  • Virtual threads (21): cheap, millions, unmount on block. I/O only · never pool · beware pinning in synchronized.
  • Safety, in order: don't share → don't mutate → synchronise everything with one lock.
Last reviewed · July 2026 · part of knowledge-base