Last Updated: February 1, 2026
A hash map is one of the most used data structures in real systems, and making it work correctly under concurrency is harder than it looks.
Multiple threads can insert, remove, and resize at the same time, and a naive lock around the whole map quickly becomes a bottleneck. The goal of a concurrent hash map is to provide safe, scalable access with high throughput under contention.
In this chapter, we will design a concurrent hash map step by step. We will start with a simple coarse-grained lock, then evolve toward finer-grained locking and CAS based approach.
A thread-safe HashMap that allows multiple threads to read and write concurrently without corrupting data or losing updates. The goal is to maximize throughput by allowing independent operations to proceed in parallel while maintaining correctness guarantees.
| Operation | Description | Expected Complexity |
|---|---|---|
put(key, value) | Insert or update a key-value pair | O(1) average |
get(key) | Retrieve value for a key | O(1) average |
remove(key) | Delete a key-value pair | O(1) average |
putIfAbsent(key, value) | Insert only if key doesn't exist | O(1) average |
computeIfAbsent(key, func) | Compute and insert if key missing | O(1) average |
size() | Return number of entries | O(1) or O(segments) |
get() simultaneously without blocking each otherput() on different keys simultaneouslyputIfAbsent() must be atomicBefore adding concurrency, let's understand how a single-threaded HashMap works. This baseline is essential because concurrent solutions must preserve these mechanics while adding thread safety.
hash(key) % capacity. A good hash function distributes keys uniformly across buckets.The diagram shows a HashMap with 8 buckets. Keys A and I both hash to bucket 2 (hash % 8 = 2), forming a chain. Keys G and O share bucket 7. Empty buckets (0, 1, 3, 4, 6) contain null. This chaining approach handles collisions but creates the first concurrency challenge: two threads modifying the same chain can corrupt it.
Before diving into solutions, let's understand what makes HashMap challenging to parallelize. These challenges will guide our design decisions.
Consider two threads inserting keys that hash to the same bucket.
Both threads read the same head pointer (X). Thread 1 inserts A pointing to X. Thread 2 then overwrites the bucket head with B pointing to X. Node A is orphaned, and its entry is silently lost. This is a classic lost update problem.
The put operation is inherently a read-modify-write sequence:
Without atomicity, another thread can interleave between steps 1 and 3, causing:
When the load factor threshold is exceeded, the table must double in capacity and rehash all entries. This is dangerous during concurrent access:
A reader using the old table reference might access entries that are being moved or have already been moved to the new table. The reader could return null for a key that actually exists.
putIfAbsent(key, value) must atomically:
Without atomicity:
Both threads see the key as absent and both insert. One value is silently lost, and both callers believe their insert succeeded.
| Property | Requirement | Why It Matters |
|---|---|---|
| Atomicity | Single-key operations must be atomic | Prevents lost updates |
| Visibility | Writes must be visible to subsequent reads | Prevents stale reads |
| Linearizability | Operations appear to happen at a single instant | Enables reasoning about concurrent behavior |
| Progress | At least one thread makes progress | Prevents system-wide stall |
The simplest approach: wrap the entire HashMap with a single lock. Every operation acquires this lock, serializing all access.
| Property | Status | Explanation |
|---|---|---|
| Thread-safe | Yes | Single lock serializes all access |
| Deadlock-free | Yes | Only one lock, no circular wait possible |
| Starvation-free | Depends | ReentrantLock with fairness=true guarantees it |
| Scalability | Poor | All operations serialize, zero parallelism |
The diagram shows the fundamental problem: all four threads must wait for a single lock, even though they're accessing completely independent keys. Threads 1 and 3 are both reading (could run in parallel) and accessing different keys (definitely could run in parallel). But the coarse-grained lock forces them to wait.
The key insight: most HashMap operations only touch one bucket. If we lock individual buckets (or groups of buckets), operations on different buckets can proceed in parallel. This is the approach used by Java 7's ConcurrentHashMap.
Divide the hash table into segments, each with its own lock. The default is 16 segments. To access a key:
segment = hash(key) % NUM_SEGMENTSThreads 1, 2, and 3 access different segments and proceed in parallel. Thread 4 hashes to the same segment as Thread 1, so it waits. But overall concurrency is much higher than with a single lock.
The size() method demonstrates the challenge of operations spanning multiple segments. We must lock all segments to get an accurate count. Without locking all, entries could be added or removed during counting, giving an inconsistent result.
Iterating over a striped map can't guarantee a point-in-time snapshot without locking everything. Instead, we provide "weakly consistent" iteration:
This is acceptable for most use cases (logging, monitoring, debugging) where an approximate view is sufficient.
| Property | Status | Explanation |
|---|---|---|
| Thread-safe | Yes | Each segment protected by its own lock |
| Deadlock-free | Yes | Single-segment ops use one lock; size() acquires in fixed order |
| Scalability | Good | Up to 16x parallelism (or more with more segments) |
| Complexity | Medium | More code, segment management logic |
Java 8 redesigned ConcurrentHashMap to eliminate segment locks for most operations. The key innovations:
get()Most HashMap buckets are either empty or have very few entries. Java 8's approach optimizes for this:
The key insight: get() never locks. It relies on:
table being volatile (sees latest array reference)Node.value being volatile (sees latest value)When resizing, Java 8's ConcurrentHashMap doesn't stop the world. Instead:
Buckets 2, 3, and 7 are marked with forwarding nodes (FWD). Their entries have been moved to the new table. Buckets 1, 4, and 6 are still in the old table. A get(key) that hashes to bucket 2 sees the forwarding node and looks in the new table instead.
putIfAbsent and computeIfAbsent must be atomic. In the CAS-based approach:
The synchronized block ensures the check-then-insert happens atomically. Between the existence check and the insert, no other thread can modify the bucket.
| Property | Status | Explanation |
|---|---|---|
| Thread-safe | Yes | CAS + synchronized ensures correctness |
| Lock-free reads | Yes | No locking for get() |
| Scalability | Excellent | Per-bucket locking, lock-free reads |
| Complexity | High | CAS loops, forwarding nodes, memory ordering |
| Aspect | Coarse-Grained | Fine-Grained (Striping) | CAS-Based (Java 8) |
|---|---|---|---|
| Read concurrency | 1 reader at a time | N readers (different segments) | Unlimited (lock-free) |
| Write concurrency | 1 writer at a time | N writers (different segments) | N writers (different buckets) |
| Lock overhead | 1 lock | 16 locks (default) | Per-bucket sync, no global lock |
| Read performance | Blocked by writes | Blocked by same-segment writes | Never blocked |
| size() complexity | O(1) | O(segments) + locking all | O(n) or approximate |
| Implementation complexity | Simple | Medium | High |
| Memory overhead | Minimal | 16 locks | Per-bucket potential lock |
| Resize strategy | Global, stop-the-world | Per-segment | Incremental, concurrent |
java.util.concurrent.ConcurrentHashMap. It's battle-tested and implements the Java 8 optimizations.tbb::concurrent_hash_map from Intel TBB or folly::ConcurrentHashMap from Facebook.multiprocessing.Manager().dict() or database-backed solutions.This section provides the production-ready lock striping implementation, which is most relevant for interviews. The code is complete, tested, and includes all methods discussed.