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 theirDelayedexpires; ideal for scheduled/expiring work instead of polling withsleep.SynchronousQueue— zero capacity; eachputblocks until atakerendezvous, a direct hand-off. It's the queue behindExecutors.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.