Java Concurrent Collections

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: 1873

Where did the items go?

  • When both threads call add at 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 HashMap is 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 locked

This 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 a ConcurrentModificationException.

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 BlockingQueue family β€” 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")); // 1

It 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 key w is missing, put 1; if it is there, replace it with the old value plus 1.
  • The whole β€œlook, add, store” runs as one locked step inside the map, so no update is lost.
  • Three threads loop 1000 times. apple appears twice per loop, banana once, 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, atomically
map.compute("score", (key, value) -> value + 5);
System.out.println(map.get("score")); // 15

And 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 time
groups.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

apple
banana
Size: 4

The 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 tail
queue.offer(2);
Integer first = queue.poll(); // take from the head, returns null if empty
System.out.println("Got: " + first); // Got: 1
System.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, ConcurrentHashMap is 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 HashMap or ArrayList across 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 crash
Map<String, Integer> map = new HashMap<>();
// βœ… Right: built for threads
Map<String, Integer> map = new ConcurrentHashMap<>();
  • Thinking every operation on ConcurrentHashMap is atomic. Each single call like get or put is safe. But a get followed by a put is two steps, and another thread can sneak in between them. This loses updates.
// ❌ Wrong: get-then-put is two steps; updates get lost
Integer current = map.get("apple");
map.put("apple", current + 1); // another thread may change it between these lines
// βœ… Right: one atomic step
map.merge("apple", 1, Integer::sum);
  • Using CopyOnWriteArrayList for 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 CopyOnWriteArrayList loop 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 synchronizedMap out of habit. It works, but for many threads it is slower than ConcurrentHashMap. Prefer the concurrent collection unless you have a specific reason not to.

βœ… Best Practices

Habits for safe, fast shared collections.

  • Default to ConcurrentHashMap for any shared map. It is safe and scales well, with almost no downside over a plain map.
  • Use merge, compute, or computeIfAbsent for any read-then-write. They are the only safe way to do compound updates on the map.
  • Pick CopyOnWriteArrayList only when reads greatly outnumber writes. Listener lists and rarely-changed config are the sweet spot.
  • Use ConcurrentLinkedQueue for a fast queue that should not block. If you need waiting, use a BlockingQueue instead.
  • Declare the variable with the interface type like Map, List, or Queue, so you can swap the implementation later without touching the rest of your code.
  • Prefer concurrent collections over Collections.synchronizedX for 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 ArrayList and HashMap are not thread-safe and silently lose data or crash under threads.
  • βœ… Collections.synchronizedX is safe but uses one lock for the whole collection, which limits throughput.
  • βœ… ConcurrentHashMap is safe with bucket-level concurrency, never throws ConcurrentModificationException, and offers atomic merge, compute, and computeIfAbsent.
  • βœ… A single get or put is atomic, but a get-then-put is not, so use merge or compute for compound updates.
  • βœ… CopyOnWriteArrayList copies on every write, so it shines for read-heavy data only.
  • βœ… ConcurrentLinkedQueue is a fast non-blocking queue that returns null when empty.
  • βœ… Concurrent collections beat synchronizedMap because 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. 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. 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. 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. 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.

Java BlockingQueue and Producer-Consumer

Share & Connect