Hello. In the previous lesson, you used synchronized and ReentrantLock to protect a complete in-memory state transition. The central rule was that every thread participating in an invariant must coordinate through the same lock.
Concurrent collections package common coordination patterns into well-tested data structures. They can reduce lock contention and, more importantly, provide atomic operations such as “install this value only if the key is absent.” They do not remove the need to reason about your business invariant: a thread-safe map protects its own mappings, not arbitrary mutable objects stored inside it or a workflow spanning several calls.
By the end of this lesson, you should be able to look at a backend workload—shared cache, listener registry, task queue, sorted registry, or high-volume metric counter—and justify the appropriate concurrent collection.
Start with the workload, not the collection name
A good selection begins with five concrete questions:
-
What shape is the shared data?
Key-value lookup, unique membership, ordered list, FIFO work queue, priority queue, or counter? -
What is the read-write ratio?
“Many reads, rare writes” requires a very different structure from “many writers updating the same keys.” -
Do you need ordering?
Hash-based maps do not preserve sorted key order. A queue has insertion order, while a priority queue deliberately does not. -
What must happen under overload?
An unbounded queue accepts more work until heap pressure becomes the failure mode. A bounded queue forces an explicit backpressure decision. -
What operation must be atomic?
A collection may make individual calls safe, but this remains unsafe:if (!map.containsKey(key)) { map.put(key, value); }Two threads can both observe that the key is absent before either performs
put.
The last point connects directly to the check-then-act race from the previous lesson. A concurrent collection gives safe individual operations; when you need a transition that combines operations, use its dedicated atomic methods or add coordination at the correct boundary.
Watch the following focused explanation before moving into the collection choices.
Java ConcurrentMap and ConcurrentHashMap
In “Java ConcurrentMap and ConcurrentHashMap,” Jakob Jenkov shows why a thread-safe collection still does not make separate check and update calls atomic, then introduces the map operations that solve that specific race.
Watch slipped conditions to see why containsKey() followed by put() is unsafe even on a concurrent map. Then watch atomic map methods for putIfAbsent, computeIfAbsent, and computeIfPresent. Focus on the distinction between protecting each method call and protecting the complete decision for one key.
Concurrent maps: the normal choice for shared keyed state
For a high-concurrency key-value structure, start with ConcurrentHashMap.
Common local-service use cases include:
- a registry of currently active jobs by ID;
- a short-lived in-memory lookup cache;
- a set of request IDs already seen by this JVM;
- per-endpoint or per-tenant metric counters;
- a map of in-flight work keyed by correlation ID.
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
final class InFlightRequests {
private final ConcurrentMap<String, RequestState> requests =
new ConcurrentHashMap<>();
void register(String requestId, RequestState state) {
requests.put(requestId, state);
}
RequestState find(String requestId) {
return requests.get(requestId);
}
}
Unlike HashMap, ConcurrentHashMap is designed for concurrent access. Unlike legacy Hashtable or a fully synchronized HashMap, it permits substantially more overlap between independent operations. In particular, retrievals such as get() generally do not block while another thread updates a different mapping.
The Java 21 API documentation is worth reading because it states the guarantees and limitations precisely.
ConcurrentHashMap (Java SE 21 & JDK 21)
Read the Java API documentation for ConcurrentHashMap. This is the authoritative reference for what concurrent reads, iteration, per-key updates, and aggregate operations actually guarantee.
In the class description, read the discussion beginning with retrieval operations and continue through the paragraph on size, isEmpty, and containsValue. Pay particular attention to iteration behavior: iteration is safe from ConcurrentModificationException, but it is not a fixed, transactionally consistent snapshot. Then, in the same class description, find the paragraph beginning “A ConcurrentHashMap can be used as a scalable frequency map.” Read the frequency-map example, noting the combination of computeIfAbsent and LongAdder. Finally, locate the method-detail entries for putIfAbsent, replace, computeIfAbsent, computeIfPresent, compute, and merge. Read the surrounding method descriptions, especially the mapping-function constraint. These methods express common compound transitions atomically for a key.
Use the atomic map operation that matches the rule
Here are the map operations you will use most often:
| Requirement | Appropriate operation | Why |
|---|---|---|
| Install a value only when no mapping exists | putIfAbsent(key, value) | Combines absence check and insertion |
| Initialize a per-key object on demand | computeIfAbsent(key, factory) | Creates and installs a value atomically when absent |
| Update a value only if it still has an expected value | replace(key, oldValue, newValue) | Useful for compare-and-set style replacement |
| Recalculate an existing or missing mapping | compute(key, remappingFunction) | Supplies the key and current value in one atomic map operation |
| Combine an existing value with a new contribution | merge(key, value, combiner) | Useful for accumulations when a simple value is sufficient |
| Remove only if a mapping has not changed | remove(key, value) | Avoids deleting a newer replacement |
For example, a per-route counter should not use get, increment, and put as separate steps. The initial creation of the counter and the counter increment need appropriate atomic operations:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.LongAdder;
final class RequestMetrics {
private final ConcurrentMap<String, LongAdder> requestsByRoute =
new ConcurrentHashMap<>();
void recordRequest(String route) {
requestsByRoute
.computeIfAbsent(route, ignored -> new LongAdder())
.increment();
}
long requestCount(String route) {
LongAdder counter = requestsByRoute.get(route);
return counter == null ? 0 : counter.sum();
}
}
This is a strong fit for telemetry. LongAdder spreads updates internally, which reduces contention when many request threads update a hot metric. Its sum() is appropriate for monitoring and dashboards, but should not be treated as an exact transactional decision point. For example, do not use it to decide whether the “next” globally unique sequence number is allowed.
ConcurrentHashMap has boundaries
Keep three limitations in mind.
First, it does not permit null keys or values. A null result from get() unambiguously means no mapping exists. Model an explicit state instead of using null as a stored value:
enum CacheState {
PRESENT,
NOT_FOUND
}
Second, iteration is weakly consistent. This is useful for diagnostics or best-effort reporting:
requests.forEach((requestId, state) ->
log.info("Request {} is {}", requestId, state));
But do not write application logic such as “if map.size() < limit, accept one more request.” Concurrent updates can make the observation stale immediately. A capacity rule needs an atomic primitive designed for that rule, often a bounded queue, semaphore, database constraint, or conditional database update.
Third, map safety does not make values safe. This code is still unsafe if OrderProgress is mutable and not independently synchronized:
ConcurrentMap<String, OrderProgress> progressByOrder = new ConcurrentHashMap<>();
OrderProgress progress = progressByOrder.get(orderId);
progress.incrementCompletedSteps(); // Safety depends on OrderProgress itself.
The map safely returns the reference. It cannot make later mutations of that referenced object atomic. Prefer immutable values and replace them atomically, use atomic value types for simple state, or encapsulate mutable state with its own synchronization.
Finally, avoid slow remote I/O inside compute, merge, or computeIfAbsent mapping functions. Those functions should be short and should not modify the same map recursively. An in-memory cache also does not automatically solve freshness, eviction, stampedes across replicas, or database consistency.
When key order and range queries matter
Choose ConcurrentSkipListMap rather than ConcurrentHashMap when you need concurrent access and sorted keys.
Examples:
- showing active job IDs in chronological or lexical order;
- retrieving entries in a key range;
- maintaining a concurrent time-indexed registry;
- querying all keys from one prefix or numeric range to another.
import java.util.concurrent.ConcurrentNavigableMap;
import java.util.concurrent.ConcurrentSkipListMap;
ConcurrentNavigableMap<Long, JobStatus> jobsByCreatedAt =
new ConcurrentSkipListMap<>();
ConcurrentNavigableMap<Long, JobStatus> recentJobs =
jobsByCreatedAt.tailMap(cutoffEpochMillis, true);
The ordering requirement is the reason to accept its overhead. If your dominant access is direct lookup by ID and you do not need ordered traversal or range views, ConcurrentHashMap is generally the better default.
For concurrent set membership, the same distinction applies:
- use
ConcurrentHashMap.newKeySet()for a high-throughput, unordered set; - use
ConcurrentSkipListSetwhen the set must be sorted and support range operations.
Read-mostly listener lists: copy on write
Some shared collections are read repeatedly but changed rarely. A typical example in a service is a local listener registry, routing rules loaded at startup, or a snapshot-like configuration list.
For this workload, use CopyOnWriteArrayList. Each mutation creates a new underlying array; readers continue to use the old stable array until the new one is published.
import java.util.concurrent.CopyOnWriteArrayList;
final class EventPublisher {
private final CopyOnWriteArrayList<EventListener> listeners =
new CopyOnWriteArrayList<>();
void addListener(EventListener listener) {
listeners.add(listener);
}
void publish(DomainEvent event) {
for (EventListener listener : listeners) {
listener.onEvent(event);
}
}
}
This gives a particularly useful property: while one thread adds or removes a listener, another can iterate safely without locking and without ConcurrentModificationException.
The trade-off is deliberate and substantial:
- Reads and iteration are very fast.
- Every write copies the complete backing array.
- Large lists or frequent writes create allocation and garbage-collection pressure.
- An iterator sees a snapshot from its creation time. It will not necessarily see listeners added halfway through publication.
That makes copy-on-write excellent for a dozen infrequently changed listeners, but poor for a shopping cart-like list receiving frequent concurrent additions and removals.
CopyOnWriteArraySet applies the same strategy when uniqueness is required. It is appropriate for a small, read-mostly set of handlers or feature flags, not a frequently changing set of millions of IDs.
Queues: choose an overload policy, not merely a FIFO structure
Queues are common in backend systems whenever producers generate work and consumers process it asynchronously. The critical design question is not just “which queue is thread-safe?” It is:
When producers are faster than consumers, where does pressure go?
ConcurrentLinkedQueue: non-blocking and unbounded
Use ConcurrentLinkedQueue for a high-throughput, in-memory FIFO queue when external controls already bound the workload and you can tolerate its lack of backpressure.
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.Queue;
Queue<AuditEvent> pendingEvents = new ConcurrentLinkedQueue<>();
pendingEvents.offer(event);
AuditEvent next = pendingEvents.poll();
offer() adds if possible, while poll() returns an element or null when empty. It is non-blocking and avoids a fixed capacity.
Its danger is architectural: if consumers slow down and producers continue, the queue grows until it consumes too much heap. “Unbounded” is not a capacity strategy. It is a decision to let memory absorb the backlog.
BlockingQueue: bounded buffering and explicit backpressure
Use a BlockingQueue when the queue itself must protect memory or a downstream dependency.
For a fixed, predictable buffer, choose ArrayBlockingQueue:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
BlockingQueue<EmailTask> queue = new ArrayBlockingQueue<>(500);
boolean accepted = queue.offer(task, 100, TimeUnit.MILLISECONDS);
if (!accepted) {
// Apply an explicit policy: reject, shed work, persist it, or return a retryable response.
}
The capacity of 500 is a product decision and an operational parameter, not a magic number. It should reflect available memory, processing rate, acceptable waiting time, and behavior under burst traffic.
The main methods make different choices:
| Method | Full queue behavior | Empty queue behavior |
|---|---|---|
offer(item) | Returns false immediately | Not applicable |
offer(item, timeout, unit) | Waits up to the timeout, then returns false | Not applicable |
put(item) | Waits indefinitely | Not applicable |
poll() | Not applicable | Returns null immediately |
poll(timeout, unit) | Not applicable | Waits up to the timeout, then returns null |
take() | Not applicable | Waits indefinitely |
In a request-handling path, indefinite put() can tie up application request threads during overload. A timed offer() followed by an intentional response or fallback is often easier to operate. The right outcome may be rejecting work, returning a retryable error, or placing work into a durable external system. Do not silently drop important work merely because an in-memory queue is full.
Use LinkedBlockingQueue when a linked-node queue is a reasonable general-purpose producer-consumer choice. It can be bounded, which is normally the safer production configuration:
BlockingQueue<ExportTask> queue = new LinkedBlockingQueue<>(1_000);
Avoid relying on its default effectively unbounded capacity unless unlimited buffering is genuinely safe.
Specialized queue semantics
A few queue types solve specific scheduling problems:
| Requirement | Collection | Key behavior |
|---|---|---|
| Fixed-size bounded work buffer | ArrayBlockingQueue | Predictable array-backed capacity |
| General producer-consumer queue, explicitly bounded | LinkedBlockingQueue | Flexible linked-node queue |
| Direct handoff to an available consumer | SynchronousQueue | Zero capacity; no buffering |
| Process higher-priority work first | PriorityBlockingQueue | Priority order, not FIFO order |
| Make work available only after a delay | DelayQueue | Consumers receive expired elements only |
| High-throughput queue without capacity control | ConcurrentLinkedQueue | Non-blocking and unbounded |
SynchronousQueue deserves special attention. It stores no elements at all: a producer and consumer must rendezvous for a handoff. It is useful in executor designs where buffering work would be undesirable, but it is not an ordinary task backlog.
The following selector table is a useful compact reference. Notice that it begins with workload behavior—ordering, reads versus writes, capacity, and handoff—not implementation details.

