Java's standard collections — HashMap, ArrayList, LinkedList — aren't designed to be used from several threads at once. As soon as multiple threads start reading from and writing to a single collection, the program starts behaving unpredictably. Java ships with ready-made alternatives that solve this problem.
What goes wrong with regular collections
Imagine two threads adding elements to an ArrayList at the same time. Internally, ArrayList keeps an array and a size counter. If both threads read the counter simultaneously (say, it equals 5), both will write a new element at position 5 — and one element gets lost. The counter still becomes 6, even though two elements were actually added.
With HashMap the situation is even more dangerous. When adding an element, the map sometimes rebuilds its internal structure (rehash). If two threads hit a rehash at the same time, you can end up with an infinite loop during traversal or lose data — and in Java 7 and earlier this would hang applications.
Typical symptoms:
- lost entries (you added an element, but it never shows up);
ArrayIndexOutOfBoundsExceptionorConcurrentModificationExceptionin unexpected places;- the program hangs for no visible reason.
ConcurrentHashMap — a thread-safe map
ConcurrentHashMap is the primary replacement for HashMap in multithreaded code. Internally, it splits the map into segments (in Java 8+ this is implemented via locks at the level of individual "buckets"), so multiple threads can work with different parts of the map at the same time — without a global lock.
ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();
// Safe from multiple threads
counter.put("events", 1);
// Atomic increment of the counter
// compute guarantees that the check and the write are a single indivisible operation
counter.compute("events", (key, value) -> value == null ? 1 : value + 1);
// merge — more convenient for counters
counter.merge("events", 1, Integer::sum);
The key advantage is that the methods compute, merge, and computeIfAbsent run atomically: no other thread can slip in between reading the old value and writing the new one. This eliminates the classic "read before you locked" mistake:
// DANGEROUS even with ConcurrentHashMap:
int old = counter.getOrDefault("x", 0);
counter.put("x", old + 1); // between get and put, another thread already changed the value
// CORRECT:
counter.merge("x", 1, Integer::sum);
ConcurrentHashMap does not allow null as a key or a value — unlike HashMap.
CopyOnWriteArrayList — a list for rare writes
CopyOnWriteArrayList solves the problem differently: on every modification (adding, removing) it creates a full copy of the internal array. Reading threads work with the old copy and are never blocked.
CopyOnWriteArrayList<String> listeners = new CopyOnWriteArrayList<>();
// Adding — creates a copy of the array, slow
listeners.add("listener-1");
listeners.add("listener-2");
// Iteration — absolutely safe, works with a snapshot taken at the start of the traversal
for (String listener : listeners) {
System.out.println(listener);
}
When it's appropriate:
- the list is read very often, while writes are rare (a list of event handlers, configuration);
- code simplicity matters more than maximum write speed.
When it's not appropriate — with frequent additions/removals: every write copies the entire array, which is expensive.
Collections.synchronizedX — wrappers and their limitations
Java provides helper methods Collections.synchronizedList, Collections.synchronizedMap, and the like. They wrap a regular collection and add synchronized to every method.
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
syncList.add("a"); // protected
syncList.add("b"); // protected
The problem is that iteration is not protected. If one thread iterates over the list while another removes an element, you get a ConcurrentModificationException. You have to lock manually:
synchronized (syncList) { // explicit lock over the entire traversal
for (String s : syncList) {
System.out.println(s);
}
}
This is inconvenient and easy to forget. That's why ConcurrentHashMap and CopyOnWriteArrayList are preferable in new code — they were designed for concurrency rather than adapted to it.
BlockingQueue — a bridge between producer and consumer
BlockingQueue is a special kind of queue that blocks a thread when an operation can't be performed right now:
putblocks the producer if the queue is full;takeblocks the consumer if the queue is empty.
This is a ready-made primitive for the classic producer-consumer scheme:
BlockingQueue<String> queue = new ArrayBlockingQueue<>(100); // capacity of 100 elements
// Producer thread
Runnable producer = () -> {
try {
for (int i = 0; i < 10; i++) {
queue.put("task-" + i); // waits if the queue is full
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
// Consumer thread
Runnable consumer = () -> {
try {
while (true) {
String task = queue.take(); // waits if the queue is empty
System.out.println("Processing: " + task);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
new Thread(producer).start();
new Thread(consumer).start();
ArrayBlockingQueue is a queue with a fixed capacity. There are other implementations too: LinkedBlockingQueue (optionally bounded), PriorityBlockingQueue (with priority), SynchronousQueue (passes an element directly without a buffer — the producer waits for the consumer).
BlockingQueue is the foundation of ExecutorService — it's exactly how tasks are handed off to the pool's threads.
How to choose the right collection
| Situation | Choice |
|---|---|
| A concurrent map with frequent updates | ConcurrentHashMap |
| A list that's read often but written rarely | CopyOnWriteArrayList |
| Passing tasks between threads (producer-consumer) | BlockingQueue / ArrayBlockingQueue |
| Quickly wrapping an existing collection (light concurrency) | Collections.synchronizedX |
In short
- Plain
HashMapandArrayListaren't protected against concurrent access: data loss, exceptions, and hangs are all possible. ConcurrentHashMapprovides thread safety without a global lock; thecomputeandmergemethods let you update values atomically.CopyOnWriteArrayListcopies the array on every write — safe for reads, expensive for frequent changes.Collections.synchronizedXprotects individual calls, but iteration has to be synchronized manually.BlockingQueueis a ready-made foundation for the producer-consumer scheme: it blocks a thread instead of failing with an error.
What to read next
- Atomic operations and the
java.util.concurrent.atomicclasses — how to avoid locks when working with single values. - ExecutorService and thread pools — how
BlockingQueueis used inside thread pools. - Race conditions — where concurrency bugs come from and how to find them.