Java Multithreading - Part 9: Concurrent Collections
Part 9: Concurrent Collections
Thread-safe collections designed for high-performance concurrent access.
Table of Contents
- Problems with Traditional Collections
- Synchronized Wrappers Issues
- Concurrent Collection Types
- ConcurrentHashMap Deep Dive
- CopyOnWriteArrayList
- BlockingQueue
- Fail-Fast vs Fail-Safe Iterators
- Operation-Level vs Object-Level Locking
Problems with Traditional Collections
Traditional Collections and Their Limitations
In multi-threaded environments, traditional collections like ArrayList, HashMap, and HashSet:
- ConcurrentModificationException: One thread modifies a collection while another thread is iterating over it
- Not Thread-Safe: Most traditional collections are not designed for concurrent access (not thread-safe)
- Data corruption: Inconsistent states during concurrent modifications
// ❌ PROBLEM: ConcurrentModificationException
List<String> list = new ArrayList<>();
// Thread 1 iterating, Thread 2 modifying simultaneously
To address these issues, Java provides concurrent collections in the java.util.concurrent package.
Synchronized Wrappers Issues
The Old Approach
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
Map<K,V> syncMap = Collections.synchronizedMap(new HashMap<>());
Set<E> syncSet = Collections.synchronizedSet(new HashSet<>());
Synchronized Wrappers
Collections like Vector and Hashtable use primitive synchronized methods, which can lead to performance bottlenecks due to excessive locking.
Synchronized wrappers like Collections.synchronizedList, Collections.synchronizedSet, and Collections.synchronizedMap too can suffer from performance issues.
Performance Problems with Synchronized Wrappers
| Problem | Description |
|---|---|
| Lock Contention | When multiple threads attempt to access a synchronized collection, they must acquire a lock before proceeding. If one thread holds the lock, other threads are blocked until the lock is released. This contention can lead to significant delays, especially under high concurrency. |
| Single Lock for Entire Collection | This means that even read operations, which could be performed concurrently, are serialized. This reduces the overall throughput of the application. |
| Memory Barriers | Synchronization introduces memory barriers, which force the JVM to flush data to main memory to ensure visibility across threads. This can prevent certain optimizations and increase the overhead of synchronization. |
| Poor Scalability | Performance degrades with more threads |
Result: Only one thread can access the collection at a time, even for read operations.
Concurrent Collection Types
Java’s java.util.concurrent package offers a variety of thread-safe collections designed to handle concurrent access efficiently:
| Collection Type | Description |
|---|---|
| ConcurrentHashMap | High-performance, thread-safe map with concurrent reads/updates without locking the entire map. |
| CopyOnWriteArrayList | Thread-safe list for read-heavy, write-rare workloads; writes copy the underlying array. |
| CopyOnWriteArraySet | Set backed by CopyOnWriteArrayList; good for read-heavy workloads. |
| ConcurrentLinkedQueue | Non-blocking, FIFO queue based on linked nodes. |
| ConcurrentLinkedDeque | Non-blocking, double-ended queue; insert/remove from both ends concurrently. |
| BlockingQueue | Producer-consumer patterns with blocking operations. |
Implementations: ArrayBlockingQueue (bounded array), LinkedBlockingQueue (optionally bounded), PriorityBlockingQueue. |
|
Implementations: DelayQueue (delayed availability), SynchronousQueue (no internal capacity). |
|
| BlockingDeque | Double-ended blocking queue. Implementation: LinkedBlockingDeque. |
| ConcurrentSkipListMap / Set | Thread-safe, sorted concurrent map/set using skip lists; maintains elements in sorted order. |
ConcurrentHashMap Deep Dive
The most commonly used concurrent collection.
Why ConcurrentHashMap?
Instead of locking the entire map for every read or write, it uses fine-grained locking mechanisms to maximize concurrency and performance.
Evolution
1. Lock Striping (Java 7 and earlier)
- The map was divided into segments (like mini hash tables)
- Each segment had its own lock, allowing multiple threads to access different segments simultaneously
- This significantly reduced contention compared to synchronizing the entire map
2. Bucket-Level Synchronization (Java 8 and later)
- Java 8 replaced segments with a lock-free, bucket-based structure
- Internally, it uses a
Node[]table (similar toHashMap) and CAS (Compare-And-Swap) operations for atomic updates - For hash collisions:
- Initially uses linked lists
- Converts to balanced trees (TreeNodes) when a bucket becomes too full, improving lookup performance
Key Features
3. Fine-Grained Synchronization
- Only individual buckets are locked during updates like
putorremove -
Read operations (
get) are non-blocking and extremely fast, thanks to minimal locking
4. Optimistic Reads
-
ConcurrentHashMapuses an optimistic locking strategy for reads - Reads proceed without locking. If a concurrent modification is detected, the operation is retried with a lock to ensure consistency
5. Atomic Methods
- Methods like
putIfAbsent,compute,merge, andcomputeIfAbsentare atomic - These ensure thread-safe updates without requiring external synchronization
Usage
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Basic operations
map.put("key", 1);
int value = map.get("key");
// Atomic operations
map.putIfAbsent("key", 2); // Only if absent
map.computeIfAbsent("key", k -> computeValue(k));
map.compute("key", (k, v) -> v == null ? 1 : v + 1);
map.merge("key", 1, Integer::sum);
// Bulk operations (Java 8+)
map.forEach((k, v) -> process(k, v));
map.search(1, (k, v) -> v > 100 ? k : null);
map.reduce(1, (k, v) -> v, Integer::sum);
When NOT Thread-Safe
Compound operations are still not atomic:
// ❌ NOT atomic - race condition!
if (!map.containsKey(key)) {
map.put(key, value);
}
// ✅ Use atomic method instead
map.putIfAbsent(key, value);
CopyOnWriteArrayList
Thread-safe list optimized for read-heavy, write-rare scenarios.
How It Works - Operation-Level Locking
CopyOnWriteArrayList uses operation-level locking, but in a different way:
-
Copy on Write: When a write operation (such as
addorremove) is performed, the entire underlying array is copied- This ensures that the write operation does not interfere with ongoing read operations
-
Read Operations: Since the array is copied on write, read operations can proceed without any locking
- This makes
CopyOnWriteArrayListvery efficient for scenarios with frequent reads and infrequent writes
- This makes
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// Reads are fast - no locking
String item = list.get(0);
// Writes copy the array
list.add("new item"); // Copies entire array!
Characteristics
| Aspect | Behavior |
|---|---|
| Read Performance | Excellent (no locking) |
| Write Performance | Poor (copies entire array) |
| Iterator | Snapshot at creation time |
| Best For | Read-heavy, small lists |
Use Cases
- Event listener lists
- Observer patterns
- Configuration settings (rarely change)
BlockingQueue
Queues that support blocking operations for producer-consumer patterns.
Types
| Queue | Characteristics |
|---|---|
| ArrayBlockingQueue | Bounded, array-based, FIFO |
| LinkedBlockingQueue | Optionally bounded, linked nodes |
| PriorityBlockingQueue | Unbounded, priority ordering |
| SynchronousQueue | Zero capacity, direct handoff |
| DelayQueue | Elements available after delay |
Blocking Operations
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
// Producer
queue.put(task); // Blocks if full
queue.offer(task, 1, TimeUnit.SECONDS); // Timeout
// Consumer
Task task = queue.take(); // Blocks if empty
Task task = queue.poll(1, TimeUnit.SECONDS); // Timeout
Method Summary
| Operation | Throws | Blocks | Times Out | Returns Special |
|---|---|---|---|---|
| Insert | add(e) | put(e) | offer(e,t,u) | offer(e) |
| Remove | remove() | take() | poll(t,u) | poll() |
| Examine | element() | - | - | peek() |
Producer-Consumer Example
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
// Producer
new Thread(() -> {
while (true) {
queue.put(produceTask()); // Blocks if full
}
}).start();
// Consumer
new Thread(() -> {
while (true) {
Task task = queue.take(); // Blocks if empty
processTask(task);
}
}).start();
Fail-Fast vs Fail-Safe Iterators
Fail-Fast Iterators
Throw ConcurrentModificationException if the collection is structurally modified during iteration.
- This behavior is implemented using an internal modification count (
modCount) -
Examples: Iterators for
ArrayList,LinkedList,Vector, andHashSet
List<String> list = new ArrayList<>();
for (String s : list) {
list.add("new"); // Throws ConcurrentModificationException!
}
Fail-Safe Iterators
Operate on a copy of the collection, allowing modifications without throwing exceptions.
- No exception thrown
- May not reflect latest changes
-
Examples: Iterators for
CopyOnWriteArrayList,CopyOnWriteArraySet, andConcurrentSkipListSet
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
for (String s : list) {
list.add("new"); // Works! Iterates over snapshot
}
Comparison
| Aspect | Fail-Fast | Fail-Safe |
|---|---|---|
| Exception | ConcurrentModificationException | None |
| Data | Current data | Snapshot (may be stale) |
| Memory | No extra memory | May use extra memory |
| Examples | ArrayList, HashMap | CopyOnWrite, Concurrent |
Operation-Level vs Object-Level Locking
Object-Level Locking
Locks the entire object for every operation.
- Common in synchronized wrappers like
Collections.synchronizedList - Drawback: Only one thread can access the collection at a time, even if operations are unrelated
- Result: High contention and poor scalability in multi-threaded environments
// synchronized wrappers use object-level locking
List<String> list = Collections.synchronizedList(new ArrayList<>());
// Only ONE thread can access at a time, even for reads
Operation-Level Locking
Operation-level locking means that the lock is applied only to the specific operation being performed, rather than locking the entire object.
This allows multiple threads to perform different operations on the same collection concurrently, as long as those operations don’t interfere with each other.
// ConcurrentHashMap uses operation-level locking
ConcurrentHashMap<K, V> map = new ConcurrentHashMap<>();
// Multiple threads can access different buckets simultaneously
Benefits of Operation-Level Locking
- Improved Concurrency: By locking only the necessary parts of the collection, multiple threads can perform operations concurrently, leading to better throughput
- Reduced Contention: Finer-grained locks reduce the likelihood of contention between threads, which can improve performance in multi-threaded environments
- Scalability: Operation-level locking allows collections to scale better with the number of threads, as different threads can work on different parts of the collection simultaneously
Comparison
| Feature | Object-Level Locking | Operation-Level Locking |
|---|---|---|
| Granularity | Coarse (entire object) | Fine (specific operation or segment) |
| Concurrency | Low | High |
| Performance | Degrades with more threads | Scales well with more threads |
| Use Case | Simple synchronization | High-performance, multi-threaded applications |
| Example | Collections.synchronizedList |
ConcurrentHashMap, CopyOnWriteArrayList
|
Summary
✅ Traditional collections not thread-safe; synchronized wrappers have poor performance
✅ ConcurrentHashMap uses bucket-level locking + CAS for high concurrency
✅ CopyOnWriteArrayList best for read-heavy, write-rare scenarios
✅ BlockingQueue for producer-consumer patterns
✅ Fail-fast iterators throw ConcurrentModificationException; fail-safe work on snapshots
✅ Operation-level locking scales better than object-level
Quick Reference
// ConcurrentHashMap
ConcurrentHashMap<K, V> map = new ConcurrentHashMap<>();
map.putIfAbsent(key, value);
map.computeIfAbsent(key, k -> compute(k));
map.merge(key, value, (old, new) -> old + new);
// CopyOnWriteArrayList
CopyOnWriteArrayList<E> list = new CopyOnWriteArrayList<>();
// BlockingQueue
BlockingQueue<E> queue = new LinkedBlockingQueue<>(capacity);
queue.put(e); // Block if full
queue.take(); // Block if empty
queue.offer(e, timeout, unit);
queue.poll(timeout, unit);
// ConcurrentSkipListMap (sorted)
ConcurrentSkipListMap<K, V> sortedMap = new ConcurrentSkipListMap<>();
Choosing the Right Collection
| Use Case | Collection |
|---|---|
| Key-value, high concurrency | ConcurrentHashMap |
| List, read-heavy | CopyOnWriteArrayList |
| Queue, producer-consumer | BlockingQueue |
| Sorted map, concurrent | ConcurrentSkipListMap |
| Sorted set, concurrent | ConcurrentSkipListSet |