A practical selection method for service code
Use this sequence when reviewing a shared collection in a Spring Boot service:
-
Confirm that the state should be local to one JVM.
AConcurrentHashMapin a singleton bean is per application instance. If three Kubernetes pods run the service, each has a separate map. Do not use it as cross-replica idempotency storage, distributed inventory, or a shared cache of record. -
Identify the shape of the state.
For keyed state, begin withConcurrentHashMap. For work transfer, begin with a queue. For a read-mostly list or set, consider copy-on-write. -
State the required consistency precisely.
“Only one thread may install this mapping” suggestsputIfAbsentorcomputeIfAbsent. “Only process a request once globally” typically requires durable storage and a database uniqueness constraint, not only an in-memory map. -
Check whether ordering is a business requirement.
UseConcurrentSkipListMaponly when sorted traversal or range queries are needed. Do not pay for ordering because it feels safer; hash-map ordering is simply unrelated to thread safety. -
For queues, decide what full means.
Prefer a boundedBlockingQueuewhen it protects a finite resource. Define the full-queue policy and monitor queue depth. -
Keep mutable values under control.
Favor immutable records as map values where possible. If a stored value is mutable, its own update protocol must be safe.
There is one older alternative you may encounter: Collections.synchronizedList(new ArrayList<>()) or a synchronized wrapper around another standard collection. It serializes calls using one lock and can be sufficient for low-contention legacy code. But it provides little concurrency and still requires external synchronization while iterating. In new backend code, prefer a collection whose semantics match the workload, rather than turning every operation on a general collection into one global bottleneck.
Key takeaways
- Select concurrent collections from the workload: data shape, read-write ratio, ordering, atomic operation, and overload behavior.
- Use
ConcurrentHashMapas the default concurrent key-value store, and useputIfAbsent,computeIfAbsent,replace,compute, ormergeinstead of manually composing check-then-act logic. - A concurrent map protects mappings, not the thread safety of mutable values stored in it, multi-key workflows, or state across service replicas.
- Use
ConcurrentSkipListMaponly when you need sorted keys or range queries. - Use
CopyOnWriteArrayListorCopyOnWriteArraySetfor small, read-heavy, rarely modified collections with snapshot-style iteration. - Use a bounded
BlockingQueuewhen overload must be controlled; an unboundedConcurrentLinkedQueuetrades coordination overhead for the risk of uncontrolled memory growth. - Concurrent collections are JVM-local tools. Database-backed and distributed consistency need different mechanisms, which you will address later in the course.
Next, you will use CompletableFuture to execute independent I/O operations concurrently and, crucially, propagate failures without accidentally hiding them.
Can't find a good explanation? Sign up and we'll make it for you
Sign up