Concurrent Collections in Java
Purpose built collections for shared access, which are both safer and far faster than wrapping an ordinary one in a lock.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
Why the ordinary ones are not enough
Map<String, Integer> unsafe = new HashMap<>();
// Concurrent writes can corrupt the internal table during a resize
Map<String, Integer> wrapped = Collections.synchronizedMap(new HashMap<>());
// Every method is atomic, but one lock serialises all access
Map<String, Integer> safe = new ConcurrentHashMap<>();
// Fine grained locking, high throughput, and atomic compound methodsThe main choices
| Ordinary | Concurrent | Approach |
|---|---|---|
HashMap | ConcurrentHashMap | Per bin locking and compare and swap |
TreeMap | ConcurrentSkipListMap | A lock free skip list |
ArrayList | CopyOnWriteArrayList | The array is copied on every write |
HashSet | ConcurrentHashMap.newKeySet() | Backed by the concurrent map |
ArrayDeque | ConcurrentLinkedDeque | Lock free |
| - | LinkedBlockingQueue | Blocking, for producer and consumer |
ConcurrentHashMap
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.put("java", 1);
counts.merge("java", 1, Integer::sum); // atomic
counts.computeIfAbsent("sql", key -> load(key)); // atomic, computed once
counts.putIfAbsent("css", 0); // atomic
counts.compute("java", (key, value) -> value * 2); // atomic
counts.forEach(4, (key, value) -> report(key, value)); // parallel above a threshold
int total = counts.reduceValues(4, Integer::sum);The compound methods are the point.merge,computeIfAbsentandputIfAbsentare single atomic operations, which is what agetfollowed by aputcan never be, no matter how thread safe each call is on its own.
// Still a race, even on a concurrent map
if (!counts.containsKey(key)) {
counts.put(key, 0); // another thread may have inserted in between
}
// Correct
counts.putIfAbsent(key, 0);Rules
nullkeys and values are rejected, sogetreturningnullunambiguously means absent.- Iterators are weakly consistent: they never throw
ConcurrentModificationException, and may or may not reflect changes made after they were created. size()is an estimate under concurrent modification.- The function passed to
computeIfAbsentmust not modify the same map, or it may deadlock.
CopyOnWriteArrayList
List<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(listener); // copies the whole array
for (Listener listener : listeners) { // iterates a stable snapshot
listener.onEvent(event); // safe even if another thread adds one
}| Operation | Cost | |
|---|---|---|
| Read and iterate | Fast, no locking at all | |
| Add or remove | O(n), the entire array is copied |
This suits listener lists and configuration snapshots: written rarely, read constantly. It is entirely wrong for a collection that changes often.
Blocking queues
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);
// Producer
queue.put(task); // blocks when full
boolean added = queue.offer(task, 1, TimeUnit.SECONDS);
// Consumer
Task task = queue.take(); // blocks when empty
Task maybe = queue.poll(500, TimeUnit.MILLISECONDS);| Implementation | Character |
|---|---|
ArrayBlockingQueue | Fixed capacity, one lock |
LinkedBlockingQueue | Optionally bounded, separate head and tail locks |
PriorityBlockingQueue | Unbounded, ordered by priority |
SynchronousQueue | No capacity; a hand off between two threads |
DelayQueue | Elements become available only after a delay |
public class Pipeline {
private final BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);
private static final Task POISON = new Task("stop");
public void produce(List<Task> tasks) throws InterruptedException {
for (Task task : tasks) {
queue.put(task);
}
queue.put(POISON); // signal the end
}
public void consume() throws InterruptedException {
Task task;
while ((task = queue.take()) != POISON) {
process(task);
}
}
}A bounded blocking queue also provides back pressure: when consumers fall behind, producers block instead of filling memory.
Coordination utilities
CountDownLatch ready = new CountDownLatch(3); // wait for three tasks
ready.countDown();
ready.await(); // one use only
CyclicBarrier barrier = new CyclicBarrier(4, this::onAllArrived); // reusable
barrier.await();
Semaphore permits = new Semaphore(5); // limit concurrent access
permits.acquire();
try {
callRateLimitedApi();
} finally {
permits.release();
}Choosing
| Situation | Use |
|---|---|
| A shared map | ConcurrentHashMap |
| A shared sorted map | ConcurrentSkipListMap |
| Listeners, read constantly | CopyOnWriteArrayList |
| Producer and consumer hand off | LinkedBlockingQueue |
| Limiting concurrent callers | Semaphore |
| Waiting for n tasks to finish | CountDownLatch |
| A shared counter | LongAdder |
Common mistakes
- Assuming a concurrent collection makes a check then act sequence atomic.
- Putting a
nullinto aConcurrentHashMap. - Using
CopyOnWriteArrayListfor a frequently modified list. - Reusing a
CountDownLatch, which cannot be reset. - Using an unbounded queue and losing back pressure.
- Modifying a
ConcurrentHashMapfrom inside its owncomputeIfAbsentfunction.
Best practices
- Prefer a concurrent collection to a synchronised wrapper.
- Use the atomic compound methods rather than composing several calls.
- Bound your queues.
- Choose the implementation from the read to write ratio.
- Release a semaphore permit in a
finallyblock. - Remember that
size()is approximate under concurrency.
Practice
- Count words from several threads with
ConcurrentHashMap.merge. - Explain why
containsKeyfollowed byputis still a race. - Build a producer and consumer pipeline with a bounded queue and a stop signal.
- When is
CopyOnWriteArrayListthe right choice, and when is it clearly wrong? - Limit concurrent calls to an external service to five using a
Semaphore.
Conclusion
The concurrent collections are designed for shared access rather than retrofitted with a lock. Use their atomic compound methods, bound your queues for back pressure, and choose the implementation from how the data is actually read and written.