Protecting critical sections, collections for a high-frequency trading book, ConcurrentModificationException causes and fixes, ReentrantLock vs synchronized, building safe concurrent task processing, immutability for threads, atomicity without synchronized, investigating OutOfMemoryError, reference types, Metaspace vs PermGen, a correct producer–consumer, and executor interruption.
Published September 25, 2026
These questions blend concurrency correctness with performance under load. Answer with the right primitive, then explain why it's correct: which happens-before edge it creates, what contention it causes, and how you'd verify it (tests, JFR, thread dumps).
Short answer: First try to eliminate the sharing: confinement, immutability, or partitioning by key. If you can't:
synchronized, or a ReentrantLock when you need timeouts, interruptibility or fairness.Semaphore when N concurrent users are allowed (for example, a limited connection pool).ReadWriteLock or StampedLock for read-heavy data.private final ReentrantLock lock = new ReentrantLock();
boolean reserveSeat(String seat) throws InterruptedException {
if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) throw new BusyException(); // bounded waiting
try {
if (booked.contains(seat)) return false;
booked.add(seat);
return true;
} finally {
lock.unlock();
}
}
Key points to cover:
SELECT … FOR UPDATE, optimistic locking) or a distributed lock.Learn it in depth → Synchronized and Locks
Short answer: You need sorted, concurrent access to price levels. A plain TreeMap/TreeSet keeps order, with O(log n) operations, but it isn't thread-safe. The options:
ConcurrentSkipListMap<BigDecimal or long, PriceLevel>: sorted, concurrent, lock-free reads, O(log n). Its firstKey(), ceilingKey() and headMap() give best-bid/best-ask and depth queries.long ticks (fixed-point), primitive collections (Eclipse Collections, fastutil or Agrona), and preallocated objects.NavigableMap<Long, Level> bids = new ConcurrentSkipListMap<>(Comparator.reverseOrder()); // best bid first
NavigableMap<Long, Level> asks = new ConcurrentSkipListMap<>(); // best ask first
long bestBid = bids.firstKey();
long spread = asks.firstKey() - bestBid;
Common trap: "a TreeMap, because it's sorted". It's correct only if a single thread touches it. And re-sorting a list on every tick is O(n log n) per update.
ConcurrentModificationException, and how do you prevent it?Short answer: Fail-fast iterators track a modCount. If the collection is structurally modified during iteration, other than through the iterator itself, the next next() throws. The cause is often a single thread (removing inside a for-each loop), not concurrency.
Prevention:
iterator.remove(), removeIf, or list.replaceAll.ConcurrentHashMap, or CopyOnWriteArrayList for read-mostly lists). Their weakly consistent iterators never throw.orders.removeIf(Order::isCancelled); // instead of remove() inside for-each
Key points to cover:
ReentrantLock, and how does it differ from synchronized?Short answer: Both are reentrant mutual-exclusion locks with the same memory semantics. ReentrantLock adds:
tryLock (with a timeout);lockInterruptibly;Conditions;getQueueLength, isHeldByCurrentThread).The cost is that you must unlock manually in finally. synchronized is simpler, can't leak, and is well optimised.
Key points to cover:
synchronized blocks, so ReentrantLock was preferred for blocking code on virtual threads. JDK 24 (JEP 491) removed most of that pinning.Learn it in depth → Synchronization, Locks & Deadlocks
Short answer:
ExecutorService, either a bounded ThreadPoolExecutor (sized for CPU-bound or I/O-bound work, with a bounded queue and a rejection policy) or virtual threads (newVirtualThreadPerTaskExecutor) for blocking I/O.CompletableFuture (supplyAsync, thenCombine, allOf, orTimeout), or structured concurrency (StructuredTaskScope, a preview API), for fan-out/fan-in with cancellation.CountDownLatch, Semaphore (to limit concurrency towards a downstream service), Phaser.try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Quote>> quotes = suppliers.stream()
.map(s -> executor.submit(() -> s.quote(request)))
.toList();
// collect results with timeouts; cancel the rest on the first failure if needed
}
Learn it in depth → ExecutorService
Short answer: An immutable object has no state changes, so there are no races, no need for locks, and no torn reads. With final fields and safe construction, the JMM guarantees every thread sees it fully initialised. You can share it freely, cache it, and use it as a map key. To "update" shared immutable state, publish a new instance atomically (volatile or AtomicReference), which is the copy-on-write style.
Key points to cover:
synchronized?Short answer:
AtomicInteger, AtomicLong, AtomicReference), using CAS (compare-and-swap) loops: incrementAndGet, compareAndSet, updateAndGet, accumulateAndGet.LongAdder/LongAccumulator, for highly contended counters.ConcurrentHashMap.compute and merge, for per-key atomic updates.VarHandle, for low-level atomic field access.Locks, which are still locks, just without the keyword.private final AtomicReference<Balance> balance = new AtomicReference<>(Balance.ZERO);
boolean withdraw(long amount) {
while (true) {
Balance cur = balance.get();
if (cur.amount() < amount) return false;
if (balance.compareAndSet(cur, cur.minus(amount))) return true; // retry if another thread won the race
}
}
Key points to cover:
OutOfMemoryError. How do you investigate?Short answer:
Java heap space, or GC overhead limit exceeded: a heap problem.Metaspace: class metadata, or class-loader leaks.unable to create native thread: too many threads, or OS limits.Direct buffer memory: NIO/Netty off-heap memory.-XX:+HeapDumpOnOutOfMemoryError configured in advance), GC logs, JFR recordings, and memory metrics over time.-XX:MaxRAMPercentage).Learn it in depth → Memory Leaks in Java
Short answer:
SoftReference): cleared at the GC's discretion, before an OutOfMemoryError. They were historically used for memory-sensitive caches.WeakReference): cleared at the next GC, once no strong or soft references remain. Used by WeakHashMap keys and canonicalising maps.PhantomReference): get() always returns null. The reference is enqueued after the object becomes unreachable, so you can run post-mortem cleanup of native resources. This is the mechanism behind java.lang.ref.Cleaner, the replacement for finalize().Key points to cover:
Short answer: PermGen (up to Java 7) was a fixed-size region of the Java heap holding class metadata and, before Java 7, interned strings. Too many classes led to OutOfMemoryError: PermGen space. Metaspace (Java 8+) stores class metadata in native memory, and grows dynamically, with -XX:MaxMetaspaceSize as an optional cap. Interned strings and static fields live on the regular heap.
Key points to cover:
wait and notify.Short answer: Use a bounded buffer guarded by one monitor:
notifyAll(), because producers and consumers share the same wait set;public final class BoundedBuffer<T> {
private final Deque<T> items = new ArrayDeque<>();
private final int capacity;
public BoundedBuffer(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
while (items.size() == capacity) wait(); // loop: spurious wakeups, and competing producers
items.addLast(item);
notifyAll(); // wake consumers (notify() could wake another producer)
}
public synchronized T take() throws InterruptedException {
while (items.isEmpty()) wait();
T item = items.removeFirst();
notifyAll(); // wake producers waiting for space
return item;
}
}
// usage: producers call buffer.put(x); consumers call buffer.take(); simulate work OUTSIDE the lock:
// T job = buffer.take(); process(job);
Common trap: the widespread version calls Thread.sleep() inside the synchronized block, so the other side can never make progress during the sleep. It also uses a single notify() with two different wait conditions, which can wake the wrong kind of thread, and stall. In production, use a BlockingQueue (ArrayBlockingQueue), or a ReentrantLock with two Conditions (notFull, notEmpty).
Learn it in depth → Producer-Consumer Class Design
Short answer: Executors interrupt worker threads on shutdownNow(), and on Future.cancel(true). Interruption is cooperative: the task must notice it (blocking calls throw InterruptedException, or it checks Thread.currentThread().isInterrupted()), then stop. Best practices:
InterruptedException. Either propagate it, or restore the flag with Thread.currentThread().interrupt().finally.future.get(timeout), orTimeout), and cancel on timeout.shutdown → awaitTermination → shutdownNow).Key points to cover:
InputStream.read) doesn't respond to interruption. Use socket timeouts, or NIO interruptible channels. CompletableFuture.cancel(true) doesn't interrupt the running task, it only completes the future.Q: What is false sharing?
A: Two threads write different variables that sit on the same CPU cache line, so the line bounces between cores, and performance drops. Mitigate it with padding (@Contended in JDK internals), or by separating hot fields. LongAdder is designed with this in mind.
Q: What is StampedLock's optimistic read?
A: You read without locking, then validate the stamp. If a writer intervened, you retry, or fall back to a read lock. It's very fast for read-mostly data, but it isn't reentrant, and it needs careful use.
Q: How do you choose the pool size for I/O-bound tasks?
A: A rough formula is threads ≈ cores × (1 + wait time / compute time), validated by load testing, or use virtual threads, and limit concurrency towards each downstream service with semaphores.
Q: What does -XX:+ExitOnOutOfMemoryError do, and why use it?
A: It terminates the JVM on the first OOM. That's better than limping on in an undefined state. The orchestrator restarts the container, and the heap dump (if configured) is captured first.