ConcurrentHashMap详解

疑问?

为什么HashTable慢? 它的并发度是什么? 那么ConcurrentHashMap并发度是什么?

Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。

​ Hashtable 最大并发度1

​ ConcurrentHashMap 最大并发度16

在JDK1.7和JDK1.8中实现有什么差别? JDK1.8解決了JDK1.7中什么问题

​ 锁机制+底层数据结构 数组中的槽位,链表过长的时候查询较慢。

​ JDK1.7 Segment数组+链表,每个Segment 都继承ReentrantLock,在高并发场景下,锁竞争强烈,会影响性能

​ JDK1.8 Node数组+链表+红黑树 采用CAS+Synchronized机制,减少锁竞争。

JDK1.7中Segment数默认值是多少? 为何一旦初始化就不可再扩容?

16 因为扩容机制是针对Segment数组内部

JDK1.7说说其put的机制?

put之前获取Segment的独占锁,之后执行put操作,之后释放锁

JDK1.7是如何扩容的?

rehash

JDK1.8是如何扩容的?

tryPresize

JDK1.8链表转红黑树的时机是什么? 临界值为什么是8?

判断是否达到临界值,达到之后判断是否大于64

临界值同HashMap

JDK1.8是如何进行数据迁移的?

transfer

JDK1.7 ConcurrentHashMap

在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap。

简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable。

​ 这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可是实现多线程put操作。

数据结构

整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。简单来说就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

concurrencyLevel: 并行级别、并发数、Segment 数,默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。

concurrencyLevel这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。

初始化

​ initialCapacity: 初始容量,这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment。

​ loadFactor: 负载因子,之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。

​ 初始化完成,我们得到了一个 Segment 数组。

​ 所以,当我们用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:

  • Segment 数组长度为 16,不可以扩容
  • Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是说只初始化了 segment[0],所以插入第一个元素不会触发扩容,插入第二个由于超过负载因子,会进行第一次扩容。
  • 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数和掩码,这两个值马上就会用到

put 过程

1)计算key的hash值。

2)根据 hash 值找到 Segment 数组中的位置 j。Segment 内部是由 数组+链表 组成的。

3)在该 segment 写入前,需要先获取该 segment 的独占锁

4)再利用 hash 值,求应该放置的数组下标

5)first 是数组该位置处的链表的表头

6)如果不为nul,根据头节点for循环列表节点,找到一致的进行覆盖。找不到,则解决hash冲突

7)在解决hash冲突过程中, 如果超过了该 segment 的阈值,这个 segment 需要扩容。如果没有达到阈值,将 node 放到数组 tab 的 index 位置,把新节点设置为链表的表头。

3)释放独占锁。

初始化槽 ensureSegment

ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。不过ensureSegment(int k) 比较简单,对于并发操作使用 CAS 进行控制。

​ 这里的设计是为了考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k],不过只要有一个成功了就可以。

获取写入锁: scanAndLockForPut

​ 在往某个 segment 中 put 的时候,首先会调用 node = tryLock() ? null : scanAndLockForPut(key, hash, value),也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。

AQS的锁状态是int型state,用二进制表示是32位,前16位(高位)表示为读锁,后面的16位(低位)表示为写锁。

​ 之后会一直循环获取锁,判断重试次数,若是小于0,那么可能是tryLock() 失败,所以该槽存在并发,不一定是该位置。当重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁。

​ 而当有元素进到了链表,成为了新的表头,便重新设置重试次数为-1。

这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。

扩容: rehash

注意,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍。

​ put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值。该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。

get过程

1)计算 hash 值,找到 segment 数组中的所属槽位

2)槽中也是一个数组,根据 hash 找到数组中具体的位置

3)到这里是链表了,顺着链表进行查找即可

并发问题说明

get过程中没有加锁,那么就会出现问题么?

​ 添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题。就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。

1)get

​ 如果get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。

​ 扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。

2)remove

​ get 操作需要遍历链表,但是 remove 操作会"破坏"链表。

