ConcurrentHashMap中节点数目并发统计的实现原理

前言:

前段时间又看了一遍ConcurrentHashMap的源码,对该并发容器的底层实现原理有了更进一步的了解,本想写一篇关于ConcurrentHashMap的put方法所涉及的初始化以及扩容操作等的源码解析的,但是这类文章在各平台上实在是太多了,写了感觉意义不是很大。但学了东西,还是想着尽量能够输出点什么,所以就打算稍微写写点偏门的,关于ConcurrentHashMap中统计节点数目的size()方法的实现原理。由于JDK 1.7和1.8中ConcurrentHashMap的源码发生了较大的变化,size()方法的底层实现也发生了变动。为此,本文将会通过对比两个版本的size()方法的底层实现来加深对ConcurrentHashMap的理解。

关键字:

源码、JDK1.7、JDK1.8、ConcurrentHashMap、LongAdder

思考:

在了解具体实现原理之前,我们可以先自己思考该如何实现并发容器中的节点数目统计问题。
一种很自然的想法是,用一个成员变量(size)来统计容器的节点数目,每次对容器进行put或者remove操作时,都对该成员变量(size)进行线程安全的更新。这样,当要获取容器的节点数目时,直接返回该值即可。
这样的计数方法在返回结果的时候,速度很快,但是在统计的过程中存在着一个很明显的问题,就是并发度不高。由于需要对该成员变量(size)进行线程安全的更新,为此只能采用独占锁(Synchronized、Lock等)或者CAS(AtomicInteger等)等方式来进行更新,无论采取何种方式,每次都只会允许一个线程执行成功。如果采用该方式来实现,节点统计将会成为该容器的瓶颈。且我们知道,ConcurrentHashMap为提高并发度,采用了分段锁的方式(无论是JDK 1.7还是1.8版本,1.8版本的可以看成进一步提高了分段锁的粒度)。为此,当采用一个成员变量线程安全更新的方式来实现时,分段锁的实现也变得没有了意义(因为要在每次put操作或者remove操作之后,执行对该变量的更新操作后才能返回)。

由于上面那种实现方式会导致存在性能瓶颈,那么为了并发而生的ConcurrentHashMap肯定是不能采用该方式实现的(事实证明,确实没有采用该方式进行实现)。我们知道, ConcurrentHashMap的底层采用了分段锁的方式。为此,另一种很自然的想法是让每个分段自己去统计该区间的节点数目,当在调用size()方法时,先一次性获取所有的分段锁(防止在统计节点数目时,还在进行节点数目的变动),将每个分段区间都给锁住,之后依次获取各个分段区间的节点数目进行汇总,从而得到ConcurrentHashMap全局的节点数目。那么该方法是否可行呢?事实上,在JDK 1.7中就是采用了该方式来进行实现的且对该实现方式进行了改进(先尝试采用不获取任何独占锁的方式进行统计)。我们可以知道,在JDK 1.7中,每次对ConcurrentHashMap执行put操作,都会获取该分段区间对应的锁。为此,该方式天然契合JDK 1.7中ConcurrentHashMap的实现,不会由于各个分段进行节点数目的统计而导致并发度降低的问题。

JDK1.7的实现方式:

在具体了解JDK1.7的实现之前,我们可以先看下JDK1.7中的源码:

size为各个分段节点数目的总和,sum为各个segment的modCount的总和。我们知道,当segment对应的hashmap底层结构发生修改时(执行了put、remove操作),modCount值便会加一,也就是modCount为segment对应的hashMap修改的次数,sum即为各个segment的修改次数的总和。last为上一次统计的各个segment的修改次数。通过源码我们可以得知,其会先进行两次非获取独占锁的统计,当sum==last时,也就是上一次统计和这一次统计的过程中,ConcurrentHashMap的各个分段都没有发生过改动(既没有新增节点,也没有删除节点),则size即为对应的结果。否则,就一次性获取各个分段的独占锁,再度统计两次各个分段的节点数,而由于两次统计的过程中一直持有着各个分段的独占锁,为此,两次统计的过程中不可能会有别的线程对该ConcurrentHashMap进行改动,sum和last值必定相同,最终会退出循环。也就是size()方法最多循环执行四次,便可以得到节点数统计的结果。

JDK1.8的实现方式:

JDK1.8中ConcurrentHashMap的size()方法的底层实现和在1.8中引入的LongAdder该并发计数类的底层实现原理是相同的。为此,我们只需要了解JDK1.8中LongAdder的底层实现方法便可知道ConcurrentHashMap中对size()方法的实现原理。
LongAdder该类继承自Striped64,同时封装了相关方法,以实现对并发计数的要求,Striped64该类借鉴了分段锁的相关思想。其将原本所有线程对一个变量进行的线程安全的更新操作,扩展为不同线程对多个不同的计数单元的线程安全的操作。

当要获取总数时,将多个计数单元的值进行累加求和,即可得到最终结果。
在讲解LongAdder该类的源码之前,我们先了解Striped64该类。
在Striped64该类中,有着如下几个重要的成员变量和内部类:

NCPU表示系统CPU的数目,cells是计数单元的数组,其大小始终为2的n次方倍,目的是使取余运算更加高效。base作为基础计数变量来使用,线程尝试先将值加在该变量上,如果成功则返回,否则认为存在竞争,则应当去将值添加到对应的计数单元中。cellsBusy是一个用volatile修饰的变量,通过CAS原子更新的方式,充当了自旋锁的作用,当该值为0时,表示该锁可以使用,当该值为1时,表示某个线程获取了锁。

接着我们来看静态内部类Cell:

内部类Cell为计数单元,其实现较为简单,成员变量只有一个被volatile所修饰的long型变量value,对该值的修改是通过CAS的方式进行的,用于叠加各个线程记录到该计数单元的值。值得注意的是,该类被注解@sun.misc.Contended所修饰,该注解是用于解决伪共享问题的,这里就暂时不展开讨论伪共享的问题,留到下一篇文章中再讲。

接着我们来看Striped64该类中的重要方法,longAccumulate(),该方法完成了计数单元数组cells的初始化,计数单元数组某个元素的初始化,扩容以及将值叠加到对应的计数单元上等相关操作。
我们先来解析当cells非空的情况,当其非空时,表明之前存在多线程更新base的值发生过冲突。

当cells数组非空,但对应的计数单元为空,且没有其它线程获取锁时,其先尝试实例化一个计数单元,同时将值传递给该计数单元,之后,尝试获取锁,并将该计数单元赋值给cells数组对应的元素,完成赋值操作后,再释放锁,并退出循环,进行返回。

当cells数组非空,对应的计数单元也非空时,尝试采用CAS的方式将值叠加到对应的计数单元中,当执行成功时返回。否则,判断Cell数组的长度是否是最大的了(大于等于CPU的数目),如已经是最大的了,则重新计算线程的hash值,继续进行操作。如不是,则进行扩容操作。
我们从源码中可以得知,每次cells数组扩容时,其大小为先前大小的2倍,同时将旧计数单元数组的每个元素直接复制到新表中,完成扩容的过程。以上便是当cells非空时,所进行的相关操作。

当cells数组为空时,其会先尝试获取锁并初始化数组,同时将值叠加到对应的计数器单元中,当获取锁失败时,则尝试将值叠加到基础计数变量base中,成功时返回,失败时继续循环操作。以上便是Striped64处理并发情况下统计值的整个过程。

总结:当cells数组非空时,尝试将值叠加到某个数组元素所表示的计数单元中,叠加失败时,表明存在多线程的竞争。此时,如果cells的大小小于CPU数目时,尝试进行扩容操作,否则,将对应线程的hash值进行rehash后,重新执行循环过程。当cells为空时,尝试对cells进行初始化,并将值叠加到对应的计数单元中,执行初始化完成之后返回。否则,当初始化失败时,尝试将值叠加到基础计数变量base中,初始化成功时结束该过程并返回,失败时,重新执行循环过程。

在了解了Striped64该类的主要实现方法和相关成员变量后,了解LongAdder该类的实现就是一件非常简单的事情了。
下面我们对LongAdder该类的源码进行解析。

我们可以看到,LongAdder类中核心的便是那个add()方法,当cells还没有进行初始化时(表明之前不存在多线程的竞争累加),其先尝试将值x通过CAS的方式累加到成员变量base中。当失败时,表明存在多线程之间的竞争,随后判断计数单元cells是否已被初始化完成,如果已经初始化(被其它线程初始化),则尝试通过将值x累加到某个计数单元中,当同样失败时,执行父类Striped64中的longAccumulate方法,将值累加到对应的计数单元或者base中,以完成累加的过程。
那么如何获取统计操作后的结果呢?
其源码实现如下所示:

从源码中我们就可以得知,其累加了base变量的值和各个计数单元Cell的值作为结果进行返回。当该方法被调用的时候,其它线程可能还在进行着修改操作,为此,其最终返回的值并非是精确的当前情况下的统计结果,其只是一个“大概”值。当想要获得精确值时,只能采用对各个计数单元进行加锁的方式来实现。

一个问题:仔细想想,ConcurrentHashMap为何采用该方式来实现size()方法?我能想到的一个原因是ConcurrentHashMap并不需要在并发修改情况下的精确节点数目的值,由于ConcurrentHashMap该数据结构是为并发而生的,为此,获取精确的节点数目的值本身意义并不大。当你消耗了性能,获取了某个时刻节点数目的精确值,随后还是可能会被其他线程修改,导致上一刻的值无法使用,为此获取一个“大概”值便是一个较好的选择。而采用该方法实现也是线程安全的,能够确保没有线程对其进行修改的时候,其值的准确性。


这个是本人的公众号,致力于写出绝大部分人都能读懂的技术文章。欢迎相互交流,我们博采众长,共同进步。

posted @ 2020-04-15 17:17  林学徒  阅读(1222)  评论(0编辑  收藏  举报