1. Introduction to Concurrent Collections in Java
Concurrent collections are data structures designed for applications in which multiple threads access shared data at the same time. Java provides these collections mainly through the java.util.concurrent package.
In a multithreaded application, one thread may update data while another reads it, or several threads may attempt to add and remove items simultaneously. A collection must handle these operations according to a well-defined concurrency strategy so that its internal state remains consistent and the application can coordinate work correctly.
Concurrent collections help reduce the need for developers to build every synchronization mechanism themselves. Different implementations use different approaches, including fine-grained coordination, snapshot iteration, non-blocking algorithms, and blocking producer-consumer operations.
The most important families covered in this guide are:
ConcurrentHashMapfor concurrent key-value storage.CopyOnWriteArrayListandCopyOnWriteArraySetfor read-heavy collections.ConcurrentLinkedQueueandConcurrentLinkedDequefor non-blocking queue operations.BlockingQueueimplementations for producer-consumer workflows.
1.1 Why collection choice matters
No single collection is best for every concurrent workload. A map accessed by many threads has different requirements from a list that changes rarely or a queue that passes tasks from producers to workers. Choosing the structure to match the access pattern can improve correctness, maintainability, and throughput.
2. Why Ordinary Collections Are Not Automatically Thread-Safe
Collections such as ArrayList and HashMap are designed for general-purpose use without built-in synchronization around every operation. They work well when access is confined to one thread or when the application provides suitable coordination.
If multiple threads mutate the same ordinary collection concurrently without coordination, operations can interfere with one another. The application may lose updates, observe inconsistent results, or encounter behavior that the collection's contract does not support. It is therefore unsafe to assume that an ordinary collection becomes thread-safe merely because it is used by multiple threads.
2.1 The limitation of synchronized wrappers
Java offers wrappers such as Collections.synchronizedMap, Collections.synchronizedList, and Collections.synchronizedSet. These wrappers synchronize individual method calls using a common lock on the wrapper.
This is useful for basic protection, but it has important limits:
- Calls on the same wrapper contend for the same lock.
- Individual methods may be safe while a sequence of multiple method calls is not atomic.
- Iteration over synchronized collection views requires additional synchronization according to the wrapper's documented contract.
For example, “check whether a key exists, and if absent, add a mapping” is a compound action. Another thread may change the map between the check and the insertion unless the whole action is coordinated appropriately.
2.2 Individual safety versus compound atomicity
A thread-safe method call does not necessarily make a larger sequence of calls thread-safe as a unit. Concurrent collections provide specialized atomic operations for common patterns, reducing the need for manual locking.
3. ConcurrentHashMap
ConcurrentHashMap is a widely used concurrent implementation of the Map interface. Its external API resembles HashMap, with methods such as put, get, remove, and containsKey, but its implementation is designed to support concurrent access.
It aims to allow multiple threads to make progress without every operation being serialized on one global map lock. The exact implementation details vary by Java version, so descriptions of its internal coordination should be tied to the relevant runtime. In modern Java implementations, synchronization and atomic techniques are applied more locally than a single table-wide lock.
3.1 A practical mental model
Think of a map as a table containing bins, or buckets. Different keys may be handled through different bins. Operations on unrelated bins can often proceed without competing for the same lock, while operations that affect the same area may need coordination.
Many get operations can read the map without taking the same kind of lock used by contending updates. This is one reason ConcurrentHashMap is generally more appropriate than a single-lock synchronized map for workloads with many concurrent readers.
This is a conceptual model, not a guarantee that every operation on every key uses a completely independent lock. Hash collisions, resizing, and implementation details can affect coordination.
3.2 Familiar Map operations
ConcurrentHashMap supports common map operations:
| Method | Purpose |
|---|---|
put(key, value) | Adds or replaces a mapping |
get(key) | Retrieves a value |
remove(key) | Removes a mapping |
containsKey(key) | Checks key presence |
putIfAbsent(key, value) | Inserts only if a mapping is not already present |
compute(key, function) | Calculates a value using the current mapping and stores the result |
merge(key, value, function) | Adds a value or combines it with the existing mapping |
These methods make the map convenient for common concurrent tasks without requiring every operation to be surrounded by an application-written synchronized block.
4. Null Keys and Null Values in ConcurrentHashMap
ConcurrentHashMap does not permit null keys or null values. This is a deliberate design decision that makes the meaning of a null result from get unambiguous: it indicates that the map did not return a mapping for the requested key.
In a concurrent environment, permitting null values could make it difficult to distinguish “no mapping exists” from “a mapping exists with a null value,” particularly while other threads are updating the map. ConcurrentHashMap avoids this ambiguity by rejecting null.
4.1 Representing absence
When an application needs to represent a missing value, possible approaches include:
- Leave the key absent from the map.
- Remove the key when no value should be stored.
- Use an application-defined sentinel where its meaning is clear.
- Use
Optionalin surrounding application logic where that design is appropriate.
Do not attempt to insert null into ConcurrentHashMap. Such operations fail with NullPointerException.
5. Atomic Compound Operations
A major advantage of ConcurrentHashMap is that it provides methods for common compound updates. These methods coordinate the operation so that competing threads do not separately perform the same check and update for a key.
5.1 putIfAbsent
putIfAbsent(key, value) inserts a mapping only if the key does not currently have a mapping. If a value is already present, the existing mapping is retained and the existing value is returned.
This is useful when several threads may attempt to initialize the same key and only one value should win.
5.2 compute
compute(key, remappingFunction) calculates a mapping based on the key and its current value. The function can return a new value, or return null to remove the mapping. The operation is atomic according to the method's contract for the relevant key.
This can be used for tasks such as updating a counter or deriving a replacement value from an existing mapping.
5.3 merge
merge(key, value, remappingFunction) provides a convenient way to add a value when no mapping exists or combine the supplied value with an existing one. For example, it can be used to maintain per-key totals.
The remapping function should be short and should not perform slow external work. Long-running work inside a map update function can delay other operations that need related internal coordination.
5.4 Why atomic methods matter
| Approach | Main limitation or benefit |
|---|---|
Separate get followed by put | Another thread may intervene between the calls |
| Manual synchronized block | Can be correct when all access follows the same lock discipline, but requires consistent coordination |
putIfAbsent | Makes conditional insertion a single operation |
compute | Supports atomic calculation and update for a key |
merge | Simplifies atomic combine-or-insert patterns |
The correct method depends on the intended semantics. Atomicity of a map method does not automatically make unrelated external actions, such as a database update, part of the same transaction.
6. CopyOnWriteArrayList
CopyOnWriteArrayList is a thread-safe list intended mainly for workloads in which reads and iteration are frequent but mutations are relatively rare.
Its defining strategy is copy-on-write. When a mutating operation changes the list, the implementation creates a new backing array containing the updated contents and publishes the new array. Existing readers can continue traversing the snapshot they already obtained.
6.1 Snapshot iteration
An iterator over CopyOnWriteArrayList observes a snapshot of the list as it existed when the iterator was created. If another thread adds an element after iteration begins, the existing iterator does not suddenly include that element. A new traversal can observe the updated contents.
This snapshot behavior means an iterator does not throw ConcurrentModificationException merely because another thread changes the list after the iterator is created.
6.2 Why the strategy is useful
Consider an event system with a collection of listeners. Many threads may read the listener list to deliver events, while the list itself changes only when a listener is registered or removed. Copy-on-write allows readers to traverse a stable snapshot without requiring writers to wait for every reader to finish.
Common use cases include:
- Event-listener registries.
- Observer lists.
- Configuration lists that change rarely.
- Small collections that are read far more often than they are updated.
6.3 The cost of writes
Every mutation may require copying the backing array. Consequently, writes can be expensive in proportion to the list size and create additional allocation and garbage-collection work.
CopyOnWriteArrayList is not a universal replacement for ArrayList. It is best when the collection is relatively small or stable and reads greatly outnumber writes.
6.4 Iterator restrictions
The iterator is based on a snapshot and does not support mutation operations such as iterator remove, set, or add. These unsupported operations throw UnsupportedOperationException. To change the list, use the list's own mutation methods.
7. CopyOnWriteArraySet
CopyOnWriteArraySet is a Set implementation built on a copy-on-write array strategy. Like CopyOnWriteArrayList, it provides snapshot-style iteration and is intended for collections that are read frequently but changed infrequently.
Its Set behavior prevents duplicate elements according to equality. The trade-off is that operations such as adding an element or checking whether a value is present may require scanning the array, making them O(n) in the collection size.
7.1 Suitable use cases
This class can be appropriate for a small set of registered listeners or other distinct items that are read frequently and changed rarely.
For a larger or more frequently changing concurrent set, ConcurrentHashMap.newKeySet() is often a more suitable starting point because it is backed by ConcurrentHashMap and offers a different performance profile.
7.2 Copy-on-write list versus set
| Feature | CopyOnWriteArrayList | CopyOnWriteArraySet |
|---|---|---|
| Collection type | List | Set |
| Duplicate elements | Allowed | Not allowed according to Set equality semantics |
| Iteration | Snapshot-based | Snapshot-based |
| Mutation cost | Copies backing array | Copy-on-write array strategy; membership checks may scan |
| Best fit | Read-heavy list | Small, read-heavy set |
8. ConcurrentLinkedQueue and ConcurrentLinkedDeque
ConcurrentLinkedQueue and ConcurrentLinkedDeque provide thread-safe, non-blocking queue structures. The queue follows FIFO order, while the deque supports operations at both ends.
The implementations use non-blocking coordination techniques based on atomic operations rather than relying on a single lock that makes each caller wait for lock ownership.
8.1 What non-blocking means
In this context, non-blocking means queue operations do not wait for a conventional lock to become available. Under contention, a thread may need to retry an atomic update, but the algorithm is designed so that the operation does not depend on acquiring one shared monitor.
Non-blocking does not mean that every operation completes in a fixed amount of time under every possible workload. Contention and scheduling can still affect how much work a thread performs.
8.2 Common methods
| Method | Purpose |
|---|---|
offer(item) | Attempts to add an item |
poll() | Removes and returns the head, or returns null if empty |
peek() | Returns the head without removing it, or null if empty |
isEmpty() | Checks whether the queue appears empty |
| Deque end operations | Add, inspect, or remove items at either end |
ConcurrentLinkedQueue is unbounded, so it does not provide a fixed capacity limit that automatically applies backpressure to producers.
8.3 The cost of size()
For ConcurrentLinkedQueue, size() requires traversing the queue and is O(n), rather than a constant-time count. Under concurrent mutation, the result may become outdated immediately after it is calculated.
If the application only needs to check whether work is available, isEmpty() is generally a better fit than calling size() and comparing the result to zero.
8.4 When to use a non-blocking queue
A non-blocking queue can be useful when a consumer should not wait for work and has other tasks it can perform when the queue is empty. If a consumer's job is to wait until work arrives, a BlockingQueue may be a better abstraction.
9. BlockingQueue: The Producer-Consumer Foundation
A BlockingQueue supports producer-consumer coordination. Producers add items, and consumers retrieve them. Depending on the queue's capacity and the method used, a producer may wait for space or a consumer may wait for an item.
This allows the queue to coordinate waiting and waking without requiring application code to implement its own low-level wait and notify protocol.
9.1 Producer-consumer model
The queue separates the rate at which work is produced from the rate at which work is processed. A bounded queue can also prevent producers from overwhelming downstream consumers.
9.2 Four ways to add or retrieve items
BlockingQueue offers method variants with different waiting and failure behavior.
| Operation style | Insertion | Retrieval |
|---|---|---|
| Wait indefinitely when necessary | put(item) | take() |
| Wait for a limited time | offer(item, timeout, unit) | poll(timeout, unit) |
| Return immediately if unsuccessful | offer(item) | poll() |
| Signal failure with an exception when the operation cannot proceed | add(item) | remove() |
The exact conditions and return values are defined by the BlockingQueue API. In particular, offer returns false when an item cannot be added immediately, and poll returns null when no item can be retrieved immediately.
The timed variants are useful when an application wants to wait only for a bounded period. The indefinite variants are useful when waiting is part of the intended workflow.
10. ArrayBlockingQueue
ArrayBlockingQueue is a bounded FIFO blocking queue backed by an array. Its capacity is fixed when the queue is created.
A fixed capacity is valuable in production systems because it places an explicit limit on buffered work. If consumers cannot keep up, producers eventually wait or receive a failed insertion result depending on the method used.
10.1 Backpressure
Backpressure means that a system slows or limits upstream production when downstream processing cannot keep up. A bounded queue can provide this naturally: once the queue is full, a producer using put must wait for a consumer to free a slot.
This helps avoid unbounded accumulation of queued tasks and can make memory usage more predictable.
10.2 When ArrayBlockingQueue fits
- The application needs a hard upper bound on queued items.
- FIFO processing is required.
- Predictable capacity is more important than having a dynamically growing linked structure.
- Producers should experience backpressure when consumers fall behind.
11. LinkedBlockingQueue
LinkedBlockingQueue is a FIFO blocking queue backed by linked nodes. It can be created with a specified capacity; when constructed without an explicit capacity, its effective capacity is Integer.MAX_VALUE.
That default can be risky. If producers continuously enqueue faster than consumers process, the queue may grow until the application consumes excessive memory. For production workloads, selecting a sensible capacity is often safer than relying on the default.
11.1 Concurrency and memory trade-offs
The implementation uses separate coordination for queue insertion and removal, allowing some producer and consumer operations to proceed with less direct lock competition than a single-lock design. It also carries linked-node allocation and memory overhead.
Whether it outperforms ArrayBlockingQueue depends on the workload, capacity, contention, and runtime. Avoid treating one implementation as universally faster; benchmark representative workloads.
11.2 When LinkedBlockingQueue fits
It can be useful for general producer-consumer workflows when linked storage is appropriate and the capacity has been chosen deliberately. Its ability to be bounded makes it possible to balance buffering against memory safety.
12. SynchronousQueue
SynchronousQueue is unusual because it has zero capacity. It does not act as a buffer that stores items for later retrieval. Instead, a producer and consumer must meet for an item to be transferred.
A producer calling put waits until a consumer takes the item. Conversely, a consumer calling take waits until a producer supplies an item. The item is handed directly from one thread to the other.
12.1 When direct handoff is useful
A direct-handoff queue fits systems in which buffering is undesirable and each submitted item should be transferred directly to a waiting consumer or worker. It is also associated with the behavior of Executors.newCachedThreadPool(), whose task handoff strategy can lead to creating additional threads when no idle worker is ready to accept a task.
Because it has no storage capacity, SynchronousQueue should not be selected when the application needs a queue to accumulate a backlog.
13. PriorityBlockingQueue
PriorityBlockingQueue is a blocking queue that orders items by their natural ordering or by a supplied Comparator. It is useful when the next item to process should be selected by priority rather than arrival order.
The element considered smallest according to the ordering is retrieved first. Therefore, the meaning of “highest priority” depends on how the application's comparator is defined.
13.1 Not a FIFO queue
PriorityBlockingQueue does not promise FIFO order among elements with different priorities. If two elements compare equally, the queue does not generally promise that they will be removed in insertion order.
13.2 Capacity and memory considerations
PriorityBlockingQueue is logically unbounded and does not provide the same fixed-capacity backpressure as ArrayBlockingQueue. Insertion does not wait for consumers to make space in the way a full bounded queue does. If items arrive faster than they are processed, memory usage can grow, so the application may need upstream limits or other workload controls.
13.3 Appropriate use cases
- Process urgent tasks before routine tasks.
- Schedule work according to a priority score.
- Handle alerts before lower-priority background events.
- Select queued items using a custom comparator.
14. Comparing the Main Concurrent Queue Implementations
| Collection | Blocking behavior | Capacity | Ordering | Typical use |
|---|---|---|---|---|
ConcurrentLinkedQueue | Non-blocking operations | Unbounded | FIFO | Consumers that should not wait for work |
ConcurrentLinkedDeque | Non-blocking operations | Unbounded | Double-ended | Concurrent access at both ends |
ArrayBlockingQueue | Supports blocking and timed operations | Fixed, bounded | FIFO | Predictable buffer and backpressure |
LinkedBlockingQueue | Supports blocking and timed operations | Bounded if specified; otherwise very large | FIFO | General producer-consumer work |
PriorityBlockingQueue | Blocking retrieval; unbounded insertion | Unbounded | Priority order | Priority-driven processing |
SynchronousQueue | Direct handoff; operations may wait for a partner | Zero stored capacity | Handoff rather than buffered FIFO | Passing tasks directly to consumers |
15. When Synchronized Wrappers Are Not the Best Default
Synchronized wrappers remain available and can still be useful in appropriate situations. However, they are not interchangeable with specialized concurrent collections.
15.1 Shared-lock contention
A synchronized wrapper uses a common lock for its methods. As a result, even operations that could otherwise proceed concurrently may contend on that same lock.
ConcurrentHashMap and the specialized concurrent collections use designs better suited to their target workloads. The performance advantage depends on the access pattern and should be measured, not assumed to be identical in every application.
15.2 Compound operations still need a plan
A synchronized wrapper does not automatically make a multi-call sequence atomic. The application must coordinate compound operations explicitly or use an API that provides the desired atomic operation.
Specialized methods such as putIfAbsent, compute, merge, and addIfAbsent can express common operations directly. They make intent clearer and can avoid mistakes caused by separate check-and-update steps.
15.3 Where wrappers may still be reasonable
A wrapper may be sufficient for simple compatibility needs, low-contention access, or code where access is already confined to one thread. The right decision depends on the actual contract and workload rather than on a blanket rule that wrappers must never be used.
16. Selecting a Collection by Access Pattern
The access pattern should drive the choice. A concurrent map, a snapshot-based list, and a blocking queue solve different problems and should not be treated as interchangeable.
17. Putting the Collections Together: An E-Commerce Example
An e-commerce backend may need several types of concurrent state:
- Product stock: A ConcurrentHashMap can store stock counts keyed by product ID. Atomic map methods such as
mergecan update a count for a key. - Inventory listeners: A CopyOnWriteArrayList can store registered listeners when reads and notifications are frequent but listener changes are uncommon.
- Order handoff: An ArrayBlockingQueue can connect web requests to fulfillment workers. Its fixed capacity provides backpressure if order processing falls behind.
17.1 Why separate structures help
Each collection is chosen for a specific access pattern. The map supports concurrent key-based access; the listener list offers stable snapshot iteration; the bounded queue coordinates producers and consumers.
This separation reduces the temptation to solve every concurrency problem with one generic synchronized collection. It also makes each component's operational behavior easier to reason about.
18. Common Mistakes and Best Practices
18.1 Common mistakes
- Sharing HashMap or ArrayList without coordination: Ordinary collections do not become thread-safe merely because they are shared.
- Assuming synchronized methods make a sequence atomic: A check followed by an update can still race.
- Using CopyOnWriteArrayList for frequent writes: Copying the array for each mutation can be expensive.
- Calling
ConcurrentLinkedQueue.size()repeatedly in a hot path: It requires traversal and can return a count that becomes stale. - Using an effectively unbounded queue without considering memory: Slow consumers can allow queued work to accumulate.
- Assuming PriorityBlockingQueue is FIFO: It orders elements according to priority, not arrival time.
- Assuming all concurrent collections are lock-free: Some use blocking coordination or locks; select based on the documented behavior.
- Running slow external work inside
computeormergefunctions: Keep remapping functions short to limit contention.
18.2 Best practices
- Match the collection to the workload: map, read-heavy list, non-blocking queue, or blocking queue.
- Use ConcurrentHashMap for concurrent key-value storage when its semantics fit.
- Use atomic map methods for common conditional or read-modify-write operations.
- Use copy-on-write structures only when mutations are uncommon relative to reads.
- Choose a bounded BlockingQueue when the application needs backpressure and controlled memory use.
- Set a deliberate capacity for LinkedBlockingQueue in production when unbounded growth would be unsafe.
- Prefer
isEmpty()oversize() == 0when checking whether a ConcurrentLinkedQueue has work. - Respect the difference between FIFO ordering, priority ordering, and direct handoff.
- Keep remapping functions short and avoid unrelated slow operations inside them.
- Benchmark realistic workloads before making broad performance claims.
19. Quick Revision Summary
- Concurrent collections are provided mainly by
java.util.concurrent. - They are designed for safe concurrent access using strategies suited to different workloads.
- ConcurrentHashMap supports concurrent map operations and does not allow null keys or values.
putIfAbsent,compute, andmergesupport common atomic update patterns.- CopyOnWriteArrayList and CopyOnWriteArraySet use copy-on-write behavior and snapshot iteration.
- Copy-on-write collections are best for read-heavy, mutation-rare workloads.
- ConcurrentLinkedQueue and ConcurrentLinkedDeque support non-blocking concurrent queue operations.
- ConcurrentLinkedQueue's
size()is O(n), and the result can become stale under concurrent updates. - BlockingQueue supports producer-consumer coordination with blocking, timed, immediate, and exception-based method variants.
- ArrayBlockingQueue has a fixed capacity and can provide backpressure.
- LinkedBlockingQueue can be bounded, but its no-argument form has a very large effective capacity.
- SynchronousQueue has zero storage capacity and performs direct handoff.
- PriorityBlockingQueue orders by natural ordering or Comparator rather than FIFO.
- Synchronized wrappers protect individual calls but do not automatically make compound sequences atomic.
20. Conclusion
Java concurrent collections provide specialized ways to share data across threads while reducing the amount of manual synchronization that application developers must write. Their designs differ because the problems they solve are different: concurrent key-value access, read-heavy snapshot iteration, non-blocking queue operations, and producer-consumer coordination all have distinct requirements.
The most reliable approach is to start with the access pattern and the correctness requirement. Choose ConcurrentHashMap for concurrent maps, copy-on-write collections for stable read-heavy lists or sets, non-blocking queues when consumers should not wait, and BlockingQueue implementations when producers and consumers need built-in coordination. For queues, consider capacity and backpressure early, because a queue that grows without limit can turn a throughput problem into a memory problem.
A well-chosen concurrent collection does not eliminate the need to reason about the application, but it gives the application a safer and clearer foundation for concurrent work.