Java concurrency¶
The senior differentiator. Prayog gives you real examples for almost every question here.
Fundamentals¶
Process vs. thread?
A process has its own memory space; threads in a process share the heap but each has its own stack and program counter. Threads are cheaper to create and switch, but sharing memory needs synchronisation.
What is a race condition? Give an example.
The result depends on the timing of threads. count++ is read-modify-write: two threads read 5, both write 6, one
increment is lost. Fix with AtomicInteger.incrementAndGet(), a lock, or by not sharing (one owner thread).
Prayog link: a pipeline start/stop race lost commands in 261 of 300 runs; found by looping the test, fixed by
making start() return only when every handler had started (failure 2).
What does synchronized guarantee?
Mutual exclusion (one thread at a time holds the monitor) and visibility (releasing a monitor
happens-before the next acquire of the same monitor, so writes made inside are seen by the next holder). It is
re-entrant. On a method it locks this (or the class for static).
What does volatile do? Is volatile int count; count++ thread-safe?
volatile guarantees visibility (a write is seen by subsequent reads in other threads) and ordering
(no reordering across it: a volatile write happens-before every later read of it). It does not make compound
actions atomic, so count++ is still a race. Use it for flags (volatile boolean running) and safe publication.
What is the Java Memory Model / happens-before?
The JMM defines when a write by one thread is guaranteed visible to another. Without a happens-before
relationship, the compiler and CPU may reorder and cache, so another thread may never see a write. Happens-before
edges: program order within a thread, unlock → later lock of the same monitor, volatile write → later read,
Thread.start() → actions in the started thread, actions in a thread → another thread's join() returning,
final fields after the constructor (safe initialisation).
Prayog link: the Disruptor relies on this: a producer writes the slot, then does a volatile/ordered write of the sequence; a consumer reads the sequence, then the slot. The sequence publish is the happens-before edge, so no locks are needed.
What is a deadlock? How do you prevent it?
Two (or more) threads each hold a lock the other needs. Four conditions: mutual exclusion, hold and wait, no
pre-emption, circular wait. Prevent by acquiring locks in a global order, using tryLock with a timeout,
keeping lock scopes small, or not sharing state. Diagnose with a thread dump (jstack), which reports deadlocks.
Also know livelock (threads keep reacting to each other, no progress) and starvation (a thread never gets
the lock/CPU).
wait/notify vs. sleep?
wait() must be called holding the monitor, releases it, and waits for notify; always call it in a loop
checking the condition (spurious wake-ups). sleep() keeps any locks and just pauses. Modern code uses
java.util.concurrent (BlockingQueue, CountDownLatch, Condition) instead of wait/notify.
java.util.concurrent¶
ReentrantLock vs. synchronized?
ReentrantLock adds tryLock (with timeout), interruptible locking, fairness option, and multiple Conditions.
You must unlock() in finally. synchronized is simpler and the JVM optimises it well; use it unless you need
the extras. ReadWriteLock lets many readers or one writer; StampedLock adds optimistic reads.
What are atomic classes and CAS?
AtomicInteger, AtomicLong, AtomicReference use compare-and-swap, a CPU instruction that sets a value
only if it still equals the expected one, retrying on failure. Lock-free, no blocking. LongAdder is faster than
AtomicLong under heavy contention (striped cells, summed on read). The ABA problem: value changes A→B→A
and CAS succeeds wrongly; AtomicStampedReference adds a version.
Explain thread pools and ExecutorService. How do you size one?
A pool reuses threads to run tasks from a queue. ThreadPoolExecutor(core, max, keepAlive, queue, rejectionHandler).
Tasks fill core threads first, then the queue, then grow to max, then are rejected. Sizing: CPU-bound ≈
number of cores (+1); I/O-bound ≈ cores × (1 + wait time / compute time). Use a bounded queue and a rejection
policy (e.g. CallerRunsPolicy for backpressure). Avoid Executors.newFixedThreadPool / newCachedThreadPool
in servers: unbounded queue (memory) or unbounded threads. Always shutdown().
Runnable vs. Callable; Future vs. CompletableFuture?
Callable returns a value and can throw checked exceptions. Future.get() blocks. CompletableFuture composes
asynchronously: supplyAsync(...).thenApply(...).thenCompose(...).thenCombine(other, ...).exceptionally(...),
and allOf/anyOf. Pass your own executor; the default is the common ForkJoinPool.
CompletableFuture<Price> price = CompletableFuture.supplyAsync(() -> prices.get(id), ioPool);
CompletableFuture<Risk> risk = CompletableFuture.supplyAsync(() -> riskSvc.get(id), ioPool);
Report r = price.thenCombine(risk, Report::new)
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> Report.unavailable())
.join();
CountDownLatch vs. CyclicBarrier vs. Semaphore?
CountDownLatch: one-shot; threads wait until the count reaches zero (wait for N services to start).
CyclicBarrier: N threads wait for each other, reusable (phases of a simulation). Semaphore: limits concurrent
access to N permits (max 10 concurrent calls to a downstream service).
What is ThreadLocal and what's the risk?
A per-thread variable (request context, SimpleDateFormat before java.time). In a thread pool, threads are
reused, so values leak between requests and cause memory leaks unless you remove() in a finally. Java 21
offers ScopedValue (preview) as a safer alternative with virtual threads.
Write a producer-consumer.
BlockingQueue<Order> queue = new ArrayBlockingQueue<>(1_000); // bounded = backpressure
Runnable producer = () -> {
try { while (true) queue.put(nextOrder()); } // blocks when full
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
Runnable consumer = () -> {
try { while (true) process(queue.take()); } // blocks when empty
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
Lock-free design and the Disruptor¶
What does 'lock-free' mean, and how is Prayog's matching lock-free?
Lock-free: at least one thread always makes progress; no thread can block others by holding a lock. Prayog
avoids locks by single-writer design: only the matching thread touches the order book, so there is nothing to
lock. Stages hand data over through the Disruptor ring using sequence numbers (CAS for multiple producers, ordered
writes for publication). The one lock near the answer path is in MarketHub, shared on purpose with snapshot
readers, and it was not a hotspot.
What is false sharing?
Two threads write different variables that sit on the same 64-byte cache line, so the line bounces between cores
and both slow down. Fix with padding (the Disruptor pads its sequence counters) or @Contended.
Busy-spin vs. blocking wait strategy?
Busy-spin keeps the consumer thread spinning on the sequence: lowest latency (0.5 µs median in Prayog's benchmark) but burns a whole core. Blocking parks the thread until signalled: frees the CPU, but wake-up costs latency (~24 µs median). Prayog defaults to blocking because a simulated market doesn't need microseconds.
Concurrency in a Spring service¶
Are Spring beans thread-safe?
Singleton beans are shared across all request threads, so they must be stateless (no mutable instance fields)
or protect shared state. Request data goes in method parameters/locals. @Async and @Scheduled methods run on
other threads.
How would you stop two instances of a service processing the same job?
A distributed lock (DB row lock with SELECT ... FOR UPDATE SKIP LOCKED, ShedLock for scheduled jobs, Redis
SET key value NX PX with a fencing token), or partition the work (Kafka consumer group assigns each partition to
one consumer). And make the job idempotent anyway.
Optimistic vs. pessimistic locking?
Pessimistic: lock the row while working (SELECT ... FOR UPDATE); safe under high contention but blocks.
Optimistic: read a version, update WHERE id = ? AND version = ?; if 0 rows updated, someone else won, so
retry. Better when conflicts are rare (JPA @Version).