Skip to content

Concurrent Collections Interview Questions & Answers

20 questions Updated 2026-06-20 Share:

Java concurrent collections interview questions — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue, the atomic classes, synchronized wrappers vs concurrent collections, and weakly consistent iterators.

Read the in-depth guideJava Concurrent Collections — ConcurrentHashMap, BlockingQueue & Atomics(opens in new tab)
20 of 20

Collections.synchronizedMap/synchronizedList wrap a plain collection so that every method holds one lock on the whole object. That's correct but it serializes all access — readers block writers and each other — so it doesn't scale under contention. The java.util.concurrent collections use finer-grained locking and lock-free techniques (CAS, lock striping, copy-on-write) so many threads can proceed at once.

Map<String,Integer> a = Collections.synchronizedMap(new HashMap<>()); // one lock
Map<String,Integer> b = new ConcurrentHashMap<>();                    // scalable

The other win: concurrent collections give weakly consistent iterators that never throw ConcurrentModificationException, whereas iterating a synchronized wrapper still requires you to manually synchronize on it for the whole loop. Rule of thumb: reach for ConcurrentHashMap/CopyOnWriteArrayList first; the synchronized wrappers are legacy.

A HashMap is not thread-safe, and concurrent writes can corrupt it. Beyond lost updates, a resize happening while another thread reads can leave the internal bucket structure in an inconsistent state — in older JDKs this could even spin a thread into an infinite loop (a hung CPU core). Behavior is simply undefined, which is worse than a clean exception.

Map<Integer,Integer> m = new HashMap<>();
// two threads both doing m.put(...) concurrently:
// -> lost entries, corrupted buckets, or a CPU-pegging infinite loop

You can't "mostly get away with it" — the failure is intermittent and catastrophic. Rule of thumb: if a map is shared across threads, use ConcurrentHashMap; a raw HashMap is only safe when confined to one thread or published immutably.

Since Java 8 it locks at the individual bucket (bin) level, not the whole map. An empty bucket is populated with a single CAS (compare-and-swap, no lock); a non-empty bucket is updated under a synchronized block on that bin's first node. Because locking is per-bucket, threads touching different buckets never contend.

// conceptually, per bucket:
if (bucket == null) casBucket(node);   // lock-free fast path
else synchronized (firstNode) { ... }  // only this bin is locked

Reads are lock-free — fields are volatile, so a get sees a consistent value without acquiring any lock. (Pre-Java 8 it used segment locking, typically 16 stripes, which is the older "lock striping" answer.) Rule of thumb: writes lock one bin; reads lock nothing — that's why it scales.

ConcurrentHashMap throws NullPointerException on a null key or value, unlike HashMap which allows them. The reason is ambiguity in a concurrent setting: with get(key) returning null, you can't tell whether the key is absent or mapped to null — and you can't fix it with containsKey because another thread may change the mapping between the two calls (a race).

Map<String,String> m = new ConcurrentHashMap<>();
m.put("k", null);   // NullPointerException
m.get("missing");   // null is now an unambiguous "absent"

By banning null, a null return always means "not present," which keeps the lock-free read path sound. Rule of thumb: never store null in a concurrent map — use a sentinel value or Optional if absence must be distinguished.

It exposes atomic compound operations so you don't have to lock externally for check-then-act patterns:

Method Atomic behavior
putIfAbsent(k,v) put only if key absent
computeIfAbsent(k,f) compute & store if missing (one-time init)
computeIfPresent(k,f) update only if present
compute(k,f) recompute from current value
merge(k,v,f) combine new value with existing
ConcurrentHashMap<String,Integer> counts = new ConcurrentHashMap<>();
counts.merge("hits", 1, Integer::sum);   // atomic increment-or-init
List<Object> cache = counts.computeIfAbsent("x", k -> new ArrayList<>()); // safe lazy init

Each runs atomically with the bin locked, so two threads can't interleave the read and the write. Rule of thumb: prefer merge/compute* over get-then-put, which is not atomic even on a concurrent map.

Because the map is never globally locked, size() cannot freeze every bucket to count exactly. Internally it sums per-bin counters (a striped LongAdder-style tally), and entries can be added or removed while that sum is computed, so the returned value is a snapshot that may already be stale.

int n = map.size();        // a hint, not a guarantee
// mappingCount() returns a long — preferred for very large maps
long exact = map.mappingCount();

It's fine for metrics or sizing heuristics, but never use it for exact control flow (if (size() == capacity)) under concurrent writes. Rule of thumb: treat size() on any concurrent collection as an estimate, and prefer mappingCount() for big maps.

A fail-fast iterator (HashMap, ArrayList) tracks a modCount; if the collection is structurally modified during iteration it throws ConcurrentModificationException to surface the bug immediately. A weakly consistent (or fail-safe) iterator from a concurrent collection never throws it — it traverses a live or snapshot view that tolerates concurrent changes.

