Hands-on Java concurrency implementations interviewers ask seniors to write — a custom thread pool, a bounded blocking queue, a countdown latch, a custom Future, and a concurrent file processor — with correct locking, conditions, interruption handling, and shutdown.
Published September 25, 2026
In concurrency coding rounds, correctness details are what get scored:
while loop (spurious wakeups);InterruptedException properly (restore the flag, or propagate it);Say what you'd use in production (java.util.concurrent), then write the simplified version. Related implementations (thread-safe singleton, producer-consumer, deadlock, concurrent LRU, read-write lock, CompletableFuture, ForkJoinPool, and the atomic counter) are covered in the Senior concurrency chapter.
Short answer: Use one ReentrantLock with two conditions, notFull and notEmpty, over a circular array:
put waits while the queue is full, inserts, then signals notEmpty;take waits while it's empty, removes, then signals notFull.(The alternative is synchronized with wait/notifyAll: simpler, but it wakes both producers and consumers.) In production, use ArrayBlockingQueue or LinkedBlockingQueue.
public final class BoundedBlockingQueue<E> {
private final Object[] items; private int head, tail, count;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(), notEmpty = lock.newCondition();
public BoundedBlockingQueue(int capacity) { items = new Object[capacity]; }
public void put(E e) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == items.length) notFull.await();
items[tail] = e; tail = (tail + 1) % items.length; count++;
notEmpty.signal();
} finally { lock.unlock(); }
}
@SuppressWarnings("unchecked")
public E take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0) notEmpty.await();
E e = (E) items[head]; items[head] = null; head = (head + 1) % items.length; count--;
notFull.signal();
return e;
} finally { lock.unlock(); }
}
}
Common trap: using if instead of while around await(). After waking up (spuriously, or after another thread consumed the item first), the condition must be re-checked.
Short answer: Use a fixed set of worker threads that loop on queue.take() and run the tasks.
shutdownNow interrupts the workers.In production, use ThreadPoolExecutor, which adds core and maximum sizes, keep-alive, thread factories and rejection handlers.
public final class SimpleThreadPool {
private final BlockingQueue<Runnable> queue;
private final List<Thread> workers = new ArrayList<>();
private volatile boolean shutdown;
public SimpleThreadPool(int threads, int queueCapacity) {
queue = new ArrayBlockingQueue<>(queueCapacity);
for (int i = 0; i < threads; i++) {
Thread t = new Thread(this::runWorker, "pool-worker-" + i);
workers.add(t); t.start();
}
}
public void execute(Runnable task) {
if (shutdown) throw new RejectedExecutionException("pool is shut down");
if (!queue.offer(task)) throw new RejectedExecutionException("queue full"); // rejection policy
}
private void runWorker() {
while (true) {
Runnable task;
try {
task = shutdown ? queue.poll() : queue.take(); // drain remaining tasks after shutdown
} catch (InterruptedException e) {
if (shutdown) { task = queue.poll(); } else continue;
}
if (task == null) return; // queue drained and shut down
try { task.run(); } catch (RuntimeException ex) { /* log; keep the worker alive */ }
}
}
public void shutdown() { shutdown = true; workers.forEach(Thread::interrupt); }
public void awaitTermination() throws InterruptedException { for (Thread t : workers) t.join(); }
}
Short answer: Keep a counter guarded by a monitor. countDown() decrements it (never below 0), and calls notifyAll() when it reaches 0. await() waits while count > 0. It's one-shot: it can't be reset (that's what CyclicBarrier is for). The JDK's CountDownLatch is built on the AQS shared mode.
public final class SimpleLatch {
private int count;
public SimpleLatch(int count) { if (count < 0) throw new IllegalArgumentException(); this.count = count; }
public synchronized void countDown() {
if (count > 0 && --count == 0) notifyAll();
}
public synchronized void await() throws InterruptedException {
while (count > 0) wait();
}
public synchronized boolean await(long timeout, TimeUnit unit) throws InterruptedException {
long deadline = System.nanoTime() + unit.toNanos(timeout);
while (count > 0) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) return false;
TimeUnit.NANOSECONDS.timedWait(this, remaining);
}
return true;
}
public synchronized long getCount() { return count; }
}
Short answer: A future holds a state (pending, done, failed or cancelled) plus a result or an exception.
complete and completeExceptionally set it once and wake the waiters.get() blocks while pending (with a timeout variant), and rethrows failures wrapped in ExecutionException.This is what FutureTask and CompletableFuture do, with lock-free CAS operations.
public final class SimpleFuture<T> {
private enum State { PENDING, DONE, FAILED, CANCELLED }
private State state = State.PENDING; private T value; private Throwable error;
private final List<Consumer<SimpleFuture<T>>> callbacks = new ArrayList<>();
public boolean complete(T v) { return finish(State.DONE, v, null); }
public boolean completeExceptionally(Throwable t) { return finish(State.FAILED, null, t); }
public boolean cancel() { return finish(State.CANCELLED, null, new CancellationException()); }
private boolean finish(State s, T v, Throwable t) {
List<Consumer<SimpleFuture<T>>> toRun;
synchronized (this) {
if (state != State.PENDING) return false; // complete only once
state = s; value = v; error = t;
notifyAll();
toRun = List.copyOf(callbacks); callbacks.clear();
}
toRun.forEach(cb -> cb.accept(this)); // run callbacks outside the lock
return true;
}
public synchronized T get() throws InterruptedException, ExecutionException {
while (state == State.PENDING) wait();
return report();
}
public synchronized T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
long deadline = System.nanoTime() + unit.toNanos(timeout);
while (state == State.PENDING) {
long left = deadline - System.nanoTime();
if (left <= 0) throw new TimeoutException();
TimeUnit.NANOSECONDS.timedWait(this, left);
}
return report();
}
private T report() throws ExecutionException {
if (state == State.CANCELLED) throw (CancellationException) error;
if (state == State.FAILED) throw new ExecutionException(error);
return value;
}
public void onComplete(Consumer<SimpleFuture<T>> cb) {
synchronized (this) { if (state == State.PENDING) { callbacks.add(cb); return; } }
cb.accept(this);
}
public synchronized boolean isDone() { return state != State.PENDING; }
}
Short answer: Example: count the words across many large files, or process each line of a huge file.
ExecutorService sized for the workload: I/O-bound work can use virtual threads (Java 21+), CPU-bound work should use about the number of cores.Files.lines or a BufferedReader); never load huge files entirely into memory.ConcurrentHashMap with merge or LongAdders, or per-task local maps merged at the end (less contention).Map<String, Long> countWords(List<Path> files) throws InterruptedException {
ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { // Java 21; close() waits for tasks
List<Future<?>> futures = new ArrayList<>();
for (Path file : files) {
futures.add(pool.submit(() -> {
try (Stream<String> lines = Files.lines(file)) {
lines.flatMap(l -> Arrays.stream(l.split("\\W+")))
.filter(w -> !w.isBlank())
.forEach(w -> counts.computeIfAbsent(w.toLowerCase(), k -> new LongAdder()).increment());
}
return null;
}));
}
for (Future<?> f : futures) {
try { f.get(); } catch (ExecutionException e) { /* log the failed file; continue with the others */ }
}
}
Map<String, Long> result = new HashMap<>();
counts.forEach((w, adder) -> result.put(w, adder.sum()));
return result;
}
Q: Why use signal() rather than signalAll() with separate conditions?
A: With separate notFull and notEmpty conditions, every waiter on a condition is waiting for the same thing, so waking one is enough and avoids a thundering herd. With a single monitor (wait/notify), you need notifyAll, because producers and consumers share one wait set.
Q: How should a worker handle InterruptedException?
A: Either propagate it, or restore the flag with Thread.currentThread().interrupt() and exit, so the code that owns the thread (the pool, during shutdown) sees the interruption. Swallowing it silently breaks cancellation.
Q: Why run future callbacks outside the lock? A: Callbacks are foreign code. Running them while holding the lock risks deadlocks (if they call back into the future or take other locks) and blocks other threads unnecessarily.
Q: LongAdder or AtomicLong for counters?
A: LongAdder spreads updates across cells, so it's far faster under heavy write contention; its sum() isn't an atomic snapshot. AtomicLong suits low contention, or when you need compare-and-set or exact point-in-time values.