ConcurrentHashMap源码阅读以及底层实现的简单分析

来源:互联网 发布:方正电子待遇 知乎 编辑:程序博客网 时间:2024/05/16 07:43

ConcurrentHashMap源码阅读以及底层实现的简单分析

     ConcurrentHashMap 是可以实现多线程并发的HashMap,它是线程安全的。

       前面分析过 HashMap的源码,它和HashMap有很多的相同点一样,比如它也有 initialCapacity 以及负载因子 loadFactor 属性。而且他们的默认值也是16和0.75.

static final int DEFAULT_INITIAL_CAPACITY =16;

static final float DEFAULT_LOAD_FACTOR =0.75f;

 

和HashMap不同的是,它还多了一个concurrencyLevel属性,它的默认值也是16。

static final int DEFAULT_CONCURRENCY_LEVEL = 16;

       从名字就可以看出他的作用是调节并发的粒度。也就是把HashMap的数组分成的segment的个数,ConcurrentHashMap就是通过Segment来对这个数组分段来实现细粒度的加锁,实现高并发,下面会进行介绍。当以上的三个参数都为默认值的时候,会计算一个ssize值,就是Segment数组的大小,具体代码如下所示:

int ssize = 1;

        while (ssize <concurrencyLevel) {

            ++sshift;

            ssize <<= 1;

        }

      由于concurrencyLevel 为 16,一直执行while循环,直到 ssize 为 16,并使用此 ssize 作为参数传入 Segment 的 newArray 方法,创建大小为 16 的 Segment 对象数组,代码如下所示:

Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];

   接着又计算 cap 变量的值,具体实现的代码如下所示:

int c = initialCapacity / ssize;

        if (c * ssize <initialCapacity)

            ++c;

        int cap =MIN_SEGMENT_TABLE_CAPACITY;

        while (cap < c)

            cap <<= 1;

       根据以上参数值,可以计算出cap 为 1,最后为 Segment 对象数组创建 Segment 对象,传入的参数为 cap 和 loadFactor,Segment 对象继承 ReentrantLock,在创建 Segment 对象时,其所做的动作为创建一个指定大小为 cap 的 HashEntry 对象数组,并基于数组的大小以及loadFactor 计算 threshold 的值:

threshold = (int)(newCapacity * loadFactor);

 

 

    下面以put方法为入口,进行分析。

       ConcurrentHashMap 并没有在此方法上加上 synchronized,首先判断 value 是否为 null,如为 null 则抛出 NullPointerException,如不为 null,则继续下面的步骤:

       首先对key.hashCode 进行 hash 操作,得到 key 的 hash 值,hash 操作的算法和 HashMap 也不同:

   根据此 hash 值计算并获取其对应的数组中的 Segment 对象,具体的代码如下:

return segments[(hash >>> segmentShift) & segmentMask];

     在找到了数组中的Segment对象后,接着调用Segment对象的put方法来完成当前操作。

       当调用 Segment 对象的 put 方法时,首先进行 lock操作,接着判断当前存储的对象个数加 1 后是否大于 threshold,如大于,则将当前的 HashEntry 对象数组大小扩大两倍,并将之前存储的对象进行重新 hash,转移到新的对象数组中,在确保了数组的大小足够后,继续向下执行。

       下面基本上和HashMap 的操作一样,通过对 hash 值和对象数组大小减 1 的值进行按位与后,得到当前 key 需要放入数组的位置,接着就遍历整个链表,来寻找对应的位置上的 HashEntry 对象链表是否有 key、hash 值和当前 key 相同的,如有,覆盖其 value,如没有,则创建一个新的HashEntry 对象,赋值给对应位置的数组对象,并构成链表。这里的HashEntry类似于HashMap中的Entry。

       上面的步骤完成了以后后,就释放了锁,整个 put 动作得以完成。

根据以上分析,可以看出,ConcurrentHashMap基于 concurrencyLevel 划分出了多个Segment 段来对 key-value 进行存储,从而避免每次 put 操作就得锁住整个数组,所以在默认的情况下,最好的情况下可以允许 16 个线程并发无阻塞的操作集合对象,实现了锁的粒度的减小,可以更大程度的实现并发。

 

 

       然后来看看get(Objectkey) 方法

      开始先对key.hashCode 进行 hash 操作,基于得到 hash 的值找到对应的 Segment 对象,调用Segment的 get 方法完成当前操作。通过对比可以发现,比HashMap多了一次寻找Segment的操作。

         进入Segment 的 get 方法以后,首先判断当前HashEntry 对象数组中的已存储的对象大小是否为 0,如为 0,则直接返回 null,如不为 0,则继续执行下面的代码。

     首先类似于HashMap,先进行hash计算,算法也和HashMap类似,找到对应的数组的下标,然后一样先遍历整个链表,寻找到 hash 值相等以及 key equals 的HashEntry 对象,在找到的情况下,获取其 value,如 value 不为 null,则直接返回此 value,如为 null,则调用 readValueUnderLock 方法,readValueUnderLock 方法首先进行 lock 操作,然后直接返回 HashEntry 的 value 属性,最后释放锁。

       所有的各种做完以后,就完成了 get 操作,从上面 Segment 的 get 步骤来看,仅在寻找到的HashEntry 对象的 value 为 null 时,才进行了锁操作,其他情况下并没有锁操作,也就是可以认为 ConcurrentHashMap 在读数据时大部分情况下是没有采用锁的,那么它是如何保证并发场景下数据的一致性的呢。

        开始的时候,没有查看源代码,我还以为它使用的是读写锁,结果发现读操作恰恰没有加锁,对源码进行分析,发现get 操作首先还是计算数组下标,来找对应的HashEntry的位置,但是在这个步骤中,可能会因为对象数组大小的改变以及数组上对应位置的 HashEntry 产生不一致性,下面就来看看它是如何实现的。

       可以发现,它是通过voliate关键字来实现的可见性,对象数组大小的改变只有在 put 操作时有可能发生,由于 HashEntry 对象数组对应的变量是 voliate 类型的,因此可以保证如 HashEntry 对象数组大小发生改变,读操作时可看到最新的对象数组大小。

       但是有一个问题,就是在put和remove操作进行时,都有可能造成HashEntry对象数组上对应位置的HashEntry发生改变,如在读操作已获取到 HashEntry 对象后,有一个 put 或 remove 操作完成,此时读操作尚未完成,那么这时会造成读的不一致性,不过这种情况不太容易出现。

        在获取到了HashEntry 对象后,怎么能保证获取的 HashEntry 对象以及其 next 属性构成的链表上的对象不会改变呢,这点 ConcurrentHashMap 采用了一个简单的方式,即 HashEntry对象中的 hash、key 以及 next 属性都是 final 的,这也就意味着没办法插入一个 HashEntry对象到HashEntry基于next属性构成的链表中间或末尾中,这样可以保证当获取到HashEntry对象后,其基于 next 属性构建的链表是不会发生变化的。

       至于为什么需要判断下获取的HashEntry 的 value 是否为 null,原因在于 put 操作创建一个新的 HashEntry 时,并发读取时有可能此时 value 属性尚未完成设置,因此将读取到默认值,不过具体出现这种现象的原因还未知,据说只有在老版本的 JDK 中才会有,而其他属性,由于是 final 的,则可以保证是线程安全的,因此这里做了个保护,当 value 为 null 则通过加锁操作来确保读到的 value 是一致的。

 

 

 

       最后再来看看remove(Objectkey) 方法

         首先对key.hashCode 进行 hash 操作,找到它对应的 Segment 对象,再调用Segment 的remove 方法完成当前操作。

Segment 的 remove 方法进行加锁操作,然后计算出在下标,在对这个数组当前下标的元素链表遍历,找到 hash 和传入的 hash 值相等,以及 key 和传入的 key equals 的 HashEntry对象,如未找到,则返回 null。如找到,则将 HashEntry 链表中位于删除元素之前的所有 HashEntry 重新创建,之所以这样,是因为上面提到的,next 属性都被设计成了 final 的,不能进行改变。但是在他后面的元素就不用任何的操作。所有的操作完成之后,就释放了锁,完成了删除的操作。

 

          通过分析源码可以发现,ConcurrentHashMap比HashMap多了一个操作,就是分段,默认使用一个长度16的Segment数组实现,然后每一个Segment元素类似于HashMap中的Entry数组。而对于ConcurrentHashMap的所有的写操作,即增删,都会加锁,对于读的操作却没有加锁,使用voliate关键字来保证了可见性。

 

 

0 0