Java程序员从笨鸟到菜鸟(二十九)ConcurrentHashMap解析

既然不能全锁(HashTable)又不能不锁(HashMap),所以就搞个部分锁,只锁部分,用到哪部分就锁哪部分。一个大仓库,里面有若干个隔间,每个隔间都有锁,同时只允许一个人进隔间存取东西。但是,在存取东西之前,需要有一个全局索引,告诉你要操作的资源在哪个隔间里,然后当你看到隔间空闲时,就可以进去存取,如果隔间正在占用,那你就得等着

一、背景

线程不安全的HashMap

因为多线程环境下,使用Hashmap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap

效率低下的HashTable

HashTable使用synchronized来保证线程安全,但是在高并发线程竞争激烈的情况下HashTable的效率会非常低下,当一个线程访问HashTable的同步方法时,其它线程访问HashTable的同步方法时,会进入阻塞或轮询状态。例如线程1使用put添加元素,线程2不但不能put添加元素,二且也不能使用get来获取元素,竞争越激烈,效率越低。

锁分段技术

HashTable竞争激烈的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的`
锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问

ConcurrentHashMap实现线程安全

引入分段锁的概念,具体可以理解为一个大的Map拆分成N个小的HashTable,根据key.hashCode()来决定把key放到哪个HashTable中

在ConcurrentHashMap中,就是把Map分成了N个Segment(类似HashTable),来提供线程安全,但是效率提升N倍,默认是16倍,put和get的时候,都是根据key.hashCode()算出放到哪个Segment中

二、应用场景

当有一个大数组时需要在多个线程共享时就可以考虑是否把它给分成多个节点了,避免大锁。并可以考虑通过hash算法进行一些模块定位。
其实不止用于线程,当设计数据表的事务时(事务某种意义上也是同步机制的体现),可以把一个表看成一个需要同步的数组,如果操作的表数据太多时就可以考虑事务分离了(这也是为什么要避免大表的出现),比如把数据进行字段拆分,水平分表等.

三、源码解读

ConcurrentHashMap主要实体类:ConcurrentHashMap(整个hash表)、Segment(桶)、HashEntry(节点)
不变(Immutable)和易变(Volatile)

ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。
如果使用传统的技术,如HashMap中的实现,如果允许可以在hash链的中间添加或删除元素,读操作不加锁将得到不一致的数据。HashEntry代表每个hash链中的一个节点,源码如下:

static final class HashEntry<K,V> {
      final int hash;
      final K key;
      volatile V value;
      volatile HashEntry<K,V> next;

      HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
          this.hash = hash;
          this.key = key;
          this.value = value;
          this.next = next;
      }
}

key值是final的,这意味着不能从hash链的中间或是尾部添加和删除节点,这需要修改next的值,节点的修改只能从头部开始,对于put操作,可以一律添加到Hash链的头部,但是对于remove操作,可能需要从中间删除一个节点,这就需要将要删除节点的前面所有节点整个复制一遍,最后一个节点指向要删除结点的下一个结点

定位操作

既然ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过哈希算法定位到Segment;ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再哈希;再哈希,其目的是为了减少哈希冲突,使元素能够均匀的分布在不同的Segment上,从而提高容器的存取效率

为了加快定位段以及段中hash槽的速度,每个段hash槽的的个数都是2^n,这使得通过位运算就可以定位段和段中hash槽的位置;当并发级别为默认值16时,也就是段的个数,hash值的高4位决定分配在哪个段中

数据结构

Hash表的一个很重要方面就是如何解决hash冲突,ConcurrentHashMap 和HashMap使用相同的方式,都是将hash值相同的节点放在一个hash链中;与HashMap不同的是,ConcurrentHashMap使用多个子Hash表,也就是段(Segment)。每个Segment相当于一个子Hash表,数据成员如下:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
        /*
         * Segments maintain a table of entry lists that are always
         * kept in a consistent state, so can be read (via volatile
         * reads of segments and tables) without locking.  This
         * requires replicating nodes when necessary during table
         * resizing, so the old lists can be traversed by readers
         * still using old version of table.
         *
         * This class defines only mutative methods requiring locking.
         * Except as noted, the methods of this class perform the
         * per-segment versions of ConcurrentHashMap methods.  (Other
         * methods are integrated directly into ConcurrentHashMap
         * methods.) These mutative methods use a form of controlled
         * spinning on contention via methods scanAndLock and
         * scanAndLockForPut. These intersperse tryLocks with
         * traversals to locate nodes.  The main benefit is to absorb
         * cache misses (which are very common for hash tables) while
         * obtaining locks so that traversal is faster once
         * acquired. We do not actually use the found nodes since they
         * must be re-acquired under lock anyway to ensure sequential
         * consistency of updates (and in any case may be undetectably
         * stale), but they will normally be much faster to re-locate.
         * Also, scanAndLockForPut speculatively creates a fresh node
         * to use in put if no node is found.
         */

        private static final long serialVersionUID = 2249069246763182397L;

        /**
         * The maximum number of times to tryLock in a prescan before
         * possibly blocking on acquire in preparation for a locked
         * segment operation. On multiprocessors, using a bounded
         * number of retries maintains cache acquired while locating
         * nodes.
         */
        static final int MAX_SCAN_RETRIES =
            Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

        /**
         * The per-segment table. Elements are accessed via
         * entryAt/setEntryAt providing volatile semantics.
         */
        transient volatile HashEntry<K,V>[] table;

        /**
         * The number of elements. Accessed only either within locks
         * or among other volatile reads that maintain visibility.
         */
        transient int count;

        /**
         * The total number of mutative operations in this segment.
         * Even though this may overflows 32 bits, it provides
         * sufficient accuracy for stability checks in CHM isEmpty()
         * and size() methods.  Accessed only either within locks or
         * among other volatile reads that maintain visibility.
         */
        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 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的值
  • modCount统计段结构改变的次数,主要是为了检测对多个段进行遍历过程中某个段是否发生改变
  • threashold用来表示需要进行rehash的界限值
  • table数组存储段中节点,每个数组元素是个hash链,用HashEntry表示。table也是volatile,这使得能够读取到最新的 table值而不需要同步
  • loadFactor表示负载因子
remove操作
remove(key, hash, value)源码
/**
   * Remove; match on key only if value null, else match both.
   */
  final V remove(Object key, int hash, Object value) {
      if (!tryLock())
          scanAndLock(key, hash);
      V oldValue = null;
      try {
          HashEntry<K,V>[] tab = table;
          int index = (tab.length - 1) & hash;
          HashEntry<K,V> e = entryAt(tab, index);
          HashEntry<K,V> pred = null;
          while (e != null) {
              K k;
              HashEntry<K,V> next = e.next;
              if ((k = e.key) == key ||
                  (e.hash == hash && key.equals(k))) {
                  V v = e.value;
                  if (value == null || value == v || value.equals(v)) {
                      if (pred == null)
                          setEntryAt(tab, index, next);
                      else
                          pred.setNext(next);
                      ++modCount;
                      --count;
                      oldValue = v;
                  }
                  break;
              }
              pred = e;
              e = next;
          }
      } finally {
          unlock();
      }
      return oldValue;
  }

整个操作是在持有段锁的情况下执行的,空白行之前的行主要是定位到要删除的节点e。接下来,如果不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍,尾结点指向e的下一个结点。e后面的结点不需要复制,它们可以重用

删除操作需要注意:

  • 当要删除的结点存在时,删除的最后一步操作要将count的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改
  • remove执行的开始就将table赋给一个局部变量tab,这是因为table是 volatile变量,读写volatile变量的开销很大
get操作

源码;

V get(Object key, int hash) {  
     if (count != 0) { // read-volatile 当前桶的数据个数是否为0 
         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;  
         }  
     }  
     returnnull;  
 }

get操作不需要加锁

除非读到的值是空的才会加锁重读,我们知道HashTable容器的get方法是需要加锁的,ConcurrentHashMap不需要加锁的原因:get方法里将要使用的共享变量都定义成volatile

  • 第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count 变量,通过这种机制保证get操作能够得到几乎最新的结构更新。对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值
  • 接下来就是根据hash和key对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null;对hash链进行遍历不需要加锁的原因在于链指针next是final的
  • 最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次;理论上结点的值不可能为空,这是因为 put的时候就进行了判断,如果为空就要抛NullPointerException。空值的唯一源头就是HashEntry中的默认值,因为 HashEntry中的value不是final的,非同步读取有可能读取到空值
put操作

源码:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
      HashEntry<K,V> node = tryLock() ? null :
          scanAndLockForPut(key, hash, value);
      V oldValue;
      try {
          HashEntry<K,V>[] tab = table;
          int index = (tab.length - 1) & hash;
          HashEntry<K,V> first = entryAt(tab, index);
          for (HashEntry<K,V> e = first;;) {
              if (e != null) {
                  K k;
                  if ((k = e.key) == key ||
                      (e.hash == hash && key.equals(k))) {
                      oldValue = e.value;
                      if (!onlyIfAbsent) {
                          e.value = value;
                          ++modCount;
                      }
                      break;
                  }
                  e = e.next;
              }
              else {
                  if (node != null)
                      node.setNext(first);
                  else
                      node = new HashEntry<K,V>(hash, key, value, first);
                  int c = count + 1;
                  if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                      rehash(node);
                  else
                      setEntryAt(tab, index, node);
                  ++modCount;
                  count = c;
                  oldValue = null;
                  break;
              }
          }
      } finally {
          unlock();
      }
      return oldValue;
  }

该方法也是在持有段锁(锁定整个segment)的情况下执行的,这当然是为了并发的安全,修改数据是不能并发进行的,必须得有个判断是否超限的语句以确保容量不足时能够rehash,接着是找是否存在同样一个key的结点,如果存在就直接替换这个结点的值,否则创建一个新的结点并添加到hash链的头部,这时一定要修改modCount和count的值,同样修改count的值一定要放在最后一步,put方法调用了rehash方法,rehash方法实现得也很精巧,主要利用了table的大小为2^n.

由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须得加锁。Put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置然后放在HashEntry数组里

  • 是否需要扩容:在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阀值,数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容
  • 如何扩容:扩容的时候首先会创建一个两倍于原容量的数组,然后将原数组里的元素进行再hash后插入到新的数组里。为了高效ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容
size操作

如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和,Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了,所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效

因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。

判断容器的count是否发生变化:使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化

原文传送门:https://www.cnblogs.com/ITtangtang/p/3948786.html

猜你喜欢

转载自blog.csdn.net/u013090299/article/details/80655968