The Concurrency Of ConcurrentHashMap
Java's ConcurrentHashMap
is an excellent point of study when learning about Java's Memory Model. It employs numerous techniques to provide lock-free reads while still guaranteeing safe publication of objects. This article represents my attempt to take the magic out of the ConcurrentHashMap
implementation.
This article covers the java.util.concurrent.ConcurrentHashMap
as implemented in JDK7. This implementation differs slightly from previous versions and is radically different from the new implementation in JDK8 (perhaps the topic of a future article). I assume you have a basic understanding of how the hash table data structure works, as this is out of scope. Finally, you may find this article to greatest effect if you review the source code yourself. I do not include code excerpts below, as most methods require contextual knowledge of the overall class.
Design
In a nutshell, the ConcurrentHashMap
contains an array, segments
, of "segments" (ConcurrentHashMap.Segment
). Each segment is, essentially, a hash table that contains an array, table
, of "entries" (ConcurrentHashMap.HashEntry
). Each entry holds a key, a value, and a pointer to it's next adjacent entry (effectively creating a linked list for the hash of a given key). You may consider a ConcurrentHashMap
a hash table of hash tables: each Segment
is indexed by the high-order bits of a given key and each HashEntry
is indexed by the full hash of a key. This will be discussed in later sections.
Noticing that the ConcurrentHashMap
implementation makes extensive use of arrays, I think to remind you that when reading or writing elements of an array, the per-element operation will never have volatile semantics. This is true even if the array itself is flagged as volatile
. As of JDK5, the Atomic(Integer|Long|Reference)Array
types were introduced to work around this fact. Interestingly, however, Doug Lea opted to use the less-portable sun.misc.Unsafe
HotSpot-specific class to accomplish the same goal. This was done attempting to "reduce the levels of indirection." I will attempt to bridge the gap when applicable.
Implementation
What may seem to be the biggest magic of the ConcurrentHashMap
is the Javadoc: developers, when not paying close attention, may think that the ConcurrentHashMap
is a completely non-locking data structure. This is not true: modifications to the each segment will entail locking, and some reads (namely, size()
and containsValue()
) may also require a lock. This topic will be discussed throughout the following subsections.
Concurrency Level
When creating a new ConcurrentHashMap
, several items are considered. Primarily, a ConcurrentHashMap
is sized according to a concurrencyLevel
(provided by the constructor or defaulting to 16). This attribute represents the "estimated number of concurrently updating threads" (from the ConcurrentHashMap
source). The concurrencyLevel
is particularly important to correctly estimate: when under-estimating, the data structure will be under extra contention, causing threads to block when attempting to write to a currently-locked Segment
. If, instead, the concurrency level over-estimated, you encounter excessive bloat due to an unnecessary number of segments; such bloat may lead to degraded performance due to high numbers of cache misses.
The actual number of segments a ConcurrentHashMap
instance will maintain is equal to the next power of two of the specified concurrencyLevel
. For example, a concurrencyLevel
of 6 results in 8 segments; similarly, a concurrencyLevel
of 65 results in 128 segments. As mentioned previously, each Segment
is indexed by the high-order bits of a given key. The exact number of bits, segmentShift
, is determined at construction time and is a function of concurrencyLevel
: 4 bytes less the next n-th power of concurrencyLevel
(for example, the upper 25 bits will be used with a concurrencyLevel
of 128, and the upper 32bits with a concurrencyLevel
of 1).
Sequence Creation
As of JDK6, ConcurrentHashMap
lazily creates segments as necessary. Only one segment is created at construction time, and this creation is the first use of Unsafe
magic. Within the constructor, a single Segment
, s0
, is created; however, this creation is slightly irregular: when s0
is put into ConcurrentHashMap.segments[0]
, it is done through Unsafe.putOrderedObject()
. Noticing, however, that segments
is an immutable (final
) instance field, which already guarantees transient visibility of s0
after the final field freeze, this explicit ordering is unexpected. This may simply be chalked up to defensive programming, style consistency, or simply a result of an over-caffeinated Doug Lea. The JSR166 mailing list seems to agree this use of putOrderedObject
is not strictly necessary for this write.
Because only a single segment is created upon instantiation, each subsequent modification of the ConcurrentHashMap
implementation must ensure the target Segment
(as determined by the high-order bits of a key's hash) exists. This check is performed in the ensureSegment(int index)
method. As ConcurrentHashMap
attempts to be lock-free whenever possible, the ensureSegment()
method makes use of several techniques of concurrent programming to remain lock-free. The next several subsections attempt to explain these techniques.
Local References
Even though segments
is marked final (and thus will never change), Doug Lea prudently creates a method-local copy, ss
, of segments
upon which to operate. Such defensive programming allows a programmer to not worry about otherwise-volatile instance member references changing during execution of a method (i.e. inconsistent reads). Of course, this is simply a new reference and does not prevent your method from seeing changes to the referent.
Retrieving Sequence k
Remembering that arrays, even when volatile
, do not provide volatile semantics when reading or writing elements, accessing the k-th element of segments
requires an explicit volatile read. This volatile read is performed through the Unsafe.getObjectVolatile()
method within ConcurrentHashMap.segmentAt()
method. Again, the ConcurrentHashMap
implementation uses Unsafe
methods instead of an AtomicReferenceArray
, of which the get
method would also accomplish this task. If already created, the k-th Segment
is returned to the invoker; otherwise, a null
value is returned.
Creating Missing Segments
When a Segment
for index k
is not found (i.e. segmentAt
returns null
), ensureSegment()
attempts to create one. Because this algorithm is lock-free, this implementation must allow for multiple contended writers without risking lost updates. Essentially, this implementation uses the technique of optimistic locking: each writer checks to see if it won and keeps trying until it finally wins (or definitely loses).
When creating a new Segment
, segment[0]
is used as a prototype. This allows for sizing according to the current table size and loadFactor
. Once the prototype has been recorded, the implementation double checks to ensure that the k-th Segment
is still null
; if so, the new Segment
, ss
, is created from the prototype. The method next enters a while
loop; this loop is, effectively, a check to determine if the k-th segment has been created by another thread.
If, upon the loop condition, the k-th segment is still null
, insertion into segments[k]
is finally attempted. This write is performed using the Unsafe.compareAndSwapObject
method, which employs a compare-and-swap (CAS) algorithm. This algorithm performs hardware-level operations to guarantee that, when multiple contended threads attempt to write to memory, a value is only written iff the current value (null
, in this case) matches the expected value (also null
). This means that when threads t1
and t2
write to segments[k]
, only one will win. The other, or both, will fail---retrying at the next loop. For example, t1.cas(seg=s,expect=null)
, seeing that segments[k] == null
; meanwhile, t2.cas(seg=s',expect=null)
seeing that segments[k] == s
. In this case, t1
wins and t2
sees, upon next check, that it has definitely lost. This is the same implementation used in AtomicReferenceArray.set
.
put
When ConcurrentHashMap.put()
is invoked, it first must find the Segment
into which the entry should be written. When doing so, put()
attempts to decrease the access latency associated with volatile reads by leveraging the fact that segments, once written, do not get destroyed. As such, a memory fence can potentially be avoided by performing a normal read on segments[k]
with Unsafe.getObject()
. The return will either be the correct Segment
, or null
. In the case a null
value is returned, one of two reasons may hold true:
- The specified
Segment
has never been created (and truly is null). - The specified
Segment
has been created but not yet flushed from writing thread's write buffer (or loaded into the reading thread's load buffer).
Regardless, a null
Segment
results in a call to ensureSegment()
, which, as previously mentioned, performs a volatile read on the necessary array element. When performing the volatile read, a null
read positively identifies a missing segment. The existing, or newly created segment (if necessary), is returned. Interestingly, AtomicReferenceArray
has no comparable method to perform a lazy read. As this type of optimization is tightly coupled with the overall design, any attempt to mimic this behaviour in your own systems should be thoroughly understood and documented. The segment.put
method is invoked to perform the actual write.
Segment.put
Because Segment.table
is, itself, a hash table, each HashEntry
sits in a specific index of table
determined by the key's hash. Each element of table
represents the head node of a linked-list of HashEntry
objects. When putting a value into the hash table, the put is, effectively, an insert-or-update operation: existing nodes for the specified key are updated, and unknown entries are added.
As previously indicated, the put
operation is performed under a lock. Interestingly, the Segment
class actually extends from ReentrantLock
"... to simplify some locking and avoid separate construction." This type of lock offers the same locking semantics as a synchronized
block but offers more developer-focused capabilities (like non-blocking acquisitions with tryAcquire()
). Importantly, the locking provides a memory fence on entry and exit: all changes to a specific segment will be sequentially consistent.
When attempting to acquire the segment lock, the put
method first uses the non-blocking tryAcquire()
. If the lock cannot be acquired, the method scanAndLockForPut()
is executed. This method is quite interesting, as, instead of simply blocking (as with lock()
), it attempts to optimistically acquire the lock and incrementally determine if the specified key is already present in the map. If not, a new entry is speculatively created. This approach has two primary advantages:
- It increases the likelihood that all members of the linked list will be retained in a CPU cache (probably L1 or L2), preventing the need to do a subsequent, more costly read from L3 or memory once the lock has been acquired. This is effectively an obtuse pre-fetch.
- It decreases the amount of time (however small) required to create a new
HashEntry
while holding a lock on the segment.
The scanAndLockForPut()
method uses a while loop to optimistically acquire the segment lock. Each time it is unable to acquire the lock, the applicable linked list is traversed, 1 element per lock acquisition failure. If the current element of traversal has the same key as the one being added, the traversal ends and the loop becomes a basic spin lock. Alternatively, if the key is not found in the linked list (after a complete traversal), a new HashEntry
is speculatively created on behalf of put
. At this time, the loop becomes a basic spin lock.
Once the loop becomes a spin lock, the applicable table element is interrogated to ensure the head element reference has not changed since the loop began. This would be indicative of another put, removal, or rehashing. In such a case, the traversal is restarted. Assuming, however, the head node does not change for a specified number of retries (defined as 64 for multi-core machines), the method gives up and blocks for the lock. Indeed, it is quite possible (and harmless) that the method will return prior to establishing the presence of a key.
Once put
has full ownership of the segment lock, it traverses the applicable linked list looking for an existing HashEntry
that matches the key being inserted. If a matching entry is found, the HashEntry
volatile member, value
, is updated. Alternatively, if no element is found, a new HashEntry
is created (unless scanAndLockForPut()
returned one), setting itself as the referent of the linked list's current head node's next
member (i.e.table[hash&mask].next
) with HashEntry.setNext()
. Finally, the HashEntry
is written into the table using setEntryAt()
.
Both setEntryAt()
and setNext()
perform ordered writes (rather than volatile writes) with Unsafe.putOrderedObject()
. This is done to eliminate the memory fence provided after a volatile write, because, upon release of the segment lock, a memory fence will already be inserted. As such, the volatile write semantics are unnecessary and the ordered write is sufficient. The same behaviour can be accomplished with AtomicReferenceArray.lazySet()
. Such optimizations should be heavily documented, as trivial changes could lead to race conditions.
Finally, note that setNext()
must always be performed before setEntryAt()
. If, instead, the two writes are reversed, there is a possibility that the entry may be read prior to its next
node being written. Such ordering may result in corruption of the table. This behaviour would certainly fit within the Java Memory Model, and may even be desired (though, I cannot contrive an example).
remove
Similar to put
, the ConcurrentHashMap
implementation of remove
delegates the actual removal implementation to the respective Segment
. Similarly, the segment removal implementation requires a lock to be taken out on the segment. Whereas put
acquires a lock through scanAndLockForPut()
, remove acquires a lock through scanAndLock()
, which simply attempts to keep the elements in cache to prevent cache misses once it acquires the lock. When removing, the specified element is simply orphaned by linking its two adjacent nodes using an ordered write.
size
And containsValue
Even though ConcurrentHashMap.size()
and ConcurrentHashMap.containsValue()
have different purposes, they share a similar algorithm. Both methods require full-map traversal and come at the potential cost of locking and eager segment creation. Both methods try to perform lock-free operations; however, to prevent on-going contentions with writes, both implementations will degrade to a pessimistic lock after several failed attempts to establish a conclusive return value. Indeed, these methods may be very expensive if they have to lock the entire structure.
To establish a conclusive value, both methods attempt to perform the same operation twice; when the results of the second attempt match the results of the first attempt, the results are considered correct and returned to the invoker. As an example, the size()
implementation runs over each segment, summing up each Segment.count
instance member, which retains an up-to-date count of its HashEntry
s. Once summed, this value is retained in a method-local, last
. Subsequently, the segment counts are once again summed. If both sums are equal, the value is deemed conclusive and is returned to the invoker.
Otherwise, the method retries two more times (as defined by ConcurrentHashMap.RETRIES_BEFORE_LOCK
) to establish a conclusive value. Failure to do so results in a whole-map lock. This lock is achieved by iterating through each element in segments
with ensureSegment()
, which creates non-existent segments as necessary (hence eager creation). Each segment is locked with Segment.lock()
, and the lock is held until the method completes.
Of interest, the Segment.count
member is neither volatile nor accessed through any sort of Unsafe
magic. While this often could result in a race condition, the ConcurrentHashMap
implementation prevents such a scenario. Consider, for example, thread t1
invoking size()
while thread t2
invokes put()
. Assuming put()
executes in-between the first and second reads in size()
, the Segment.count
value is still up-to-date for t1
(hence different between reads). This is accomplished without locks by means of memory fencing (and, again, should be subject to heavy documentation).
Remembering that when t2
unlocks the segment lock at the end of put()
, its write buffers are flushed and all writes performed within the monitor become visible to other threads. This includes the incremented count
member. Next, when size()
loads each Segment
, it uses segmentAt()
; recalling that segmentAt()
loads the specified segment with volatile read semantics, a memory fence is introduced. This results in the invalidation of t1
's load buffer and guarantees that t1
will see the up-to-date write of t2
(assuming no other operations occurred).
get
And containsKey
In comparisons to the previous methods, the get()
and containsKey()
methods are fairly mundane. Also unlike the previous methods, both are entirely lock-free. First, a Segment
is retrieved from the segments
array using the applicable high-order bits of the key's hash. The retrieval is performed using Unsafe.getObjectVolatile()
. Next, a HashEntry
of the segment's table
array is retrieved using the key's hash. This retrieval is also performed using Unsafe.getObjectVolatile
. From this head node, the linked list of HashEntry
objects is traversed until the specified key is found (or not found) and the applicable value is returned.
Comments
The ConcurrentHashMap
implementation offers numerous techniques from which Java developers can learn to write efficient, lock-free concurrent code. While the ConcurrentHashMap
source code provides fairly comprehensive documentation, I hope this article has provided you with some additional insight into the overall design.
While Doug Lea made use of numerous undocumented APIs, most instances have public API counterparts. I would encourage you to use these public APIs, as they are less likely to change across releases. Finally, regardless which APIs you use, I also encourage you to heavily document any assumptions or preconditions you make in your code. As seen in several instances above, seeming-trivial changes may cause unexpected, difficult-to-detect behaviour within your system.