ConcurrentHashMap原理分析

HashTable是一个线程安全的类,它使用synchronized来锁住整张Hash表来实现线程安全,即每次锁住整张表让线程独占。ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的Hashtable,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。

有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。

1. 实现原理

ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。如下图是ConcurrentHashMap的内部结构图:

 

 

从图中可以看到,ConcurrentHashMap内部分为很多个Segment,每一个Segment拥有一把锁,然后每个Segment(继承ReentrantLock)

static final class Segment<K,V> extends ReentrantLock implements Serializable

 

Segment继承了ReentrantLock,表明每个segment都可以当做一个锁。(ReentrantLock前文已经提到,不了解的话就把当做synchronized的替代者吧)这样对每个segment中的数据需要同步操作的话都是使用每个segment容器对象自身的锁来实现。只有对全局需要改变时锁定的是所有的segment。

Segment下面包含很多个HashEntry列表数组。对于一个key,需要经过三次(为什么要hash三次下文会详细讲解)hash操作,才能最终定位这个元素的位置,这三次hash分别为:

  1. 对于一个key,先进行一次hash操作,得到hash值h1,也即h1 = hash1(key);
  2. 将得到的h1的高几位进行第二次hash,得到hash值h2,也即h2 = hash2(h1高几位),通过h2能够确定该元素的放在哪个Segment;
  3. 将得到的h1进行第三次hash,得到hash值h3,也即h3 = hash3(h1),通过h3能够确定该元素放置在哪个HashEntry。

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      volatile HashEntry<K,V> next;  
6  }
复制代码

 

在JDK 1.6中,HashEntry中的next指针也定义为final,并且每次插入将新添加节点作为链的头节点(同HashMap实现),而且每次删除一个节点时,会将删除节点之前的所有节点 拷贝一份组成一个新的链,而将当前节点的上一个节点的next指向当前节点的下一个节点,从而在删除以后 有两条链存在,因而可以保证即使在同一条链中,有一个线程在删除,而另一个线程在遍历,它们都能工作良好,因为遍历的线程能继续使用原有的链。因而这种实现是一种更加细粒度的happens-before关系,即如果遍历线程在删除线程结束后开始,则它能看到删除后的变化,如果它发生在删除线程正在执行中间,则它会使用原有的链,而不会等到删除线程结束后再执行,即看不到删除线程的影响。如果这不符合你的需求,还是乖乖的用Hashtable或HashMap的synchronized版本,Collections.synchronizedMap()做的包装。

而HashMap中的Entry只有key是final的

复制代码
1 static class Entry<K,V> implements Map.Entry<K,V> {
2         final K key;
3         V value;
4         Entry<K,V> next;
5         int hash;
复制代码

 

不变模式(immutable)是多线程安全里最简单的一种保障方式。因为你拿他没有办法,想改变它也没有机会。
不变模式主要通过final关键字来限定的。在JMM中final关键字还有特殊的语义。Final域使得确保初始化安全性(initialization safety)成为可能,初始化安全性让不可变形对象不需要同步就能自由地被访问和共享。

1.1 初始化

先看看ConcurrentHashMap的初始化做了哪些事情,构造函数的源码如下:

复制代码
 1 public ConcurrentHashMap(int initialCapacity,
 2                              float loadFactor, int concurrencyLevel) {
 3         if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
 4             throw new IllegalArgumentException();
 5         if (concurrencyLevel > MAX_SEGMENTS)
 6             concurrencyLevel = MAX_SEGMENTS;
 7         // Find power-of-two sizes best matching arguments
 8         int sshift = 0;
 9         int ssize = 1;
10         while (ssize < concurrencyLevel) {
11             ++sshift;
12             ssize <<= 1;
13         }
14         this.segmentShift = 32 - sshift;
15         this.segmentMask = ssize - 1;
16         if (initialCapacity > MAXIMUM_CAPACITY)
17             initialCapacity = MAXIMUM_CAPACITY;
18         int c = initialCapacity / ssize;
19         if (c * ssize < initialCapacity)
20             ++c;
21         int cap = MIN_SEGMENT_TABLE_CAPACITY;
22         while (cap < c)
23             cap <<= 1;
24         // create segments and segments[0]
25         Segment<K,V> s0 =
26             new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
27                              (HashEntry<K,V>[])new HashEntry[cap]);
28         Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
29         UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
30         this.segments = ss;
31     }
复制代码

 

