Java concurrency
synchronized vs volatile in Java
Two keywords, two different jobs. synchronized gives mutual exclusion plus visibility: one thread at a time holds the monitor, and releasing it publishes every write made inside to the next thread that acquires it. volatile gives visibility and ordering only, no mutual exclusion: a write is published to later reads, and the compiler can't hoist the field into a register or reorder accesses across it.
The trap interviewers reach for first is the counter:
volatile long count = 0;
// ten threads each run this a million times
count++; // NOT thread-safe, final total < 10_000_000
Wrong "It's volatile, so the increment is safe." count++ is three actions (load, add, store). volatile makes each action visible, but nothing stops two threads loading the same value and both storing value+1, so increments overwrite each other. Reach for AtomicLong (a lock-free CAS loop) or LongAdder when many threads write a hot counter. volatile is the right tool for a one-writer flag, like a boolean stop a background loop polls.
happens-before: the rule that decides correctness
The Java Memory Model does not promise that a write by one thread is visible to another. It promises visibility only when a happens-before edge connects them. Without an edge the JIT, CPU, and caches are free to reorder and to keep values thread-local. The edges you cite in interviews:
- Monitor: unlocking a lock happens-before the next lock on the same monitor.
- volatile: a write happens-before every later read of that field.
- Thread:
Thread.start()happens-before the new thread's first action; a thread's actions happen-before another's return fromjoin().
If two accesses to shared mutable state have no edge between them, you have a data race, and a race is a bug even if it passes on your laptop.
Thread pools instead of raw threads
Creating a new Thread() per task is a bug at scale: no bound on thread count, no queue, no reuse. Use an ExecutorService. Size it to the work. CPU-bound tasks want about one thread per core (cores or cores+1), because more threads just fight over the same CPUs. I/O-bound tasks, where each thread spends most of its life waiting, want many more, scaled by the wait-to-compute ratio.
Watch the queue. Executors.newFixedThreadPool(n) pairs a bounded pool with an unbounded queue, so a flood of tasks grows the queue until you run out of heap. newCachedThreadPool has the opposite failure: it spawns a new thread per task with no ceiling. For anything load-bearing, construct a ThreadPoolExecutor with a bounded queue and a rejection policy you chose on purpose.
The JMM as a reasoning tool, not trivia
At this level happens-before stops being a list to recite and becomes the thing you argue with. Double-checked locking is the proof interviewers reach for:
private static Singleton instance; // broken without volatile
static Singleton get() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}
The write instance = new Singleton() is really allocate, construct, publish. Without a happens-before edge those steps can be reordered so the reference is visible before the constructor finished, and a second thread on the fast path (no lock) reads a half-built object. Marking instance volatile inserts the edge and forbids the reorder. Safe publication comes from a final field (frozen at construction), a volatile field, or a hand-off through a concurrent collection.
CompletableFuture composition and its executor trap
CompletableFuture is the async pipeline: thenApply maps a value, thenCompose flattens a stage that returns its own future (map vs flatMap), thenCombine joins two, allOf/anyOf fan in, exceptionally/handle recover. The detail that separates people who've run this in production:
CompletableFuture.supplyAsync(() -> blockingDbCall()) // runs on the COMMON pool
.thenApply(row -> transform(row)); // runs on the completing thread
Wrong "supplyAsync gives every task its own thread." The default async executor is ForkJoinPool.commonPool(), sized to cores minus one. Block inside it (JDBC, HTTP) and you starve every other user of the common pool, including parallel streams. Pass an explicit Executor for blocking work. A non-Async stage runs in whatever thread completed the previous stage, which may be a pool thread you did not mean to occupy.
What virtual threads solve, and what they don't
A virtual thread (JEP 444, GA in JDK 21) mounts on a carrier platform thread from a small ForkJoinPool. When it blocks on JDK I/O or Thread.sleep, it unmounts and frees the carrier, so a million blocked threads cost almost nothing. The pitch: write straight-line blocking code, get event-loop scalability for I/O-bound work. They give nothing to CPU-bound work, still capped at one busy thread per core, and you create one per task rather than pooling them.
The pinning trap is the senior tell. On JDK 21 through 23, a virtual thread that blocks inside a synchronized block pins its carrier, which cannot then be reused, and throughput quietly collapses under load. The fix was to swap synchronized for ReentrantLock. JEP 491 reworked the JVM monitor in JDK 24 (March 2025) so synchronized no longer pins; native (JNI) frames still do. Diagnose either era with the jdk.VirtualThreadPinned JFR event.
ConcurrentHashMap vs the synchronized collections
Collections.synchronizedMap and Hashtable wrap one lock around every operation, so readers block writers and each other. ConcurrentHashMap (Java 8 form) is a bucket array where reads are lock-free, an insert into an empty bucket is a CAS, and a collision synchronizes on that bucket's head node only. Saying "16 segments" is a Java 7 answer and dates you. Two consequences interviewers probe: iterators are weakly consistent and never throw ConcurrentModificationException, and check-then-act across two calls is still a race, which is why compute, merge, and computeIfAbsent do the read-modify-write atomically under the bin lock. Keys and values cannot be null, because a null get would be ambiguous with absence.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.