Skip to content
Java collections 6 min read

Concurrent Collections

When multiple threads read and write shared data structures at the same time, classic collections like ArrayList and HashMap break in subtle, hard-to-debug ways. Java’s java.util.concurrent package ships a set of concurrent collections — purpose-built data structures that are thread-safe, performant, and far easier to use than hand-rolled synchronization.

Why Not Just Use synchronized?

You could wrap any collection with Collections.synchronizedList(...) or write synchronized blocks everywhere, but that approach has real costs:

  • Coarse-grained locking — the entire structure is locked for every read and write, so threads constantly block each other.
  • Compound operations aren’t atomic — a check-then-act pattern (e.g., “if absent, add”) is still a race condition even with a synchronized wrapper.
  • Iterator failures — iterating a synchronized collection while another thread modifies it still throws ConcurrentModificationException.

Concurrent collections solve all three problems with finer-grained or lock-free designs.

Note: All concurrent collections live in java.util.concurrent. Most also implement the standard java.util interfaces (List, Map, Queue), so you can swap them in with minimal code changes.


ConcurrentHashMap

ConcurrentHashMap<K, V> is a drop-in replacement for HashMap in concurrent code. It divides the map’s internal buckets into independent segments (Java 7) or uses CAS (Compare-And-Swap) operations on individual nodes (Java 8+), so multiple threads can read and write different parts of the map simultaneously.

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentMapDemo {
    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap<String, Integer> scores = new ConcurrentHashMap<>();

        Runnable increment = () -> {
            for (int i = 0; i < 1000; i++) {
                scores.merge("player1", 1, Integer::sum); // atomic merge
            }
        };

        Thread t1 = new Thread(increment);
        Thread t2 = new Thread(increment);
        t1.start(); t2.start();
        t1.join();  t2.join();

        System.out.println("Final score: " + scores.get("player1"));
    }
}

Output:

Final score: 2000

Key atomic operations you should prefer over the raw get/put pair:

MethodWhat it does atomically
putIfAbsent(k, v)Inserts only if key is absent
computeIfAbsent(k, fn)Computes and inserts only if absent
merge(k, v, fn)Combines existing value with new one
compute(k, fn)Updates value in-place atomically

Warning: ConcurrentHashMap does not allow null keys or null values. Using null throws a NullPointerException. This is intentional — null would make it impossible to distinguish “key not present” from “key mapped to null.”


CopyOnWriteArrayList

CopyOnWriteArrayList<E> implements List and is optimised for read-heavy workloads where writes are rare. Every mutating operation (add, set, remove) creates a fresh copy of the underlying array, applies the change to the copy, then atomically swaps the reference.

Reads (including iteration) always see a consistent snapshot and never throw ConcurrentModificationException.

import java.util.concurrent.CopyOnWriteArrayList;

public class CowListDemo {
    public static void main(String[] args) {
        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
        list.add("Alpha");
        list.add("Beta");
        list.add("Gamma");

        // Safe to iterate while another thread could be adding
        for (String s : list) {
            System.out.println(s);
        }

        list.add("Delta"); // creates a new internal array
        System.out.println("Size after add: " + list.size());
    }
}

Output:

Alpha
Beta
Gamma
Size after add: 4

Tip: Use CopyOnWriteArrayList for things like a list of event listeners or plugin registrations — rarely written, frequently iterated. Avoid it for high-write workloads; copying the array on every write becomes expensive.

Similarly, CopyOnWriteArraySet<E> provides a thread-safe Set backed by CopyOnWriteArrayList.


BlockingQueue

BlockingQueue<E> (an interface) adds two powerful behaviours on top of a regular Queue:

  • put(e) — blocks if the queue is full until space is available.
  • take() — blocks if the queue is empty until an element arrives.

This is the foundation of the classic producer-consumer pattern, eliminating the need for wait/notify entirely.

ArrayBlockingQueue

Backed by a fixed-size array. You must specify the capacity at construction.

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProducerConsumer {
    public static void main(String[] args) {
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);

        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    queue.put(i);
                    System.out.println("Produced: " + i);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 5; i++) {
                    int val = queue.take();
                    System.out.println("Consumed: " + val);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();
    }
}

Output (order may vary slightly):

Produced: 1
Consumed: 1
Produced: 2
Consumed: 2
...

LinkedBlockingQueue