传入的参数有initialCapacity,loadFactor,concurrencyLevel这三个。

  • initialCapacity表示新创建的这个ConcurrentHashMap的初始容量,也就是上面的结构图中的Entry数量。默认值为static final int DEFAULT_INITIAL_CAPACITY = 16;
  • loadFactor表示负载因子,就是当ConcurrentHashMap中的元素个数大于loadFactor * 最大容量时就需要rehash,扩容。默认值为static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • concurrencyLevel表示并发级别,这个值用来确定Segment的个数,Segment的个数是大于等于concurrencyLevel的第一个2的n次方的数。比如,如果concurrencyLevel为12,13,14,15,16这些数,则Segment的数目为16(2的4次方)。默认值为static final int DEFAULT_CONCURRENCY_LEVEL = 16;。理想情况下ConcurrentHashMap的真正的并发访问量能够达到concurrencyLevel,因为有concurrencyLevel个Segment,假如有concurrencyLevel个线程需要访问Map,并且需要访问的数据都恰好分别落在不同的Segment中,则这些线程能够无竞争地自由访问(因为他们不需要竞争同一把锁),达到同时访问的效果。这也是为什么这个参数起名为“并发级别”的原因。

初始化的一些动作:

  1. 验证参数的合法性,如果不合法,直接抛出异常。
  2. concurrencyLevel也就是Segment的个数不能超过规定的最大Segment的个数,默认值为static final int MAX_SEGMENTS = 1 << 16;,如果超过这个值,设置为这个值。
  3. 然后使用循环找到大于等于concurrencyLevel的第一个2的n次方的数ssize,这个数就是Segment数组的大小,并记录一共向左按位移动的次数sshift,并令segmentShift = 32 - sshift,并且segmentMask的值等于ssize - 1,segmentMask的各个二进制位都为1,目的是之后可以通过key的hash值与这个值做&运算确定Segment的索引。
  4. 检查给的容量值是否大于允许的最大容量值,如果大于该值,设置为该值。最大容量值为static final int MAXIMUM_CAPACITY = 1 << 30;。
  5. 然后计算每个Segment平均应该放置多少个元素,这个值c是向上取整的值。比如初始容量为15,Segment个数为4,则每个Segment平均需要放置4个元素。
  6. 最后创建一个Segment实例,将其当做Segment数组的第一个元素。

1.2 put操作

put操作的源码如下:

复制代码
 1 public V put(K key, V value) {
 2       Segment<K,V> s;
 3       if (value == null)
 4           throw new NullPointerException();
 5       int hash = hash(key);
 6       int j = (hash >>> segmentShift) & segmentMask;
 7       if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
 8            (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
 9           s = ensureSegment(j);
10       return s.put(key, hash, value, false);
11   }
复制代码

 

操作步骤如下:

  1. 判断value是否为null,如果为null,直接抛出异常。
  2. key通过一次hash运算得到一个hash值。(这个hash运算下文详说)
  3. 将得到hash值向右按位移动segmentShift位,然后再与segmentMask做&运算得到segment的索引j。
    在初始化的时候我们说过segmentShift的值等于32-sshift,例如concurrencyLevel等于16,则sshift等于4,则segmentShift为28。hash值是一个32位的整数,将其向右移动28位就变成这个样子:
    0000 0000 0000 0000 0000 0000 0000 xxxx,然后再用这个值与segmentMask做&运算,也就是取最后四位的值。这个值确定Segment的索引。
  4. 使用Unsafe的方式从Segment数组中获取该索引对应的Segment对象。
  5. 向这个Segment对象中put值,这个put操作也基本是一样的步骤(通过&运算获取HashEntry的索引,然后set)。
复制代码
 1 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
 2             HashEntry<K,V> node = tryLock() ? null :
 3                 scanAndLockForPut(key, hash, value);
 4             V oldValue;
 5             try {
 6                 HashEntry<K,V>[] tab = table;
 7                 int index = (tab.length - 1) & hash;
 8                 HashEntry<K,V> first = entryAt(tab, index);
 9                 for (HashEntry<K,V> e = first;;) {
10                     if (e != null) {
11                         K k;
12                         if ((k = e.key) == key ||
13                             (e.hash == hash && key.equals(k))) {
14                             oldValue = e.value;
15                             if (!onlyIfAbsent) {
16                                 e.value = value;
17                                 ++modCount;
18                             }
19                             break;
20                         }
21                         e = e.next;
22                     }
23                     else {
24                         if (node != null)
25                             node.setNext(first);
26                         else
27                             node = new HashEntry<K,V>(hash, key, value, first);
28                         int c = count + 1;
29                         if (c > threshold && tab.length < MAXIMUM_CAPACITY)
30                             rehash(node);
31                         else
32                             setEntryAt(tab, index, node);
33                         ++modCount;
34                         count = c;
35                         oldValue = null;
36                         break;
37                     }
38                 }
39             } finally {
40                 unlock();
41             }
42             return oldValue;
43         }
复制代码

 

