Java集合---ConcurrentHashMap原理分析
来源:互联网 发布:杭州5年经验程序员工资 编辑:程序博客网 时间:2024/06/06 11:41
1、ConcurrentHashMap原理分析
集合是编程中最常用的数据结构。而谈到并发,几乎总是离不开集合这类高级数据结构的支持。比如两个线程需要同时访问一个中间临界区(Queue),比如常会用缓存作为外部文件的副本(HashMap)。这篇文章主要分析jdk1.5的3种并发集合类型(concurrent,copyonright,queue)中的ConcurrentHashMap,让我们从原理上细致的了解它们,能够让我们在深度项目开发中获益非浅。
通过分析Hashtable就知道,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。
一、结构解析
ConcurrentHashMap和Hashtable主要区别就是围绕着锁的粒度以及如何锁,可以简单理解成把一个大的HashTable分解成多个,形成了锁分离。如图:
而Hashtable的实现方式是---锁整个hash表
二、应用场景
当有一个大数组时需要在多个线程共享时就可以考虑是否把它给分层多个节点了,避免大锁。并可以考虑通过hash算法进行一些模块定位。
其实不止用于线程,当设计数据表的事务时(事务某种意义上也是同步机制的体现),可以把一个表看成一个需要同步的数组,如果操作的表数据太多时就可以考虑事务分离了(这也是为什么要避免大表的出现),比如把数据进行字段拆分,水平分表等.
三、源码解读
ConcurrentHashMap中主要实体类就是三个:ConcurrentHashMap(整个Hash表),Segment(桶),HashEntry(节点),对应上面的图可以看出之间的关系
/** * 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. static final class HashEntry<K,V> { 2. final K key; 3. final int hash; 4. volatile V value; 5. final HashEntry<K,V> next; 6. }
可以看到除了value不是final的,其它值都是final的,这意味着不能从hash链的中间或尾部添加或删除节点,因为这需要修改next 引用值,所有的节点的修改只能从头部开始。对于put操作,可以一律添加到Hash链的头部。但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点。这在讲解删除操作时还会详述。为了确保读操作能够看到最新的值,将value设置成volatile,这避免了加锁。
其它
为了加快定位段以及段中hash槽的速度,每个段hash槽的的个数都是2^n,这使得通过位运算就可以定位段和段中hash槽的位置。当并发级别为默认值16时,也就是段的个数,hash值的高4位决定分配在哪个段中。但是我们也不要忘记《算法导论》给我们的教训:hash槽的的个数不应该是 2^n,这可能导致hash槽分配不均,这需要对hash值重新再hash一次。(这段似乎有点多余了 )
这是定位段的方法:
1. final Segment<K,V> segmentFor(int hash) { 2. return segments[(hash >>> segmentShift) & segmentMask]; 3. }
数据结构
关于Hash表的基础数据结构,这里不想做过多的探讨。Hash表的一个很重要方面就是如何解决hash冲突,ConcurrentHashMap 和HashMap使用相同的方式,都是将hash值相同的节点放在一个hash链中。与HashMap不同的是,ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。下面是ConcurrentHashMap的数据成员:
1. public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> 2. implements ConcurrentMap<K, V>, Serializable { 3. /** 4. * Mask value for indexing into segments. The upper bits of a 5. * key's hash code are used to choose the segment. 6. */ 7. final int segmentMask; 8. 9. /** 10. * Shift value for indexing within segments. 11. */ 12. final int segmentShift; 13. 14. /** 15. * The segments, each of which is a specialized hash table 16. */ 17. final Segment<K,V>[] segments; 18. }
所有的成员都是final的,其中segmentMask和segmentShift主要是为了定位段,参见上面的segmentFor方法。
每个Segment相当于一个子Hash表,它的数据成员如下:
1. static final class Segment<K,V> extends ReentrantLock implements Serializable { 2. private static final long serialVersionUID = 2249069246763182397L; 3. /** 4. * The number of elements in this segment's region. 5. */ 6. transient volatile int count; 7. 8. /** 9. * Number of updates that alter the size of the table. This is 10. * used during bulk-read methods to make sure they see a 11. * consistent snapshot: If modCounts change during a traversal 12. * of segments computing size or checking containsValue, then 13. * we might have an inconsistent view of state so (usually) 14. * must retry. 15. */ 16. transient int modCount; 17. 18. /** 19. * The table is rehashed when its size exceeds this threshold. 20. * (The value of this field is always <tt>(int)(capacity * 21. * loadFactor)</tt>.) 22. */ 23. transient int threshold; 24. 25. /** 26. * The per-segment table. 27. */ 28. transient volatile HashEntry<K,V>[] table; 29. 30. /** 31. * The load factor for the hash table. Even though this value 32. * is same for all segments, it is replicated to avoid needing 33. * links to outer object. 34. * @serial 35. */ 36. final float loadFactor; 37. }
count用来统计该段数据的个数,它是volatile(volatile 变量使用指南),它用来协调修改和读取操作,以保证读取操作能够读取到几乎最新的修改。协调方式是这样的,每次修改操作做了结构上的改变,如增加/删除节点(修改节点的值不算结构上的改变),都要写count值,每次读取操作开始都要读取count的值。这利用了 Java 5中对volatile语义的增强,对同一个volatile变量的写和读存在happens-before关系。modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变,在讲述跨段操作时会还会详述。threashold用来表示需要进行rehash的界限值。table数组存储段中节点,每个数组元素是个hash链,用HashEntry表示。table也是volatile,这使得能够读取到最新的 table值而不需要同步。loadFactor表示负载因子。
先来看下删除操作remove(key)。
1. public V remove(Object key) { 2. hash = hash(key.hashCode()); 3. return segmentFor(hash).remove(key, hash, null); 4. } 整个操作是先定位到段,然后委托给段的remove操作。当多个删除操作并发进行时,只要它们所在的段不相同,它们就可以同时进行。下面是Segment的remove方法实现:1. V remove(Object key, int hash, Object value) { 2. lock(); 3. try { 4. int c = count - 1; 5. HashEntry<K,V>[] tab = table; 6. int index = hash & (tab.length - 1); 7. HashEntry<K,V> first = tab[index]; 8. HashEntry<K,V> e = first; 9. while (e != null && (e.hash != hash || !key.equals(e.key))) 10. e = e.next; 11. 12. V oldValue = null; 13. if (e != null) { 14. V v = e.value; 15. if (value == null || value.equals(v)) { 16. oldValue = v; 17. // All entries following removed node can stay 18. // in list, but all preceding ones need to be 19. // cloned. 20. ++modCount; 21. HashEntry<K,V> newFirst = e.next; 22. *for (HashEntry<K,V> p = first; p != e; p = p.next) 23. *newFirst = new HashEntry<K,V>(p.key, p.hash, 24. newFirst, p.value); 25. tab[index] = newFirst; 26. count = c; // write-volatile 27. } 28. } 29. return oldValue; 30. } finally { 31. unlock(); 32. } 33. }
整个操作是在持有段锁的情况下执行的,空白行之前的行主要是定位到要删除的节点e。接下来,如果不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍,尾结点指向e的下一个结点。e后面的结点不需要复制,它们可以重用。
中间那个for循环是做什么用的呢?(*号标记)从代码来看,就是将定位之后的所有entry克隆并拼回前面去,但有必要吗?每次删除一个元素就要将那之前的元素克隆一遍?这点其实是由entry的不变性来决定的,仔细观察entry定义,发现除了value,其他所有属性都是用final来修饰的,这意味着在第一次设置了next域之后便不能再改变它,取而代之的是将它之前的节点全都克隆一次。至于entry为什么要设置为不变性,这跟不变性的访问不需要同步从而节省时间有关
下面是个示意图
删除元素之前:
删除元素3之后:
第二个图其实有点问题,复制的结点中应该是值为2的结点在前面,值为1的结点在后面,也就是刚好和原来结点顺序相反,还好这不影响我们的讨论。
整个remove实现并不复杂,但是需要注意如下几点。第一,当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改。第二,remove执行的开始就将table赋给一个局部变量tab,这是因为table是 volatile变量,读写volatile变量的开销很大。编译器也不能对volatile变量的读写做任何优化,直接多次访问非volatile实例变量没有多大影响,编译器会做相应优化。
接下来看put操作,同样地put操作也是委托给段的put方法。下面是段的put方法:
1. V put(K key, int hash, V value, boolean onlyIfAbsent) { 2. lock(); 3. try { 4. int c = count; 5. if (c++ > threshold) // ensure capacity 6. rehash(); 7. HashEntry<K,V>[] tab = table; 8. int index = hash & (tab.length - 1); 9. HashEntry<K,V> first = tab[index]; 10. HashEntry<K,V> e = first; 11. while (e != null && (e.hash != hash || !key.equals(e.key))) 12. e = e.next; 13. 14. V oldValue; 15. if (e != null) { 16. oldValue = e.value; 17. if (!onlyIfAbsent) 18. e.value = value; 19. } 20. else { 21. oldValue = null; 22. ++modCount; 23. tab[index] = new HashEntry<K,V>(key, hash, first, value); 24. count = c; // write-volatile 25. } 26. return oldValue; 27. } finally { 28. unlock(); 29. } 30. }
该方法也是在持有段锁(锁定整个segment)的情况下执行的,这当然是为了并发的安全,修改数据是不能并发进行的,必须得有个判断是否超限的语句以确保容量不足时能够rehash。接着是找是否存在同样一个key的结点,如果存在就直接替换这个结点的值。否则创建一个新的结点并添加到hash链的头部,这时一定要修改modCount和count的值,同样修改count的值一定要放在最后一步。put方法调用了rehash方法,reash方法实现得也很精巧,主要利用了table的大小为2^n,这里就不介绍了。而比较难懂的是这句int index = hash & (tab.length - 1),原来segment里面才是真正的hashtable,即每个segment是一个传统意义上的hashtable,如上图,从两者的结构就可以看出区别,这里就是找出需要的entry在table的哪一个位置,之后得到的entry就是这个链的第一个节点,如果e!=null,说明找到了,这是就要替换节点的值(onlyIfAbsent == false),否则,我们需要new一个entry,它的后继是first,而让tab[index]指向它,什么意思呢?实际上就是将这个新entry插入到链头,剩下的就非常容易理解了
修改操作还有putAll和replace。putAll就是多次调用put方法,没什么好说的。replace甚至不用做结构上的更改,实现要比put和delete要简单得多,理解了put和delete,理解replace就不在话下了,这里也不介绍了。
获取操作
首先看下get操作,同样ConcurrentHashMap的get操作是直接委托给Segment的get方法,直接看Segment的get方法:
1. V get(Object key, int hash) { 2. if (count != 0) { // read-volatile 当前桶的数据个数是否为0 3. HashEntry<K,V> e = getFirst(hash); 得到头节点4. while (e != null) { 5. if (e.hash == hash && key.equals(e.key)) { 6. V v = e.value; 7. if (v != null) 8. return v; 9. return readValueUnderLock(e); // recheck 10. } 11. e = e.next; 12. } 13. } 14. return null; 15. }
get操作不需要锁。第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count 变量,通过这种机制保证get操作能够得到几乎最新的结构更新。对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。接下来就是根据hash和key对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。对hash链进行遍历不需要加锁的原因在于链指针next是final的。但是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在 table数组中的值。这使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。这是可以允许,通过对count变量的协调机制,get能读取到几乎最新的数据,虽然可能不是最新的。要得到最新的数据,只有采用完全的同步。
最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。这似乎有些费解,理论上结点的值不可能为空,这是因为 put的时候就进行了判断,如果为空就要抛NullPointerException。空值的唯一源头就是HashEntry中的默认值,因为 HashEntry中的value不是final的,非同步读取有可能读取到空值。仔细看下put操作的语句:tab[index] = new HashEntry<K,V>(key, hash, first, value),在这条语句中,HashEntry构造函数中对value的赋值以及对tab[index]的赋值可能被重新排序,这就可能导致结点的值为空。这里当v为空时,可能是一个线程正在改变节点,而之前的get操作都未进行锁定,根据bernstein条件,读后写或写后读都会引起数据的不一致,所以这里要对这个e重新上锁再读一遍,以保证得到的是正确值。
1. V readValueUnderLock(HashEntry<K,V> e) { 2. lock(); 3. try { 4. return e.value; 5. } finally { 6. unlock(); 7. } 8. }
另一个操作是containsKey,这个实现就要简单得多了,因为它不需要读取值:
1. boolean containsKey(Object key, int hash) { 2. if (count != 0) { // read-volatile 3. HashEntry<K,V> e = getFirst(hash); 4. while (e != null) { 5. if (e.hash == hash && key.equals(e.key)) 6. return true; 7. e = e.next; 8. } 9. } 10. return false; 11. }
优秀博文:
ConcurrentHashMap
2、JDK8中的实现
ConcurrentHashMap在JDK8中进行了巨大改动,很需要通过源码来再次学习下Doug Lea的实现方法。
它摒弃了Segment(锁段)的概念,而是启用了一种全新的方式实现,利用CAS算法。它沿用了与它同时期的HashMap版本的思想,底层依然由“数组”+链表+红黑树的方式思想(JDK7与JDK8中HashMap的实现),但是为了做到并发,又增加了很多辅助的类,例如TreeBin,Traverser等对象内部类。
2.1 重要的属性
首先来看几个重要的属性,与HashMap相同的就不再介绍了,这里重点解释一下sizeCtl这个属性。可以说它是ConcurrentHashMap中出镜率很高的一个属性,因为它是一个控制标识符,在不同的地方有不同用途,而且它的取值不同,也代表不同的含义。
- 负数代表正在进行初始化或扩容操作
- -1代表正在初始化
- -N 表示有N-1个线程正在进行扩容操作
- 正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,这一点类似于扩容阈值的概念。还后面可以看到,它的值始终是当前ConcurrentHashMap容量的0.75倍,这与loadfactor是对应的。
/**
* 盛装Node元素的数组 它的大小是2的整数次幂
* Size is always a power of two. Accessed directly by iterators.
*/
transient
volatile
Node<K,V>[] table;
/**
* Table initialization and resizing control. When negative, the
* table is being initialized or resized: -1 for initialization,
* else -(1 + the number of active resizing threads). Otherwise,
* when table is null, holds the initial table size to use upon
* creation, or 0 for default. After initialization, holds the
* next element count value upon which to resize the table.
hash表初始化或扩容时的一个控制位标识量。
负数代表正在进行初始化或扩容操作
-1代表正在初始化
-N 表示有N-1个线程正在进行扩容操作
正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小
*/
private
transient
volatile
int
sizeCtl;
// 以下两个是用来控制扩容的时候 单线程进入的变量
/**
* The number of bits used for generation stamp in sizeCtl.
* Must be at least 6 for 32bit arrays.
*/
private
static
int
RESIZE_STAMP_BITS =
16
;
/**
* The bit shift for recording size stamp in sizeCtl.
*/
private
static
final
int
RESIZE_STAMP_SHIFT =
32
- RESIZE_STAMP_BITS;
/*
* Encodings for Node hash fields. See above for explanation.
*/
static
final
int
MOVED = -
1
;
// hash值是-1,表示这是一个forwardNode节点
static
final
int
TREEBIN = -
2
;
// hash值是-2 表示这时一个TreeBin节点
2.2 重要的类
2.2.1 Node
Node是最核心的内部类,它包装了key-value键值对,所有插入ConcurrentHashMap的数据都包装在这里面。它与HashMap中的定义很相似,但是但是有一些差别它对value和next属性设置了volatile同步锁(与JDK7的Segment相同),它不允许调用setValue方法直接改变Node的value域,它增加了find方法辅助map.get()方法。
2.2.2 TreeNode
树节点类,另外一个核心的数据结构。当链表长度过长的时候,会转换为TreeNode。但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。而且TreeNode在ConcurrentHashMap集成自Node类,而并非HashMap中的集成自LinkedHashMap.Entry<K,V>类,也就是说TreeNode带有next指针,这样做的目的是方便基于TreeBin的访问。
2.2.3 TreeBin
这个类并不负责包装用户的key、value信息,而是包装的很多TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。另外这个类还带有了读写锁。
这里仅贴出它的构造方法。可以看到在构造TreeBin节点时,仅仅指定了它的hash值为TREEBIN常量,这也就是个标识为。同时也看到我们熟悉的红黑树构造方法
2.2.4 ForwardingNode
一个用于连接两个table的节点类。它包含一个nextTable指针,用于指向下一张表。而且这个节点的key value next指针全部为null,它的hash值为-1. 这里面定义的find的方法是从nextTable里进行查询节点,而不是以自身为头节点进行查找。
/**
* A node inserted at head of bins during transfer operations.
*/
static
final
class
ForwardingNode<K,V>
extends
Node<K,V> {
final
Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super
(MOVED,
null
,
null
,
null
);
this
.nextTable = tab;
}
Node<K,V> find(
int
h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
outer:
for
(Node<K,V>[] tab = nextTable;;) {
Node<K,V> e;
int
n;
if
(k ==
null
|| tab ==
null
|| (n = tab.length) ==
0
||
(e = tabAt(tab, (n -
1
) & h)) ==
null
)
return
null
;
for
(;;) {
int
eh; K ek;
if
((eh = e.hash) == h &&
((ek = e.key) == k || (ek !=
null
&& k.equals(ek))))
return
e;
if
(eh <
0
) {
if
(e
instanceof
ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue
outer;
}
else
return
e.find(h, k);
}
if
((e = e.next) ==
null
)
return
null
;
}
}
}
}
2.3 Unsafe与CAS
在ConcurrentHashMap中,随处可以看到U, 大量使用了U.compareAndSwapXXX的方法,这个方法是利用一个CAS算法实现无锁化的修改值的操作,他可以大大降低锁代理的性能消耗。这个算法的基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。这一点与乐观锁,SVN的思想是比较类似的。
2.3.1 unsafe静态块
unsafe代码块控制了一些属性的修改工作,比如最常用的SIZECTL 。在这一版本的concurrentHashMap中,大量应用来的CAS方法进行变量、属性的修改工作。利用CAS进行无锁操作,可以大大提高性能。
private
static
final
sun.misc.Unsafe U;
private
static
final
long
SIZECTL;
private
static
final
long
TRANSFERINDEX;
private
static
final
long
BASECOUNT;
private
static
final
long
CELLSBUSY;
private
static
final
long
CELLVALUE;
private
static
final
long
ABASE;
private
static
final
int
ASHIFT;
static
{
try
{
U = sun.misc.Unsafe.getUnsafe();
Class<?> k = ConcurrentHashMap.
class
;
SIZECTL = U.objectFieldOffset
(k.getDeclaredField(
"sizeCtl"
));
TRANSFERINDEX = U.objectFieldOffset
(k.getDeclaredField(
"transferIndex"
));
BASECOUNT = U.objectFieldOffset
(k.getDeclaredField(
"baseCount"
));
CELLSBUSY = U.objectFieldOffset
(k.getDeclaredField(
"cellsBusy"
));
Class<?> ck = CounterCell.
class
;
CELLVALUE = U.objectFieldOffset
(ck.getDeclaredField(
"value"
));
Class<?> ak = Node[].
class
;
ABASE = U.arrayBaseOffset(ak);
int
scale = U.arrayIndexScale(ak);
if
((scale & (scale -
1
)) !=
0
)
throw
new
Error(
"data type scale not a power of two"
);
ASHIFT =
31
- Integer.numberOfLeadingZeros(scale);
}
catch
(Exception e) {
throw
new
Error(e);
}
}
2.3.2 三个核心方法
ConcurrentHashMap定义了三个原子操作,用于对指定位置的节点进行操作。正是这些原子操作保证了ConcurrentHashMap的线程安全。
//获得在i位置上的Node节点
static
final
<K,V> Node<K,V> tabAt(Node<K,V>[] tab,
int
i) {
return
(Node<K,V>)U.getObjectVolatile(tab, ((
long
)i << ASHIFT) + ABASE);
}
//利用CAS算法设置i位置上的Node节点。之所以能实现并发是因为他指定了原来这个节点的值是多少
//在CAS算法中,会比较内存中的值与你指定的这个值是否相等,如果相等才接受你的修改,否则拒绝你的修改
//因此当前线程中的值并不是最新的值,这种修改可能会覆盖掉其他线程的修改结果 有点类似于SVN
static
final
<K,V>
boolean
casTabAt(Node<K,V>[] tab,
int
i,
Node<K,V> c, Node<K,V> v) {
return
U.compareAndSwapObject(tab, ((
long
)i << ASHIFT) + ABASE, c, v);
}
//利用volatile方法设置节点位置的值
static
final
<K,V>
void
setTabAt(Node<K,V>[] tab,
int
i, Node<K,V> v) {
U.putObjectVolatile(tab, ((
long
)i << ASHIFT) + ABASE, v);
}
2.4 初始化方法initTable
对于ConcurrentHashMap来说,调用它的构造方法仅仅是设置了一些参数而已。而整个table的初始化是在向ConcurrentHashMap中插入元素的时候发生的。如调用put、computeIfAbsent、compute、merge等方法的时候,调用时机是检查table==null。
初始化方法主要应用了关键属性sizeCtl 如果这个值〈0,表示其他线程正在进行初始化,就放弃这个操作。在这也可以看出ConcurrentHashMap的初始化只能由一个线程完成。如果获得了初始化权限,就用CAS方法将sizeCtl置为-1,防止其他线程进入。初始化数组后,将sizeCtl的值改为0.75*n。
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private
final
Node<K,V>[] initTable() {
Node<K,V>[] tab;
int
sc;
while
((tab = table) ==
null
|| tab.length ==
0
) {
//sizeCtl表示有其他线程正在进行初始化操作,把线程挂起。对于table的初始化工作,只能有一个线程在进行。
if
((sc = sizeCtl) <
0
)
Thread.yield();
// lost initialization race; just spin
else
if
(U.compareAndSwapInt(
this
, SIZECTL, sc, -
1
)) {
//利用CAS方法把sizectl的值置为-1 表示本线程正在进行初始化
try
{
if
((tab = table) ==
null
|| tab.length ==
0
) {
int
n = (sc >
0
) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings
(
"unchecked"
)
Node<K,V>[] nt = (Node<K,V>[])
new
Node<?,?>[n];
table = tab = nt;
sc = n - (n >>>
2
);
//相当于0.75*n 设置一个扩容的阈值
}
}
finally
{
sizeCtl = sc;
}
break
;
}
}
return
tab;
}
2.5 扩容方法 transfer
当ConcurrentHashMap容量不足的时候,需要对table进行扩容。这个方法的基本思想跟HashMap是很像的,但是由于它是支持并发扩容的,所以要复杂的多。原因是它支持多线程进行扩容操作,而并没有加锁。我想这样做的目的不仅仅是为了满足concurrent的要求,而是希望利用并发处理去减少扩容带来的时间影响。因为在扩容的时候,总是会涉及到从一个“数组”到另一个“数组”拷贝的操作,如果这个操作能够并发进行,那真真是极好的了。
整个扩容操作分为两个部分
- 第一部分是构建一个nextTable,它的容量是原来的两倍,这个操作是单线程完成的。这个单线程的保证是通过RESIZE_STAMP_SHIFT这个常量经过一次运算来保证的,这个地方在后面会有提到;
- 第二个部分就是将原来table中的元素复制到nextTable中,这里允许多线程进行操作。
先来看一下单线程是如何完成的:
它的大体思想就是遍历、复制的过程。首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素:
- 如果这个位置为空,就在原table中的i位置放入forwardNode节点,这个也是触发并发扩容的关键点;
- 如果这个位置是Node节点(fh>=0),如果它是一个链表的头节点,就构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上
- 如果这个位置是TreeBin节点(fh<0),也做一个反序处理,并且判断是否需要untreefi,把处理的结果分别放在nextTable的i和i+n的位置上
- 遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为新容量的0.75倍 ,完成扩容。
再看一下多线程是如何完成的:
在代码的69行有一个判断,如果遍历到的节点是forward节点,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。多线程遍历节点,处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后遍历。这样交叉就完成了复制工作。而且还很好的解决了线程安全的问题。 这个方法的设计实在是让我膜拜。
/**
* 一个过渡的table表 只有在扩容的时候才会使用
*/
private
transient
volatile
Node<K,V>[] nextTable;
/**
* Moves and/or copies the nodes in each bin to new table. See
* above for explanation.
*/
private
final
void
transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int
n = tab.length, stride;
if
((stride = (NCPU >
1
) ? (n >>>
3
) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
// subdivide range
if
(nextTab ==
null
) {
// initiating
try
{
@SuppressWarnings
(
"unchecked"
)
Node<K,V>[] nt = (Node<K,V>[])
new
Node<?,?>[n <<
1
];
//构造一个nextTable对象 它的容量是原来的两倍
nextTab = nt;
}
catch
(Throwable ex) {
// try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return
;
}
nextTable = nextTab;
transferIndex = n;
}
int
nextn = nextTab.length;
ForwardingNode<K,V> fwd =
new
ForwardingNode<K,V>(nextTab);
//构造一个连节点指针 用于标志位
boolean
advance =
true
;
//并发扩容的关键属性 如果等于true 说明这个节点已经处理过
boolean
finishing =
false
;
// to ensure sweep before committing nextTab
for
(
int
i =
0
, bound =
0
;;) {
Node<K,V> f;
int
fh;
//这个while循环体的作用就是在控制i-- 通过i--可以依次遍历原hash表中的节点
while
(advance) {
int
nextIndex, nextBound;
if
(--i >= bound || finishing)
advance =
false
;
else
if
((nextIndex = transferIndex) <=
0
) {
i = -
1
;
advance =
false
;
}
else
if
(U.compareAndSwapInt
(
this
, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride :
0
))) {
bound = nextBound;
i = nextIndex -
1
;
advance =
false
;
}
}
if
(i <
0
|| i >= n || i + n >= nextn) {
int
sc;
if
(finishing) {
//如果所有的节点都已经完成复制工作 就把nextTable赋值给table 清空临时对象nextTable
nextTable =
null
;
table = nextTab;
sizeCtl = (n <<
1
) - (n >>>
1
);
//扩容阈值设置为原来容量的1.5倍 依然相当于现在容量的0.75倍
return
;
}
//利用CAS方法更新这个扩容阈值,在这里面sizectl值减一,说明新加入一个线程参与到扩容操作
if
(U.compareAndSwapInt(
this
, SIZECTL, sc = sizeCtl, sc -
1
)) {
if
((sc -
2
) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return
;
finishing = advance =
true
;
i = n;
// recheck before commit
}
}
//如果遍历到的节点为空 则放入ForwardingNode指针
else
if
((f = tabAt(tab, i)) ==
null
)
advance = casTabAt(tab, i,
null
, fwd);
//如果遍历到ForwardingNode节点 说明这个点已经被处理过了 直接跳过 这里是控制并发扩容的核心
else
if
((fh = f.hash) == MOVED)
advance =
true
;
// already processed
else
{
//节点上锁
synchronized
(f) {
if
(tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//如果fh>=0 证明这是一个Node节点
if
(fh >=
0
) {
int
runBit = fh & n;
//以下的部分在完成的工作是构造两个链表 一个是原链表 另一个是原链表的反序排列
Node<K,V> lastRun = f;
for
(Node<K,V> p = f.next; p !=
null
; p = p.next) {
int
b = p.hash & n;
if
(b != runBit) {
runBit = b;
lastRun = p;
}
}
if
(runBit ==
0
) {
ln = lastRun;
hn =
null
;
}
else
{
hn = lastRun;
ln =
null
;
}
for
(Node<K,V> p = f; p != lastRun; p = p.next) {
int
ph = p.hash; K pk = p.key; V pv = p.val;
if
((ph & n) ==
0
)
ln =
new
Node<K,V>(ph, pk, pv, ln);
else
hn =
new
Node<K,V>(ph, pk, pv, hn);
}
//在nextTable的i位置上插入一个链表
setTabAt(nextTab, i, ln);
//在nextTable的i+n的位置上插入另一个链表
setTabAt(nextTab, i + n, hn);
//在table的i位置上插入forwardNode节点 表示已经处理过该节点
setTabAt(tab, i, fwd);
//设置advance为true 返回到上面的while循环中 就可以执行i--操作
advance =
true
;
}
//对TreeBin对象进行处理 与上面的过程类似
else
if
(f
instanceof
TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo =
null
, loTail =
null
;
TreeNode<K,V> hi =
null
, hiTail =
null
;
int
lc =
0
, hc =
0
;
//构造正序和反序两个链表
for
(Node<K,V> e = t.first; e !=
null
; e = e.next) {
int
h = e.hash;
TreeNode<K,V> p =
new
TreeNode<K,V>
(h, e.key, e.val,
null
,
null
);
if
((h & n) ==
0
) {
if
((p.prev = loTail) ==
null
)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else
{
if
((p.prev = hiTail) ==
null
)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
//如果扩容后已经不再需要tree的结构 反向转换为链表结构
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc !=
0
) ?
new
TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc !=
0
) ?
new
TreeBin<K,V>(hi) : t;
//在nextTable的i位置上插入一个链表
setTabAt(nextTab, i, ln);
//在nextTable的i+n的位置上插入另一个链表
setTabAt(nextTab, i + n, hn);
//在table的i位置上插入forwardNode节点 表示已经处理过该节点
setTabAt(tab, i, fwd);
//设置advance为true 返回到上面的while循环中 就可以执行i--操作
advance =
true
;
}
}
}
}
}
}
2.6 Put方法
前面的所有的介绍其实都为这个方法做铺垫。ConcurrentHashMap最常用的就是put和get两个方法。现在来介绍put方法,这个put方法依然沿用HashMap的put方法的思想,根据hash值计算这个新插入的点在table中的位置i,如果i位置是空的,直接放进去,否则进行判断,如果i位置是树节点,按照树的方式插入新的节点,否则把i插入到链表的末尾。ConcurrentHashMap中依然沿用这个思想,有一个最重要的不同点就是ConcurrentHashMap不允许key或value为null值。另外由于涉及到多线程,put方法就要复杂一点。在多线程中可能有以下两个情况
- 如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容;
- 如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable的synchronized要好得多。
整体流程就是首先定义不允许key或value为null的情况放入 对于每一个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来确定这个值在table中的位置。
如果这个位置是空的,那么直接放入,而且不需要加锁操作。
如果这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。如果是链表节点(fh>0),则得到的结点就是hash值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到hash值与key值都与新加入节点是一致的情况,则只需要更新value值即可。否则依次向后遍历,直到链表尾插入这个结点。如果加入这个节点以后链表长度大于8,就把这个链表转换成红黑树。如果这个节点的类型已经是树节点的话,直接调用树节点的插入方法进行插入新的值。
public
V put(K key, V value) {
return
putVal(key, value,
false
);
}
/** Implementation for put and putIfAbsent */
final
V putVal(K key, V value,
boolean
onlyIfAbsent) {
//不允许 key或value为null
if
(key ==
null
|| value ==
null
)
throw
new
NullPointerException();
//计算hash值
int
hash = spread(key.hashCode());
int
binCount =
0
;
//死循环 何时插入成功 何时跳出
for
(Node<K,V>[] tab = table;;) {
Node<K,V> f;
int
n, i, fh;
//如果table为空的话,初始化table
if
(tab ==
null
|| (n = tab.length) ==
0
)
tab = initTable();
//根据hash值计算出在table里面的位置
else
if
((f = tabAt(tab, i = (n -
1
) & hash)) ==
null
) {
//如果这个位置没有值 ,直接放进去,不需要加锁
if
(casTabAt(tab, i,
null
,
new
Node<K,V>(hash, key, value,
null
)))
break
;
// no lock when adding to empty bin
}
//当遇到表连接点时,需要进行整合表的操作
else
if
((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else
{
V oldVal =
null
;
//结点上锁 这里的结点可以理解为hash值相同组成的链表的头结点
synchronized
(f) {
if
(tabAt(tab, i) == f) {
//fh〉0 说明这个节点是一个链表的节点 不是树的节点
if
(fh >=
0
) {
binCount =
1
;
//在这里遍历链表所有的结点
for
(Node<K,V> e = f;; ++binCount) {
K ek;
//如果hash值和key值相同 则修改对应结点的value值
if
(e.hash == hash &&
((ek = e.key) == key ||
(ek !=
null
&& key.equals(ek)))) {
oldVal = e.val;
if
(!onlyIfAbsent)
e.val = value;
break
;
}
Node<K,V> pred = e;
//如果遍历到了最后一个结点,那么就证明新的节点需要插入 就把它插入在链表尾部
if
((e = e.next) ==
null
) {
pred.next =
new
Node<K,V>(hash, key,
value,
null
);
break
;
}
}
}
//如果这个节点是树节点,就按照树的方式插入值
else
if
(f
instanceof
TreeBin) {
Node<K,V> p;
binCount =
2
;
if
((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) !=
null
) {
oldVal = p.val;
if
(!onlyIfAbsent)
p.val = value;
}
}
}
}
if
(binCount !=
0
) {
//如果链表长度已经达到临界值8 就需要把链表转换为树结构
if
(binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if
(oldVal !=
null
)
return
oldVal;
break
;
}
}
}
//将当前ConcurrentHashMap的元素数量+1
addCount(1L, binCount);
return
null
;
}
我们可以发现JDK8中的实现也是锁分离的思想,只是锁住的是一个Node,而不是JDK7中的Segment,而锁住Node之前的操作是无锁的并且也是线程安全的,建立在之前提到的3个原子操作上。
2.6.1 helpTransfer方法
这是一个协助扩容的方法。这个方法被调用的时候,当前ConcurrentHashMap一定已经有了nextTable对象,首先拿到这个nextTable对象,调用transfer方法。回看上面的transfer方法可以看到,当本线程进入扩容方法的时候会直接进入复制阶段。
2.6.2 treeifyBin方法
这个方法用于将过长的链表转换为TreeBin对象。但是他并不是直接转换,而是进行一次容量判断,如果容量没有达到转换的要求,直接进行扩容操作并返回;如果满足条件才链表的结构抓换为TreeBin ,这与HashMap不同的是,它并没有把TreeNode直接放入红黑树,而是利用了TreeBin这个小容器来封装所有的TreeNode.
2.7 get方法
get方法比较简单,给定一个key来确定value的时候,必须满足两个条件 key相同 hash值相同,对于节点可能在链表或树上的情况,需要分别去查找。
public
V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p;
int
n, eh; K ek;
//计算hash值
int
h = spread(key.hashCode());
//根据hash值确定节点位置
if
((tab = table) !=
null
&& (n = tab.length) >
0
&&
(e = tabAt(tab, (n -
1
) & h)) !=
null
) {
//如果搜索到的节点key与传入的key相同且不为null,直接返回这个节点
if
((eh = e.hash) == h) {
if
((ek = e.key) == key || (ek !=
null
&& key.equals(ek)))
return
e.val;
}
//如果eh<0 说明这个节点在树上 直接寻找
else
if
(eh <
0
)
return
(p = e.find(h, key)) !=
null
? p.val :
null
;
//否则遍历链表 找到对应的值并返回
while
((e = e.next) !=
null
) {
if
(e.hash == h &&
((ek = e.key) == key || (ek !=
null
&& key.equals(ek))))
return
e.val;
}
}
return
null
;
}
2.8 Size相关的方法
对于ConcurrentHashMap来说,这个table里到底装了多少东西其实是个不确定的数量,因为不可能在调用size()方法的时候像GC的“stop the world”一样让其他线程都停下来让你去统计,因此只能说这个数量是个估计值。对于这个估计值,ConcurrentHashMap也是大费周章才计算出来的。
2.8.1 辅助定义
为了统计元素个数,ConcurrentHashMap定义了一些变量和一个内部类
/**
* A padded cell for distributing counts. Adapted from LongAdder
* and Striped64. See their internal docs for explanation.
*/
@sun
.misc.Contended
static
final
class
CounterCell {
volatile
long
value;
CounterCell(
long
x) { value = x; }
}
/******************************************/
/**
* 实际上保存的是hashmap中的元素个数 利用CAS锁进行更新
但它并不用返回当前hashmap的元素个数
*/
private
transient
volatile
long
baseCount;
/**
* Spinlock (locked via CAS) used when resizing and/or creating CounterCells.
*/
private
transient
volatile
int
cellsBusy;
/**
* Table of counter cells. When non-null, size is a power of 2.
*/
private
transient
volatile
CounterCell[] counterCells;
2.8.2 mappingCount与Size方法
mappingCount与size方法的类似 从Java工程师给出的注释来看,应该使用mappingCount代替size方法 两个方法都没有直接返回basecount 而是统计一次这个值,而这个值其实也是一个大概的数值,因此可能在统计的时候有其他线程正在执行插入或删除操作。
public
int
size() {
long
n = sumCount();
return
((n < 0L) ?
0
:
(n > (
long
)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(
int
)n);
}
/**
* Returns the number of mappings. This method should be used
* instead of {@link #size} because a ConcurrentHashMap may
* contain more mappings than can be represented as an int. The
* value returned is an estimate; the actual count may differ if
* there are concurrent insertions or removals.
*
* @return the number of mappings
* @since 1.8
*/
public
long
mappingCount() {
long
n = sumCount();
return
(n < 0L) ? 0L : n;
// ignore transient negative values
}
final
long
sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long
sum = baseCount;
if
(as !=
null
) {
for
(
int
i =
0
; i < as.length; ++i) {
if
((a = as[i]) !=
null
)
sum += a.value;
//所有counter的值求和
}
}
return
sum;
}
2.8.3 addCount方法
在put方法结尾处调用了addCount方法,把当前ConcurrentHashMap的元素个数+1这个方法一共做了两件事,更新baseCount的值,检测是否进行扩容。
private
final
void
addCount(
long
x,
int
check) {
CounterCell[] as;
long
b, s;
//利用CAS方法更新baseCount的值
if
((as = counterCells) !=
null
||
!U.compareAndSwapLong(
this
, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a;
long
v;
int
m;
boolean
uncontended =
true
;
if
(as ==
null
|| (m = as.length -
1
) <
0
||
(a = as[ThreadLocalRandom.getProbe() & m]) ==
null
||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return
;
}
if
(check <=
1
)
return
;
s = sumCount();
}
//如果check值大于等于0 则需要检验是否需要进行扩容操作
if
(check >=
0
) {
Node<K,V>[] tab, nt;
int
n, sc;
while
(s >= (
long
)(sc = sizeCtl) && (tab = table) !=
null
&&
(n = tab.length) < MAXIMUM_CAPACITY) {
int
rs = resizeStamp(n);
//
if
(sc <
0
) {
if
((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs +
1
||
sc == rs + MAX_RESIZERS || (nt = nextTable) ==
null
||
transferIndex <=
0
)
break
;
//如果已经有其他线程在执行扩容操作
if
(U.compareAndSwapInt(
this
, SIZECTL, sc, sc +
1
))
transfer(tab, nt);
}
//当前线程是唯一的或是第一个发起扩容的线程 此时nextTable=null
else
if
(U.compareAndSwapInt(
this
, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) +
2
))
transfer(tab,
null
);
s = sumCount();
}
}
}
总结
JDK6,7中的ConcurrentHashmap主要使用Segment来实现减小锁粒度,把HashMap分割成若干个Segment,在put的时候需要锁住Segment,get时候不加锁,使用volatile来保证可见性,当要统计全局时(比如size),首先会尝试多次计算modcount来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回size。如果有,则需要依次锁住所有的Segment来计算。
jdk7中ConcurrentHashmap中,当长度过长碰撞会很频繁,链表的增改删查操作都会消耗很长的时间,影响性能,所以jdk8 中完全重写了concurrentHashmap,代码量从原来的1000多行变成了 6000多 行,实现上也和原来的分段式存储有很大的区别。
主要设计上的变化有以下几点:
- 不采用segment而采用node,锁住node来实现减小锁粒度。
- 设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize。
- 使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁。
- sizeCtl的不同值来代表不同含义,起到了控制的作用。
至于为什么JDK8中使用synchronized而不是ReentrantLock,我猜是因为JDK8中对synchronized有了足够的优化吧。
Reference:
1. http://www.jianshu.com/p/4806633fcc55
2. https://www.zhihu.com/question/22438589
3. http://blog.csdn.net/u010723709/article/details/48007881
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- Java集合---ConcurrentHashMap原理分析
- SpringMVC如何区分返回页面和返回数据?@ResponseBody注解
- C/C++ 工程师能力评估试题
- 在trafodion数据库查询mysql数据库的表和oracle的表
- HDU 4417 线段树离线查询
- docker存储结构解析
- Java集合---ConcurrentHashMap原理分析
- Python标准模块--ContextManager
- 七层登陆
- 通过time和strftime函数获取当前年月日
- 最短路
- JavaScript是一种弱类型语言,有什么优点和缺点
- 算法学习之旅,中级篇(8)-–分治之二分搜索(递归)
- POJ 2449 A*K短路
- 数组中两个只出现一次的数字