Map<Integer,Integer> chm = new ConcurrentHashMap<>(Map.of(1,1,2,2));
for (var e : chm.entrySet()) chm.put(99, 99);  // no exception

Map<Integer,Integer> hm = new HashMap<>(Map.of(1,1));
for (var e : hm.entrySet()) hm.put(99, 99);    // ConcurrentModificationException

The trade-off: a weakly consistent iterator may or may not reflect changes made after it started — it gives no freshness guarantee. Rule of thumb: fail-fast = bug detector for single-threaded code; weakly consistent = safe traversal under concurrency, but possibly slightly stale.

Every mutation (add, set, remove) creates a fresh copy of the entire backing array under a lock and swaps it in. Reads and iterators work against the immutable snapshot that existed when they started, so reads are completely lock-free and iterators never throw ConcurrentModificationException.

List<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(l);                  // copies whole array
for (Listener x : listeners)       // iterates a frozen snapshot
    x.onEvent();                   // safe even if another thread adds

The cost is O(n) per write plus a full array allocation, so it only pays off when reads vastly outnumber writes — the classic case is a rarely-changing listener/observer list. Rule of thumb: use it for read-mostly, small, seldom-mutated lists; never for write-heavy or large collections.

A Set backed by a CopyOnWriteArrayList, with the same snapshot semantics: lock-free reads, copy-on-write mutations, and iterators that never throw. Because it stores elements in an array and checks duplicates with equals on add, add and contains are O(n) — there's no hashing.

Set<String> tags = new CopyOnWriteArraySet<>();
tags.add("a");   // scans for duplicate, then copies array

It suits small, read-mostly sets (e.g. a handful of subscribers). For larger or write-heavy sets, prefer ConcurrentHashMap.newKeySet() (a concurrent hash set) which gives O(1) operations. Rule of thumb: CopyOnWriteArraySet for tiny read-dominated sets only.

A BlockingQueue is a thread-safe queue whose put waits when the queue is full and whose take waits when it's empty — the blocking handles the coordination so threads don't busy-wait. It's the backbone of the producer-consumer pattern and of ThreadPoolExecutor's work queue.

BlockingQueue<Task> q = new LinkedBlockingQueue<>(100);
// producer
q.put(task);              // blocks if full
// consumer
Task t = q.take();        // blocks until an item is available

It offers method families with different failure modes: throw (add/remove), return special value (offer/poll), block (put/take), and time out (offer(e,t,u)/poll(t,u)). Rule of thumb: use put/take for producer-consumer hand-off; the blocking does the waiting and signaling for you.

Implementation Characteristics
ArrayBlockingQueue bounded, array-backed, single lock, FIFO
LinkedBlockingQueue optionally bounded, linked nodes, separate put/take locks (higher throughput)
PriorityBlockingQueue unbounded, ordered by comparator (not FIFO)
DelayQueue elements become available only after their delay expires
SynchronousQueue zero capacity — each put hands directly to a take
BlockingQueue<Job> q = new ArrayBlockingQueue<>(50);  // fixed back-pressure
BlockingQueue<Job> p = new PriorityBlockingQueue<>(); // highest-priority first

Bounded queues provide back-pressure (producers block when full), which is usually what you want in a pipeline. Rule of thumb: ArrayBlockingQueue for a simple bounded buffer, LinkedBlockingQueue for higher throughput, PriorityBlockingQueue/DelayQueue when ordering or timing matters.

A SynchronousQueue has no internal capacity — it holds zero elements. A put blocks until another thread does a take (and vice versa), so it's a direct hand-off rather than a buffer. Each insert must rendezvous with a removal.

SynchronousQueue<Task> q = new SynchronousQueue<>();
// producer thread: q.put(task) parks until a consumer is ready
// consumer thread: q.take() picks it up directly

It's the work queue used by Executors.newCachedThreadPool(): a task is handed straight to an idle thread, or a new thread is spawned if none is free. Rule of thumb: use it for direct hand-off where you want a task picked up immediately or not queued at all.

A DelayQueue holds elements implementing Delayed; an element can only be taken once its delay has elapsed. take blocks until the head element's delay expires, making it ideal for scheduled or expiring work (caches, retries, timeouts).

class Expiring implements Delayed {
  long readyAt;
  public long getDelay(TimeUnit u){ return u.convert(readyAt - now(), MILLISECONDS); }
  public int compareTo(Delayed o){ /* order by readyAt */ }
}
DelayQueue<Expiring> q = new DelayQueue<>();

It's internally a PriorityQueue ordered by remaining delay, so the soonest-ready item is always at the head. Rule of thumb: reach for DelayQueue when items must "become available later," instead of polling with sleep.

