JAVA / THREADS AND CONCURRENCY
Concurrent collections for shared data structures
Use ConcurrentHashMap, CopyOnWriteArrayList and BlockingQueue for shared state, and merge or computeIfAbsent when a read-then-write must be atomic.
What you will learn
- Replace HashMap plus synchronized blocks with ConcurrentHashMap for shared key/value state
- Use merge or compute instead of get-then-put so increments are never lost
- Pick CopyOnWriteArrayList only for read-heavy lists; every write copies the whole array
- Let a bounded BlockingQueue own the blocking in a handoff between threads
Understanding Concurrent collections for shared data structures
A concurrent collection guards its own internal state, so you never wrap its calls in a lock of your own. ConcurrentHashMap does not use one big lock: it locks only the bin that a key hashes into, and inserts into an empty bin with a single compare-and-swap, so threads writing different keys usually never contend, and readers take no lock at all. Collections.synchronizedMap(new HashMap<>()) is also correct, but it funnels every get and put through one monitor, so eight threads reading get no more throughput than one. What both give you is the same narrow guarantee: one method call is atomic, two method calls are not.
That narrowness is exactly why the API has putIfAbsent, computeIfAbsent, compute, merge and replace(key, expected, newValue). Writing counts.put(w, counts.get(w) + 1) is a get, an add and a put; another thread can write between the get and the put, and that increment silently disappears even though the map is thread-safe. counts.merge(w, 1, Integer::sum) does the same read-modify-write inside one locked call, so it cannot be lost. The cost is that your lambda runs while the bin is locked, so it must be short, must not block on anything, and must not touch the same map, because a recursive update can throw IllegalStateException or deadlock that bin.
Iteration is where the mental model shifts most. ConcurrentHashMap and the concurrent queues hand out weakly consistent iterators: they never throw ConcurrentModificationException, they reflect the state at some point at or after creation, and they may or may not show later changes, which also makes size() and isEmpty() estimates while other threads write. CopyOnWriteArrayList goes further and gives a true frozen snapshot, because every add replaces the entire backing array, which is ideal for a listener list read constantly and edited rarely and terrible for a list you append to in a loop. Choose the structure whose atomic unit matches the unit of work you actually need.
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class WordCounts {
public static void main(String[] args) throws InterruptedException {
Map<String, Integer> counts = new ConcurrentHashMap<>();
String[] words = {"alpha", "beta", "gamma", "beta"};
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int t = 0; t < 4; t++) {
pool.execute(() -> {
for (int i = 0; i < 25_000; i++) {
for (String word : words) {
// one atomic read-modify-write per call
counts.merge(word, 1, Integer::sum);
}
}
});
}
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
int total = 0;
for (int c : counts.values()) {
total += c;
}
// ConcurrentHashMap has no defined iteration order, so sort for a stable printout
System.out.println(new TreeMap<>(counts));
System.out.println("total = " + total);
}
}A concurrent collection makes each single operation atomic, so only its built-in compound methods can make a read-then-write atomic.
Worked examples
Snapshot iteration with CopyOnWriteArrayList
Shows that a CopyOnWriteArrayList iterator walks a frozen copy, so writing during a loop is legal but invisible to that loop.
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class SnapshotIteration {
public static void main(String[] args) {
List<String> listeners = new CopyOnWriteArrayList<>(List.of("a", "b"));
for (String name : listeners) {
System.out.println("visiting " + name);
listeners.add("added-by-" + name);
}
System.out.println("after loop: " + listeners);
}
}Example explained
Line 1The for-each loop takes an iterator bound to the two-element array that existed when the loop started, so it visits a and b and stops.
Line 2Each listeners.add(...) allocates a longer copy of the array and swaps it in; the running iterator still points at the old array, which is why no ConcurrentModificationException is possible.
Line 3The final line proves the writes did happen: the iterator was stale, not the list.
Line 4An ArrayList in the same loop would throw ConcurrentModificationException on the second iteration, because its iterator checks a modification counter.
Atomic computeIfAbsent for per-key lists
Four threads build per-key lists in a ConcurrentHashMap without ever creating two lists for the same key or dropping an element.
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
public class GroupByKey {
public static void main(String[] args) throws InterruptedException {
Map<String, List<Integer>> byKey = new ConcurrentHashMap<>();
CountDownLatch done = new CountDownLatch(4);
for (int t = 0; t < 4; t++) {
final int id = t;
new Thread(() -> {
for (int i = 0; i < 250; i++) {
String key = (i % 2 == 0) ? "even" : "odd";
byKey.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(id);
}
done.countDown();
}).start();
}
done.await();
System.out.println("lists created: " + byKey.size());
System.out.println("even entries: " + byKey.get("even").size());
System.out.println("odd entries: " + byKey.get("odd").size());
}
}Example explained
Line 1computeIfAbsent runs the mapping function while holding that key's bin lock, so exactly one list is created per key even with four threads racing on the same key.
Line 2The .add(id) happens after computeIfAbsent returns and is not covered by that lock, which is why the inner list must itself be thread-safe.
Line 3Writing if (!byKey.containsKey(key)) byKey.put(key, new ...) instead would let two threads each install a fresh list, and the losing thread's later elements would land in a list nobody can reach.
Line 4done.await() makes main wait for all four workers, so the three reads happen after every write and the counts are exact rather than estimates.
Bounded handoff with ArrayBlockingQueue
Uses a capacity-2 BlockingQueue so the producer is throttled and the consumer waits, with no locking code written by hand.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class QueueHandoff {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);
Thread consumer = new Thread(() -> {
int sum = 0;
try {
while (true) {
int value = queue.take();
if (value == -1) {
break;
}
sum += value;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("consumer sum = " + sum);
});
consumer.start();
for (int i = 1; i <= 5; i++) {
queue.put(i);
}
queue.put(-1);
consumer.join();
System.out.println("queue empty: " + queue.isEmpty());
}
}Example explained
Line 1The capacity of 2 means put() parks the producer whenever two items are already waiting, so a fast producer cannot grow an unbounded backlog.
Line 2take() parks the consumer while the queue is empty and returns as soon as an element is handed over, so the loop never spins.
Line 3-1 is a sentinel value because the queue itself has no end-of-stream signal; without it the consumer would block forever after the last item.
Line 4consumer.join() before the final print guarantees the sum was computed and made visible to main.
Important notes
ConcurrentHashMap rejects null keys and values with a NullPointerException, which is deliberate: a null from get() then unambiguously means absent, but code ported from HashMap that stored nulls will break.
size(), isEmpty() and iteration on concurrent collections are approximations while other threads are writing, so read them only after the writers have finished, or accumulate totals inside atomic operations instead.
Common mistakes
Calling counts.put(w, counts.get(w) + 1) on a ConcurrentHashMap and assuming the map's thread safety covers it: two threads read the same value, one write overwrites the other, and the final count is quietly too low and different on every run.
Using containsKey followed by put to create a per-key sub-collection: two threads both see the key missing, both install a new collection, and everything added to the one that lost the race is unreachable.
Reaching for CopyOnWriteArrayList as a general shared list and appending in a loop: each add copies the entire backing array, so 10,000 appends perform about 50 million element copies and the code becomes far slower than a synchronized list.
Try it yourself
Change, predict, then run
Share a HashMap between four threads that each run map.put("hits", map.get("hits") + 1) ten thousand times, print the final value, and run it five times to watch it fall short of 40000 and vary. Then switch to ConcurrentHashMap with merge("hits", 1, Integer::sum) and confirm you get 40000 every run.
Open the Java workspaceCheck your understanding
Four threads share a ConcurrentHashMap<String,Integer>, and each thread runs map.put("k", map.get("k") + 1) 1000 times after the key has been initialised to 0. What is the final value of "k"?
- Often less than 4000, because each call is atomic on its own but the get and the put are two separate operations
- Always 4000, because ConcurrentHashMap synchronises every access to the map
- Always 4000, but only if the map variable is also declared volatile
- Undefined, because concurrent writes to the same key throw ConcurrentModificationException
Show answer
Between the get and the put another thread can read the same old value and write its own increment, so one of the two updates is overwritten and lost; merge("k", 1, Integer::sum) or compute performs the whole read-modify-write in one locked call and fixes it. Option two is the classic trap: thread safety means no corrupted internal state and no external locking needed, not that a sequence of your calls is atomic. volatile only affects reads of the reference itself, and ConcurrentModificationException comes from fail-fast iterators of non-concurrent collections, never from concurrent puts.