← Back to the section

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);
  • ArrayIndexOutOfBoundsException or ConcurrentModificationException in 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:

  • put blocks the producer if the queue is full;
  • take blocks 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

SituationChoice
A concurrent map with frequent updatesConcurrentHashMap
A list that's read often but written rarelyCopyOnWriteArrayList
Passing tasks between threads (producer-consumer)BlockingQueue / ArrayBlockingQueue
Quickly wrapping an existing collection (light concurrency)Collections.synchronizedX

In short

  • Plain HashMap and ArrayList aren't protected against concurrent access: data loss, exceptions, and hangs are all possible.
  • ConcurrentHashMap provides thread safety without a global lock; the compute and merge methods let you update values atomically.
  • CopyOnWriteArrayList copies the array on every write — safe for reads, expensive for frequent changes.
  • Collections.synchronizedX protects individual calls, but iteration has to be synchronized manually.
  • BlockingQueue is a ready-made foundation for the producer-consumer scheme: it blocks a thread instead of failing with an error.
  • Atomic operations and the java.util.concurrent.atomic classes — how to avoid locks when working with single values.
  • ExecutorService and thread pools — how BlockingQueue is used inside thread pools.
  • Race conditions — where concurrency bugs come from and how to find them.