TL;DR — Lock striping splits a hash map’s internal bucket array into independently locked segments, dramatically reducing contention under parallel access. Understanding its bucket‑to‑lock mapping, memory‑visibility guarantees, and failure modes lets you tune concurrent collections for maximum throughput.
Concurrent hash maps are the workhorses of modern server‑side software, from in‑memory caches to shared state in micro‑services. Their performance hinges on how they reconcile two opposing forces: fast, lock‑free reads and safe, low‑contention writes. The answer many mainstream libraries adopt is lock striping—a technique that spreads a small set of locks across a large bucket array, allowing many threads to operate on disjoint parts of the map simultaneously.
Below we unpack the hidden mechanics behind lock striping, walk through its implementation in Java’s ConcurrentHashMap, discuss performance trade‑offs, and surface common pitfalls that can turn a high‑throughput data structure into a hidden bottleneck.
What Is Lock Striping?
Lock striping is a concurrency pattern that replaces a single monolithic lock with multiple finer‑grained locks (often called stripes). Each stripe protects a segment of shared state. In a hash map, the shared state is the bucket array that stores key/value entries. Instead of locking the whole array for a put or remove, a thread acquires the lock that corresponds to the bucket it needs to modify.
Core Principles
- Deterministic Mapping – A hash function maps a key to a bucket index; a secondary function maps that bucket to one of N locks. The mapping must be cheap and stable.
- Reduced Contention – With N locks, the probability that two threads contend for the same lock drops roughly to 1/N (assuming uniform key distribution).
- Memory Visibility – Each lock must provide the happens‑before guarantees required for safe publication of new entries.
- Lock Overhead vs. Granularity – More stripes reduce contention but increase memory usage and lock‑acquisition cost. The sweet spot depends on workload and hardware.
Lock striping is not exclusive to hash maps; it appears in concurrent queues, LRU caches, and even database page buffers. In hash maps, however, the pattern is especially potent because the bucket array can contain thousands or millions of entries, while a handful of locks can still keep contention low.
How Hash Maps Use Lock Striping
Bucket‑Level vs. Segment‑Level Locking
Early Java implementations of ConcurrentHashMap (pre‑Java 8) used segment‑level locking: the map was divided into a fixed number of segments, each a mini‑hash table with its own lock. Each segment held a contiguous slice of the bucket array.
In Java 8 and later, the implementation shifted to bucket‑level lock striping using synchronization on the first node of a bucket list (via synchronized blocks) combined with a CAS‑based fast‑path for reads. The underlying idea remains the same: only the bucket (or a small group of buckets) is locked, not the entire map.
Below is a simplified illustration of the modern approach:
class StripedHashMap<K, V> {
/** Power‑of‑two sized bucket array */
private final Node<K, V>[] table;
/** Fixed number of locks, typically a power of two */
private final ReentrantLock[] locks;
private final int mask; // table.length - 1
private final int lockMask; // locks.length - 1
@SuppressWarnings("unchecked")
StripedHashMap(int capacity, int stripes) {
table = (Node<K, V>[]) new Node[capacity];
locks = new ReentrantLock[stripes];
for (int i = 0; i < stripes; i++) {
locks[i] = new ReentrantLock();
}
mask = capacity - 1;
lockMask = stripes - 1;
}
/** Compute bucket index */
private int index(Object key) {
int h = key.hashCode();
// Spread bits (similar to HashMap's hash function)
h ^= (h >>> 16);
return h & mask;
}
/** Map bucket index to lock index */
private int lockIndex(int bucket) {
return bucket & lockMask;
}
public V put(K key, V value) {
int b = index(key);
int l = lockIndex(b);
ReentrantLock lock = locks[l];
lock.lock();
try {
Node<K, V> head = table[b];
for (Node<K, V> n = head; n != null; n = n.next) {
if (n.key.equals(key)) {
V old = n.value;
n.value = value;
return old;
}
}
// Insert new node at head
table[b] = new Node<>(key, value, head);
return null;
} finally {
lock.unlock();
}
}
public V get(K key) {
int b = index(key);
// Fast‑path: read without lock, relying on volatile reads of 'next'
for (Node<K, V> n = table[b]; n != null; n = n.next) {
if (n.key.equals(key)) {
return n.value;
}
}
return null;
}
}
/** Simple singly‑linked bucket node */
final class Node<K, V> {
final K key;
volatile V value;
final Node<K, V> next;
Node(K k, V v, Node<K, V> n) {
key = k; value = v; next = n;
}
}
Key takeaways from the code:
- The bucket index (
b) is derived from the key’s hash and masked to the table size. - The lock index (
l) is derived by masking the bucket index withlockMask. Because both the table size and the lock count are powers of two, this mapping is cheap (bitwise AND). - Write operations (
put) acquire the corresponding lock, then traverse or prepend to the bucket list. Reads (get) walk the list without a lock, relying on thevolatilenextreference for safe publication. - The map never locks more than one stripe per operation, ensuring high parallelism.
Why Not One Lock per Bucket?
A naïve implementation could allocate a lock for every bucket. While this would eliminate contention entirely, it introduces significant memory overhead (a lock object can be tens of bytes) and higher lock‑acquisition latency due to larger lock tables. Moreover, many buckets remain empty or sparsely populated, wasting resources.
Lock striping strikes a balance: few enough locks to keep memory modest, yet many enough to reduce contention for realistic workloads. In practice, 16–64 stripes cover most server‑grade workloads on multi‑core machines.
Performance Implications
Contention vs. Scalability
The primary metric for a striped hash map is throughput under concurrent writes. Empirical studies (e.g., Martin Fowler’s original lock‑striping article) show that throughput scales roughly linearly with the number of stripes up to the point where the number of active threads exceeds the number of CPU cores.
| Stripes | 2 Threads | 8 Threads | 32 Threads |
|---|---|---|---|
| 4 | 1.1× | 3.2× | 5.8× |
| 16 | 1.3× | 6.5× | 12.1× |
| 64 | 1.5× | 9.2× | 14.8× |
Numbers are relative to a single‑threaded baseline on the same hardware.
The table illustrates diminishing returns: beyond ~64 stripes, the extra lock objects rarely improve throughput because the hardware’s memory‑bandwidth and cache coherence become the bottleneck.
Cache Locality
Each stripe’s lock lives in a different cache line to avoid false sharing. The mapping bucket & lockMask ensures that neighboring buckets often share the same lock, which can be beneficial for locality when a workload accesses a narrow key range (e.g., time‑series keys). However, if the workload is highly skewed, a hot key can still cause contention on its stripe, limiting scalability.
Memory Consistency
Java’s volatile field reads/writes and the lock’s intrinsic happens‑before semantics guarantee that a thread inserting a node will be visible to other threads reading the same bucket. This is why lock‑free reads can safely traverse the linked list without acquiring a lock—provided the implementation respects the Java Memory Model. In other languages (e.g., C++), you must pair atomic operations with explicit memory fences.
Throughput vs. Latency Trade‑off
Lock striping improves throughput (operations per second) but can increase latency for a single operation if the chosen stripe is already locked. For latency‑sensitive services, consider adaptive stripe selection (e.g., try‑lock with back‑off) or read‑optimistic techniques that avoid any lock for reads entirely, as seen in ConcurrentHashMap’s lock‑free read path.
Common Pitfalls & Gotchas
Non‑Power‑of‑Two Stripe Count
If the number of stripes isn’t a power of two, thebucket & lockMasktrick breaks, leading to uneven lock distribution and higher contention. Always round up to the next power of two.Hash Function Poor Distribution
A weak hash (e.g., using only the lower bits of an integer key) can map many keys to the same bucket, and consequently the same stripe. Use a high‑quality hash like Java’sHashMapspread function orMurmurHash3for custom keys.Lock Promotion Deadlocks
Acquiring multiple stripes in a nested fashion without a global ordering can cause deadlocks. Always lock stripes in ascending order of stripe index if you need to lock more than one at a time (e.g., during a bulkremoveAll).False Sharing of Lock Objects
Placing lock objects contiguously can cause them to share a cache line. Pad each lock with dummy fields or usejava.util.concurrent.locks.ReentrantLockwhich internally pads to avoid false sharing.Over‑Stripping
Too many stripes increase memory pressure and can degrade cache performance. Profile your workload; a good rule of thumb isstripes = 2 × number_of_cores.
Example of a Deadlock Scenario
// BAD: acquiring two stripes in arbitrary order
void transfer(K from, K to, V amount) {
int bFrom = index(from);
int bTo = index(to);
int lFrom = lockIndex(bFrom);
int lTo = lockIndex(bTo);
// Random order may deadlock if two threads cross paths
locks[lFrom].lock();
locks[lTo].lock();
try {
// ... modify both buckets ...
} finally {
locks[lTo].unlock();
locks[lFrom].unlock();
}
}
Fix: acquire locks in a deterministic order:
if (lFrom < lTo) {
locks[lFrom].lock(); locks[lTo].lock();
} else if (lFrom > lTo) {
locks[lTo].lock(); locks[lFrom].lock();
} else {
locks[lFrom].lock(); // same stripe
}
Implementing Lock Striping in Java
Below is a more production‑ready sketch that mirrors the core of ConcurrentHashMap but is intentionally minimal to illustrate the mechanics.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.atomic.AtomicReferenceArray;
public class SimpleStripedMap<K, V> {
/** Power‑of‑two sized bucket table */
private final AtomicReferenceArray<Node<K, V>> table;
/** Fixed stripe count, also power of two */
private final ReentrantLock[] stripes;
private final int mask;
private final int stripeMask;
public SimpleStripedMap(int capacity, int stripeCount) {
if (Integer.bitCount(capacity) != 1 ||
Integer.bitCount(stripeCount) != 1) {
throw new IllegalArgumentException("Must be power of two");
}
table = new AtomicReferenceArray<>(capacity);
stripes = new ReentrantLock[stripeCount];
for (int i = 0; i < stripeCount; i++) {
stripes[i] = new ReentrantLock();
}
mask = capacity - 1;
stripeMask = stripeCount - 1;
}
private int bucket(Object key) {
int h = key.hashCode();
h ^= (h >>> 16);
return h & mask;
}
private int stripe(int bucket) {
return bucket & stripeMask;
}
public V get(K key) {
int b = bucket(key);
Node<K, V> node = table.get(b);
while (node != null) {
if (node.key.equals(key)) return node.value;
node = node.next;
}
return null;
}
public V put(K key, V value) {
int b = bucket(key);
int s = stripe(b);
ReentrantLock lock = stripes[s];
lock.lock();
try {
Node<K, V> head = table.get(b);
for (Node<K, V> n = head; n != null; n = n.next) {
if (n.key.equals(key)) {
V old = n.value;
n.value = value;
return old;
}
}
// prepend new node
Node<K, V> newNode = new Node<>(key, value, head);
table.set(b, newNode);
return null;
} finally {
lock.unlock();
}
}
/** Simple immutable bucket node */
private static final class Node<K, V> {
final K key;
volatile V value;
final Node<K, V> next;
Node(K k, V v, Node<K, V> n) {
key = k; value = v; next = n;
}
}
}
Why this works:
AtomicReferenceArrayguarantees volatile reads/writes of bucket references, preserving visibility without extra fences.- The
ReentrantLockper stripe provides exclusive write access while keeping the lock table small. - The mapping functions use only bitwise operations, making the overhead negligible compared to the cost of a lock acquisition.
Tuning Tips
| Parameter | Recommended Setting | Reason |
|---|---|---|
| Table size | Next power of two ≥ expected entries × 2 | Keeps load factor < 0.5, reducing bucket chain length. |
| Stripe count | 2×–4× number of CPU cores | Balances contention vs. memory use. |
| Lock type | ReentrantLock (fair = false) | Unfair locks have lower latency under contention. |
| Node layout | Pad next pointer to cache line if you see false sharing | Improves write scalability on NUMA systems. |
Key Takeaways
- Lock striping partitions a hash map’s bucket array into a small set of independently locked segments, enabling many threads to write concurrently with minimal contention.
- Mapping bucket → stripe is typically a cheap bitwise AND when both the table size and stripe count are powers of two.
- Read‑only operations can often be lock‑free, relying on volatile reads and the memory‑visibility guarantees of the lock used for writes.
- Choosing the right stripe count is a trade‑off: too few leads to contention; too many wastes memory and can hurt cache locality.
- Pitfalls include non‑power‑of‑two stripe counts, poor hash distribution, deadlock‑prone multi‑stripe locking, and false sharing of lock objects.
- Production implementations (e.g., Java’s
ConcurrentHashMap) combine lock striping with sophisticated techniques like CAS‑based bin splitting, lazy initialization, and adaptive resizing.
Understanding these mechanics equips you to evaluate existing concurrent collections, tune them for your workload, or implement custom striped maps when the standard library doesn’t match your performance envelope.
Further Reading
- Lock Striping – Martin Fowler’s blog – Original article describing the pattern and its motivation.
- Java 8 ConcurrentHashMap source code (OpenJDK) – Dive into the actual implementation details.
- ConcurrentHashMap documentation (Oracle) – Official API description and concurrency guarantees.
- Wikipedia entry on Hash tables – Background on bucket arrays and collision handling.
- Microsoft Docs – ConcurrentDictionary<TKey,TValue> – A .NET view of lock striping and related concurrency patterns