DevNotes
Support
Multithreading · Chapter 14

Concurrent Collections

Synchronized wrappers vs concurrent collections, fail-fast vs fail-safe (weakly consistent and snapshot) iterators, ConcurrentHashMap atomic operations, CopyOnWriteArrayList, the BlockingQueue family and ConcurrentLinkedQueue.

◷ 9 min read§ 13 sections? 7 interview questions{ } 8 code examples

#Introduction

Standard collections like ArrayList and HashMap are not thread-safe: concurrent modification can lose entries, corrupt internal structure or throw exceptions. Java offers two ways to fix that — synchronized wrappers (lock everything) and the concurrent collections in java.util.concurrent (designed for concurrency from the ground up).

#Option 1 — Synchronized Wrappers

Java
List<String> list = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

Every method locks one shared lock. Simple, but:

  • all threads contend for the same lock, so throughput is poor under load;
  • compound actions (check-then-act) still need external locking;
  • iteration requires holding the lock for the whole loop.
Java
synchronized (list) {          // required — otherwise ConcurrentModificationException is possible
    for (String s : list) {
        process(s);
    }
}

Vector and Hashtable are legacy classes with the same "lock every method" approach.

#Option 2 — Concurrent Collections

InterfaceNot thread-safeConcurrent alternative
MapHashMapConcurrentHashMap
SortedMapTreeMapConcurrentSkipListMap
SetHashSetConcurrentHashMap.newKeySet()
SortedSetTreeSetConcurrentSkipListSet
List (read-mostly)ArrayListCopyOnWriteArrayList
QueueLinkedList, ArrayDequeConcurrentLinkedQueue, BlockingQueue implementations
DequeArrayDequeConcurrentLinkedDeque, LinkedBlockingDeque

#Fail-Fast vs Fail-Safe Iterators

In Java, fail-fast and fail-safe describe how iterators behave when a collection is modified during iteration.

Fail-Fast Iterators

  • Throw ConcurrentModificationException if the collection is structurally modified (add(), remove()) during iteration — by another thread or by the same thread outside the iterator.
  • Work directly on the original collection.
  • Track changes with an internal modCount: the iterator records it at creation and checks it on every next(); if it changed, it throws.
  • Used by the non-concurrent collections: ArrayList, LinkedList, HashSet, HashMap and so on.
Java
List<String> names = new ArrayList<>(List.of("a", "b", "c"));
for (String n : names) {
    if (n.equals("b")) names.remove(n);   // ConcurrentModificationException
}

names.removeIf(n -> n.equals("b"));        // ✅ safe way to remove while iterating

Fail-Safe Iterators

  • Don't throw ConcurrentModificationException, even if the collection is modified during iteration.
  • Used by concurrent collections from java.util.concurrent.

But they don't all work the same way — there are two kinds:

KindHow it worksSees changes made during iteration?Examples
SnapshotIterates over an immutable copy of the array taken when the iterator was created❌ NeverCopyOnWriteArrayList, CopyOnWriteArraySet
Weakly consistentIterates the live structure, tolerating concurrent changes⚠️ May or may not reflect themConcurrentHashMap, ConcurrentLinkedQueue, ConcurrentSkipListMap, LinkedBlockingQueue
Fail-fastFail-safe (snapshot / weakly consistent)
Exception on concurrent modificationConcurrentModificationExceptionNone
Works onThe original collectionA snapshot copy, or the live structure
Memory overheadNoneCopy-on-write: a full copy per write
Sees latest changes—Snapshot: no; weakly consistent: maybe
ExamplesArrayList, HashMapCopyOnWriteArrayList, ConcurrentHashMap

#ConcurrentHashMap — Use the Atomic Methods

ConcurrentHashMap locks only small parts of the table (per-bin locking plus CAS since Java 8), so many threads can read and write at once. Reads don't lock at all.

But individual calls being thread-safe doesn't make your logic atomic:

Java
ConcurrentHashMap<String, Integer> hits = new ConcurrentHashMap<>();

// ❌ Race: two threads read the same value and both write value + 1
Integer current = hits.get(page);
hits.put(page, current == null ? 1 : current + 1);

// ✅ Atomic per key
hits.merge(page, 1, Integer::sum);
MethodAtomic operation
putIfAbsent(k, v)Insert only if no mapping exists
computeIfAbsent(k, fn)Compute and insert if absent — the classic cache loader
computeIfPresent(k, fn)Update only if present
compute(k, fn)Compute a new value from the old (or null)
merge(k, v, fn)Insert v, or combine with the existing value
remove(k, v) / replace(k, old, new)Conditional remove / replace
Java
// A thread-safe local cache: the loader runs at most once per key
Map<Long, Customer> cache = new ConcurrentHashMap<>();
Customer c = cache.computeIfAbsent(id, customerRepository::load);

// Counting with LongAdder values for very hot keys
ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
counts.computeIfAbsent(event, k -> new LongAdder()).increment();

size() is an estimate under concurrent updates; mappingCount() returns a long.

#CopyOnWriteArrayList

Every write (add, set, remove) copies the entire underlying array; reads and iteration use the current array without any locking.

Java
List<OrderListener> listeners = new CopyOnWriteArrayList<>();

listeners.add(auditListener);              // rare: copies the array

for (OrderListener l : listeners) {        // frequent: no lock, no CME
    l.onOrderPlaced(order);
}
Good forBad for
Read-mostly lists — listeners, observers, configuration, routing tablesFrequent writes or large lists — each write is O(n) and allocates a full copy
Iterating while other threads modifyNeeding to see updates during an ongoing iteration

Iterators are snapshots and don't support remove() (it throws UnsupportedOperationException).