put操作是要加锁的。

1.3 get操作

get操作的源码如下:

复制代码
 1 public V get(Object key) {
 2         Segment<K,V> s; // manually integrate access methods to reduce overhead
 3         HashEntry<K,V>[] tab;
 4         int h = hash(key);
 5         long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
 6         if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
 7             (tab = s.table) != null) {
 8             for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
 9                      (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
10                  e != null; e = e.next) {
11                 K k;
12                 if ((k = e.key) == key || (e.hash == h && key.equals(k)))
13                     return e.value;
14             }
15         }
16         return null;
17     }
复制代码

 

操作步骤为:

  1. 和put操作一样,先通过key进行两次hash确定应该去哪个Segment中取数据。
  2. 使用Unsafe获取对应的Segment,然后再进行一次&运算得到HashEntry链表的位置,然后从链表头开始遍历整个链表(因为Hash可能会有碰撞,所以用一个链表保存),如果找到对应的key,则返回对应的value值,如果链表遍历完都没有找到对应的key,则说明Map中不包含该key,返回null。

值得注意的是,get操作是不需要加锁的(如果value为null,会调用readValueUnderLock,只有这个步骤会加锁),通过前面提到的volatile和final来确保数据安全。

1.4 size操作

size操作与put和get操作最大的区别在于,size操作需要遍历所有的Segment才能算出整个Map的大小,而put和get都只关心一个Segment。假设我们当前遍历的Segment为SA,那么在遍历SA过程中其他的Segment比如SB可能会被修改,于是这一次运算出来的size值可能并不是Map当前的真正大小。所以一个比较简单的办法就是计算Map大小的时候所有的Segment都Lock住,不能更新(包含put,remove等等)数据,计算完之后再Unlock。这是普通人能够想到的方案,但是牛逼的作者还有一个更好的Idea:先给3次机会,不lock所有的Segment,遍历所有Segment,累加各个Segment的大小得到整个Map的大小,如果某相邻的两次计算获取的所有Segment的更新的次数(每个Segment都有一个modCount变量,这个变量在Segment中的Entry被修改时会加一,通过这个值可以得到每个Segment的更新操作的次数)是一样的,说明计算过程中没有更新操作,则直接返回这个值。如果这三次不加锁的计算过程中Map的更新次数有变化,则之后的计算先对所有的Segment加锁,再遍历所有Segment计算Map大小,最后再解锁所有Segment。源代码如下:

复制代码
 1 public int size() {
 2         // Try a few times to get accurate count. On failure due to
 3         // continuous async changes in table, resort to locking.
 4         final Segment<K,V>[] segments = this.segments;
 5         int size;
 6         boolean overflow; // true if size overflows 32 bits
 7         long sum;         // sum of modCounts
 8         long last = 0L;   // previous sum
 9         int retries = -1; // first iteration isn't retry
10         try {
11             for (;;) {
12                 if (retries++ == RETRIES_BEFORE_LOCK) {
13                     for (int j = 0; j < segments.length; ++j)
14                         ensureSegment(j).lock(); // force creation
15                 }
16                 sum = 0L;
17                 size = 0;
18                 overflow = false;
19                 for (int j = 0; j < segments.length; ++j) {
20                     Segment<K,V> seg = segmentAt(segments, j);
21                     if (seg != null) {
22                         sum += seg.modCount;
23                         int c = seg.count;
24                         if (c < 0 || (size += c) < 0)
25                             overflow = true;
26                     }
27                 }
28                 if (sum == last)
29                     break;
30                 last = sum;
31             }
32         } finally {
33             if (retries > RETRIES_BEFORE_LOCK) {
34                 for (int j = 0; j < segments.length; ++j)
35                     segmentAt(segments, j).unlock();
36             }
37         }
38         return overflow ? Integer.MAX_VALUE : size;
39     }
复制代码

 

