• 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该数据结构是为并发而生的,为此,获取精确的节点数目的值本身意义并不大。当你消耗了性能,获取了此时此刻的节点数目的精确值,随后还是可能会被其他线程修改,导致上一刻的值无法使用,为此获取一个“大概”值便是一个较好的选择。


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

  • 相关阅读:
    nginx能访问html静态文件但无法访问php文件
    LeetCode "498. Diagonal Traverse"
    LeetCode "Teemo Attacking"
    LeetCode "501. Find Mode in Binary Search Tree"
    LeetCode "483. Smallest Good Base" !!
    LeetCode "467. Unique Substrings in Wraparound String" !!
    LeetCode "437. Path Sum III"
    LeetCode "454. 4Sum II"
    LeetCode "445. Add Two Numbers II"
    LeetCode "486. Predict the Winner" !!
  • 原文地址:https://www.cnblogs.com/MyStringIsNotNull/p/12706641.html
Copyright © 2020-2023  润新知