Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
Chaturmind
← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
HomeLearnJavaJava Concurrency & MultithreadingConcurrent Collections
✓ FreeIntermediate· 7 min read

ConcurrentHashMap & CopyOnWriteArrayList

Thread-safe collections — when ConcurrentHashMap beats synchronized HashMap.

Published September 21, 2026


ConcurrentHashMap & CopyOnWriteArrayList

Ordinary collections (HashMap, ArrayList) are not safe to share between threads when at least one thread writes. Concurrent writes can lose updates, corrupt internal structure, or throw ConcurrentModificationException during iteration. The java.util.concurrent package provides collections designed for sharing, each tuned for a different access pattern. The two you'll use and be asked about most are ConcurrentHashMap and CopyOnWriteArrayList, plus the blocking queues behind producer–consumer designs.

Why not just wrap with Collections.synchronizedMap?

Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

This puts one lock around every method. It's correct for single calls, but:

  • All threads queue on that one lock, even readers, so throughput collapses under contention.
  • Compound actions aren't atomic. if (!map.containsKey(k)) map.put(k, v); is two separately locked calls, and another thread can slip in between.
  • Iteration isn't protected. You must wrap loops in synchronized (map) { ... } yourself, or risk ConcurrentModificationException.

Hashtable and Vector have the same single-lock design, which is why they're considered legacy.

ConcurrentHashMap

ConcurrentHashMap (CHM) allows many threads to read and write at the same time:

  • Reads take no lock. They read volatile fields safely, so they never block and scale across cores.
  • Writes lock only one bucket (Java 8+). An empty bucket is filled with a lock-free CAS (compare-and-set). A non-empty bucket is locked with synchronized on its first node, so threads writing to different buckets never block each other. (Java 7 used a fixed number of lock "segments" instead.)
  • Resizing is cooperative: threads that notice a resize in progress help move buckets instead of waiting.

Atomic compound operations: the real reason to use it

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

counts.merge(word, 1, Integer::sum);                       // atomic "increment or insert 1"
counts.putIfAbsent("config", 42);                           // atomic check-then-put
counts.computeIfAbsent(userId, id -> loadProfile(id));     // atomic "get or create"
counts.compute(key, (k, v) -> v == null ? 1 : v * 2);      // atomic read-modify-write

Each runs atomically for that key. The classic bug is doing it in two steps:

// ❌ Race: two threads can both see null and both put 1 — one increment is lost
Integer old = counts.get(word);
counts.put(word, old == null ? 1 : old + 1);

// ✅
counts.merge(word, 1, Integer::sum);

Caveat: the function passed to compute/computeIfAbsent runs while the bucket is locked. Keep it short, and never modify the same map from inside it, since that can deadlock or throw.

What "thread-safe" does and doesn't mean here

  • No nulls: put(key, null) throws NullPointerException. It's deliberate: in a concurrent map, get(k) == null must unambiguously mean "absent".
  • Weakly consistent iteration: iterators never throw ConcurrentModificationException and reflect the map at some point during iteration. They may or may not see concurrent updates. It isn't a frozen snapshot.
  • Aggregates are estimates under concurrency: size() and isEmpty() may already be out of date by the time you use them. Don't base decisions on them while other threads are writing.
  • Reads see completed writes (a happens-before relationship), but a read can of course happen just before another thread's write. Thread-safe doesn't mean "always the latest possible value".
  • Multi-key operations aren't atomic. Transferring a value between two keys needs external coordination.

CopyOnWriteArrayList

A list that, on every modification, copies the whole internal array, applies the change to the copy, and swaps the reference. Readers just read whatever array is current:

List<Listener> listeners = new CopyOnWriteArrayList<>();

listeners.add(listener);                  // copies the array (O(n))
for (Listener l : listeners) {            // iterates a snapshot — no locking, never throws CME
    l.onEvent(event);                     // even if another thread adds/removes listeners meanwhile
}
  • Reads and iteration are lock-free and very fast, and each iterator sees a stable snapshot.
  • Every write is O(n) in time and allocation. With frequent writes or large lists, it's terrible.
  • Iterators are read-only: iterator.remove() throws UnsupportedOperationException.

Use it for read-mostly, write-rarely lists that are iterated often: event listeners, observers, routing tables, configuration. CopyOnWriteArraySet is the set equivalent.

