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 Concurrency interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.