前言
太久没看jdk,今天面试被问到ConcurrentHashMap(下面可能会简称为chm)的扩容原理,只能含糊的答出来一些,翻了翻自己的笔记,似乎当时看了(而且印象里之前看的时候就感觉网上讲得很清晰的文章很难找),但是也没有特别地整理这一块的内容。因此这里特地对它进行整理。主要包括put、get、扩容。
jdk1.8之前和之后的区别
之前看《java并发艺术》这本书的时候,对chm的印象是,将一个map划分为16个段,并发修改时只会对各自段加锁,这样就可以大大提高并发性能了。
jdk1.8之后就不是这样了,一个最重要的区别就是,取消了段的概念,而是直接对桶加锁。直观地想一下,这样的并发性能肯定会更好嘛。其它区别我就先不提了,那是真八股文。我们只对jdk1.8及之后的hashmap做一个分析,我的jdk版本是11.0.8(其实最重要的是大版本为11),1.8之后的各个版本可能略有不同,但是原理应该都类似。
分界线
下面我们就要正式开始讲原理和代码了。在这之前,我们先带着一些问题来阅读,可能会更有目标一些:
-
没有扩容的情况下
- 并发修改(put函数)同一个桶,会不会出现异常情况(指使chm处在一个不一致的状态,比如链表有环)?
- 一边改,一边读,会不会出现异常情况(读会不会死循环)?
-
扩容过程
-
什么时候开始扩容?
-
扩容时,读、写服务会中断/阻塞吗?和不扩容时有什么区别呢?
-
那从这里开始看代码吧~
put&remove函数
这俩函数是对表进行修改的函数,也是分析的重中之重
put函数流程解析
在真正开始之前,我们先回忆一下,普通的HashMap是怎么做put的
- 计算key的hashcode,并对桶数量取余,得到桶的下标
- 如果当前的桶是空的,就为它创建一个新的桶链表头,结束插入流程;否则在已有的桶链表/树中,查找是否已经存在这个key
- 若存在key,则更新它的value
- 不存在,则插入新节点。顺便还会在链表超出设定范围的时候,执行一次“树化”
chm不过是HashMap的升级版,大致流程是类似的,只不过对并发访问的正确性和效率做了针对性处理。我们首先将其进行拆解,并且忽略一些不重要的因素,得到一个简化版的函数
public class ConcurrentHashMap<K,V>
extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
// 这个死循环的意义在于,为了尽量避免同步锁(阻塞)、加快扩容速度,chm会根据当前桶的情况做不同的操作。循环内是多个if-elif-else语句块,每次循环只会执行其中的一块逻辑,因此总体还是清晰的
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
// 当数据结构还未初始化,就使用CAS初始化,并且从头开始循环
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 当桶为空,cas设置桶,如果设置成功,put操作就算完成了,否则从头开始循环
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
// 如果当前处于扩容过程,那就为扩容尽一份力。扩容完成后会从头开始循环
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 这里是真正地,对已存在的桶加锁并处理的情况
else {
synchronized (f) {
// 首先要判断,锁住的这个桶是否和之前获取的一样(因为可能会出现这个节点被删掉/删掉之后又插入的情况,那就锁了个“假桶”),如果不一样,再次回到循环开头处理
if (tabAt(tab, i) == f) {
// 忽略这段又臭又长的代码,和HashMap基本一样
}<