Skip to content
2 min read

Concurrent Collections in Java

Regular collections (ArrayList, HashMap, HashSet) are not thread-safe. When multiple threads read and write to them simultaneously, you get corrupted data or ConcurrentModificationException. Concurrent collections solve this problem.


The Problem

Java
// This WILL throw ConcurrentModificationException
List<String> list = new ArrayList<>(List.of("A", "B", "C"));

// Thread 1: iterating
for (String s : list) {
    System.out.println(s);
}

// Thread 2: modifying (simultaneously)
list.add("D");  // ConcurrentModificationException!

Solutions at a Glance

Problem Bad solution Good solution
Thread-safe list Collections.synchronizedList() CopyOnWriteArrayList
Thread-safe set Collections.synchronizedSet() CopyOnWriteArraySet, ConcurrentSkipListSet
Thread-safe map Collections.synchronizedMap() / Hashtable ConcurrentHashMap
Thread-safe queue ConcurrentLinkedQueue, BlockingQueue

Why are synchronized wrappers bad? They lock the entire collection on every operation. One thread reading blocks all other threads from reading or writing. Very slow under contention.


ConcurrentHashMap

The most important concurrent collection. Used heavily in production systems.

How It Works

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
    subgraph OLD["🐌 Hashtable / synchronizedMap"]
        direction LR
        O1{{"Thread-1: lock map → read"}} --> OL(("🔒 ONE lock for entire map"))
        O2{{"Thread-2: ⛔ BLOCKED"}} --> OL
        O3{{"Thread-3: ⛔ BLOCKED"}} --> OL
    end

    subgraph NEW["🚀 ConcurrentHashMap"]
        direction LR
        N1{{"Thread-1: lock bucket-3 → read"}} --> NL1(["🔑 Bucket-3 lock"])
        N2{{"Thread-2: lock bucket-7 → write ✅ parallel!"}} --> NL2(["🔑 Bucket-7 lock"])
        N3{{"Thread-3: bucket-3 → ⛔ BLOCKED same bucket"}} --> NL1
    end

    style OLD fill:#FEF3C7,stroke:#FCD34D,color:#1E40AF
    style NEW fill:#EFF6FF,stroke:#93C5FD,color:#1E40AF
    style OL fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
    style NL1 fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
    style NL2 fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
    style O1 fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
    style O2 fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
    style O3 fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
    style N1 fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
    style N2 fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF
    style N3 fill:#FEE2E2,stroke:#FCA5A5,color:#1E40AF
  • Java 7: Segment-based locking (16 segments by default)
  • Java 8+: Per-bucket locking using CAS (Compare-And-Swap) operations + synchronized blocks. Even more fine-grained.

Key Operations

Java
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// Basic operations (thread-safe)
map.put("Java", 1);
map.get("Java");
map.remove("Java");

// Atomic compound operations (key differentiator)
map.putIfAbsent("Java", 1);       // only puts if key doesn't exist
map.computeIfAbsent("Java", k -> expensiveCompute(k));  // lazy computation
map.computeIfPresent("Java", (k, v) -> v + 1);          // atomic update
map.merge("Java", 1, Integer::sum);                       // atomic merge

// Atomic counter pattern
map.compute("pageViews", (key, val) -> val == null ? 1 : val + 1);

ConcurrentHashMap vs Hashtable vs synchronizedMap

Feature Hashtable synchronizedMap ConcurrentHashMap
Lock granularity Entire table Entire map Per-bucket (fine-grained)
Null keys/values No Depends on backing map No
Iteration Fail-fast Fail-fast Weakly consistent (no exception)
Performance Slow Slow Fast under contention
Read locking Yes Yes No (reads are lock-free)

CopyOnWriteArrayList

Creates a new copy of the internal array on every write operation. Reads are always lock-free.

Java
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");

// Safe to iterate while another thread modifies
for (String s : list) {
    System.out.println(s);  // reads the snapshot — never throws CME
}

When to Use

