疑问?
为什么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 这两个属性来协调多线程之间的并发操作,并且在扩容过程中大部分数据依旧可以做到访问不阻塞