java.util.concurrent.ConcurrentHashMap是Java 5中支持高并发、高吞吐量的线程安全HashMap实现。
实现原理
锁分离 (Lock Stripping)
ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。
它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,
每个段其实就是一个小的hash table,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,
这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,
在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,
但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。
可以确保不会出现死锁,因为获得锁的顺序是固定的。不变性是多线程编程占有很重要的地位,下面还要谈到。
注1:即使数组成员也是final的,也不能解决死锁吧。
这里解决死锁注要是“按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁“来解决的。
Java代码
/**
* The segments, each of which is a specialized hash table
*/
final Segment<K,V>[] segments;
不变(Immutable)和易变(Volatile)
ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。
如果使用传统的技术,如HashMap中的实现,如果允许可以在hash链的中间添加或删除元素,读操作不加锁将得到不一致的数据。
ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。HashEntry代表每个hash链中的一个节点,其结构如下所示:
注1:注意”读操作“也是要从hash链的头开始寻找的。所以在HashMap中”hash链的中间添加或删除元素,读操作不加锁将得到不一致的数据“
Java代码
static final class HashEntry<K,V> {
final K key;
final int hash;
volatile V value;
final HashEntry<K,V> next;
}
可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,
因为这需要修改next 引用值,所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。
但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,
最后一个节点指向要删除结点的下一个结点。这在讲解删除操作时还会详述。为了确保读操作能够看到最新的值,
将value设置成volatile,这避免了加锁。
注意1:”对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍“
这操作的代价应该不下哦。这个也应该是它和HashTable比的一个劣势。
其它
为了加快定位段以及段中hash槽的速度,每个段hash槽的的个数都是2^n,这使得通过位运算就可以定位段和段中hash槽的位置。
当并发级别为默认值16时,也就是段的个数,hash值的高4位决定分配在哪个段中。
但是我们也不要忘记《算法导论》给我们的教训:hash槽的的个数不应该是 2^n,这可能导致hash槽分配不均,
这需要对hash值重新再hash一次。
这是重新hash的算法,还比较复杂,我也懒得去理解了。
Java代码
private static int hash(int h) {
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
这是定位段的方法:
Java代码
final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask];
}
数据结构
关于Hash表的基础数据结构,这里不想做过多的探讨。Hash表的一个很重要方面就是如何解决hash冲突,
ConcurrentHashMap 和HashMap使用相同的方式,都是将hash值相同的节点放在一个hash链中。
与HashMap不同的是,ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。
下面是ConcurrentHashMap的数据成员:
Java代码
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
/**
* Mask value for indexing into segments. The upper bits of a
* key's hash code are used to choose the segment.
*/
final int segmentMask;
/**
* Shift value for indexing within segments.
*/
final int segmentShift;
/**
* The segments, each of which is a specialized hash table
*/
final Segment<K,V>[] segments;
}
所有的成员都是final的,其中segmentMask和segmentShift主要是为了定位段,参见上面的segmentFor方法。
每个Segment相当于一个子Hash表,它的数据成员如下:
Java代码
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
/**
* The number of elements in this segment's region.
*/
transient volatile int count;
/**
* Number of updates that alter the size of the table. This is
* used during bulk-read methods to make sure they see a
* consistent snapshot: If modCounts change during a traversal
* of segments computing size or checking containsValue, then
* we might have an inconsistent view of state so (usually)
* must retry.
*/
transient int modCount;
/**
* The table is rehashed when its size exceeds this threshold.
* (The value of this field is always <tt>(int)(capacity *
* loadFactor)</tt>.)
*/
transient int threshold;
/**
* The per-segment table.
*/
transient volatile HashEntry<K,V>[] table;
/**
* The load factor for the hash table. Even though this value
* is same for all segments, it is replicated to avoid needing
* links to outer object.
* @serial
*/
final float loadFactor;
}
count用来统计该段数据的个数,它是volatile,它用来协调修改和读取操作,以保证读取操作能够读取到几乎最新的修改。
协调方式是这样的,每次修改操作做了结构上的改变,如增加/删除节点(修改节点的值不算结构上的改变),都要写count值,
每次读取操作开始都要读取count的值。这利用了 Java 5中对volatile语义的增强,对同一个volatile变量的写和读存在happens-before关系。
modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变,在讲述跨段操作时会还会详述。
threashold用来表示需要进行rehash的界限值。table数组存储段中节点,每个数组元素是个hash链,用HashEntry表示。
table也是volatile,这使得能够读取到最新的 table值而不需要同步。loadFactor表示负载因子。
实现细节
修改操作
先来看下删除操作remove(key)。
Java代码
public V remove(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).remove(key, hash, null);
}
整个操作是先定位到段,然后委托给段的remove操作。当多个删除操作并发进行时,只要它们所在的段不相同,它们就可以同时进行。
下面是Segment的remove方法实现:
Java代码
V remove(Object key, int hash, Object value) {
lock();
try {
int c = count - 1;
HashEntry<K,V>[] tab = table;
int index = hash & (tab.length - 1);
HashEntry<K,V> first = tab[index];
HashEntry<K,V> e = first;
while (e != null && (e.hash != hash || !key.equals(e.key)))
e = e.next;
V oldValue = null;
if (e != null) {
V v = e.value;
if (value == null || value.equals(v)) {
oldValue = v;
// All entries following removed node can stay
// in list, but all preceding ones need to be
// cloned.
++modCount;
HashEntry<K,V> newFirst = e.next;
for (HashEntry<K,V> p = first; p != e; p = p.next)
newFirst = new HashEntry<K,V>(p.key, p.hash,
newFirst, p.value);
tab[index] = newFirst;
count = c; // write-volatile
}
}
return oldValue;
} finally {
unlock();
}
}
整个操作是在持有段锁的情况下执行的,空白行之前的行主要是定位到要删除的节点e。
接下来,如果不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍,尾结点指向e的下一个结点。
e后面的结点不需要复制,它们可以重用。
注:删除一个节点后,它前面的节点的顺序会颠倒。比如对于节点1,2,3,4,5。如果删除了节点4,就变成了3,2,1,5.
整个remove实现并不复杂,但是需要注意如下几点。
第一,当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。
第二,remove执行的开始就将table赋给一个局部变量tab,这是因为table是volatile变量,读写volatile变量的开销很大。
因为编译器也不能对volatile变量的读写做任何优化。
接下来看put操作,同样地put操作也是委托给段的put方法。下面是段的put方法:
Java代码
V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock();
try {
int c = count;
if (c++ > threshold) // ensure capacity
rehash();
HashEntry<K,V>[] tab = table;
int index = hash & (tab.length - 1);
HashEntry<K,V> first = tab[index];
HashEntry<K,V> e = first;
while (e != null && (e.hash != hash || !key.equals(e.key)))
e = e.next;
V oldValue;
if (e != null) {
oldValue = e.value;
if (!onlyIfAbsent)
e.value = value;
}
else {
oldValue = null;
++modCount;
tab[index] = new HashEntry<K,V>(key, hash, first, value);
count = c; // write-volatile
}
return oldValue;
} finally {
unlock();
}
}
该方法也是在持有段锁的情况下执行的,首先判断是否需要rehash,需要就先rehash。
接着是找是否存在同样一个key的结点,如果存在就直接替换这个结点的值。否则创建一个新的结点并添加到hash链的头部,
这时一定要修改modCount和count的值,同样修改count的值一定要放在最后一步。
put方法调用了rehash方法,reash方法实现得也很精巧,主要利用了table的大小为2^n,这里就不介绍了。
修改操作还有putAll和replace。putAll就是多次调用put方法,没什么好说的。replace甚至不用做结构上的更改,
实现要比put和delete要简单得多,理解了put和delete,理解replace就不在话下了,这里也不介绍了。
获取操作
首先看下get操作,同样ConcurrentHashMap的get操作是直接委托给Segment的get方法,直接看Segment的get方法:
Java代码
V get(Object key, int hash) {
if (count != 0) { // read-volatile
HashEntry<K,V> e = getFirst(hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
V v = e.value;
if (v != null)
return v;
return readValueUnderLock(e); // recheck
}
e = e.next;
}
}
return null;
}
get操作不需要锁。第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count 变量,
通过这种机制保证get操作能够得到几乎最新的结构更新。对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是 volatile的, 也能保证读取到最新的值。接下来就是对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。
对hash链进行遍历不需要加锁的原因在于链指针next是final的。但是头指针却不是final的,这是通过getFirst(hash)方法返回,
也就是存在 table数组中的值。这使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,
另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。这是可以允许,通过对count变量的协调机制,
get能读取到几乎最新的数据,虽然可能不是最新的。要得到最新的数据,只有采用完全的同步。
最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。
这种情况应当很罕见,一旦发生这种情况,ConcurrentHashMap采取的方式是在持有锁的情况下再读一遍,
这能够保证读到最新的值,并且一定不会为空值。
Java代码
V readValueUnderLock(HashEntry<K,V> e) {
lock();
try {
return e.value;
} finally {
unlock();
}
}
所求的结点的值为空,什么情况才会发生呢?且readValueUnderLock(e)能生效呢?网上有两种比较常见的说法:
1)另外一条线程在修改节点
2)HashEntry构造函数中对value的赋值以及对tab[index]的赋值可能被重新排序,或许源出自http://www.javaeye.com/topic/344876
查看ConcurrentHashMap源码能看到put方法其实是创建一个新的节点并以之前的第一个节点为这个节点的next节点
(即这个新创建的节点为单向链表的第一个节点),然后
Java代码
tab[index] = new HashEntry<K,V>(key, hash, first, value);
执行赋值
remove方法则是重新把整条单向链表(除被删除Entry)全部拷贝一次执行赋值
Java代码
for (HashEntry<K,V> p = first; p != e; p = p.next)
newFirst = new HashEntry<K,V>(p.key, p.hash,
newFirst, p.value);
tab[index] = newFirst;
所有,既不是另外一条线程正在修改某节点,也不是重新排序。
最后追踪到源码的注释:
引用
This is possible only if a
compiler happens to reorder a HashEntry initialization with
its table assignment, which is legal under memory model
but is not known to ever occur.
原来还是Java内存模型的问题(Jmm),执行
Java代码
tab[index] = newFirst
或
Java代码
tab[index] = new HashEntry<K,V>(key, hash, first, value);
时,Entry 已经被认为初始化ok并返回给table(在堆中分派一块空间),这是正好有其他线程调用到get方法,
获取到这个刚“实例化好”的单向链表,但由于 JMM初始化的无序性,当你get这个链表Entry中的value 时,
他有可能还没完全初始化好!或按注释所说不知道发生过这样的初始化但这是合法的。。。。
这和为什么Java的单例模式不能使用双重检查加锁的原因类似,
都是由于Java内存模型(JMM)的无序性。这里有部分介绍http://rjx2008.javaeye.com/admin/blogs/342474
另一个操作是containsKey,这个实现就要简单得多了,因为它不需要读取值:
Java代码
boolean containsKey(Object key, int hash) {
if (count != 0) { // read-volatile
HashEntry<K,V> e = getFirst(hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key))
return true;
e = e.next;
}
}
return false;
}
跨段操作
有些操作需要涉及到多个段,比如说size(), containsValaue()。先来看下size()方法:
Java代码
public int size() {
final Segment<K,V>[] segments = this.segments;
long sum = 0;
long check = 0;
int[] mc = new int[segments.length];
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
check = 0;
sum = 0;
int mcsum = 0;
for (int i = 0; i < segments.length; ++i) {
sum += segments[i].count;
mcsum += mc[i] = segments[i].modCount;
}
if (mcsum != 0) {
for (int i = 0; i < segments.length; ++i) {
check += segments[i].count;
if (mc[i] != segments[i].modCount) {
check = -1; // force retry
break;
}
}
}
if (check == sum)
break;
}
if (check != sum) { // Resort to locking all segments
sum = 0;
for (int i = 0; i < segments.length; ++i)
segments.lock();
for (int i = 0; i < segments.length; ++i)
sum += segments.count;
for (int i = 0; i < segments.length; ++i)
segments.unlock();
}
if (sum > Integer.MAX_VALUE)
return Integer.MAX_VALUE;
else
return (int)sum;
}
size方法主要思路是先在没有锁的情况下对所有段大小求和,如果不能成功
(这是因为遍历过程中可能有其它线程正在对已经遍历过的段进行结构性更新),
最多执行RETRIES_BEFORE_LOCK次,如果还不成功就在持有所有段锁的情况下再对所有段大小求和。
在没有锁的情况下主要是利用 Segment中的modCount进行检测,在遍历过程中保存每个Segment的modCount,
遍历完成之后再检测每个Segment的 modCount有没有改变,如果有改变表示有其它线程正在对Segment进行结构性并发更新,需要重新计算。
其实这种方式是存在问题的,在第一个内层for循环中,在这两条语句sum += segments.count; mcsum += mc = segments.modCount;之间,
其它线程可能正在对Segment进行结构性的修改,导致segments.count和 segments.modCount读取的数据并不一致。
这可能使size()方法返回任何时候都不曾存在的大小,很奇怪javadoc居然没有明确标出这一点,可能是因为这个时间窗口太小了吧。
size()的实现还有一点需要注意,必须要先segments.count,才能 segments.modCount,这是因为segment.count是对volatile变量的访问,
接下来 segments.modCount才能得到几乎最新的值(前面我已经说了为什么只是“几乎”了)。
这点在containsValue方法中得到了淋漓尽致的展现:
关于size()的实现"必须要先segments.count,才能 segments.modCount"的原因:
写volatile变量和它之前的读写操作是不能reorder的,读volatile变量和它之后的读写操作也是不能reorder的。
修改 modCount发生在修改count之前,由于count是volatile变量,修改modCount不能和写count的操作reorder,读取 count和它之后的操作, 比如读取modCount,不能reorder。有了这两个不能reorder才能保证读取了count之后,能读到线程在写 count之前的写入的modCount值, 这个modCount值是几乎最新的。如果在读modCount之前不读count,读modCount甚至可能会reorder到写modCount之前。
用 reorder解释总是太复杂了,不如用happens-before来得简洁。当一个线程I对count的读时,它读到的值必定是另一个线程,
假设是线程II,最近对count的写。这个两个操作存在happens-before关系,即线程II对count的写happens-before线程I对 count的读,
记作:II:W(count) < I:R(count)。单线程的happens-before规则,又有II:W(modCount) < II:W(count)(查看源代码会发现在写count之前必定有写modCount),
以及 I:R(count) < I:R(modCount),根据传递规则有,II:W(modCount) < I:R(modCount),这就是说线程I至少能够读取到线程II写count之前的modCount值。
我曾经写了一篇关于happens-before的文章,有些表达可能有误,但大致还是对的,http://www.javaeye.com/topic/260515。
不理解的话,也只能告诉你结论了,如果没有对count的写的话(对volatile的写是一种同步操作), 读modCount可能读到很久很久很久以前的值(初始值0都有可能)。
Java代码
public boolean containsValue(Object value) {
if (value == null)
throw new NullPointerException();
// See explanation of modCount use above
final Segment<K,V>[] segments = this.segments;
int[] mc = new int[segments.length];
// Try a few times without locking
for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
int sum = 0;
int mcsum = 0;
for (int i = 0; i < segments.length; ++i) {
int c = segments[i].count;
mcsum += mc[i] = segments[i].modCount;
if (segments[i].containsValue(value))
return true;
}
boolean cleanSweep = true;
if (mcsum != 0) {
for (int i = 0; i < segments.length; ++i) {
int c = segments[i].count;
if (mc[i] != segments[i].modCount) {
cleanSweep = false;
break;
}
}
}
if (cleanSweep)
return false;
}
// Resort to locking all segments
for (int i = 0; i < segments.length; ++i)
segments.lock();
boolean found = false;
try {
for (int i = 0; i < segments.length; ++i) {
if (segments.containsValue(value)) {
found = true;
break;
}
}
} finally {
for (int i = 0; i < segments.length; ++i)
segments.unlock();
}
return found;
}
同样注意内层的第一个for循环,里面有语句int c = segments[i].count; 但是c却从来没有被使用过,即使如此,编译器也不能做优化将这条语句去掉,因为存在对volatile变量count的读取,这条语句存在的唯一目的就是保证segments.modCount读取到几乎最新的值。
关于containsValue方法的其它部分就不分析了,它和size方法差不多。
跨段方法中还有一个isEmpty()方法,其实现比size()方法还要简单,也不介绍了。
最后简单地介绍下迭代方法,如keySet(), values(), entrySet()方法,这些方法都返回相应的迭代器
,所有迭代器都继承于Hash_Iterator类,里实现了主要的方法。其结构是:
Java代码
abstract class HashIterator {
int nextSegmentIndex;
int nextTableIndex;
HashEntry<K,V>[] currentTable;
HashEntry<K, V> nextEntry;
HashEntry<K, V> lastReturned;
HashIterator() {
nextSegmentIndex = segments.length - 1;
nextTableIndex = -1;
advance();
}
public boolean hasMoreElements() { return hasNext(); }
final void advance() {
if (nextEntry != null && (nextEntry = nextEntry.next) != null)
return;
while (nextTableIndex >= 0) {
if ( (nextEntry = currentTable[nextTableIndex--]) != null)
return;
}
while (nextSegmentIndex >= 0) {
Segment<K,V> seg = segments[nextSegmentIndex--];
if (seg.count != 0) {
currentTable = seg.table;
for (int j = currentTable.length - 1; j >= 0; --j) {
if ( (nextEntry = currentTable[j]) != null) {
nextTableIndex = j - 1;
return;
}
}
}
}
}
public boolean hasNext() { return nextEntry != null; }
HashEntry<K,V> nextEntry() {
if (nextEntry == null)
throw new NoSuchElementException();
lastReturned = nextEntry;
advance();
return lastReturned;
}
public void remove() {
if (lastReturned == null)
throw new IllegalStateException();
ConcurrentHashMap.this.remove(lastReturned.key);
lastReturned = null;
}
}
nextSegmentIndex是段的索引,nextTableIndex是nextSegmentIndex对应段中中hash链的索引,
currentTable是nextSegmentIndex对应段的table。调用next方法时主要是调用了advance方法:
不想再多介绍了,唯一需要注意的是跳到下一个段时,一定要先读取下一个段的count变量。
这种迭代方式的主要效果是不会抛出ConcurrentModificationException。一旦获取到下一个段的table,
也就意味着这个段的头结点在迭代过程中就确定了,在迭代过程中就不能反映对这个段节点并发的删除和添加,
对于节点的更新是能够反映的,因为节点的值是一个 volatile变量。
结束语
ConcurrentHashMap是一个支持高并发的高性能的HashMap实现,它支持完全并发的读以及一定程度并发的写。
参考文章:
http://www.ibm.com/developerworks/java/library/j-jtp08223/,这个是讨论的是Doug Lea's util.concurrent包中的ConcurrentHashMap的实现,
不过大致思想是一致的。
http://floatingpoint.tinou.com/2008/09/performance-optimization-in-concurrenthashmap.html
http://www.javaeye.com/topic/344876?page=3