1. Introduction
Java’s ConcurrentHashMap
is a thread-safe implementation of a hash-based map that allows concurrent reads and updates without requiring full synchronization. It was designed to scale better than Collections.synchronizedMap()
or Hashtable
in multi-threaded environments.
However, when multiple threads frequently access or modify the map, especially on the same keys or buckets, performance bottlenecks can arise. These issues are referred to as scalability challenges under high contention.
2. Understanding ConcurrentHashMap Internals
2.1 Java 7 vs Java 8
- Java 7:
ConcurrentHashMap
used Segment-based locking (each segment had its own lock). - Java 8 and later: Internally uses a synchronized CAS-based mechanism. Locking is done at a finer granularity — per bucket (bin), and in some cases per node.
3. Scalability Challenges Under High Contention
3.1. Contention on Buckets
ConcurrentHashMap
uses an internal array of nodes (or bins). Each key is hashed and mapped to a bin.- When multiple threads attempt to update values in the same bin, they are forced to serialize their updates.
- This contention slows down operations and reduces throughput.
Example Scenario:
map.compute("sameKey", (k, v) -> v == null ? 1 : v + 1);
If 100 threads do this at the same time on the same key, they cannot execute concurrently, despite the map being thread-safe.
3.2. Hot Keys
- A hot key is a key that is accessed or updated very frequently.
- Since updates to the same key must be serialized, this becomes a performance bottleneck.
Why?
- Internally,
ConcurrentHashMap
uses synchronized blocks on the node corresponding to the key. - Multiple threads trying to update that node are forced to wait.
3.3. Resize Contention
- When the map size exceeds a threshold (based on load factor), it resizes (doubles the internal capacity).
- Resizing is expensive, involving rehashing and transferring elements to a new table.
- Although resizing is done concurrently, many threads may get involved, causing CPU spikes and performance drops.
3.4. Overhead of compute/merge Methods
- Methods like
compute
,computeIfAbsent
, andmerge
involve locking the bucket and updating atomically. - These are contentious under high load, especially when used on the same keys.
map.computeIfAbsent("hotKey", k -> heavyComputation());
Multiple threads trying this will block, even if they could have completed independently.
3.5. False Sharing (Advanced Issue)
- On multicore CPUs, when two variables that are frequently updated by different threads share the same CPU cache line, it can cause false sharing.
- In some
ConcurrentHashMap
implementations, threads updating adjacent buckets might suffer from this, reducing scalability.
4. Optimization Techniques
To mitigate these challenges, several optimization strategies can be applied.
4.1 Sharding (Manual Partitioning)
Instead of using one large map, create multiple smaller maps (shards). Each thread or key group is assigned to a specific shard.
final int NUM_SHARDS = 16; ConcurrentHashMap<String, Integer>[] shards = new ConcurrentHashMap[NUM_SHARDS]; for (int i = 0; i < NUM_SHARDS; i++) { shards[i] = new ConcurrentHashMap<>(); } int getShardIndex(String key) { return Math.abs(key.hashCode() % NUM_SHARDS); }
4.2. Use LongAdder for Counting
If you’re incrementing counters:
map.compute(key, (k, v) -> v == null ? 1 : v + 1);
Instead, use LongAdder
, which is designed for high-concurrency atomic additions:
ConcurrentHashMap<String, LongAdder> counterMap = new ConcurrentHashMap<>(); counterMap.computeIfAbsent(key, k -> new LongAdder()).increment();
Why it works better:
LongAdder
maintains multiple cells internally.- Threads update different cells, reducing contention.
4.3. Reduce Use of compute/merge
These methods are atomic but synchronized under the hood.
Avoid this:
map.computeIfAbsent("key", k -> doSomething());
Do this (only when thread safety on value creation isn’t critical):
V val = map.get("key"); if (val == null) { V newVal = doSomething(); map.putIfAbsent("key", newVal); }
4.4. Use Thread-Local Batching
Instead of updating the map on every event, accumulate changes in a thread-local structure, and periodically flush them.
ThreadLocal<Map<String, Integer>> localBatch = ThreadLocal.withInitial(HashMap::new); // Use it localBatch.get().merge(key, 1, Integer::sum); // Later, flush to shared map localBatch.get().forEach((k, v) -> globalMap.merge(k, v, Integer::sum));
4.5. Pre-size the Map to Avoid Resizing
Resizing under high contention is expensive.
Tip:
If you know the map will hold ~100,000 entries, initialize accordingly:
ConcurrentHashMap<String, Object> map = new ConcurrentHashMap<>(131072);
Set initial capacity slightly more than needed to avoid rehashing during runtime.
4.6. Avoid Hot Keys
Redesign your keying logic if one or a few keys are updated far more frequently than others.
If hot keys are unavoidable, use alternative techniques like:
LongAdder
as discussed.- Thread-local caching.