Java Concurrent Collections
Table of Contents + β
In the last lesson you learned about Java atomic variables, which keep a single number safe across threads. But real programs hold whole collections: lists of orders, maps of user counts, queues of jobs. Share a plain ArrayList or HashMap across threads and you get corrupted data, lost updates, even crashes. Java has a special set of concurrent collections built for exactly this.
π€ The problem: normal collections break under threads
A plain ArrayList and HashMap assume one thread at a time. The moment two threads change them together, things go wrong in ways that are hard to spot. The example below has two threads adding to one ArrayList. It looks innocent. It is not:
import java.util.ArrayList;import java.util.List;
public class Main { public static void main(String[] args) throws InterruptedException { List<Integer> list = new ArrayList<>(); // β not thread-safe
Runnable task = () -> { for (int i = 0; i < 1000; i++) { list.add(i); } };
Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join();
System.out.println("Expected 2000, got: " + list.size()); }}Two threads each add 1000 items, so you expect 2000. But run it and you get something else, and it changes every time:
Output
Expected 2000, got: 1873Where did the items go?
- When both threads call
addat the same instant, they grab the same internal slot and one write stomps on the other. - Sometimes the list throws an error mid-resize.
- That silent data loss is the whole danger. Nothing crashes loudly, so the bug ships to production as βthe count is sometimes wrong.β
- A
HashMapis worse: its internal links can tangle and a read can loop forever, eating a whole CPU core.
π The old fix: synchronizedX locks the whole thing
Javaβs first answer was the Collections.synchronizedList and Collections.synchronizedMap wrappers. They wrap every method in a lock. The example below wraps a map so it is safe to share:
import java.util.Collections;import java.util.HashMap;import java.util.Map;
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>()); // every method is lockedThis is safe, but notice the cost:
- It puts one lock around the entire map.
- So if thread A reads key
"apple", thread B cannot read key"banana"at the same time. - One lock for the whole collection means only one thread touches it at a time, ever.
- With many threads, they spend most of their time waiting for that lock, so throughput drops hard.
- Iterating is still unsafe unless you wrap the whole loop in
synchronized, or you risk aConcurrentModificationException.
We need collections built for threads from the ground up, not wrapped after the fact.
π§© Enter the concurrent collections
The java.util.concurrent package gives you collections that are thread-safe and fast. They skip the one big lock so threads on different parts run in parallel. The main ones you will reach for:
ConcurrentHashMapβ a thread-safe map where many threads can read and write different keys at the same time.CopyOnWriteArrayListβ a thread-safe list tuned for reading far more than writing.ConcurrentLinkedQueueβ a thread-safe queue where threads add at one end and take from the other without blocking.- The
BlockingQueuefamily β queues that can make a thread wait for work. We will cover those fully in the next lesson.
Letβs go through the first three with real code.
πΊοΈ ConcurrentHashMap: the workhorse
ConcurrentHashMap is the one you will use most. It is a drop-in replacement for HashMap that is safe across threads:
- It does not lock the whole map. It locks only the small bucket where a key lives.
- So two threads writing different keys usually do not wait for each other.
- This is bucket-level concurrency: many writers run in parallel as long as they touch different buckets.
You create and use it just like a HashMap:
import java.util.concurrent.ConcurrentHashMap;import java.util.Map;
Map<String, Integer> map = new ConcurrentHashMap<>();map.put("apple", 1);map.put("banana", 2);System.out.println(map.get("apple")); // 1It also never throws ConcurrentModificationException while you loop. If another thread adds a key as you iterate, the loop keeps going and may or may not see it. It will not crash.
Counting safely with merge
The classic job for a shared map is counting things. The naive way is broken, but ConcurrentHashMap gives you merge to do it atomically. The example below has many threads counting word hits into one shared map. Each thread calls merge, which combines the old and new value as one safe step:
import java.util.concurrent.ConcurrentHashMap;import java.util.Map;
public class Main { public static void main(String[] args) throws InterruptedException { Map<String, Integer> counts = new ConcurrentHashMap<>(); String[] words = {"apple", "banana", "apple"};
Runnable task = () -> { for (int i = 0; i < 1000; i++) { for (String w : words) { counts.merge(w, 1, Integer::sum); // β
atomic add-to-count } } };
Thread t1 = new Thread(task); Thread t2 = new Thread(task); Thread t3 = new Thread(task); t1.start(); t2.start(); t3.start(); t1.join(); t2.join(); t3.join();
System.out.println(counts); }}Reading the merge call:
counts.merge(w, 1, Integer::sum)means: if keywis missing, put1; if it is there, replace it with the old value plus1.- The whole βlook, add, storeβ runs as one locked step inside the map, so no update is lost.
- Three threads loop 1000 times.
appleappears twice per loop,bananaonce, so the totals are exact every run:
Output
{banana=3000, apple=6000}Always 6000 and 3000, no lost updates. That is the power of an atomic compound operation.
compute and computeIfAbsent
merge is perfect for βadd to a number.β For richer updates, compute hands you the current value and lets you return the new one, again as one atomic step:
import java.util.concurrent.ConcurrentHashMap;import java.util.Map;
Map<String, Integer> map = new ConcurrentHashMap<>();map.put("score", 10);
// add 5 to the current score, atomicallymap.compute("score", (key, value) -> value + 5);System.out.println(map.get("score")); // 15And computeIfAbsent is the safe way to set a default only when a key is missing. It is great for building a map of lists, where each key needs its own empty list created once:
import java.util.ArrayList;import java.util.List;import java.util.concurrent.ConcurrentHashMap;import java.util.Map;
Map<String, List<String>> groups = new ConcurrentHashMap<>();
// create the list once, even if many threads ask at the same timegroups.computeIfAbsent("fruits", key -> new ArrayList<>()).add("apple");System.out.println(groups); // {fruits=[apple]}All three, merge, compute, and computeIfAbsent, do a read-then-write as one unbreakable step inside the map. That is the only safe way to do compound updates.
π CopyOnWriteArrayList: built for reading
CopyOnWriteArrayList is a thread-safe list with an unusual trick:
- Every change (add or remove) makes a fresh copy of the whole internal array, changes the copy, and swaps it in.
- Reads just use the current array with no lock at all.
The example below shares one list between a reader and a writer:
import java.util.List;import java.util.concurrent.CopyOnWriteArrayList;
List<String> list = new CopyOnWriteArrayList<>();list.add("apple");list.add("banana");
for (String item : list) { // safe to loop, never throws list.add("cherry"); // even adding during the loop is fine System.out.println(item);}System.out.println("Size: " + list.size());Looping while adding would crash a normal ArrayList. Here it is fine. The loop walks the snapshot it started with, so it sees the original two items, while the adds land on a new copy:
Output
applebananaSize: 4The trade-off:
- Reads are extremely fast and never lock.
- Every write copies the entire array, which is slow and uses memory if the list is big or changes often.
- So use it only when reads vastly outnumber writes: event listeners or rarely-changed config are perfect, a list you append to constantly is terrible.
β‘οΈ ConcurrentLinkedQueue: a non-blocking queue
ConcurrentLinkedQueue is a thread-safe queue where threads add at the tail and remove from the head. It uses atomic operations under the hood, no locks, so threads rarely wait. The example below has producers offering numbers and a consumer polling them:
import java.util.Queue;import java.util.concurrent.ConcurrentLinkedQueue;
Queue<Integer> queue = new ConcurrentLinkedQueue<>();queue.offer(1); // add to the tailqueue.offer(2);
Integer first = queue.poll(); // take from the head, returns null if emptySystem.out.println("Got: " + first); // Got: 1System.out.println("Remaining: " + queue); // Remaining: [2]Note poll returns null immediately when the queue is empty. It never waits. That is non-blocking. If you want a thread to wait until an item appears, that is the BlockingQueue, the topic of the next lesson.
π Why concurrent collections beat synchronizedMap
It comes down to one idea: a synchronized wrapper locks the whole collection, a concurrent collection lets unrelated work run in parallel.
synchronizedMapβ one lock, whole map. Two threads on different keys still wait. Throughput falls as you add threads.ConcurrentHashMapβ per-bucket safety. Two threads on different keys usually run at once. Throughput stays high.- For a map touched by many threads,
ConcurrentHashMapis almost always better. The more threads and keys, the bigger the gap.
Quick picker
Need a shared map? Use ConcurrentHashMap. Need a shared list that is read far more than written? Use CopyOnWriteArrayList. Need a shared queue with no waiting? Use ConcurrentLinkedQueue. Need a queue that waits for work? Wait for the next lesson on BlockingQueue.
β οΈ Common Mistakes
A few traps that catch people with concurrent collections.
- Using a plain
HashMaporArrayListacross threads. It is not safe, even if it seems to work in testing. The bugs only show up under real load.
// β Wrong: shared across threads, will lose data or crashMap<String, Integer> map = new HashMap<>();
// β
Right: built for threadsMap<String, Integer> map = new ConcurrentHashMap<>();- Thinking every operation on
ConcurrentHashMapis atomic. Each single call likegetorputis safe. But agetfollowed by aputis two steps, and another thread can sneak in between them. This loses updates.
// β Wrong: get-then-put is two steps; updates get lostInteger current = map.get("apple");map.put("apple", current + 1); // another thread may change it between these lines
// β
Right: one atomic stepmap.merge("apple", 1, Integer::sum);-
Using
CopyOnWriteArrayListfor a write-heavy list. Every add copies the whole array. For a list you change often, this is painfully slow and wastes memory. Use it only for read-heavy data. -
Forgetting that iteration sees a snapshot. A
CopyOnWriteArrayListloop walks the array as it was when the loop began. Items added during the loop are not seen by that loop. That is by design, but it surprises people. -
Reaching for
synchronizedMapout of habit. It works, but for many threads it is slower thanConcurrentHashMap. Prefer the concurrent collection unless you have a specific reason not to.
β Best Practices
Habits for safe, fast shared collections.
- Default to
ConcurrentHashMapfor any shared map. It is safe and scales well, with almost no downside over a plain map. - Use
merge,compute, orcomputeIfAbsentfor any read-then-write. They are the only safe way to do compound updates on the map. - Pick
CopyOnWriteArrayListonly when reads greatly outnumber writes. Listener lists and rarely-changed config are the sweet spot. - Use
ConcurrentLinkedQueuefor a fast queue that should not block. If you need waiting, use aBlockingQueueinstead. - Declare the variable with the interface type like
Map,List, orQueue, so you can swap the implementation later without touching the rest of your code. - Prefer concurrent collections over
Collections.synchronizedXfor code that many threads hit, because they keep throughput high. - Never share a plain collection across threads. If two or more threads touch it and at least one writes, reach for a concurrent collection.
π§© What Youβve Learned
Nicely done. Letβs recap the concurrent collections.
- β
Plain
ArrayListandHashMapare not thread-safe and silently lose data or crash under threads. - β
Collections.synchronizedXis safe but uses one lock for the whole collection, which limits throughput. - β
ConcurrentHashMapis safe with bucket-level concurrency, never throwsConcurrentModificationException, and offers atomicmerge,compute, andcomputeIfAbsent. - β
A single
getorputis atomic, but a get-then-put is not, so usemergeorcomputefor compound updates. - β
CopyOnWriteArrayListcopies on every write, so it shines for read-heavy data only. - β
ConcurrentLinkedQueueis a fast non-blocking queue that returnsnullwhen empty. - β
Concurrent collections beat
synchronizedMapbecause unrelated work runs in parallel instead of taking turns.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why is a plain HashMap unsafe across threads?
Why: A plain HashMap assumes one thread; concurrent writes can lose updates or tangle its internals.
- 2
What is the main downside of Collections.synchronizedMap?
Why: A synchronized wrapper uses a single lock for the entire map, so unrelated keys still block each other.
- 3
Why use merge instead of get-then-put on a ConcurrentHashMap?
Why: A get followed by a put is two separate steps; merge does the read-and-write as one atomic operation.
- 4
When is CopyOnWriteArrayList a good choice?
Why: Every write copies the whole array, so it only pays off when reads far outnumber writes.
π Whatβs Next?
ConcurrentLinkedQueue is fast, but it never makes a thread wait. When the queue is empty, poll returns null right away. Often you want the opposite: a worker that sleeps until a job arrives, then wakes and handles it. That is the producer-consumer pattern, and Javaβs BlockingQueue family is built for it. Next you will learn how a blocking queue lets producers and consumers hand off work cleanly without manual locks. Letβs learn it.