Use when Don't use when
Reads >>> Writes (e.g., listener lists, config) Frequent writes (copying the array on every write is expensive)
Small lists (< 100 elements) Large lists (copying 10,000 elements per write is slow)
Need safe iteration without locking Need fast concurrent writes

Real-world example: Spring's event listener registry uses CopyOnWriteArrayList because listeners are registered once but invoked on every event.


BlockingQueue — Producer-Consumer Pattern

BlockingQueue blocks the calling thread when the queue is full (for put) or empty (for take).

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
    P[/"🏭 Producer"/] -->|"put()"| Q{{"📦 BlockingQueue<br/>⏸️ blocks if full"}}
    Q -->|"take()"| C(["🛒 Consumer<br/>⏸️ blocks if empty"])

    style P fill:#BFDBFE,stroke:#93C5FD,color:#1E40AF
    style Q fill:#FEF3C7,stroke:#FCD34D,color:#1E40AF
    style C fill:#DBEAFE,stroke:#93C5FD,color:#1E40AF

Implementations

Type Bounded? Ordering Best for
ArrayBlockingQueue Yes (fixed size) FIFO Bounded producer-consumer
LinkedBlockingQueue Optional FIFO High-throughput queues
PriorityBlockingQueue No Priority Task scheduling
SynchronousQueue No (size 0) Direct handoff Thread pools (Executors)
DelayQueue No Delayed Scheduled tasks

Producer-Consumer Example

Java
BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);

// Producer thread
new Thread(() -> {
    try {
        queue.put("Task-1");  // blocks if queue is full
        queue.put("Task-2");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

// Consumer thread
new Thread(() -> {
    try {
        String task = queue.take();  // blocks if queue is empty
        process(task);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

ConcurrentLinkedQueue

Non-blocking, lock-free queue using CAS operations. Best for high-throughput scenarios where you don't need blocking behavior.

Java
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.offer("A");
queue.offer("B");
String head = queue.poll();  // "A" (returns null if empty, never blocks)

ConcurrentSkipListMap / ConcurrentSkipListSet

Thread-safe sorted collections (like TreeMap/TreeSet but concurrent).

Java
ConcurrentSkipListMap<String, Integer> map = new ConcurrentSkipListMap<>();
map.put("Banana", 2);
map.put("Apple", 5);
map.put("Cherry", 1);
// Iterates in sorted order: Apple, Banana, Cherry

Choosing the Right Concurrent Collection

Need Use
Thread-safe map, high concurrency ConcurrentHashMap
Thread-safe sorted map ConcurrentSkipListMap
Thread-safe list, mostly reads CopyOnWriteArrayList
Producer-consumer pattern ArrayBlockingQueue / LinkedBlockingQueue
Non-blocking queue ConcurrentLinkedQueue
Thread-safe sorted set ConcurrentSkipListSet

Interview Questions

1. Why does ConcurrentHashMap not allow null keys or values?

Because null is ambiguous in concurrent contexts. If map.get(key) returns null, you can't tell if the key doesn't exist or if the value is null — and in a concurrent map, you can't use containsKey() + get() atomically (another thread might modify between the two calls). HashMap doesn't have this problem because it's single-threaded.

2. Can you iterate over a ConcurrentHashMap while another thread modifies it?

Yes. ConcurrentHashMap iterators are weakly consistent — they reflect the state at (or after) the time the iterator was created. They never throw ConcurrentModificationException. They might or might not reflect concurrent modifications made after the iterator was created.

3. How would you implement a thread-safe counter using ConcurrentHashMap?

Use compute() or merge():

Java
ConcurrentHashMap<String, Long> counters = new ConcurrentHashMap<>();
counters.merge("pageViews", 1L, Long::sum);  // atomic increment
Or for simple counters, use AtomicLong or LongAdder (faster under high contention).

4. What is the difference between ArrayBlockingQueue and LinkedBlockingQueue?

ArrayBlockingQueue has a fixed capacity (must specify at creation), uses a single lock for put and take. LinkedBlockingQueue has optional capacity (defaults to Integer.MAX_VALUE), uses two separate locks (one for put, one for take) so producers and consumers can work in parallel. LinkedBlockingQueue generally has higher throughput.