​ 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。

​ 如果 remove 先破坏了一个节点,分两种情况考虑:

1、如果此节点是头节点,那么需要将头节点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。

2、如果要删除的节点不是头节点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。

JDK1.8 ConcurrentHashMap

在JDK1.8中,选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现。

初始化

​ 通过提供初始容量,计算了 sizeCtl,sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。

put过程

1)获取key的hash值

2)如果数组"空",进行数组初始化,就采用CAS操作将新值放入到其中。如果CAS失败说明有并发,就进入到下一个环节。

3)如果当前槽位数组在扩容,则先执行扩容。

4)当数组不为空,并且CAS头节点失败,会尝试获取数组头节点的监视器锁。

5)当头节点的hash值大于0,说明是链表。就会遍历链表,若发现相同的key,则进行覆盖。否则到链表的最末端,将新值放到末尾。

6)而当头节点是红黑树结构,就调用红黑树差值方法插入新节点。此过程中会进行判断是否存在一样的,存在则直接返回。

7)最后判断是否达到临界值,若是达到且当前数据结构是链表,就将链表转为红黑树。临界值也是8,同时判断当前数组的长度是否大于等于64,是的会就转为红黑树。

初始化数组:initTable

首先是初始化一个合适大小的数组,然后会设置 sizeCtl。初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。

​ 如果当前数组并未被其他初始化,就会采用CAS操作,设置sizeCtl 设置为 -1,所以当sizeCtl等于-1时,代表抢到了锁,那么抢到锁的线程就可以执行初始化任务,初始化数组长度为16。

链表转红黑树(treeifyBin)

​ treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容。

​ 当数组长度小于64,则进行数组扩容。当数组长度大于64,头节点也不为空,就对头节点进行加锁,遍历列表建立红黑树,并把红黑树设置到数组响应的位置中。

扩容: tryPresize

如果当前size大小为MAXIMUM_CAPACITY最大总量的一半,那么直接扩容为MAXIMUM_CAPACITY,否则计算最小幂次方(size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方)。

​ 如果sizeCtl为正数或0,且table还未进行初始化,就采用cas操作设置sizeCtl为-1,对table执行初始化操作。

​ 如果扩容大小没有达到阈值,或者超过最大容量,就直接返回

​ 如果当前table是正在执行的table,则执行初始化操作。

​ 如果没有线程正在扩容,则执行扩容初始化。

数据迁移: transfer

transfer是用来将原来的 tab 数组的元素迁移到新的 nextTab 数组中,并把指针位置指向新数组。此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。

​ 说白了,原数组长度为N,那么就会有N个迁移任务,让每个线程负责一个任务。transferIndex是用来安排那个线程执行哪几个任务。第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride(步长) 个任务属于第一个线程,依此类推。

stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16。stride 可以理解为”步长“,有 n 个位置是需要进行迁移的。也就是说,将这 n 个任务分为多个任务包,每个任务包有 stride 个任务。

​ 而在迁移的过程中,如果nextTab为Null,会先进行一次初始化。

​ 说到底,transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制。

get过程分析

1)计算 hash 值

2)根据 hash 值找到数组对应位置: (n - 1) & h

3)根据该位置处结点性质进行相应查找

  • 如果该位置为 null,那么直接返回 null 就可以了
  • 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
  • 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,则获取新node节点中获取值,获取到返回值,否则返回null。
  • 如果以上 3 条都不满足,那就是链表,进行遍历比对即可

总结

  • HashTable : 使用了synchronized关键字对put等操作进行加锁;
  • ConcurrentHashMap JDK1.7: 使用分段锁机制实现;
  • ConcurrentHashMap JDK1.8: 则使用数组+链表+红黑树数据结构和CAS原子操作实现; 在扩容过程中主要使用 sizeCtl 和 transferIndex 这两个属性来协调多线程之间的并发操作,并且在扩容过程中大部分数据依旧可以做到访问不阻塞
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值