Skip to content

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(); }
};
Then say: "In Prayog I used the LMAX Disruptor instead: a pre-allocated ring buffer with no locks and no allocation per message, and batching for free."

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