Skip to content

Java · Concurrency

Java Concurrent Collections — ConcurrentHashMap, BlockingQueue & Atomics

9 min read Updated 2026-06-20 Share:

Practice Concurrent Collections interview questions

Why a separate toolbox for concurrency

A HashMap or ArrayList shared across threads without coordination is a latent bug: concurrent writes corrupt internal structure, and in older JDKs a racing resize could even spin a core into an infinite loop. The historical fix was Collections.synchronizedMap/synchronizedList, which wrap a plain collection so every method grabs one lock on the whole object. That's correct, but it serializes all access — readers block readers, readers block writers — so it collapses under contention. The java.util.concurrent package replaces that single coarse lock with finer-grained locking and lock-free techniques (CAS, per-bucket locks, copy-on-write, skip lists) so many threads make progress at once. This guide walks the families you reach for and the judgment behind each.

Map<String,Integer> wrapped = Collections.synchronizedMap(new HashMap<>()); // one global lock
Map<String,Integer> scalable = new ConcurrentHashMap<>();                    // per-bucket locking

The other quiet win is iteration: synchronized wrappers still demand you manually synchronize on the wrapper for the whole loop, while concurrent collections hand you iterators that simply don't break under concurrent change.

ConcurrentHashMap: the workhorse

ConcurrentHashMap is the default thread-safe map. Since Java 8 it locks at the individual bucket (bin) granularity rather than the whole map. Inserting into an empty bucket is a single CAS (compare-and-swap) with no lock at all; updating a non-empty bucket takes a synchronized block on that bin's first node. Because the lock is per-bin, threads touching different buckets never contend. Crucially, reads are lock-free — the relevant fields are volatile, so a get sees a consistent value without acquiring anything. (The pre-Java-8 design used 16 segment stripes — the classic "lock striping" answer.)

ConcurrentHashMap<String,Integer> m = new ConcurrentHashMap<>();
// conceptual write path, per bucket:
//   empty bucket  -> casBucket(node)             (lock-free fast path)
//   non-empty     -> synchronized(firstNode){...} (only this bin is locked)
int v = m.getOrDefault("k", 0);  // read acquires no lock at all

One sharp edge: it forbids null keys and values and throws NullPointerException if you try. In a concurrent setting a null return from get would be ambiguous — absent, or mapped to null? — and you can't disambiguate with containsKey because another thread may mutate between the two calls. Banning null makes a null return always mean "absent," which keeps the lock-free read path sound.

Atomic operations and the compound-action trap

The reason ConcurrentHashMap is more than "a HashMap that won't crash" is its atomic compound operations. putIfAbsent, computeIfAbsent, computeIfPresent, compute, and merge each run atomically with the bin locked, so the read and the write can't be interleaved by another thread. They exist precisely because per-method thread-safety does not make a sequence of calls atomic — a get-then-put is still a race even on a concurrent map.

ConcurrentHashMap<String,Integer> counts = new ConcurrentHashMap<>();

// BROKEN: two threads can both pass the check and both put
if (!counts.containsKey("hits")) counts.put("hits", 0);

// CORRECT: each of these is a single atomic step
counts.merge("hits", 1, Integer::sum);                 // atomic increment-or-init
var cache = counts.computeIfAbsent("x", k -> new ArrayList<>()); // safe lazy init

This is the single most important habit with concurrent collections: prefer the built-in merge/compute*/putIfAbsent over check-then-act. Thread-safe per call is not thread-safe per transaction — when no built-in fits, you must wrap the whole sequence in external synchronization yourself.

One more consequence of never globally locking: size() is approximate. The map can't freeze every bin to count, so it sums per-bin counters while entries come and go; treat the result as a hint and prefer mappingCount() (a long) for very large maps. Never branch control flow on it under concurrent writes.

Fail-fast vs weakly consistent iterators

Iterating a plain HashMap or ArrayList while it's structurally modified throws ConcurrentModificationException — these are fail-fast iterators that track a modCount to surface bugs immediately. Concurrent collections instead give weakly consistent iterators that never throw it: they traverse a live or snapshot view that tolerates concurrent change.

Map<Integer,Integer> chm = new ConcurrentHashMap<>(Map.of(1,1, 2,2));
for (var e : chm.entrySet()) chm.put(99, 99);   // fine — 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 is freshness: a weakly consistent iterator may or may not reflect changes made after it started, giving no guarantee either way. So fail-fast is a bug detector for single-threaded code; weakly consistent is safe traversal under concurrency that may be slightly stale.

Copy-on-write: snapshots for read-mostly data