#BlockingQueue — the Producer–Consumer Workhorse

A BlockingQueue adds operations that wait when the queue is empty (on take) or full (on put):

OperationThrowsReturns a special valueBlocksTimes out
Insertadd(e)offer(e) → falseput(e)offer(e, time, unit)
Removeremove()poll() → nulltake()poll(time, unit)
Examineelement()peek() → null——
Java
BlockingQueue<Order> queue = new ArrayBlockingQueue<>(1_000);

// Producer — e.g. a request thread
if (!queue.offer(order, 100, TimeUnit.MILLISECONDS)) {
    throw new ServiceUnavailableException("Order queue full");
}

// Consumer — a background worker
while (!Thread.currentThread().isInterrupted()) {
    Order next = queue.take();    // waits for work
    process(next);
}

Implementations

ImplementationCharacteristics
ArrayBlockingQueueBounded, array-backed, one lock; optional fairness
LinkedBlockingQueueOptionally bounded (unbounded by default), separate locks for head and tail → higher throughput
PriorityBlockingQueueUnbounded, ordered by priority / Comparator
DelayQueueElements become available only after their delay expires — scheduled retries
SynchronousQueueZero capacity: each put waits for a matching take — a direct hand-off
LinkedTransferQueuetransfer() waits until a consumer receives the element
LinkedBlockingDequeBlocking double-ended queue

Graceful Shutdown with a Poison Pill

Java
static final Order POISON_PILL = new Order(-1);

// Producer, when done:
queue.put(POISON_PILL);

// Consumer:
while (true) {
    Order o = queue.take();
    if (o == POISON_PILL) break;
    process(o);
}

#ConcurrentLinkedQueue

A non-blocking, unbounded, lock-free (CAS-based) FIFO queue. offer and poll never block — poll() returns null when empty. Use it when threads shouldn't wait for elements; use a BlockingQueue when consumers need to wait for work.

#Choosing a Thread-Safe Collection

NeedUse
General-purpose thread-safe map / cacheConcurrentHashMap
Sorted concurrent map or setConcurrentSkipListMap / ConcurrentSkipListSet
Thread-safe setConcurrentHashMap.newKeySet()
Read-mostly list, iterated oftenCopyOnWriteArrayList
Producer–consumer hand-off with back-pressureArrayBlockingQueue / bounded LinkedBlockingQueue
Non-blocking queueConcurrentLinkedQueue
Delayed / scheduled itemsDelayQueue
Data that never changes after constructionList.of, Map.of — immutable collections need no locking at all

#Interview Questions

Q1What is the difference between fail-fast and fail-safe iterators?

Fail-fast iterators (ArrayList, HashMap) throw ConcurrentModificationException when the collection is structurally modified during iteration, detected via modCount. Fail-safe iterators (concurrent collections) don't throw: copy-on-write collections iterate a snapshot, while ConcurrentHashMap and similar iterate the live structure in a weakly consistent way.

Q2Does ConcurrentHashMap's iterator work on a copy?

No — that's true only for CopyOnWriteArrayList / CopyOnWriteArraySet. ConcurrentHashMap's iterator is weakly consistent: it traverses the live table and may or may not reflect concurrent changes, but never throws ConcurrentModificationException.

Q3Collections.synchronizedMap vs ConcurrentHashMap?

synchronizedMap wraps every method in one lock, so all access is serialized and iteration needs manual locking. ConcurrentHashMap uses fine-grained locking and CAS, allows concurrent reads and writes, offers atomic compound operations, and has weakly consistent iterators.

Q4Is if (!map.containsKey(k)) map.put(k, v) thread-safe on a ConcurrentHashMap?

No — the check and the put are separate calls. Use putIfAbsent or computeIfAbsent.

Q5When would you use CopyOnWriteArrayList?

For lists that are read and iterated far more often than they're modified — listeners, subscribers, configuration. Each write copies the whole array, so it's a poor fit for write-heavy data.

Q6What is a BlockingQueue?

A queue whose put() waits for space and take() waits for an element — the standard building block for producer–consumer designs. Bounded implementations provide back-pressure.

Q7Why doesn't ConcurrentHashMap allow null keys or values?

Because in a concurrent map, get() returning null must mean "absent" — otherwise you couldn't distinguish a null value from a missing key without a separate, racy containsKey() call.

#Common Mistakes

  • Using HashMap from multiple threads — lost entries and corrupted state.
  • Check-then-act on a concurrent map instead of putIfAbsent / compute* / merge.
  • Iterating a synchronized wrapper without holding its lock.
  • Believing every fail-safe iterator works on a copy.
  • CopyOnWriteArrayList for write-heavy data.
  • Unbounded LinkedBlockingQueue in producer–consumer pipelines.
  • Modifying the map inside its own computeIfAbsent function.

#Best Practices

  • Prefer concurrent collections over synchronized wrappers.
  • Use the atomic compound methods of ConcurrentHashMap.
  • Use bounded blocking queues for producer–consumer hand-offs.
  • Prefer immutable collections for data that doesn't change.
  • Choose by access pattern: read-mostly vs write-heavy, blocking vs non-blocking, sorted vs unsorted.

#Key Takeaways

  • Synchronized wrappers serialize everything; concurrent collections allow real concurrency.
  • Fail-fast iterators throw ConcurrentModificationException; fail-safe iterators are either snapshots (copy-on-write) or weakly consistent (ConcurrentHashMap).
  • ConcurrentHashMap is thread-safe per call — use merge, compute* and putIfAbsent for atomic logic.
  • CopyOnWriteArrayList suits read-mostly lists.
  • BlockingQueue implements producer–consumer with built-in waiting and back-pressure.