Blocking queues: the producer–consumer workhorse

A BlockingQueue adds operations that wait. put blocks while the queue is full, and take blocks while it's empty. It's the cleanest way to hand work from producer threads to consumer threads without writing wait/notify yourself:

BlockingQueue<Order> queue = new ArrayBlockingQueue<>(1_000);   // bounded → natural backpressure

// producer
queue.put(order);                                  // waits if consumers are behind

// consumer
while (!Thread.currentThread().isInterrupted()) {
    Order o = queue.take();                        // waits for work
    process(o);
}
ImplementationBehaviour
ArrayBlockingQueueBounded, array-backed, one lock
LinkedBlockingQueueOptionally bounded (unbounded by default, so it can grow until memory runs out), separate locks for put and take
PriorityBlockingQueueUnbounded, ordered by priority
DelayQueueElements become available after their delay expires
SynchronousQueueZero capacity: each put waits for a matching take (direct hand-off)

Prefer bounded queues. An unbounded queue hides a slow consumer until the JVM runs out of memory. A bounded one applies backpressure by slowing producers down. Timed variants (offer(e, timeout), poll(timeout)) let threads give up instead of waiting forever.

ConcurrentLinkedQueue is a non-blocking, lock-free queue for when you never want threads to wait, since poll() just returns null when it's empty.

Counters: AtomicLong vs LongAdder

AtomicLong hits = new AtomicLong();
hits.incrementAndGet();          // a CAS on one variable; retries under contention

LongAdder hits2 = new LongAdder();
hits2.increment();               // spreads updates across several cells under contention
long total = hits2.sum();        // adds the cells up when you read

Under heavy contention, many threads hammering one counter, LongAdder is much faster because threads update different cells. Use AtomicLong when you need exact read-modify-write semantics such as compareAndSet or unique ID generation. Use LongAdder for statistics that are mostly written and occasionally summed. ConcurrentHashMap<String, LongAdder> with computeIfAbsent(k, x -> new LongAdder()).increment() is an idiomatic high-throughput per-key counter.

Choosing quickly

  • Shared map, many readers and writers → ConcurrentHashMap
  • Sorted concurrent map or set → ConcurrentSkipListMap / ConcurrentSkipListSet
  • List that's iterated constantly and changed rarely → CopyOnWriteArrayList
  • Handing work between threads → a bounded BlockingQueue (or simply an ExecutorService, which contains one)
  • A hot counter → LongAdder

Follow-up questions this topic invites — and their answers

Q: Why doesn't ConcurrentHashMap allow null keys or values? A: In a concurrent map, get(k) returning null must mean "not present". If null values were allowed, you couldn't tell "absent" from "present with null" without a second call, and another thread could change the map between the two calls. Banning nulls removes that ambiguity.

Q: Is if (!map.containsKey(k)) map.put(k, v) safe on a ConcurrentHashMap? A: No. Each call is thread-safe, but the pair isn't atomic, so two threads can both see "absent" and both put. Use putIfAbsent, computeIfAbsent or merge, which perform the whole check-and-act atomically.

Q: How does ConcurrentHashMap achieve concurrency in Java 8+? A: Lock-free reads using volatile semantics, CAS to insert into empty buckets, synchronized on the head node of a bucket for other writes (so only that bucket is locked), cooperative multi-threaded resizing, and conversion of long bucket chains into red-black trees, as in HashMap.

Q: When is CopyOnWriteArrayList the wrong choice? A: Whenever writes are frequent or the list is large. Each write copies the whole array, so costs grow with size × write rate and create lots of garbage. For write-heavy shared lists, use a concurrent queue or deque, or protect an ArrayList with a lock and copy it only when you need a snapshot.

Q: Why prefer a bounded BlockingQueue? A: It limits memory and applies backpressure: when consumers fall behind, put blocks, or offer fails, and producers slow down or shed load. An unbounded queue silently accumulates work until latency explodes or the heap is exhausted.

Previous

Deadlock, Starvation, Livelock

Next

HashMap Concurrency Variants

AI Tutor

Lesson: ConcurrentHashMap & CopyOnWriteArrayList

Quick actions

AI responses can be inaccurate. Verify critical information.