CopyOnWriteArrayList (and CopyOnWriteArraySet) takes the opposite stance: every mutation copies the entire backing array under a lock and swaps in the new one. Reads and iterators then run against the immutable snapshot that existed when they began — completely lock-free, never throwing ConcurrentModificationException.

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

The cost is O(n) plus a full allocation per write, so this only pays off when reads vastly outnumber writes — the textbook case is a rarely-changing listener/observer list. Use it for small, read-mostly, seldom-mutated collections; never for write-heavy or large ones. For a general-purpose concurrent Set (large or write-heavy), there's no ConcurrentHashSet class — use ConcurrentHashMap.newKeySet(), which gives O(1) lock-free-read set operations.

The BlockingQueue family and producer-consumer

A BlockingQueue is the backbone of the producer-consumer pattern (and of ThreadPoolExecutor's work queue). Its put waits when the queue is full and its take waits when it's empty, so the blocking itself handles the hand-off — no busy-waiting, no manual wait/notify. It also offers method families with different failure modes: throw (add/remove), special value (offer/poll), block (put/take), and time out (offer(e,t,u)/poll(t,u)).

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

The implementations trade off differently:

  • ArrayBlockingQueue — bounded, array-backed, single lock, FIFO; a simple bounded buffer.
  • LinkedBlockingQueue — optionally bounded, linked nodes, separate put/take locks for higher throughput.
  • PriorityBlockingQueue — unbounded, ordered by comparator instead of FIFO.
  • DelayQueue — elements become takeable only after their Delayed expires; ideal for scheduled/expiring work instead of polling with sleep.
  • SynchronousQueuezero capacity; each put blocks until a take rendezvous, a direct hand-off. It's the queue behind Executors.newCachedThreadPool().

Bounded queues give you back-pressure — producers block when full — which is usually what you want in a pipeline so a fast producer can't exhaust memory.

Lock-free and sorted alternatives

When consumers can do other work rather than wait for an item, you don't want blocking at all. ConcurrentLinkedQueue (and ConcurrentLinkedDeque) is an unbounded, non-blocking, lock-free FIFO built on CAS (the Michael-Scott algorithm): offer never blocks and poll returns null on empty instead of waiting. And when you need thread-safety plus sorted/range access — what ConcurrentHashMap can't give you — ConcurrentSkipListMap is the concurrent TreeMap, backed by a lock-free skip list with O(log n) operations and the full NavigableMap API.

Queue<Event> events = new ConcurrentLinkedQueue<>();
events.offer(e);                  // never blocks, never "full"
Event e = events.poll();          // returns null if empty (poll-and-continue)

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

Pick ConcurrentLinkedQueue for non-blocking pipelines and a BlockingQueue when consumers should genuinely block on take. Reach for ConcurrentSkipListMap (or ConcurrentSkipListSet) only when you actually need ordering — a hash-based ConcurrentHashMap is faster when you don't.

The atomic package and CAS

For a single shared variable, a whole collection is overkill. The java.util.concurrent.atomic classes — AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference<T> — provide lock-free, thread-safe single-variable updates backed by hardware CAS. compareAndSet(expected, new) atomically writes new only if the current value still equals expected; code that updates based on the current value loops and retries on failure — this is optimistic concurrency: assume no conflict, verify at commit.

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

AtomicReference<Node> head = new AtomicReference<>();
int cur, next;
do {
    cur  = counter.get();
    next = cur * 2;
} while (!counter.compareAndSet(cur, next)); // retry if another thread won the race

Atomics beat synchronized under low-to-moderate contention because there's no blocking or context switch — just a CAS retry. But under heavy contention every thread CASes the same cell, so most attempts fail and waste CPU. That's the case for LongAdder: it spreads writes across multiple internal cells so threads rarely collide, summing them only when you read the total. Use it for write-heavy counters (metrics) where you read infrequently; stick with AtomicLong when you need the live value on every update.

Recap

Concurrent collections exist because a single coarse lock doesn't scale and a raw HashMap is unsafe. ConcurrentHashMap is the default — per-bucket locking, lock-free reads, atomic merge/compute*, no nulls, approximate size() — and its compound methods are the cure for the check-then-act race that survives even on a thread-safe map. Weakly consistent iterators trade freshness for never throwing; copy-on-write structures trade write cost for cheap snapshot reads on read-mostly data. The BlockingQueue family powers producer-consumer with built-in back-pressure, while ConcurrentLinkedQueue (lock-free) and ConcurrentSkipListMap (sorted) cover non-blocking and ordered needs. Down at the variable level, the atomic classes give CAS-based updates, with LongAdder for write-heavy counters. The throughline: thread-safe per call is not thread-safe per transaction — make compound actions atomic on purpose.

More ways to practice

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

or
Join our WhatsApp Channel