What this is: a deep, practical reference for reasoning about, writing, and defending concurrent code on the JVM — Java threads/java.util.concurrent, virtual threads (Loom), and Kotlin coroutines — with the trade-offs, failure modes, and "how to sound senior" framing an interviewer at Staff+ level is actually probing for. All code is Kotlin + Java, Spring Boot where it earns it.
Version anchor (facts change — say the year): Java 25 LTS (released 16 Sep 2025). Virtual threads are GA since Java 21 (JEP 444). Scoped Values finalized in Java 25 (JEP 506). Structured Concurrency is still preview — and its finalization keeps slipping: JEP 505 (JDK 25) → JEP 525 sixth preview (JDK 26) → JEP 533 seventh preview, targeting JDK 27 (announced May 2026). It has not finalized; JDK 27 is the earliest. The API has also been reshaped repeatedly, so pin your JDK whenever you discuss it. JEP 491 (JDK 24) removed
synchronizedpinning of virtual threads — the old "neversynchronizedaround I/O on a virtual thread" advice is now largely obsolete. Kotlin 2.x /kotlinx.coroutines1.10–1.11.
0. The mental model (everything below is a corollary of this)#
Two sentences carry the whole topic. Internalize them and you can derive every rule live in an interview:
(1) The enemy is shared mutable state. A bug needs all three words. Remove any one and the bug is impossible:
- Not shared → confinement: the state lives on one thread / one coroutine / one request. (Thread-per-request,
ThreadLocal, single-threaded dispatchers, actors.) - Not mutable → immutability: publish it once, never change it. (
final/val, records,data class+copy, persistent collections.) - Coordinated access → synchronization: if you must share mutable state, serialize access so operations don't interleave. (locks, atomics,
synchronized,Mutex.)
Senior instinct is to reach for the first two (design the sharing/mutation away) and treat locks as the last resort, because locks are where deadlocks, contention, and priority inversion live.
(2) There is no thread safety without a happens-before edge. Every primitive — volatile, synchronized, locks, atomics, Thread.start/join, Future.get, a coroutine join(), a Channel send/receive — exists to do exactly one of two things: create a happens-before edge (so a write on thread A is guaranteed visible to a read on thread B), or eliminate the need for one (by removing sharing or mutability). When someone asks "is this thread-safe?", the rigorous answer is always "what's the happens-before edge between the write and the read?" If you can't name it, it isn't safe — it just hasn't failed yet.
Everything else — the JMM, volatile vs synchronized, virtual threads, coroutine dispatchers — is machinery in service of those two sentences.
1. The Java Memory Model (JMM) — the foundation nobody can skip#
This is where Staff+ interviews separate people. Anyone can say "use synchronized." The signal is understanding why, at the memory-model level.
1.1 Why the JMM exists#
Your source code is a lie about execution order. Three layers reorder it, each for speed:
- The compiler (javac + especially the JIT/C2) reorders instructions and hoists reads into registers.
- The CPU executes out of order and speculatively.
- The memory hierarchy — per-core caches and store buffers mean a write by core 0 may sit in core 0's store buffer, invisible to core 1 for an indefinite time.
On a single thread these reorderings are invisible: the hardware and compiler preserve as-if-serial semantics (your thread always sees its own actions in program order). Across threads, all bets are off unless you establish a happens-before edge. The JMM (specified in JLS Ch. 17, originally JSR-133 for Java 5) is the contract that says exactly which cross-thread writes a read is guaranteed to observe. It is deliberately weak — it gives the JIT and hardware room to optimize — and thread-safety is the art of adding just enough ordering back.
1.2 The three distinct guarantees (interviewers conflate these; you shouldn't)#
| Guarantee | Question it answers | Broken example |
|---|---|---|
| Visibility | Will thread B ever see the value thread A wrote? | A sets stop=true; B loops on while(!stop) forever because B cached stop in a register. |
| Atomicity | Can a compound action be interrupted mid-way? | count++ is read-modify-write; two threads both read 5, both write 6, one increment lost. |
| Ordering | Can operations be seen out of program order by another thread? | A writes data then ready=true; B sees ready==true but data still null (the two writes were reordered / seen out of order). |
volatile fixes visibility + ordering but not atomicity. synchronized/locks fix all three (for the code they guard). Atomics fix atomicity and visibility for a single variable. Knowing which tool gives which guarantee is the core competency.
1.3 happens-before — the actual rule set#
happens-before is a partial order over actions. If action X happens-before Y, then X's memory effects are visible to Y. The edges you must be able to recite:
- Program order: within a single thread, each action happens-before every later action in source order.
- Monitor lock: an unlock of monitor M happens-before every subsequent lock of M. (This is why
synchronizedpublishes writes.) - Volatile: a write to a
volatilefield happens-before every subsequent read of that same field. - Thread start:
Thread.start()happens-before any action in the started thread. - Thread termination: every action in a thread happens-before another thread's successful return from
join()on it. - Transitivity: if X hb Y and Y hb Z, then X hb Z. (This is the workhorse — you chain a volatile/lock edge to publish a whole object graph, not just one field.)
- Final fields: a correctly constructed object's
finalfields are visible without synchronization (strictly this is the separate JLS §17.5 final-field freeze guarantee, not a §17.4 happens-before edge — but it functions like one; see safe publication).
If no chain of these edges connects a write and a read of the same non-final field, and at least one is a write, you have a data race and the result is undefined (not "stale but consistent" — actually undefined; the read may see the write partially, or never).
1.4 Data race vs race condition (the single most common depth check)#
These are not synonyms, and mixing them up flags you as shallow:
- A data race is a low-level, memory-model phenomenon: two threads access the same non-
finalvariable, at least one writes, and there's no happens-before ordering. It's a property of the memory access. The fix is always to add ordering (volatile/lock/atomic). - A race condition is a correctness property: the result depends on the timing/interleaving of operations. The fix is to make the compound operation atomic as a unit.
The killer point: you can have a race condition with no data race. Example — each individual operation is thread-safe (atomic), but their composition isn't:
// Each map operation below is atomic and data-race-free. The COMPOSITION is still
// a race condition (lost updates possible) — no data race, yet still incorrect.
if (map.get(key) == null) { // atomic read
map.put(key, compute(key)); // atomic write — but another thread may have put between the two
}
Two threads can both see null and both compute+put. No data race (every access is synchronized), but a check-then-act race condition. The fix isn't more volatile — it's making check-and-act one atomic step: map.computeIfAbsent(key, this::compute). Being able to draw this distinction cleanly is worth a lot.
1.5 volatile — precisely what it gives you#
volatile on a field guarantees: (a) reads/writes go to main memory, not a cached register → visibility; (b) a volatile write happens-before a subsequent volatile read → ordering (it's a memory fence — writes before the volatile write are also published, this is the "piggybacking"/transitivity trick); (c) reads and writes of long/double become atomic (see 1.7).
What it does not give you: atomicity of compound actions. volatileCounter++ is still broken. Rule of thumb — volatile is correct only when one thread writes and others read (a flag, a published reference), or when writes don't depend on the current value.
// Kotlin — @Volatile is the JVM volatile. Canonical correct use: a stop flag.
class Worker {
private var running = true // one writer (stop), many readers (loop)
fun stop() { running = false }
fun run() { while (running) { /* ... */ } } // guaranteed to observe stop()
}
// Java — same semantics.
class Worker {
private volatile boolean running = true; // WITHOUT volatile the JIT may hoist the read
void stop() { running = false; } // and loop forever on a value cached in a register
void run() { while (running) { /* ... */ } }
}
Kotlin note: Kotlin runs on the JVM, so it obeys the same JMM. @Volatile, @Synchronized, and @JvmStatic are annotations that map to the Java bytecode constructs. val gives you a final field (visibility on safe construction); Kotlin has no synchronized keyword — you use the synchronized(lock){} inline function or a Mutex in coroutines.
1.6 Safe publication — the subtle bug that ships to production#
Making an object thread-safe internally is not enough; you must also publish the reference safely, or a reader can see a non-null reference to a partially constructed object (fields still at defaults). An object is safely published if the reference and its state are made visible via a happens-before edge. The four blessed ways:
- Initialize it from a static initializer (class-init locking gives you the edge).
- Store the reference into a
volatilefield orAtomicReference. - Store it into a
finalfield of a properly constructed object. - Store it into a field guarded by a lock (put and get both under the same lock / concurrent collection).
// UNSAFE publication — the classic bug. Another thread may see `config != null`
// but read config.timeout == 0 (default) because the write of the field and the
// write of the reference can be seen out of order.
class Holder { Config config; void init() { config = new Config(5000); } }
// SAFE — final fields are frozen at end of a correctly-constructed object:
class Config { final int timeout; Config(int t){ this.timeout = t; } } // readers see 5000, always
The final-field guarantee has one condition: this must not escape the constructor (don't register listeners, start threads, or publish this before construction finishes) — otherwise another thread can grab a reference before the fields freeze.
1.7 The 64-bit non-atomicity footnote (know it, don't lead with it)#
Per the JMM, a non-volatile long or double write may be split into two 32-bit stores, so a racing read can observe a torn value (half of the old, half of the new). Marking it volatile (or using AtomicLong) makes it atomic. On all mainstream 64-bit JVMs this never actually happens, but it's legal, so it's a correctness fact, not a "it works on my machine" fact. int, boolean, references, etc. are always atomic for single reads/writes (just not visible without ordering).
2. Atomicity and the compound-action problem#
Once visibility is handled, the remaining bugs are compound actions that must be indivisible. Three canonical shapes, all race conditions:
- Read-modify-write:
count++,balance -= amount. - Check-then-act:
if (!map.containsKey(k)) map.put(k, v); lazy init; "if absent then create". - Multi-variable invariant: two fields that must change together (e.g.
low <= high) — you must guard both with the same lock.
2.1 Intrinsic locks — synchronized#
Every Java object has a monitor. synchronized acquires it: mutual exclusion + a happens-before edge on enter/exit. It's reentrant (a thread can re-lock a monitor it already holds — prevents self-deadlock in recursive/inherited calls).
class Counter {
private var count = 0
private val lock = Any() // lock on a private object, never on `this` or a String
fun increment() = synchronized(lock) { count++ }
fun get() = synchronized(lock) { count } // read must ALSO synchronize (visibility!)
}
class Counter {
private int count = 0;
private final Object lock = new Object();
void increment() { synchronized (lock) { count++; } }
int get() { synchronized (lock) { return count; } } // reading without the lock = visibility bug
}
Two things people get wrong: (1) they synchronize the writer but not the reader — the read then has no happens-before edge and can see a stale value; (2) they lock on a public object (this, a Class, an interned String) so unrelated code can accidentally lock the same monitor → deadlock/contention. Lock on a private final field.
2.2 Atomics and CAS — lock-free for single variables#
AtomicInteger/AtomicLong/AtomicReference wrap a volatile value with compare-and-set (CAS) hardware instructions. CAS is optimistic: "if the value is still X, set it to Y, atomically; else tell me it changed and I'll retry." No lock, no blocking, no deadlock — but a spin under contention.
val hits = AtomicInteger(0)
hits.incrementAndGet() // atomic RMW, no lock
hits.updateAndGet { if (it < MAX) it + 1 else it } // atomic conditional update via CAS retry loop
AtomicInteger hits = new AtomicInteger();
hits.incrementAndGet();
hits.updateAndGet(v -> v < MAX ? v + 1 : v); // the lambda may run more than once (CAS retries)
Two Staff-level nuances:
- ABA problem: CAS checks value equality, not change. If the value goes A→B→A, CAS thinks nothing changed. Matters for lock-free stacks/pointers. Fix:
AtomicStampedReference(value + version stamp). - Contention collapse: under heavy write contention,
AtomicLong's single hot cache line makes every CAS retry — throughput cratering.LongAdder/LongAccumulatorstripe the count across multiple cells (one per contending thread, roughly) and sum on read. UseLongAdderfor high-frequency counters (metrics, hit counts) where you write often and read rarely; useAtomicLongwhen you need the exact current value on every op.
2.3 Explicit locks — when synchronized isn't enough#
java.util.concurrent.locks.ReentrantLock gives you what intrinsic locks can't: tryLock (with timeout) to back off instead of deadlocking, interruptible acquisition, fairness option, and multiple condition variables. The cost is you must unlock() in a finally.
private final ReentrantLock lock = new ReentrantLock();
boolean transfer(...) {
if (!lock.tryLock(1, TimeUnit.SECONDS)) return false; // deadlock avoidance: bounded wait
try { /* critical section */ } finally { lock.unlock(); } // ALWAYS unlock in finally
return true;
}
ReadWriteLock/ReentrantReadWriteLock: many concurrent readers or one writer. Wins only for read-heavy, long critical sections; under write pressure or short sections its overhead loses to plainsynchronized.StampedLock(Java 8+): adds an optimistic read mode — read without locking, then validate the stamp; retry as a real read-lock if a writer intervened. Fastest read-mostly lock, but not reentrant and easy to misuse (you must not do side effects during optimistic reads). A Staff-level "I know it exists and its footguns" mention.
Modern caveat: since JEP 491 (JDK 24) synchronized no longer pins virtual threads, so the old "prefer ReentrantLock on virtual threads" reason is gone. Choose ReentrantLock now for its features (tryLock/timeout/conditions), not to dodge pinning.
3. The thread-safety toolbox — strategies, ranked by how a senior reaches for them#
3.1 Immutability (reach for this first)#
An immutable object is inherently thread-safe — no writes means no race, no visibility problem, no lock. Publish once, share freely.
data class Money(val amount: BigDecimal, val currency: Currency) // val = final; copy() for "changes"
val discounted = order.copy(total = order.total * 0.9.toBigDecimal()) // new object, original untouched
public record Money(BigDecimal amount, Currency currency) {} // records are shallowly immutable + final
Watch the shallow trap: val list: List<X> in Kotlin is a read-only reference, not an immutable list (the underlying object may still be an ArrayList someone else mutates). For true immutability use List.copyOf(...), Guava ImmutableList, or Kotlin's toList() defensive copy — and make the elements immutable too.
3.2 Confinement (reach for this second)#
If only one thread ever touches the state, you need no synchronization. Forms:
- Thread-per-request / instance confinement: Spring's default — a stateless singleton bean holds no per-request state; each request runs on its own thread and keeps mutable state in local variables.
ThreadLocal: one copy per thread (e.g.SimpleDateFormat, which is not thread-safe, or a per-requestSecurityContext). Leak warning: in a pooled thread (servlet container,@Asyncexecutor), aThreadLocalyou set is never cleaned up and (a) leaks memory and (b) bleeds state into the next request on that pooled thread. Alwaysremove()in afinally. With virtual threads, don't cache expensive per-thread objects inThreadLocal— there are millions of them; use a scoped value instead (§4.4).- Single-threaded confinement in coroutines: confine mutable state to one dispatcher thread (§5.5).
3.3 Delegate to a thread-safe building block (reach for this third)#
Prefer battle-tested java.util.concurrent types over hand-rolled locking:
| Need | Use | Not |
|---|---|---|
| Concurrent map | ConcurrentHashMap | synchronized(map) / Hashtable |
| Read-mostly list | CopyOnWriteArrayList | Vector |
| Producer/consumer handoff | ArrayBlockingQueue / LinkedBlockingQueue | a list + wait/notify |
| Bounded resource permits | Semaphore | manual counters |
| One-shot "wait for N tasks" | CountDownLatch | busy-wait flags |
| Reusable barrier | CyclicBarrier / Phaser | — |
| Async result | CompletableFuture | raw Future (can't compose) |
ConcurrentHashMap deserves its own note: individual ops are atomic and lock-free for reads, but compound ops need the atomic methods — computeIfAbsent, merge, compute, putIfAbsent — not a get then put. size() is an estimate. It rejects null keys/values (so get==null unambiguously means absent).
// Atomic "count occurrences" — no lock, no lost updates:
val counts = ConcurrentHashMap<String, Int>()
counts.merge(word, 1, Int::plus) // read-modify-write done atomically inside the map
// Atomic "create once" (thread-safe memoization):
val conn = pool.computeIfAbsent(key) { openConnection(it) } // compute runs at most once per key
3.4 Only then: explicit synchronization#
Locks/atomics are the fallback when you genuinely have shared mutable state that isn't expressible as immutable, confined, or a concurrent collection. Design goals when you must lock: hold the lock for the shortest possible time, never do I/O or call foreign/unknown code while holding a lock (that's how deadlocks and pinning happen), and acquire multiple locks in a globally consistent order (§7.3).
4. Java's execution models: threads → pools → virtual threads (Loom)#
4.1 Platform threads and why you pool them#
A platform thread is a thin wrapper over an OS thread: ~1 MB of stack committed, expensive to create, and the OS scheduler caps you at a few thousand before context-switching overhead dominates. So the classic model is a bounded thread pool (ExecutorService) that reuses a fixed set of threads across many tasks.
ExecutorService pool = Executors.newFixedThreadPool(cpuBound ? cores : cores * someBlockingFactor);
Future<Report> f = pool.submit(() -> buildReport());
Report r = f.get(); // blocks the CALLING thread until done
Sizing math (worth saying out loud): for CPU-bound work, threads ≈ number of cores (more just adds context-switch overhead). For I/O-bound work, threads ≈ cores × (1 + wait_time/compute_time) — you oversize so someone's computing while others block. The whole problem: with platform threads, blocking wastes a precious, expensive resource, so you're forced into async/reactive code to avoid blocking.
4.2 CompletableFuture — composable async (pre-Loom answer to "don't block")#
CompletableFuture
.supplyAsync(() -> loadUser(id), pool)
.thenCombine(CompletableFuture.supplyAsync(() -> loadOrders(id), pool),
(user, orders) -> new Dashboard(user, orders))
.thenApply(this::render)
.exceptionally(ex -> fallback(ex)); // non-blocking error handling
Powerful but produces "callback-flavored" code, and error handling / context propagation get awkward. Loom and coroutines exist to give you the composition without the callback shape.
4.3 Virtual threads (Project Loom) — the paradigm shift, GA in Java 21#
A virtual thread is a thread scheduled by the JVM, not the OS. Its stack lives on the heap; creating one costs ~a few hundred bytes; you can have millions. The JVM multiplexes many virtual threads onto a small pool of carrier (platform) threads. The magic: when a virtual thread blocks (I/O, lock, sleep), the JVM unmounts it from its carrier and parks it on the heap, freeing the carrier to run another virtual thread. When the I/O completes, it's remounted.
The consequence reframes everything: blocking is now cheap. You go back to writing simple, sequential, thread-per-request, blocking code — and it scales like async did, without the callback/reactive complexity.
// One virtual thread PER TASK. Do NOT pool virtual threads — they're disposable.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Price>> futures = tickers.stream()
.map(t -> exec.submit(() -> fetchPrice(t))) // 10,000 blocking calls, 10,000 virtual threads — fine
.toList();
// ... exec.close() waits for all (it's an AutoCloseable ExecutorService)
}
What virtual threads are NOT: they do not make code faster or add parallelism. CPU-bound work still needs ≤ cores of real parallelism — running a matrix multiply on a virtual thread buys nothing. Virtual threads win only for high-concurrency, I/O-bound, blocking workloads (the classic web service: thousands of requests each waiting on DB/HTTP). The slogan: "virtual threads make blocking cheap, not computation fast."
Pinning — and the crucial 2026 update:
- In Java 21–23, a virtual thread inside a
synchronizedblock could not unmount — it was pinned to its carrier. If it then blocked on I/O, it held the carrier hostage; enough of those and your carrier pool starves. The advice was "replacesynchronizedwithReentrantLockaround blocking calls." - JEP 491 (JDK 24) fixed this:
synchronizedno longer pins. So on Java 24/25, that advice is obsolete. Remaining pinning causes: blocking inside a native/JNI or Foreign-Function (FFM) call, and class-initializer blocking. These are rare. (You can still monitor with-Djdk.tracePinnedThreads/ JFR.)
Sizing: you don't size virtual threads — you create one per task. You may still bound downstream resources (a DB connection pool of 50) with a Semaphore, because your database can't take 10,000 concurrent connections even if your JVM can.
4.4 Scoped values (finalized in Java 25) — the ThreadLocal successor#
ThreadLocal is mutable, inheritable in surprising ways, and — with millions of virtual threads — a memory problem. ScopedValue (JEP 506, final in 25) is an immutable, bounded-lifetime binding: set for the dynamic scope of a lambda, automatically visible to structured-concurrency children, automatically torn down at scope exit (no leak).
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, authenticatedUser).run(() -> {
handleRequest(); // anywhere in here (and in forked child tasks) CURRENT_USER.get() works
}); // binding is gone here — impossible to leak into a pooled thread
4.5 Structured concurrency (still preview in 25 — JEP 505/525)#
The idea (borrowed from Kotlin, ironically): child tasks must not outlive the scope that spawned them. If you fork subtasks, they live and die inside a try block — one fails → the siblings are cancelled → the parent joins them all. No leaked threads, error propagation that actually works, and a call tree that matches your code's block structure.
// Java 25, PREVIEW (--enable-preview). API shape as of JEP 505/525 — it has changed across releases.
Response handle(long id) throws Exception {
try (var scope = StructuredTaskScope.open()) { // default: fail-fast (all-or-throw)
Subtask<User> user = scope.fork(() -> loadUser(id));
Subtask<List<Order>> orders = scope.fork(() -> loadOrders(id));
scope.join(); // waits; if either fails, cancels the other & throws
return new Response(user.get(), orders.get()); // .get() only valid after a successful join()
}
}
Flag the version-dependence in the interview: earlier previews (through JDK 24) used new StructuredTaskScope.ShutdownOnFailure() + scope.join().throwIfFailed(); JDK 25 (JEP 505) redesigned it to StructuredTaskScope.open(Joiner...) with joiner factories like Joiner.allSuccessfulOrThrow() and Joiner.anySuccessfulResultOrThrow(). Say "structured concurrency — still preview; it's on its seventh preview (JEP 533, targeting JDK 27) and the API keeps changing, so I pin my JDK when I use it" and you sound genuinely current.
5. Kotlin coroutines — a different, richer concurrency model#
Coroutines are not threads. A coroutine is a suspendable computation multiplexed onto threads by a dispatcher. Conceptually they're to Kotlin what virtual threads are to Java — cheap, blocking-looking, sequential code that scales — but they predate Loom, are library-based (not JVM-magic), and come with a structured model and Flow baked in.
5.1 suspend — what it actually compiles to#
A suspend function can pause without blocking a thread and resume later. The compiler rewrites it via CPS (continuation-passing style) into a state machine: each suspension point becomes a state, and the function receives a hidden Continuation parameter (a callback that resumes it). "Suspension" = "return the thread to the pool, remember where I was, resume later on some thread." That's why a million coroutines cost almost nothing — a suspended coroutine is just a small object on the heap, not a parked thread.
suspend fun dashboard(id: Long): Dashboard = coroutineScope { // structured scope
val user = async { loadUser(id) } // launches a child coroutine, returns Deferred<User>
val orders = async { loadOrders(id) } // runs concurrently with the above
Dashboard(user.await(), orders.await()) // suspends (doesn't block a thread) until both done
}
5.2 Structured concurrency — the core discipline#
Every coroutine runs in a CoroutineScope and has a parent Job. The rules:
- A scope doesn't complete until all its children complete (
coroutineScope { ... }suspends until done). - Cancellation propagates down: cancel the scope → all children cancelled.
- Failure propagates up: a child throws → it cancels its parent → which cancels its siblings. (Unless the parent is a
supervisorScope, where children fail independently.) - No leaked coroutines: when the scope exits, nothing it launched is still running.
This is the same principle Java is now adopting (§4.5) — Kotlin just had it first and made it the default.
// supervisorScope: one child failing does NOT kill the siblings — for independent fan-out.
suspend fun loadWidgets(): List<Widget> = supervisorScope {
ids.map { id -> async { runCatching { loadWidget(id) } } } // isolate failures per widget
.awaitAll().mapNotNull { it.getOrNull() }
}
5.3 Dispatchers — how coroutines map to threads#
| Dispatcher | Backing threads | Use for |
|---|---|---|
Dispatchers.Default | pool sized ≈ #cores (min 2) | CPU-bound work (parsing, hashing, computation) |
Dispatchers.IO | elastic, up to max(64, #cores); shares threads with Default | blocking I/O (JDBC, blocking HTTP, file, RestTemplate) |
Dispatchers.Main | the single UI thread | Android/JavaFX UI updates |
Dispatchers.Unconfined | starts on caller, resumes anywhere | advanced/testing only — rarely correct in prod |
The point of two pools: never do blocking I/O on Dispatchers.Default — a handful of blocking calls exhaust the CPU pool and stall all CPU work. Move blocking calls to IO (which is allowed to spawn many threads because they'll mostly be parked). withContext(Dispatchers.IO) { ... } switches dispatcher for a block and switches back — cheaply, without a new coroutine.
suspend fun report(): Report = withContext(Dispatchers.IO) { // hop to IO for the blocking JDBC call
jdbcTemplate.query(/* blocking */) // then withContext hops back automatically
}
Coroutines vs virtual threads: they solve the same problem (cheap blocking-style concurrency). Coroutines are a compile-time transformation (colored functions — suspend is viral, you can only call suspend from suspend) with a rich operator library (Flow, Channel, select); virtual threads are runtime JVM threads with no function coloring (any blocking code just works, no suspend keyword). On Loom-era JDKs many teams run coroutines on virtual-thread dispatchers. Neither is "faster"; both make blocking cheap.
5.4 Cancellation is cooperative (the #1 coroutine bug)#
Cancelling a coroutine just sets a flag and throws CancellationException at the next suspension point. A coroutine doing a tight CPU loop with no suspension ignores cancellation entirely and runs to completion. You must cooperate: call ensureActive() / yield() in loops, or check isActive.
while (isActive) { /* CPU chunk */ } // cooperates with cancellation
// or, in a suspend loop:
for (item in items) { ensureActive(); process(item) }
Two corollaries: (1) don't swallow CancellationException — a blanket catch (e: Exception) that eats it breaks structured cancellation; rethrow it. (2) cleanup that must run on cancel goes in finally, but if it needs to suspend, wrap it in withContext(NonCancellable).
5.5 Shared mutable state in coroutines — confine, don't lock#
Same three-word enemy. Preferred order:
- Confinement to a single-threaded context — no synchronization needed:
val counterCtx = newSingleThreadContext("counter") // all mutations on ONE thread withContext(counterCtx) { sharedCounter++ } // race-free by confinement Mutex— a suspending, non-blocking lock (the coroutine-correct lock; never usesynchronized/ReentrantLockinsidesuspend— they block the underlying thread, defeating the model):val mutex = Mutex() suspend fun inc() = mutex.withLock { counter++ } // suspends to acquire, doesn't block a thread- Atomics (
AtomicInteger) — fine, they don't block. Channel/ actor — pass ownership by message instead of sharing (CSP style).
5.6 Flow — asynchronous streams, and their thread-safety story#
Flow<T> is a cold async stream (nothing runs until collect). It's sequential and context-preserving — the emitter and collector run in the collector's context by default; you change the upstream context with flowOn (never by calling withContext around emit, which throws). StateFlow/SharedFlow are hot (always live); StateFlow is a thread-safe, conflated "current value" holder — the idiomatic replacement for @Volatile var + listeners in reactive code.
val prices: Flow<Price> = flow { while (true) { emit(fetch()); delay(1000) } }
.flowOn(Dispatchers.IO) // upstream (fetch) runs on IO; collector stays on its own dispatcher
6. Spring Boot: where this actually bites in practice#
This is the section that makes the guide presentable — concrete framework code and the traps that show up in real services.
6.1 Why Spring's default model is thread-safe (and the trap)#
Spring beans are singletons by default, and a servlet/WebFlux container calls them from many threads at once. They're safe only because they're stateless — they hold immutable dependencies (other singletons) and keep all request state in local variables (confinement). The trap is adding a mutable instance field:
class OrderService {
private var lastOrderId: Long = 0 // ❌ SHARED MUTABLE STATE across all requests — data race
private val df = SimpleDateFormat("yyyy-MM-dd") // ❌ SimpleDateFormat is NOT thread-safe — corrupts
fun handle(o: Order) { lastOrderId = o.id; /* ... */ } // two requests race on lastOrderId
}
Fixes: don't keep per-request state on a singleton (use locals); if you need shared counters use AtomicLong/LongAdder; replace SimpleDateFormat with the immutable DateTimeFormatter (java.time) or confine it in a ThreadLocal. If a bean genuinely needs per-request state, use @Scope("request") or @Scope("prototype") — but that's usually a design smell.
Known-thread-safe Spring beans (share freely): JdbcTemplate, RestTemplate, WebClient, JmsTemplate, KafkaTemplate, a configured ObjectMapper. Not thread-safe: SimpleDateFormat, Calendar, a StringBuilder field, raw JPA EntityManager (Spring hands each thread a proxy that resolves to a per-transaction one).
6.2 @Async — offloading work, and its footguns#
class AsyncConfig {
fun taskExecutor(): Executor = // ALWAYS define your own — the default can be unbounded
ThreadPoolTaskExecutor().apply {
corePoolSize = 8; maxPoolSize = 16; setQueueCapacity(100)
setThreadNamePrefix("async-")
initialize()
}
}
class Mailer {
fun send(email: Email): CompletableFuture<Unit> = // return CompletableFuture, not raw value
CompletableFuture.completedFuture(smtp.send(email))
}
Footguns interviewers probe: (1) self-invocation — calling an @Async (or @Transactional) method from within the same bean bypasses the proxy, so it runs synchronously; the annotation only works through the Spring proxy, i.e. from another bean. (2) exceptions in a void/Unit @Async method vanish unless you set an AsyncUncaughtExceptionHandler. (3) The default executor historically was a SimpleAsyncTaskExecutor that creates a new thread per call (no pooling) — always configure your own bounded one. (Spring Boot 3.2+ can back @Async with virtual threads, see below.)
6.3 @Transactional and threads — the rule that trips people#
A Spring transaction (and its JDBC connection / Hibernate session) is bound to the current thread via a ThreadLocal. Consequence: a transaction cannot cross a thread boundary. If you @Async or spawn a thread inside a transactional method, the new thread has no transaction — it gets its own connection or none. So:
- Don't expect work handed to another thread/
@Async/coroutine to run in the caller's transaction. It won't. - Parallel DB calls inside one transaction are a mistake — the connection isn't thread-safe.
- With coroutines, the same applies: the transactional
ThreadLocaldoesn't follow awithContext(Dispatchers.IO)hop. Use Spring's reactive/coroutine transaction support (TransactionalOperator, R2DBC) rather than blocking@Transactionalacross dispatchers.
6.4 Virtual threads in Spring Boot (3.2+)#
One property flips the whole app from a bounded Tomcat pool to thread-per-request on virtual threads:
spring.threads.virtual.enabled=true # Spring Boot 3.2+, on Java 21+
Now each HTTP request runs on its own virtual thread; your blocking JdbcTemplate/RestTemplate/WebClient().block() code scales to thousands of concurrent requests without going reactive. Staff-level caveats to volunteer: (1) bound your connection pool (Hikari) with a Semaphore-like limit — the DB is still the bottleneck; (2) on JDK < 24 audit synchronized blocks around I/O for pinning (fixed by JEP 491 on 24+); (3) don't put virtual threads behind a small fixed pool — that re-serializes them.
6.5 Kotlin suspend controllers (Spring MVC & WebFlux)#
Spring supports suspend controller methods directly — the framework bridges the coroutine to the reactive/servlet pipeline:
class DashboardController(private val svc: DashboardService) {
suspend fun get( id: Long): Dashboard = svc.dashboard(id) // suspends, no thread blocked
}
6.6 Double-checked locking (the classic "implement a thread-safe singleton" question)#
If asked to lazily init a singleton, the DCL pattern is a trap unless the field is volatile:
class Lazy {
private volatile Config config; // volatile is MANDATORY — without it, DCL is broken
Config get() {
Config c = config; // read volatile once into a local (perf)
if (c == null) {
synchronized (this) {
c = config;
if (c == null) config = c = new Config(); // safe publication via volatile write
}
}
return c;
}
}
The senior answer: "I'd avoid hand-rolled DCL — I'd use the initialization-on-demand holder idiom (a static nested class, JVM class-init lock gives lazy + thread-safe for free) in Java, or just by lazy { } / object in Kotlin." Kotlin's object is a thread-safe lazy singleton by construction; by lazy {} defaults to LazyThreadSafetyMode.SYNCHRONIZED.
7. Problems & how to solve them (Problem → Root cause → Fix)#
7.1 The infinite loop that "works in the debugger."
- Problem: a background thread never notices a
stopflag set by another thread. - Root cause: visibility — the flag isn't
volatile, so the JIT hoisted the read into a register (and a debugger's breakpoints insert memory barriers that accidentally "fix" it — hence the Heisenbug). - Fix:
@Volatile/volatilethe flag, or useAtomicBoolean.
7.2 Lost updates on a counter.
- Problem: 1,000 increments from 10 threads yields < 10,000.
- Root cause: atomicity —
count++is read-modify-write; increments interleave and clobber. - Fix:
AtomicInteger.incrementAndGet(), orLongAdderunder heavy contention, or guard with a lock.
7.3 Deadlock.
- Problem: two threads hang forever; thread dump shows each holding one lock and waiting for the other.
- Root cause: inconsistent lock ordering — T1 takes A then B, T2 takes B then A (the classic "transfer(a,b)" vs "transfer(b,a)").
- Fix: impose a global lock ordering (e.g. by
System.identityHashCodeor a business key) so all threads acquire in the same order; or usetryLockwith timeout + back-off; or don't hold two locks (redesign). Never do I/O or call foreign code while holding a lock.
7.4 Check-then-act race (double creation / duplicate charge).
- Problem: two requests both see "no record" and both create it → duplicate.
- Root cause: compound action not atomic (race condition, possibly with zero data race).
- Fix: make it one atomic step —
computeIfAbsent, a DBINSERT ... ON CONFLICT/ unique constraint,putIfAbsent, or an idempotency key. (At Staff level, note that in a distributed system a local lock doesn't help at all — see 7.9.)
7.5 ConcurrentModificationException.
- Problem: iterating a collection while it (or another thread) modifies it.
- Root cause: fail-fast iterator on a non-concurrent collection.
- Fix: iterate a copy, or use
CopyOnWriteArrayList(read-mostly) /ConcurrentHashMap(whose iterators are weakly consistent, never throw).
7.6 ThreadLocal leak / state bleed in a pool.
- Problem: memory grows; occasionally a request sees another user's data.
- Root cause: a
ThreadLocalset on a pooled thread and never removed — it persists into the next task on that thread. - Fix:
remove()in afinally(or use a request-scoped mechanism /ScopedValue). With virtual threads, preferScopedValueand don't cache per-thread.
7.7 Virtual thread carrier starvation (pinning).
- Problem: on JDK 21–23, throughput collapses under load; pinned-thread tracing lights up.
- Root cause: virtual threads blocked on I/O inside
synchronizedcouldn't unmount → carriers exhausted. - Fix: on JDK 24+ this is fixed by JEP 491 — upgrade. On older JDKs, replace
synchronizedaround blocking calls withReentrantLock. Also watch native/FFM calls (still pin).
7.8 Coroutine that won't cancel / blocks the dispatcher.
- Problem: a coroutine ignores
cancel(), or a few requests freeze the whole service. - Root cause: cooperative cancellation ignored in a CPU loop, or blocking I/O on
Dispatchers.Default, or a strayrunBlockinginside asuspendfunction. - Fix:
ensureActive()/yield()in loops and rethrowCancellationException; move blocking toDispatchers.IOviawithContext; neverrunBlockingin suspend code.
7.9 The distributed race — the Staff-level twist on 7.4.
- Problem: you added a
synchronized/ReentrantLock, it fixed it on one node, and it still breaks in production. - Root cause: production runs N instances; a JVM lock only serializes within one process. Across nodes there's no shared monitor.
- Fix: push the invariant to a shared source of truth — a DB unique constraint / optimistic version column (
@Version), a conditional write, or a distributed lock (Redis Redlock, ZooKeeper/etcd lease) — and treat those as expensive, fallible, and needing fencing tokens. Recognizing that "thread safety" doesn't compose across processes is a top signal.
8. Interview framing — how to sound Staff+#
- Lead with the mental model, not the API. "Concurrency bugs come from shared mutable state; my first move is to remove the sharing or the mutability — immutability and confinement before locks. If I must share, I ask: what's the happens-before edge guaranteeing visibility?" This one paragraph signals depth before you write a line.
- Separate correctness from performance, explicitly, in that order. "First I'll make it correct with the coarsest safe locking; then, if profiling shows contention, I'll reduce lock scope, switch to a concurrent collection, or go lock-free." Premature lock-splitting is a red flag; so is ignoring contention entirely.
- Nail the two distinctions interviewers use as depth probes: visibility vs atomicity vs ordering, and data race vs race condition (give the "race condition with no data race" example — §1.4).
- Name the cost model. Uncontended locks are cheap (biased/thin locks); the cost is contention (threads serialize, cache lines bounce). Mention false sharing (two unrelated variables on one 64-byte cache line ping-ponging between cores; fixed with padding/
@Contended) if hardware-level depth is invited. - Be current on Loom. "Virtual threads make blocking cheap, so the industry is swinging back from reactive to simple blocking thread-per-request. They don't add parallelism — CPU-bound work still needs real threads. And since JDK 24,
synchronizedno longer pins them." That sentence alone reads as 2026-current. - Know when concurrency doesn't help. Amdahl's law: the serial fraction caps your speedup. Adding threads to a lock-bound or memory-bandwidth-bound workload makes it slower. Sometimes the right answer is "single-threaded with batching."
- Testing concurrency honestly. "It passed" means nothing — a data race is nondeterministic. Use stress tests with many iterations and thread interleavings, tools like
jcstress(the JMM stress harness), thread-sanitizer-style tooling, and design for determinism (confine, immutability) so there's less to test. Static reasoning ("here's the happens-before edge") beats hoping a test catches it. - Connect to system design (the Staff move). Local thread safety ≠ distributed correctness. A JVM lock is worthless across instances; idempotency, optimistic concurrency (
@Version), unique constraints, outbox/idempotency keys, and distributed locks-with-fencing are the distributed analogues. Tie it to the [[distributed-systems-study-guide]] / [[databases-and-data-storage-study-guide]] material when the conversation goes multi-node.
Common wrong answers to avoid: "just add synchronized everywhere" (contention, deadlock, doesn't compose across nodes); "volatile makes count++ thread-safe" (it doesn't — no atomicity); "use ReentrantLock inside a coroutine" (blocks the dispatcher — use Mutex); "virtual threads make my code faster" (they make blocking cheap, not compute fast); "pool virtual threads" (never — one per task); stating the pre-JDK-24 pinning advice as if it's still current.
9. Cheat-sheets#
Primitive → guarantee:
| Tool | Visibility | Atomicity (single var) | Atomicity (compound) | Ordering | Blocks? |
|---|---|---|---|---|---|
volatile / @Volatile | ✅ | ✅ (incl. long/double) | ❌ | ✅ | no |
synchronized / lock | ✅ | ✅ | ✅ (in the block) | ✅ | yes |
AtomicX (CAS) | ✅ | ✅ | ❌ (single var only) | ✅ | no (spins) |
final / val (safe ctor) | ✅ (after construction) | n/a (immutable) | n/a | ✅ | no |
ConcurrentHashMap | ✅ | ✅ per op | ✅ via compute*/merge | ✅ | mostly no |
Coroutine Mutex | ✅ | ✅ | ✅ (in withLock) | ✅ | suspends (no thread block) |
Which tool when:
| Situation | Reach for |
|---|---|
| One writer, many readers (flag/reference) | volatile / @Volatile / StateFlow |
| Counter/accumulator, low contention | AtomicInteger/AtomicLong |
| Counter, high contention, read rarely | LongAdder |
| Shared map with atomic upserts | ConcurrentHashMap + computeIfAbsent/merge |
| Read-mostly small list | CopyOnWriteArrayList |
| Multi-variable invariant | one lock guarding all of them (ReentrantLock/synchronized) |
| Need timeout / interruptible / conditions | ReentrantLock |
| Read-heavy, long critical sections | ReadWriteLock / StampedLock (optimistic) |
| High-concurrency blocking I/O (Java) | virtual threads, one per task |
| Async composition (pre-Loom / reactive) | CompletableFuture |
| Kotlin async, structured, cancellable | coroutines + coroutineScope/async |
| Shared state in coroutines | confine to single dispatcher → Mutex → atomics → Channel |
| Per-request context, virtual threads | ScopedValue (not ThreadLocal) |
Java threads vs virtual threads vs coroutines:
| Platform thread | Virtual thread (Loom) | Kotlin coroutine | |
|---|---|---|---|
| Scheduled by | OS | JVM | dispatcher (library) |
| Cost each | ~1 MB stack, expensive | ~hundreds of bytes | tiny heap object |
| How many | thousands | millions | millions |
| Blocking | wastes the thread | cheap (unmounts) | cheap (suspends) |
| Function coloring | none | none | suspend is viral |
| Cancellation | interrupt (cooperative) | interrupt (cooperative) | cooperative, structured |
| Best for | CPU-bound / small N | high-concurrency blocking I/O | async + streams, structured |
10. Self-test (say the answers out loud)#
- Give an example of a race condition with no data race, and one of a data race. Which fix applies to which?
- Exactly which guarantees does
volatileprovide and which does it not? Why isvolatileCount++still broken? - Recite four ways to safely publish an object. What breaks the
final-field guarantee? - A background thread never sees a
boolean stopflag. Name the JMM property at fault and two fixes. - Why does
AtomicLongcollapse under heavy contention and what replaces it? What's the ABA problem? - What actually happens, mechanically, when a virtual thread blocks on I/O? When does it still pin, and what changed in JDK 24?
- Why must you not call blocking I/O on
Dispatchers.Default? Why isMutexcorrect inside asuspendfunction butReentrantLockis not? - Explain cooperative cancellation in coroutines. Why might a CPU-bound coroutine ignore
cancel()? - A Spring
@Servicesingleton has aprivate var count. Why is it a bug, and what are two fixes? - Your local lock fixed a race in test but it still duplicates in production. Why, and what's the distributed fix?
- Sketch structured concurrency in both Java (JDK 25 preview) and Kotlin. What does "failure propagates up, cancellation propagates down" mean?
- When does adding threads make a program slower? (Amdahl, contention, false sharing, memory bandwidth.)
Lineage & sources: JMM/happens-before per JLS Ch. 17 (JSR-133, Java 5); Goetz et al., Java Concurrency in Practice (the canonical text — dated on Loom but timeless on the JMM). Virtual threads: JEP 444 (GA, Java 21); synchronized pinning removed by JEP 491 (JDK 24). Scoped Values: JEP 506 (final, Java 25). Structured Concurrency: JEP 505 (25) → JEP 525 (26) → JEP 533 seventh preview, targeting JDK 27 — still preview, not yet finalized. Kotlin coroutines: kotlinx.coroutines structured-concurrency model, Dispatchers, Flow. Verify version-specific claims against your target JDK — the Loom-adjacent APIs are still moving.