ConcurrentHashMap vs HashMap and Collections.synchronizedMap
1. Overview
When multiple threads read and write to a Map simultaneously, you have three familiar options: HashMap, Collections.synchronizedMap, and ConcurrentHashMap. These three differ significantly in their thread-safety guarantees, locking strategies, and iteration behavior.
This article explains each type through runnable examples. After reading, you’ll know which one to choose, how to write compound operations atomically, and how to avoid common pitfalls.
2. Setup
The examples use Java 21. Testing requires JUnit 5 and AssertJ:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
If your project is Spring Boot 3.x, spring-boot-starter-test already includes both libraries.
3. HashMap: Fast but Not Thread-Safe
HashMap has no synchronization mechanism. In a single thread, it’s the fastest option. When multiple threads write concurrently, data can be lost.
3.1. Lost Updates During Concurrent Counting
The following example counts a customer’s orders using 8 threads:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HashMapRaceDemo {
public static void main(String[] args) {
Map<String, Integer> orderCount = new HashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
}
} // close() waits for all tasks to complete
System.out.println("Order count: " + orderCount.get("customer-1"));
}
}
We submit 10,000 tasks, each incrementing by 1 using merge. Since Java 19, ExecutorService is AutoCloseable, so try-with-resources waits for all tasks to complete. The result varies with each run:
Order count: 9817
The reason is that HashMap.merge() involves multiple steps: read the old value, compute the new value, then write it back. Two threads can both read 100, both write 101, and an increment is lost. Worse, if two threads trigger a resize simultaneously, the internal structure can become corrupted.
3.2. ConcurrentModificationException During Iteration
HashMap’s iterator is fail-fast. If the map is modified during iteration without going through the iterator itself, the next call to next() may throw ConcurrentModificationException. This error occurs even in a single thread—for example, when you call map.remove() inside a for-each loop.
Note: The fail-fast mechanism is only a best-effort. The absence of an exception doesn’t mean your code is correct.
4. Collections.synchronizedMap: One Lock for All
Collections.synchronizedMap wraps any Map. Every method of the wrapper runs inside synchronized (mutex), with the mutex defaulting to the wrapper object itself.
Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());
If you replace the initialization line from section 3.1 with the line above, the result is always correct:
Order count: 10000
Since Java 8, the wrapper overrides default methods like merge, compute, and putIfAbsent, so each individual call is atomic.
4.1. Compound Operations Still Aren’t Atomic
Each method is synchronized independently. When you combine two calls together, that sequence is no longer atomic:
Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());
// WRONG: two threads can both see "absent" and both put
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
// CORRECT: hold the lock throughout check-then-act
synchronized (customers) {
if (!customers.containsKey(id)) {
customers.put(id, new Customer(id));
}
}
A simpler approach is to use customers.putIfAbsent(id, new Customer(id)), since this is a single call.
4.2. You Must Lock Manually When Iterating
The wrapper’s iterator is not synchronized. The Javadoc requires you to manually lock on the map itself:
synchronized (customers) {
for (Customer c : customers.values()) {
System.out.println(c);
}
}
If you forget the synchronized block, you can still encounter ConcurrentModificationException. Once locked, any other thread that tries to read or write must wait until the loop completes.
4.3. Performance Bottleneck
There’s only one lock for the entire map. Two threads reading different keys still must wait in line. As the number of threads increases, lock contention rises, and throughput drops significantly.
5. ConcurrentHashMap: Designed for Multithreading
ConcurrentHashMap (package java.util.concurrent) is designed specifically for concurrent access. Since Java 8, its implementation works as follows:
- Reads don’t lock.
getuses volatile reads, so it’s almost never blocked. - Writes lock per bucket. For empty buckets, it uses CAS (compare-and-swap). For occupied buckets, it synchronizes only on the first node of that bucket. Threads writing to different buckets run in parallel.
- Cooperative resizing. Multiple threads participate in transferring data to the new table, rather than locking the entire map.
5.1. Compound Operations Are Atomic
The methods putIfAbsent, computeIfAbsent, computeIfPresent, compute, and merge are all atomic per key:
import java.util.concurrent.ConcurrentHashMap;
public class InventoryService {
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
public void addStock(String product, int quantity) {
stock.merge(product, quantity, Integer::sum);
}
public boolean reserve(String product) {
Integer result = stock.computeIfPresent(product,
(key, current) -> current > 0 ? current - 1 : current);
return result != null && result >= 0;
}
public int available(String product) {
return stock.getOrDefault(product, 0);
}
}
addStock accumulates quantity using merge. If the key doesn’t exist, merge uses quantity as the initial value. reserve decrements stock inside computeIfPresent. The entire read-check-write operation happens atomically for that key, so no two threads can both claim the last unit.
For extremely high-frequency counting, combining with LongAdder reduces contention even further:
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();
5.2. Iterator Is Weakly Consistent
ConcurrentHashMap’s iterator never throws ConcurrentModificationException. You can modify the map during iteration:
ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
Map.of("apple", 10, "banana", 0, "cherry", 3));
for (String product : stock.keySet()) {
if (stock.get(product) == 0) {
stock.remove(product);
}
}
System.out.println(stock);
{apple=10, cherry=3}
The iterator reflects the map’s state at some point after its creation. It may or may not see changes that occur afterward. Each element is returned at most once. This is an intentional trade-off: you’re neither blocked nor thrown an exception, but you don’t get a precise snapshot.
5.3. No Null Support
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1); // NullPointerException
With HashMap, a get(key) returning null can mean two things: the key doesn’t exist, or the key maps to null. In a single thread, you can call containsKey separately to distinguish. In a concurrent environment, the map could change between those two calls. Therefore, ConcurrentHashMap forbids null so that get returning null has only one meaning: the key doesn’t exist.
5.4. size() Is Only an Estimate
While other threads are writing, size() and isEmpty() only reflect an approximate state. If the map might exceed Integer.MAX_VALUE entries, use mappingCount(), which returns a long. Don’t write business logic that depends on size() being absolutely precise at a single point in time.
6. Testing Concurrent Behavior
The following test confirms that ConcurrentHashMap counts correctly under concurrent load and rejects null values:
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ConcurrentMapTest {
@Test
void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
Map<String, Integer> orderCount = new ConcurrentHashMap<>();
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
}
assertThat(orderCount).containsEntry("customer-1", 10_000);
}
@Test
void givenNullValue_whenPut_thenThrowNpe() {
Map<String, Integer> stock = new ConcurrentHashMap<>();
assertThatThrownBy(() -> stock.put("apple", null))
.isInstanceOf(NullPointerException.class);
}
}
The first test always passes. If you change it to new HashMap<>(), the test fails most of the time, with the actual value less than 10,000.
Note: A multithreaded test passing doesn’t prove your code is correct. Race conditions may only appear under real load. Rely on the API’s guarantees, such as the atomicity of
merge, rather than trusting only that tests pass.
7. Common Mistakes with ConcurrentHashMap
Combining get and put. Using ConcurrentHashMap doesn’t make your code automatically thread-safe:
// WRONG: race condition between get and put
Integer current = stock.get("apple");
stock.put("apple", current - 1);
// CORRECT
stock.computeIfPresent("apple", (k, v) -> v - 1);
Functions in compute* are slow or have side effects. The mapping function runs while the bucket is locked. If you make HTTP calls or query a database inside computeIfAbsent, any other thread writing to that bucket will be blocked. Keep the function short and side-effect-free.
Modifying the same map inside compute*. Calling map.put or map.computeIfAbsent nested within the mapping function of the same map can throw IllegalStateException: Recursive update or cause a deadlock. A classic example is recursive caching like memoized Fibonacci using computeIfAbsent.
Atomicity across multiple keys. ConcurrentHashMap guarantees atomicity only per key. For example, transferring inventory from warehouse A to warehouse B operates on two keys. It requires its own lock or a redesign to consolidate into a single key.
Requiring order. ConcurrentHashMap doesn’t maintain insertion order. If you need sorted keys, use ConcurrentSkipListMap.
8. Comprehensive Comparison
Criterion | HashMap | synchronizedMap | ConcurrentHashMap
----------------------|------------------|--------------------------|--------------------------
Thread-safe | No | Yes | Yes
Locking mechanism | None | Single lock for all | CAS + per-bucket lock
Concurrent reads | Not safe | Sequential (blocked) | No lock
Compound operations | Not atomic | Needs synchronized block | compute/merge atomic
Iterator | Fail-fast | Fail-fast, manual lock | Weakly consistent
Key/value null | Allowed | Depends on wrapped map | Not allowed
Multithreaded perf. | — | Poor at high contention | Good
So which one should you choose?
HashMap: the map is accessed by only one thread, or is created, safely published, and read-only. For read-only cases,Map.copyOfis even better.Collections.synchronizedMap: you need to wrap an existingMaplikeLinkedHashMaporTreeMapto preserve order or special behavior, concurrent load is low, or you need to lock the entire map for a long sequence of operations.ConcurrentHashMap: the default choice for caches, counters, registries, and any map shared among multiple threads.
9. Conclusion
HashMap is fast but loses data when multiple threads write concurrently. Collections.synchronizedMap is safe thanks to a single lock. The downside is it creates a bottleneck, and you still must lock manually when iterating or combining operations. ConcurrentHashMap allows lock-free reads and parallel writes across buckets. It provides atomic operations like merge and computeIfAbsent, and its iterator doesn’t throw exceptions when the map changes.
Remember two principles. First, a thread-safe collection doesn’t make your operation sequences automatically atomic, so use compute* and merge instead of get then put. Second, keep mapping functions short and don’t modify the map itself within them.