Optionally bounded (defaults to Integer.MAX_VALUE). Uses separate locks for the head and tail, so producers and consumers rarely block each other.

PriorityBlockingQueue

An unbounded queue that orders elements by their natural order or a Comparator. It does not block on put (it’s unbounded), but take blocks when empty.

import java.util.concurrent.PriorityBlockingQueue;

public class PriorityQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        PriorityBlockingQueue<Integer> pq = new PriorityBlockingQueue<>();
        pq.put(50);
        pq.put(10);
        pq.put(30);

        System.out.println(pq.take()); // 10 (smallest first)
        System.out.println(pq.take()); // 30
        System.out.println(pq.take()); // 50
    }
}

Output:

10
30
50

Common BlockingQueue Implementations at a Glance

ClassBounded?OrderingBest Use
ArrayBlockingQueueYes (fixed)FIFOFixed-capacity work queues
LinkedBlockingQueueOptionalFIFOHigh-throughput pipelines
PriorityBlockingQueueNoPriorityTask scheduling
DelayQueueNoDelay-basedScheduled task execution
SynchronousQueue0 capacityHand-offDirect thread-to-thread transfer

ConcurrentLinkedQueue and ConcurrentLinkedDeque

ConcurrentLinkedQueue<E> is a non-blocking, lock-free unbounded FIFO queue based on the Michael-Scott algorithm using CAS operations. It offers extremely high throughput when many threads are producing and consuming.

import java.util.concurrent.ConcurrentLinkedQueue;

public class CLQDemo {
    public static void main(String[] args) {
        ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
        queue.offer("First");
        queue.offer("Second");
        System.out.println(queue.poll()); // First
        System.out.println(queue.poll()); // Second
    }
}

ConcurrentLinkedDeque<E> extends this to a double-ended queue (deque), allowing thread-safe insertion and removal from both ends.

Note: Unlike BlockingQueue, these do not block. poll() returns null when empty rather than waiting. Use them when you want non-blocking behaviour with lock-free speed.


Under the Hood

ConcurrentHashMap Internals (Java 8+)

The Java 8 rewrite replaced segment-level locking with a node-level approach:

  • The table is an array of Node<K,V> references stored with volatile for visibility.
  • Reads are lock-free — they read directly from the volatile node reference.
  • Writes CAS the head node of a bucket. Only when there’s a collision (two threads racing on the same bucket) does a synchronized block kick in — on just that single bucket node.
  • When a bucket’s chain grows beyond 8 entries, it’s converted to a red-black tree (TreeNode) for O(log n) lookup — the same treeification used in HashMap.

This means ConcurrentHashMap can support true parallel reads and near-parallel writes with minimal contention.

CopyOnWriteArrayList Internals

The internal array is held in a volatile field. Writers acquire a ReentrantLock, copy the array, mutate the copy, then do a volatile write to swap the reference. Readers see a stable snapshot because they read the volatile reference once at the start of iteration and then walk the fixed-length array — no lock needed.

False Sharing and Cache Lines

High-throughput concurrent collections are carefully padded to avoid false sharing — a performance problem where two unrelated volatile variables end up on the same CPU cache line, causing threads to bounce the line between cores even when they’re accessing different data. ConcurrentHashMap’s counter cells (LongAdder internally) use @Contended padding to prevent this.


Choosing the Right Concurrent Collection

ScenarioUse
Shared key-value store, many threadsConcurrentHashMap
Read-heavy list (event listeners, callbacks)CopyOnWriteArrayList
Producer-consumer with backpressureArrayBlockingQueue / LinkedBlockingQueue
Non-blocking high-throughput queueConcurrentLinkedQueue
Priority-based task queuePriorityBlockingQueue
Thread-safe set, rare writesCopyOnWriteArraySet

  • HashMap — understand the single-threaded map that ConcurrentHashMap improves upon
  • Synchronization — the manual locking alternative and why concurrent collections are often better
  • Thread Pools & ExecutorsBlockingQueue powers the work queues inside ThreadPoolExecutor
  • Multithreading — foundational concepts for writing concurrent Java programs
  • Volatile Keyword — the memory visibility guarantee that concurrent collections rely on internally
  • Deadlock — a hazard that concurrent collections help you avoid by replacing manual synchronized blocks
Last updated June 13, 2026
Was this helpful?