举个例子:

1.5 containsValue操作

containsValue操作采用了和size操作一样的想法:

复制代码
 1 public boolean containsValue(Object value) {
 2         // Same idea as size()
 3         if (value == null)
 4             throw new NullPointerException();
 5         final Segment<K,V>[] segments = this.segments;
 6         boolean found = false;
 7         long last = 0;
 8         int retries = -1;
 9         try {
10             outer: for (;;) {
11                 if (retries++ == RETRIES_BEFORE_LOCK) {
12                     for (int j = 0; j < segments.length; ++j)
13                         ensureSegment(j).lock(); // force creation
14                 }
15                 long hashSum = 0L;
16                 int sum = 0;
17                 for (int j = 0; j < segments.length; ++j) {
18                     HashEntry<K,V>[] tab;
19                     Segment<K,V> seg = segmentAt(segments, j);
20                     if (seg != null && (tab = seg.table) != null) {
21                         for (int i = 0 ; i < tab.length; i++) {
22                             HashEntry<K,V> e;
23                             for (e = entryAt(tab, i); e != null; e = e.next) {
24                                 V v = e.value;
25                                 if (v != null && value.equals(v)) {
26                                     found = true;
27                                     break outer;
28                                 }
29                             }
30                         }
31                         sum += seg.modCount;
32                     }
33                 }
34                 if (retries > 0 && sum == last)
35                     break;
36                 last = sum;
37             }
38         } finally {
39             if (retries > RETRIES_BEFORE_LOCK) {
40                 for (int j = 0; j < segments.length; ++j)
41                     segmentAt(segments, j).unlock();
42             }
43         }
44         return found;
45     }
复制代码

2. 关于hash

看看hash的源代码:

复制代码
 1 private int hash(Object k) {
 2         int h = hashSeed;
 3 
 4         if ((0 != h) && (k instanceof String)) {
 5             return sun.misc.Hashing.stringHash32((String) k);
 6         }
 7 
 8         h ^= k.hashCode();
 9 
10         // Spread bits to regularize both segment and index locations,
11         // using variant of single-word Wang/Jenkins hash.
12         h += (h <<  15) ^ 0xffffcd7d;
13         h ^= (h >>> 10);
14         h += (h <<   3);
15         h ^= (h >>>  6);
16         h += (h <<   2) + (h << 14);
17         return h ^ (h >>> 16);
18     }
复制代码

 

源码中的注释是这样的:

这里用到了Wang/Jenkins hash算法的变种,主要的目的是为了减少哈希冲突,使元素能够均匀的分布在不同的Segment上,从而提高容器的存取效率。假如哈希的质量差到极点,那么所有的元素都在一个Segment中,不仅存取元素缓慢,分段锁也会失去意义。

举个简单的例子:

1 System.out.println(Integer.parseInt("0001111", 2) & 15);
2 System.out.println(Integer.parseInt("0011111", 2) & 15);
3 System.out.println(Integer.parseInt("0111111", 2) & 15);
4 System.out.println(Integer.parseInt("1111111", 2) & 15);

这些数字得到的hash值都是一样的,全是15,所以如果不进行第一次预hash,发生冲突的几率还是很大的,但是如果我们先把上例中的二进制数字使用hash()函数先进行一次预hash,得到的结果是这样的:

 

上面这个例子引用自:  InfoQ

可以看到每一位的数据都散开了,并且ConcurrentHashMap中是使用预hash值的高位参与运算的。比如之前说的先将hash值向右按位移动28位,再与15做&运算,得到的结果都别为:4,15,7,8,没有冲突!

3. 注意事项

  • ConcurrentHashMap中的key和value值都不能为null,HashMap中key可以为null,HashTable中key不能为null。
  • ConcurrentHashMap是线程安全的类并不能保证使用了ConcurrentHashMap的操作都是线程安全的!
  • ConcurrentHashMap的get操作不需要加锁,put操作需要加锁

 

参考:

1. JDK7与JDK8中HashMap的实现

2. [高并发Java 五] JDK并发包1

posted @ 2017-05-23 21:24  罗志均  阅读(123)  评论(0编辑  收藏  举报
友情链接:回力 | 中老年女装 | 武汉英语培训机构 | 留学费用一览表 | 托福考试费用