ConcurrentLinkedQueue (and ConcurrentLinkedDeque) is an unbounded, non-blocking, lock-free FIFO queue built on CAS (the Michael-Scott algorithm). Its offer/poll never block — poll simply returns null when the queue is empty rather than waiting.

Queue<Event> q = new ConcurrentLinkedQueue<>();
q.offer(e);              // never blocks, never full
Event e = q.poll();      // returns null if empty (no waiting)

Use it for high-throughput hand-off where consumers can do other work when the queue is empty (poll-and-continue), not where you want a thread to wait for an item. Rule of thumb: lock-free ConcurrentLinkedQueue for non-blocking pipelines; a BlockingQueue when consumers should block on take.

It's a sorted, thread-safe map — the concurrent equivalent of TreeMap, implementing ConcurrentNavigableMap. Instead of a red-black tree it uses a lock-free skip list, so it keeps keys ordered while supporting concurrent access without global locking. Operations are O(log n).

ConcurrentNavigableMap<Long,Order> book = new ConcurrentSkipListMap<>();
book.put(price, order);
var cheapest = book.firstEntry();        // ordered access
var window   = book.headMap(limit);      // range views, thread-safe

You get the navigation methods (firstKey, ceilingEntry, subMap) that ConcurrentHashMap lacks. ConcurrentSkipListSet is the sorted-set counterpart. Rule of thumb: use it when you need both thread-safety and sorted/range queries; if you only need a hash map, ConcurrentHashMap is faster.

Classes like AtomicInteger, AtomicLong, AtomicBoolean, and AtomicReference<T> provide lock-free, thread-safe single-variable updates backed by hardware CAS instructions. They turn a read-modify-write into one atomic step without synchronized.

AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();          // atomic ++
counter.addAndGet(5);               // atomic += 5

AtomicReference<Node> head = new AtomicReference<>();
head.compareAndSet(oldHead, newHead); // atomic pointer swap

They're far cheaper than a lock under moderate contention because there's no blocking or context switch — just a retry loop on CAS. Rule of thumb: for a single shared counter or flag, an atomic class beats a synchronized block.

compareAndSet(expected, new) atomically sets the value to new only if it currently equals expected, returning true/false. It maps to a single CPU instruction, so no lock is held. Code that wants to update based on the current value loops: read, compute, CAS, retry if it failed because another thread won the race.

AtomicInteger v = new AtomicInteger();
int cur, next;
do {
  cur  = v.get();
  next = cur * 2;
} while (!v.compareAndSet(cur, next));   // retry on contention

This is optimistic concurrency — assume no conflict, verify at commit. The updateAndGet/accumulateAndGet helpers wrap that loop for you. Rule of thumb: CAS shines under low-to-moderate contention; under heavy contention the retry loop wastes CPU and a different structure may win.

Under high contention, an AtomicLong becomes a bottleneck: every thread CASes the same cell, so most attempts fail and retry. LongAdder spreads writes across multiple internal cells (one per contending thread, roughly), so threads rarely collide; sum() adds the cells together when you need the total.

LongAdder hits = new LongAdder();
hits.increment();          // updates a per-thread cell, low contention
long total = hits.sum();   // combines all cells (a snapshot)

The trade-off: it uses more memory and sum() is not a perfectly atomic instant value (it's accurate when quiescent). Use it for write-heavy counters (metrics, statistics) where you read the total infrequently. Rule of thumb: many threads incrementing -> LongAdder; you need the live value on every update -> AtomicLong.

Per-method thread-safety guarantees each individual call is atomic — it does not make a sequence of calls atomic. A classic check-then-act like "if absent, put" can interleave: two threads both see "absent" and both put.

// BROKEN even on a ConcurrentHashMap:
if (!map.containsKey(k)) map.put(k, v);   // two threads can both pass the check

// CORRECT — single atomic operation:
map.putIfAbsent(k, v);

The fix is to use the collection's built-in atomic compound method (putIfAbsent, compute, merge) or, if none fits, wrap the whole sequence in external synchronization. Rule of thumb: thread-safe per call ≠ thread-safe per transaction — make compound actions atomic explicitly.

There's no ConcurrentHashSet class, so you use ConcurrentHashMap.newKeySet(), which returns a Set backed by a ConcurrentHashMap (values are a shared dummy object). It gives O(1), fully concurrent set operations with the same lock-free reads as the map.

Set<String> seen = ConcurrentHashMap.newKeySet();
seen.add("a");                 // concurrent, O(1)
boolean isNew = seen.add("b"); // returns false if already present

This is the right choice for a large, write-heavy concurrent set; CopyOnWriteArraySet only suits tiny read-mostly cases, and ConcurrentSkipListSet is for when you need sorted order. Rule of thumb: for a general-purpose thread-safe Set, use ConcurrentHashMap.newKeySet().

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel