Java

Concurrent Collections in Java

Executive Summary

Concurrent collections in Java exist because HashMap under concurrency corrupts and loses updates. Collections.synchronizedMap fixes correctness with one coarse lock that serializes everything, while ConcurrentHashMap instead locks per bucket and reads lock-free, so independent keys proceed in parallel. Its true power is the atomic compound operations. For example, putIfAbsent, computeIfAbsent, compute, and merge collapse the check-then-act races that even a concurrent map cannot otherwise prevent. They are the backbone of caches and counters, per the ConcurrentHashMap contract. Iteration is weakly consistent: no ConcurrentModificationException, and no promise of a frozen snapshot.

CopyOnWriteArrayList copies the array on every write and reads without any locking. As a result, it wins for read-mostly data like listener registries and loses for write-heavy lists. BlockingQueue is the producer-consumer tool: put blocks when full, and take blocks when empty. Also, a bounded ArrayBlockingQueue gives backpressure for free, which pairs naturally with the executor’s bounded pool from the pools article. The selection rule is shape-based. Maps get ConcurrentHashMap or ConcurrentSkipListMap for ordering, and handoff gets BlockingQueue. Meanwhile, read-mostly lists get copy-on-write, and counters get LongAdder rather than any map. Keep the values inside these collections immutable, records, and the structure’s guarantees cover the whole object.

Why HashMap Breaks and What Replaces It

A HashMap shared across threads is not “sometimes wrong”; it is structurally wrong. Concurrent resizes can corrupt the internal bucket table, and concurrent puts lose entries. The failure modes include wrong results and, historically, infinite loops during resize. The naive fixes each fall short:

var counts = new HashMap<String, Integer>();            // corrupt under real sharing

var guarded = Collections.synchronizedMap(new HashMap<String, Integer>());
// correct, but ONE lock for the whole map: every get, every put, serialized,
// and compound operations (check then put) still race between the calls

var concurrent = new ConcurrentHashMap<String, Integer>();
// correct AND scalable: reads lock-free, writes lock a single bucket's region,
// independent keys touch independent locks and proceed in parallel

The design difference is the whole story. synchronizedMap guards everything with one monitor, while ConcurrentHashMap shards the map so contention lands on tiny independent regions. Under 4 threads and a million keys, the difference is not a percentage. Instead, it is a design: one lane versus many.

The Atomic Methods: Where the Real Power Lives

Here is the subtlety that separates people who have used ConcurrentHashMap from people who understand it. The map’s individual operations are atomic, but a sequence of them is not. Check-then-act is still a race:

// WRONG: two operations with a gap - racy on ANY map, concurrent or not
if (catalog.get(isbn) == null) {
    catalog.put(isbn, fetchBook(isbn));       // two threads pass the check together,
}                                              // two fetches, one overwrites the other

// RIGHT: one atomic compound operation
var book = catalog.computeIfAbsent(isbn, key -> fetchBook(key));
// exactly one fetch per key, ever, even with 100 threads calling at once

The delta is the difference between a map whose operations are safe and a cache whose behavior is correct. The four compound methods cover the standard patterns:

// putIfAbsent: install a value only if the key is free
map.putIfAbsent("config", defaults);

// computeIfAbsent: lazy memoization - build only when the key is missing
var price = prices.computeIfAbsent(isbn, key -> fetchPrice(key));

// merge: counters without read-modify-write races
var wordCounts = new ConcurrentHashMap<String, Long>();
for (var word : words) {
    wordCounts.merge(word, 1L, Long::sum);          // add 1 to whatever is there
}

// compute: transform a value in place, atomically
state.compute(user, (key, current) -> current == null ? new Counter() : current);

merge and computeIfAbsent are the two you will use weekly. For example, merge turns any loop into a safe counter, and computeIfAbsent is the entire memoization pattern in one call. One production caveat belongs here. computeIfAbsent holds the bucket’s region while your function runs, so keep the function fast, and never let it re-enter the same map. A slow fetch inside computeIfAbsent is a latency problem for everything hashing to the same bucket. That is why the highest-throughput caches fetch the value outside the map and use putIfAbsent for the install.

Weakly Consistent Iteration

The sequential collections throw ConcurrentModificationException when you modify during iteration. This is fail-fast behavior that turns an unnoticed bug into an immediate error. In contrast, concurrent collections in Java make a different trade. Iteration is weakly consistent: it reflects the state at some point and may or may not reflect concurrent updates, and it never throws:

for (var entry : wordCounts.entrySet()) {    // no ConcurrentModificationException
    report(entry);                           // and no promise of a frozen snapshot:
}                                            // fine for monitoring, totals, dashboards;
                                             // not a substitute for synchronization
                                             // when you need a consistent view

Read that as a design choice, not a defect. Monitoring loops over a live cache must not crash because a writer arrived. Meanwhile, code that needs a consistent snapshot must still coordinate explicitly, typically by copying under a lock or by publishing immutable snapshots.

CopyOnWriteArrayList: Read-Mostly Lists

Some shared data is written once at startup and read forever after: listener registries, event subscriptions, route tables. CopyOnWriteArrayList serves exactly that shape by taking the name literally. Every write copies the entire backing array, and reads iterate a stable snapshot with no lock at all:

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

void register(Listener l) {
    listeners.add(l);            // writes: copy the whole array (rare, so acceptable)
}

void publish(Event e) {
    for (var l : listeners) {    // reads: lock-free, snapshot-stable, the common case
        l.onEvent(e);
    }
}

The contract to internalize is the cost profile, not the mechanism. Reads are essentially free and safe to iterate while writers run, but writes cost proportional to the list size. So frequency of writes is the entire decision. For example, a listener list that registers at startup and publishes a thousand events a second is the perfect case. By contrast, a hot list that appends per request is the disaster case, where the copy per write becomes your throughput ceiling. Also, since iteration reads a snapshot, a listener that deregisters mid-publication does not corrupt the loop. That removes an entire class of listener-management bugs for free.

BlockingQueue: Producer, Consumer, and Backpressure

The last shape is not shared data but work handoff. Producers create tasks, consumers process them, and the queue between them is the entire concurrency architecture. BlockingQueue, per its contract, makes the two blocking behaviors the design:

var queue = new ArrayBlockingQueue<String>(100);    // BOUNDED: 100 waiting tasks, no more

// producer side
queue.put(task);      // blocks when the queue is full: the producer waits for capacity
                      // this is backpressure, expressed as blocking, for free

// consumer side
var task = queue.take();     // blocks when empty: no busy-waiting, no polling loops

// the bounded alternatives when blocking does not fit:
queue.offer(task, 1, TimeUnit.SECONDS);    // wait up to 1s for space, then report false
var task2 = queue.poll(1, TimeUnit.SECONDS); // wait up to 1s for an item, else null
  producer ----put()----> [ | | | | | | | ] ----take()----> consumer
                           ArrayBlockingQueue(100)
        put blocks when FULL  ^           ^  take blocks when EMPTY
        = producer slowed to consumer's pace: overload is IMPOSSIBLE

A complete producer-consumer skeleton, with the poison-pill shutdown idiom:

var queue = new ArrayBlockingQueue<String>(100);
var POISON = new String("__done__");               // sentinel value: signals shutdown

var producer = new Thread(() -> {
    try {
        for (var uri : urls) {
            queue.put(uri);                        // blocks when consumers fall behind
        }
        queue.put(POISON);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "producer");

var consumer = new Thread(() -> {
    try {
        while (true) {
            var item = queue.take();               // blocks until work arrives
            if (item == POISON) { break; }          // clean exit through the sentinel
            process(item);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "consumer");

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

Notice what the bounded queue did that the executor article’s unbounded shelf did not. When production outruns consumption, the producer blocks at put. As a result, the overload surfaces at the source instead of accumulating in the heap. That is the same lesson as the pools article’s bounded ThreadPoolExecutor, arrived at from the data-structure side. The two compose naturally: a bounded queue feeds an executor, and the whole pipeline refuses overload politely.

Choosing Concurrent Collections in Java: The Shape Decides

The records article made your values immutable. This table pairs each shared shape with its concurrent structure and the Part 3 sequential version it replaces:

Shared shape Sequential version Concurrent replacement The contract to respect
Map with updates HashMap, TreeMap ConcurrentHashMap Single ops atomic, sequences not; use compute/merge; no null keys or values
Sorted concurrent map TreeMap ConcurrentSkipListMap Ordered traversal at concurrent-map throughput
Read-mostly list ArrayList CopyOnWriteArrayList Writes copy the array: write frequency is the whole decision
Work handoff, bounded ArrayDeque ArrayBlockingQueue put blocks when full: backpressure is the feature
Work handoff, unbounded LinkedList as queue LinkedBlockingQueue / ConcurrentLinkedQueue Unbounded is the same shelf warning as the pools article: choose deliberately
Counters and statistics long, int fields LongAdder, AtomicInteger Not a collection, but the right answer when the shape is a number

The pattern under the table is the one this part keeps teaching: name the shape, then take the tool built for the shape. After all, a lock you do not write is a lock you cannot get wrong. Every row here deletes an entire category of synchronized blocks you would otherwise be maintaining by hand, exactly the escalation the locks article ended with.

How Real Systems Do This

Count the concurrent structures in any production service and the list is short and familiar. You will find a ConcurrentHashMap cache keyed by id and a CopyOnWriteArrayList of metrics listeners. You will also find a bounded queue in front of the request processor and LongAdders behind every dashboard counter. Frameworks lean on them even harder. For example, caching libraries are ConcurrentHashMap with eviction policies bolted on. Similarly, event buses are copy-on-write listener lists, and message consumers are bounded queues with threads attached. The java.util.concurrent package is, in a real sense, the most battle-tested concurrency code your application will ever run.

The production story I would repeat to a new engineer is about what a bounded queue made visible. An ingestion service I worked on received events over HTTP and handed each to a processing pool. The first version used an unbounded internal queue. Under a downstream slowdown, it did what unbounded queues do: it looked healthy while filling the heap, then died loudly. It was the executor article’s war story with a different class name. The redesign put a bounded ArrayBlockingQueue between the web layer and the processors, with put as the handoff.

The next downstream slowdown produced a completely different failure mode. Ingestion requests slowed, clients retried, and alerts fired on latency. Meanwhile, the system shed load instead of memory. Nobody enjoyed the incident. However, the queue turned a crash into a degraded mode, which is the entire art of production concurrency. The rule I took from it is simple. Pick the queue bound at the number you can drain, and treat blocking at the boundary as the system telling the truth.

Decision Framework

  1. Is the collection confined to one thread, a local variable, a single-threaded pipeline stage? Use the sequential version: concurrent classes cost throughput for guarantees you are not consuming.
  2. Is the shape a map with concurrent updates? ConcurrentHashMap, and putIfAbsent or computeIfAbsent for the check-then-act patterns.
  3. Is the shape counters or statistics? LongAdder or the atomics from the threads article: not a map at all.
  4. Is the shape a list written rarely and read constantly? CopyOnWriteArrayList; if writes are frequent, a synchronized list or a redesign, never copy-on-write.
  5. Is the shape work moving between threads? Bounded BlockingQueue with put and take, sized to what consumers drain, and the poison pill or interrupt for shutdown.
  6. Does the map need ordering under concurrency? ConcurrentSkipListMap, the sorted concurrent answer.
  7. Do multiple map operations need to be atomic together? compute and merge make the common ones atomic. Anything more is the locks article’s territory, usually a sign the design wants a different shape.
  8. Are the stored values mutable objects? Fix that first: the structure’s guarantees protect references, not the objects behind them.

When NOT to Use This

  • Do not use a concurrent collection for thread-confined data. The sequential versions are faster, and “concurrent” on a confined structure is cost without benefit.
  • Do not trust single-operation atomicity for compound logic. Get-then-put, contains-then-add, and size-checks all race on any structure. The fix is an atomic method or a lock, not a different collection.
  • Do not put write-heavy lists on copy-on-write. Every append copies the array, and the cost grows with the list until it is your bottleneck.
  • Do not choose unbounded LinkedBlockingQueue by default. It has exactly the shelf problem the pools article showed, and bounded is the production default.
  • Do not iterate a concurrent collection expecting a snapshot. Weakly consistent means monitoring-grade views, and consistent views need explicit coordination or immutable snapshots.
  • Do not store mutable values and mutate them from many threads. The map is safe, but your objects are not, and the race just moved inside the values.

Common Mistakes

  • Check-then-act on a concurrent map: the most common error in the whole category. Two threads pass the null check and both write, whereas computeIfAbsent or putIfAbsent closes the gap.
  • Long I/O inside computeIfAbsent: the map holds the bucket region during your function, so a slow fetch stalls unrelated keys that hash nearby. Instead, fetch outside, install with putIfAbsent.
  • Nulls into ConcurrentHashMap: null keys and null values throw by design. That is because “absent” and “present but null” must never be ambiguous under concurrency.
  • Assuming iteration throws ConcurrentModificationException on concurrent collections: it does not. So the bug that fail-fast would have caught now needs different detection, such as tests with real interleaving or explicit snapshots.
  • Using size() for coordination: on a live concurrent structure, the number is a moving estimate. It is fine for metrics, wrong for control flow.
  • Replacing every HashMap reflexively: the confined-map case is everywhere. Using concurrent classes everywhere is both slower and a sign the design’s thread ownership was never decided.
  • CopyOnWriteArrayList for hot lists: the write cost scales with size. For example, a per-request append on a 10,000-element list is 10,000 copies a second waiting to happen.

Key Takeaways

  • ConcurrentHashMap shards its locking so independent keys proceed in parallel, where synchronizedMap serializes everything on one monitor.
  • The atomic methods are the real API. putIfAbsent, computeIfAbsent, compute, and merge collapse the check-then-act races that safe single operations cannot prevent.
  • Iteration is weakly consistent: never throws, never promises a snapshot, perfect for monitoring, insufficient for consistency.
  • CopyOnWriteArrayList is a contract about frequency: reads free and lock-free, writes copy the array, so read-mostly only.
  • A bounded BlockingQueue gives backpressure for free: put blocks at the bound, and overload surfaces at the source. Then the poison pill or interrupt ends the loop cleanly.
  • Counters are not maps: LongAdder and the atomics are the right shape for numbers.
  • The stored values must be immutable: the structure protects references, and mutable values move the race inside your objects.
  • Every row of the selection table deletes a category of hand-written locking: shape first, then the tool built for the shape.

FAQ

What is ConcurrentHashMap in Java?

A hash map safe for concurrent use. Reads are lock-free, writes lock only the affected bucket region, and compound operations like computeIfAbsent and merge are atomic. It forbids null keys and values, and its iteration is weakly consistent rather than fail-fast.

Why does HashMap fail with multiple threads?

HashMap’s internal bucket table does not support concurrent modification. Concurrent writes lose entries, and concurrent resizes can corrupt the structure itself. The synchronized-map wrapper fixes correctness with one coarse lock; ConcurrentHashMap fixes it with per-bucket locking and much better parallelism.

What does computeIfAbsent do in Java?

It returns the value for a key. It computes and installs the value with your function only if the key is absent, as one atomic operation. It is the standard memoization and cache-population pattern, guaranteeing exactly one computation per key even under concurrent calls.

What is CopyOnWriteArrayList in Java?

A thread-safe list where every write copies the entire backing array, so readers iterate a stable snapshot with no locking. It suits read-mostly data like listener registries. However, it degrades badly on write-heavy lists because each write costs proportionally to the list size.

What is BlockingQueue in Java?

A queue for producer-consumer handoff: put blocks when the queue is full and take blocks when it is empty. It also has offer and poll as timed non-blocking variants. A bounded ArrayBlockingQueue provides backpressure, slowing producers to the consumers’ pace instead of accumulating work without limit.

Conclusion

You now hold the layer that makes Java concurrency tractable in practice: concurrent collections in Java whose safety is inherited rather than maintained. You also have atomic methods that close the races naive usage leaves open, and queues that turn overload into polite blocking instead of heap exhaustion. The selection table is the working reference: shape first, concurrent tool for the shape, immutable values inside.

The next article addresses what none of these tools covered: composition. A Future gives you one value when it is done. However, real pipelines are chains: fetch this, then transform it, then combine it with that, with fallbacks and independent branches. CompletableFuture is the API for that composition, and it builds directly on everything in this part so far.

Share the shapes, not the variables. The collections enforce what the locks only promised.

Last updated on 4 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *