Java集合:ConcurrentHashMap(JDK 1.7 & JDK 1.8)

版权声明:本博客为记录本人学习过程而开,内容大多从网上学习与整理所得,若侵权请告知! https://blog.csdn.net/Fly_as_tadpole/article/details/87865210

ConcurrentHashMap(JDK 1.7)

  • ConcurrentHashMap 是由 Segment 数组和 HashEntry 数组和链表组成

  • Segment 是基于重入锁(ReentrantLock):一个数据段竞争锁。每个 HashEntry 一个链表结构的元素,利用 Hash 算法得到索引确定归属的数据段,也就是对应到在修改时需要竞争获取的锁。ConcurrentHashMap 支持 CurrencyLevel(Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment

  • 核心数据如 value,以及链表都是 volatile 修饰的,保证了获取时的可见性

  • 首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put 操作如下:

    1. 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。

    2. 遍历该 HashEntry,如果不为空则判断传入的  key 和当前遍历的 key 是否相等,相等则覆盖旧的 value

    3. 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容

    4. 最后会解除在 1 中所获取当前 Segment 的锁

  • 虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理

首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁

  • 尝试自旋获取锁

  • 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。最后解除当前 Segment 的锁

ConcurrentHashMap设计


ConcurrentHashMap是线程安全,通过分段锁的方式提高了并发度。分段Segment是一开始就确定的了,后期不能再进行扩容的。

其中的段Segment继承了重入锁ReentrantLock所以调用lock方法将当前segment进行加锁,有了锁的功能,同时含有类似HashMap中的数组加链表结构(这里没有使用红黑树)

虽然Segment的个数是不能扩容的但是单个Segment里面的数组是可以扩容的

jdk1.7中采用Segment + HashEntry的方式进行实现,

结构如下:

2.1 整体概览


ConcurrentHashMap有3个参数:
·        initialCapacity:初始总容量,默认16

·        loadFactor:加载因子,默认0.75

·        concurrencyLevel:并发级别,默认16

segment的个数即ssize(段数)

取大于等于并发级别的最小的2的幂次。如concurrencyLevel=16,那么sszie=16,如concurrencyLevel=10,那么ssize=16

单个segment的初始容量cap(容量/段数,如果有余数则向上+1)

c=initialCapacity/ssize,并且可能需要+1。如15/7=2,那么c要取3,如16/8=2,那么c取2,c可能是一个任意值,那么同上述一样,cap取的值就是大于等于c的最下2的幂次。最小值要求是2

单个segment的阈值threshold(cap*loadFactor)

如2*0.75=1.5。

·        所以默认情况下,

·        initialCapacity:初始总容量,默认16

·        loadFactor:加载因子,默认0.75

·        concurrencyLevel:并发级别,默认16

segment的个数sszie=16,每个segment的初始容量cap=2(最少为2),单个segment的阈值threshold=1

2.2 put过程


·        首先根据key计算出一个hash值,找到对应的Segment

·        调用Segment的lock方法,为后面的put操作加锁

·        根据key计算出hash值,找到Segment中数组中对应index的链表,并将该数据放置到该链表中

·        判断当前Segment包含元素的数量大于阈值,则Segment进行扩容

2.4 get过程

·        根据key计算出对应的segment

·        再根据key计算出对应segment中数组的index

·        最终遍历上述index位置的链表,查找出对应的key的value

2.5size实现


因为ConcurrentHashMap是可以并发插入数据的,所以在准确计算元素时存在一定的难度,一般的思路是统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的,因为在计算后面几个Segment的元素个数时,已经计算过的Segment同时可能有数据的插入或则删除,在1.7的实现中,采用了如下方式:

先采用不加锁的方式,连续计算元素的个数,最多计算3次:
1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;
2、如果前后两次计算结果都不同,则给每个Segment进行加锁(限制无法插入或者删除数据),再计算一次元素的个数;
 


ConcurrentHashMap(JDK 1.8)

CocurrentHashMap 抛弃了原有的 Segment 分段锁,采用了 CAS + synchronized 来保证并发安全性。

其中的 val next 都用了 volatile 修饰,保证了可见性

最大特点是引入了 CAS

借助 Unsafe 来实现 native code。CAS有3个操作数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值V修改为 B,否则什么都不做。Unsafe 借助 CPU 指令 cmpxchg 来实现。

CAS 使用实例

对 sizeCtl 的控制都是用 CAS 来实现的:

  • -1 代表 table 正在初始化

  • N 表示有 -N-1 个线程正在进行扩容操作

  • 如果 table 未初始化,表示table需要初始化的大小

  • 如果 table 初始化完成,表示table的容量,默认是table大小的0.75倍,用这个公式算 0.75(n – (n >>> 2))

CAS 会出现的问题:ABA

解决:对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。

put 过程

  • 根据 key 计算出 hashcode

  • 判断是否需要进行初始化

  • 通过 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功

  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容

  • 如果都不满足,则利用 synchronized 锁写入数据

  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树

get 过程

  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值

  • 如果是红黑树那就按照树的方式获取值

  • 就不满足那就按照链表的方式遍历获取值

1.8的ConcurrentHashMap设计


1.8的ConcurrentHashMap摒弃了1.7的segment(锁段)设计,而是启用了一种全新的方式实现,利用CAS算法,在1.8HashMap的基础上实现了线程安全的版本,即也是采用数组+链表+红黑树的形式。

数组可以扩容,链表可以转化为红黑树

看完ConcurrentHashMap整个类的源码,给自己的感觉就是和HashMap的实现基本一模一样,当有修改操作时借助了synchronized来对table[i]进行锁定保证了线程安全以及使用了CAS来保证原子性操作,其它的基本一致.

例如:ConcurrentHashMap的get(int key)方法的实现思路为:根据key的hash值找到其在table所对应的位置i,然后在table[i]位置所存储的链表(或者是树)进行查找是否有键为key的节点,如果有,则返回节点对应的value,否则返回null。思路是不是很熟悉,是不是和HashMap中该方法的思路一样。所以,如果你也在看ConcurrentHashMap的源码,不要害怕,思路还是原来的思路,只是多了些许东西罢了。


3.1 整体概览(JDK1.8)


sizeCtl最重要的属性之一,看源码之前,这个属性表示什么意思,一定要记住。

private transientvolatile int sizeCtl;//控制标识符//(  transient是Java语言的关键字,用来表示一个域不是该对象串行化的一部分。当一个对象被串行化的时候,transient型变量的值不包括在串行化的表示中,然而非transient型的变量是被包括进去的。  )

sizeCtl是控制标识符,不同的值表示不同的意义。

·        负数代表正在进行初始化或扩容操作 ,其中-1代表正在初始化 ,-N 表示有N-1个线程正在进行扩容操作

·        正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,类似于扩容阈值。它的值始终是当前ConcurrentHashMap容量的0.75倍,表示阈值,实际容量>=sizeCtl,则扩容。

改进一:取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。

改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。

为了说明以上2个改动,看一下put操作是如何实现的。

其实在我看来,就是两处改进:

1.在未初始化的时候,采用CAS初始化,这样可以避免多个key相同同时初始化导致某些先来的键值对丢失。

2.在检测到容器在扩容的时候,不做任何的操作,等待新表再进一步操作。这样既可以解决多个线程同时导致扩容所带来的链表死循环问题;也可以解决put的同时扩容,旧表在赋值给新表后,另外线程在get到null值。

3.最后在每个真正put的操作都加锁,可以避免由于同时put导致某个位置重叠之类的问题。

3.2 Put 过程

finalV putVal(K key, V value, boolean onlyIfAbsent) {

    if(key == null || value == null) thrownew NullPointerException();

    inthash = spread(key.hashCode());

    intbinCount = 0;

    for(Node<K,V>[] tab = table;;) {

        Node<K,V> f;int n, i, fh;

       // 如果table为空或者长度为0,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新建节点Node即可(此处用cas写入新的数值,大概就是是null则新建Node)。注:tab[i]实质为链表或者红黑树的首节点。

       if (tab == null || (n= tab.length) == 0)

           tab = initTable();

       elseif ((f = tabAt(tab, i = (n - 1) & hash)) ==null) {

           if (casTabAt(tab, i, null,

                         newNode<K,V>(hash, key, value, null)))

                break;                   // no lock when adding to empty bin

       }

       // 如果tab[i]不为空并且hash值为MOVED,说明该链表正在进行transfer操作,返回扩容完成后的table。(sizeCtl是控制标识符,此处MOVED=-1)//在hashmap有可能会因为取得旧表而get null,在这儿解决了 。因为这儿是发现扩容就等待新表再做下一步的操作。

       elseif ((fh = f.hash) == MOVED)

           tab = helpTransfer(tab, f);

       else {

           V oldVal = null;

           // 针对首个节点进行加锁操作,而不是segment,进一步减少线程冲突。1.7是整个segment加锁,一个segment又有很多个数组节点。

           synchronized(f) {

                if(tabAt(tab, i) == f) {

                    if (fh>= 0) {

                        binCount = 1;

                        for(Node<K,V> e = f;; ++binCount) {

                            K ek;

                            // 如果在链表中找到值为key的节点e,直接设置e.val =value即可。

                            if(e.hash == hash &&

                                ((ek = e.key)== key ||

                                 (ek != null&& key.equals(ek)))) {

                                oldVal = e.val;

                                if(!onlyIfAbsent)

                                    e.val = value;

                                break;

                            }

                            // 如果没有找到值为key的节点,直接新建Node并加入链表即可。

                            Node<K,V>pred = e;

                            if((e = e.next) == null) {

                                pred.next = newNode<K,V>(hash, key,

                                                         value, null);

                                break;

                            }

                        }

                    }

                    // 如果首节点为TreeBin类型,说明为红黑树结构,执行putTreeVal操作。

                    elseif(f instanceof TreeBin) {

                        Node<K,V> p;

                        binCount = 2;

                        if((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,

                                                      value)) != null) {

                            oldVal = p.val;

                            if(!onlyIfAbsent)

                                p.val = value;

                        }

                   }

                }

           }

           if (binCount != 0) {

                // 如果节点数>=8,那么转换链表结构为红黑树结构。

                if(binCount >= TREEIFY_THRESHOLD)

                    treeifyBin(tab, i);

                if(oldVal != null)

                    returnoldVal;

                break;

           }

       }

    }

    // 计数增加1,有可能触发transfer操作(扩容)。

   addCount(1L, binCount);

    returnnull;

}

当执行 put 方法插入数据时,根据key的hash值,在Node 数组中找到相应的位置,实现如下:
1. 如果相应位置的 Node 还未初始化,则通过CAS插入相应的数据;
2. 如果相应位置的 Node 不为空,且当前该节点不处于移动状态,则对该节点加 synchronized 锁,如果该节点的 hash 不小于0,则遍历链表更新节点或插入新节点;
3. 如果该节点是 TreeBin 类型的节点,说明是红黑树结构,则通过 putTreeVal 方法往红黑树中插入节点;
4. 如果 binCount 不为0,说明put 操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin 方法转化为红黑树,如果 oldVal 不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;
5. 如果插入的是一个新节点,则执行 addCount() 方法尝试更新元素个数 baseCount ;


3.3 get过程


1.    根据k计算出hash值,首先定位到table[]中的i。

2.    若table[i]存在,则继续查找。

3.    首先比较链表头部,如果是则返回。

4.    然后如果为红黑树,查找树。

5.    最后再循环链表查找。

·         

3.4扩容过程


一旦链表中的元素个数超过了8个,那么可以执行数组扩容或者链表转为红黑树,这里依据的策略跟HashMap依据的策略是一致的。

当数组长度还未达到64个时,优先数组的扩容,否则选择链表转为红黑树。

3.5 size实现


1.8中使用一个 volatile 类型的变量 baseCount记录元素的个数,当插入新数据或则删除数据时,会通过 addCount() 方法更新 baseCount,实现如下:

在size()方法和mappingCount方法中都出现了sumCount()方法,因此,我们也顺便看一下。

    /* ----------------Counter support -------------- */

    /**

     * A padded cell for distributingcounts.  Adapted from LongAdder

     * and Striped64.  See their internal docs for explanation.

     */

    @sun.misc.Contended staticfinal class CounterCell {

        volatilelong value;

        CounterCell(long x) { value = x; }

    }

    // Table of countercells. When non-null, size is a power of 2

    privatetransientvolatile CounterCell[] counterCells;

    //ConcurrentHashMap中元素个数,基于CAS无锁更新,但返回的不一定是当前Map的真实元素个数。

    privatetransientvolatilelong baseCount;

    finallong sumCount() {

        CounterCell[] as = counterCells;CounterCell a;

        long sum = baseCount;

        if (as != null) {

            for (int i = 0; i < as.length; ++i) {

                if ((a = as[i]) != null)

                    sum += a.value;

            }

        }

        return sum;

    }

 所以在1.8中的 size 实现比1.7简单多,因为元素个数保存baseCount 中,部分元素的变化个数保存在 CounterCell 数组中,实现如下:

1. 初始化时 counterCells 为空,在并发量很高时,如果存在两个线程同时执行 CAS 修改baseCount值,则失败的线程会继续执行方法体中的逻辑,使用CounterCell 记录元素个数的变化;
2. 如果 CounterCell 数组 counterCells 为空,调用 fullAddCount() 方法进行初始化,并插入对应的记录数,通过 CAS 设置cellsBusy字段,只有设置成功的线程才能初始化 CounterCell 数组,实现如下:
3. 如果通过 CAS 设置cellsBusy字段失败的话,则继续尝试通过 CAS 修改 baseCount 字段,如果修改 baseCount 字段成功的话,就退出循环,否则继续循环插入 CounterCell 对象;


 通过累加 baseCount 和 CounterCell 数组中的数量,即可得到元素的总个数;
 

猜你喜欢

转载自blog.csdn.net/Fly_as_tadpole/article/details/87865210
今日推荐