目录
3、ConcurrentHashMap 的 put 方法执行逻辑是什么?
4、ConcurrentHashMap 的 get 方法执行逻辑是什么?
5、ConcurrentHashMap 的 get 方法是否要加锁,为什么?
6、ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
7、JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?
8、ConcurrentHashMap 和 Hashtable 的效率哪个更高?为什么?
3、请问节点的 Node.hash 字段一般情况下必须 >=0 这是为什么?
4、简述 ConcurrentHashMap 中 sizeCtl 字段的作用
5、ConcurrentHashMap如何保证写数据线程安全(扩容流程)?
9、扩容期间,扩容工作线程如何维护sizeCtl的低16位呢?
10、当桶位中链表升级为红黑树,且当前红黑树上有读线程正在访问,那么如果再来新的写线程请求该怎么处理?
一、jdk1.7
1、ConcurrentHashMap的结构
使用 Segment数组 + HashEntry数组 + 链表,存储数据的单元还是Node结构,Node结构包括key、value、指向下一个node的next指针以及hash值。其中next字段的作用还是解决hash冲突之后,生成一个链表使用的。
2、HashEntry和Entry的不同点
类似与HashMap节点Entry,HashEntry也是一个单向链表,它包含了key、hash、value和下一个节点信息。HashEntry和Entry的不同点:
不同点一:使用了多个final关键字(final class 、final hash) ,这意味着不能从链表的中间或尾部添加或删除节点,后面删除操作时会讲到。
不同点二:使用volatile,是为了更新值后能立即对其它线程可见。这里没有使用锁,效率更高。
3、ConcurrentHashMap 的 put 方法执行逻辑是什么?
put 方法的总体流程是,
-
通过哈希算法计算出当前 key 的 hash 值
-
通过这个 hash 值找到它所对应的 Segment 数组的下标
-
再通过 hash 值计算出它在对应 Segment 的 HashEntry数组的下标
-
找到合适的位置插入元素
①根据key的哈希值定位到相应的 Segment ,如果该Segment为空,则参考Segment表中的第一个Segment的参数创建一个Segment并通过CAS操作将它记录到Segment表中去(参考ensureSegment方法,ensureSegment()确保拿到的对象一定是不为空的,否则无法执行s.put了)。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
//计算Segment的位置,在初始化的时候对segmentShift和segmentMask做了解释
int j = (hash >>> segmentShift) & segmentMask;
//从Segment数组中获取segment元素的位置
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
//
return s.put(key, hash, value, false);
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
②put操作(jdk1.7)
a、使用tryLock非阻塞获取锁,获取成功node=null,失败则进入scanAndLockForPut方法:持续查找key对应的节点链中是已存在该机节点,如果没有找到,则预创建一个新节点,并且尝试n次,直到尝试次数操作限制,才真正进入加锁等待状态,自旋结束并返回节点
b、获取所在HashEntry链表index的头结点,遍历该链表查找是否有相同key,有则覆盖,没有则使用头插法插入新Node,并检查扩容。
//Segment中的 put 方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//这里通过tryLock尝试加锁,如果加锁成功,返回null,否则执行 scanAndLockForPut方法
//这里说明一下,tryLock 和 lock 是 ReentrantLock 中的方法,
//区别是 tryLock 不会阻塞,抢锁成功就返回true,失败就立马返回false,
//而 lock 方法是,抢锁成功则返回,失败则会进入同步队列,阻塞等待获取锁。
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
//当前Segment的table数组
HashEntry<K,V>[] tab = table;
//这里就是通过hash值,与tab数组长度取模,找到其所在HashEntry数组的下标
int index = (tab.length - 1) & hash;
//当前下标位置的第一个HashEntry节点
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
//如果第一个节点不为空
if (e != null) {
K k;
//并且第一个节点,就是要插入的节点,则替换value值,否则继续向后查找
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
//替换旧值
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
//说明当前index位置不存在任何节点,此时first为null,
//或者当前index存在一条链表,并且已经遍历完了还没找到相等的key,此时first就是链表第一个元素
else {
//如果node不为空,则直接头插
if (node != null)
node.setNext(first);
//否则,创建一个新的node,并头插
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
//如果当前Segment中的元素大于阈值,并且tab长度没有超过容量最大值,则扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
//否则,就把当前node设置为index下标位置新的头结点
else
setEntryAt(tab, index, node);
++modCount;
//更新count值
count = c;
//这种情况说明旧值肯定为空
oldValue = null;
break;
}
}
} finally {
//需要注意ReentrantLock必须手动解锁
unlock();
}
//返回旧值
return oldValue;
}
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//根据hash值定位到它对应的HashEntry数组的下标位置,并找到链表的第一个节点
//注意,这个操作会从主内存中获取到最新的状态,以确保获取到的first是最新值
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
//重试次数,初始化为 -1
int retries = -1; // negative while locating node
//若抢锁失败,就一直循环,直到成功获取到锁。有三种情况
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
//1.若 retries 小于0,
if (retries < 0) {
if (e == null) {
//若 e 节点和 node 都为空,则创建一个 node 节点。这里只是预测性的创建一个node节点
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
//如当前遍历到的 e 节点不为空,则判断它的key是否等于传进来的key,若是则把 retries 设为0
else if (key.equals(e.key))
retries = 0;
//否则,继续向后遍历节点
else
e = e.next;
}
//2.若是重试次数超过了最大尝试次数,则调用lock方法加锁。表明不再重试,我下定决心了一定要获取到锁。
//要么当前线程可以获取到锁,要么获取不到就去排队等待获取锁。获取成功后,再 break。
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
//3.若 retries 的值为偶数,并且从内存中再次获取到最新的头节点,判断若不等于first
//则说明有其他线程修改了当前下标位置的头结点,于是需要更新头结点信息。
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
//更新头结点信息,并把重试次数重置为 -1,继续下一次循环,从最新的头结点遍历当前链表。
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
scanAndLockForPut() 这个方法逻辑比较复杂,会一直循环尝试获取锁,若获取成功,则返回。否则的话,每次循环时,都会同时遍历当前链表。若遍历完了一次,还没找到和key相等的节点,就会预先创建一个节点。注意,这里只是预测性的创建一个新节点,也有可能在这之前,就已经获取锁成功了。
同时,当重试次每偶数次时,就会检查一次当前最新的头结点是否被改变。因为若有变化的话,还需要从最新的头结点开始遍历链表。
还有一种情况,就是循环次数达到了最大限制,则停止循环,用阻塞的方式去获取锁。这时,也就停止了遍历链表的动作,当前线程也不会再做其他预热(warm up)的事情。
scanAndLockForPut 这个方法可以确保返回时,当前线程一定是获取到锁的状态。
4、ConcurrentHashMap 的 get 方法执行逻辑是什么?
首先,根据 key 计算出 hash 值定位到具体的 Segment ,再根据 hash 值获取定位 HashEntry 对象,并对 HashEntry 对象进行链表遍历,找到对应元素。
由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值。
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
5、ConcurrentHashMap 的 get 方法是否要加锁,为什么?
get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。
这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 效率高的原因之一。
6、ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。
而用于单线程状态的 HashMap 却可以用containsKey(key) 去判断到底是否包含了这个 null 。但是在我们调用 ConcurrentHashMap.get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap.put(key, null)的操作。那么我们调用containsKey方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。
参考ConcurrentHashMap为什么key和value不能为null_Jaemon-CSDN博客_concurrenthashmap为什么key不能为null
7、JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?
数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
保证线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用CAS+synchronized保证线程安全。
锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。
链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。
8、ConcurrentHashMap 和 Hashtable 的效率哪个更高?为什么?
ConcurrentHashMap 的效率要高于 Hashtable,因为 Hashtable 给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在 JDK1.7 中采用分段锁实现线程安全,在 JDK1.8 中采用CAS+synchronized实现线程安全。
9、具体说一下Hashtable的锁机制
Hashtable 是使用 synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差。
二、jdk1.8
1、ConcurrentHashMap 简单介绍
结构:使用 Node数组+链表+ 红黑树
①、重要的常量:
private transient volatile int sizeCtl;
sizeCtl = -1 表示正在初始化;
sizeCtl = -n 表示 n - 1 个线程正在进行扩容;
sizeCtl = 0 ,表示 table 还没有初始化;使用默认容量进行初始化;
sizeCtl > 0,表示下一次进行扩容的阈值。例如sizeCtl = 12时,插入新数据会检查容量是否>=12,满足条件则触发扩容。
②、数据结构:
Node 是存储结构的基本单元,继承 HashMap 中的 Entry,用于存储数据;
TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储结构,用于红黑树中存储数据;
TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。
③、存储对象时(put() 方法):
如果没有初始化,就调用 initTable() 方法来进行初始化;
如果没有 hash 冲突就直接 CAS 无锁插入;
如果需要扩容,就先进行扩容;
如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入;
如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一次进入循环
如果添加成功就调用 addCount() 方法统计 size,并且检查是否需要扩容。
④、扩容方法 transfer():默认容量为 16,扩容时,容量变为原来的两倍。
helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。
⑤、获取对象时(get()方法):
计算 hash 值,定位到该 table 索引位置,如果是首结点符合就返回;
如果遇到扩容时,会调用标记正在扩容结点 ForwardingNode.find()方法,查找该结点,匹配就返回;
以上都不符合的话,就往下遍历结点,匹配就返回,否则最后就返回 null。
2、并发Map的负载因子可以修改吗?可以指定吗?
普通的 HashMap 的负载因子可以修改,但是 ConcurrentHashMap 不可以,因为它的负载因子使用final关键字修饰,值是固定的 0.75 。
3、请问节点的 Node.hash 字段一般情况下必须 >=0 这是为什么?
如果 Node.hash = -1,表示当前节点是 ForWardingNode节点(表示已经被迁移的节点)。
如果 Node.hash = -2,表示当前节点已经树化,且当前节点为 TreeBin 对象,TreeBin 对象代理操作红黑树。
如果 Node.hash > 0,表示当前节点是正常的 Node 节点,可能是链表,或者单个 Node。
4、简述 ConcurrentHashMap 中 sizeCtl 字段的作用
sizeCtl 即Size Control。
sizeCtl = -1,表示正在初始化。有线程正在对当前散列表(table) 进行初始化操作。
ConcurrentHashMap 的散链表是延迟初始化的,在并发条件下必须确保只能初始化一次,所以 sizeCtl == -1 就相当于一个"标识锁",防止多个线程去初始化散列表。
具体初始化操作就是在initTable()方法中,会通过 CAS 的方式去修改 sizeCtl 的值为 -1,表示已经有线程正在对散链表进行初始化,其他线程不可以再次初始化,只能等待!
如果 CAS 修改 sizeCtl = -1 操作成功的线程,可以接着执行对散链表初始化的逻辑。而 CAS 修改失败的线程,在这里会不断的自旋检查,直到散链表初始化结束。
这里 CAS 失败的线程会走如下逻辑,即自旋的线程会通过Thread.yield();方法,短暂释放 CPU 资源,把 CPU 资源让给更饥饿的线程去使用。目的是为了减轻自旋对CPU 性能的消耗。
sizeCtl = -n, 表示 n - 1 个线程正在进行扩容;sizeCtl
的高 16 位表示扩容标识戳,低 16 位表示参与并发扩容线程数:1 + nThread
, 即当前参与并发扩容的线程数量为 n
个。
sizeCtl = 0 ,是默认值。
表示在真正第一次初始化散链表的时候使用默认容量 16 进行初始化。
sizeCtl > 0,表示下一次进行扩容的阈值。
例如sizeCtl = 12时,插入新数据会检查容量是否>=12,满足条件则触发扩容。
5、ConcurrentHashMap如何保证写数据线程安全(扩容流程)?
① 首先,先判断散链表是否已经初始化,如果没初始化则先初始化散链表,再进行写入操作。
② 当向桶位中写数据时,先判断桶中是否为空,如果是空桶,则直接通过 CAS 的方式将新增数据节点写入桶中。如果 CAS 写入失败,则说明有其他线程已经在当前桶位中写入数据,当前线程竞争失败,回到自旋位置,自旋等待。
如果当前桶中不为空,就需要判断当前桶中头结点的类型:
③ 如果桶中头结点的 hash 值 为 -1,表示当前桶位的头结点为 FWD 结点,目前散链表正处于扩容过程中。这时候当前线程需要去协助扩容。
④ 如果 ②、③ 条件不满足,则表示当前桶位的存放的可能是一条链表,也可能是红黑树的代理对象 TreeBin。这种情况下会使用 synchronized 锁住桶中的头结点,来保证桶内的写操作是线程安全的。
6、触发扩容条件的线程,执行的预处理工作都有哪些?
①首先,触发扩容条件的线程,要做的第一件事就是通过 CAS 的方式修改 sizeCtl 字段值,使其高 16 位为扩容唯一标识戳,低 16 位为(参与扩容的线程数 + 1),表示有线程正在进行扩容逻辑。
注意:这里高 16 位的扩容唯一标识戳是根据当前散链表的长度计算得来,其最高位是 1。那么最终得到的 sizeCtl 就应该是一个负数。
②然后,当前触发扩容条件的线程会创建一个新的散链表,大小为原来旧散链表的 2 倍。并且将新散链表的引用赋给 map.nextTable 字段。
③因为 ConcurrentHashMap 是支持多线程并发扩容的,所以需要让协助扩容的线程知道旧散链表数据迁移到新散链表的进度。为此 ConcurrentHashMap 提供了一个 transferIndex 字段,用于记录旧散链表数据的总迁移进度!迁移工作进度是从 高位桶开始,一直迁移到下标是 0 的桶位。
7、旧散链表中迁移完毕后的桶,如何做标记?
旧散链表中迁移完毕的桶,需要用 ForwardingNode 对象表示桶内节点,这种 Node 比较特殊,是用来表示当前桶中的数据已经被迁移到新散链表的桶中去了。
- ForwardingNode 有哪些作用?
答:ForwardingNode 对象内部提供了一个用于向新散链表中查询目标数据的find()方法。
当此时某个线程刚好在旧散链表中查询目标元素时,ForwardingNode可以把find()转发到扩容后的nextTable上,而执行put()方法的线程如果碰到ForwardingNode节点,也会协助迁移。
8、如果散列表正在扩容时,再来新的写入请求该如何处理呢?
- 如果当前线程执行写入操作时,根据寻址算法访问到的桶中不是 FWD 节点(即,当前桶中数据没有被迁移)。那么此时先判断桶中是否为空,如果为空则 CAS 方式写入数据。而如果桶不为空,则可能是链表或者 TreeBin,这时候需要通过 synchronized 关键字锁住桶的头结点再进行写入操作。
- 而如果如果当前线程执行写入操作时,根据寻址算法访问到的桶中是 FWD 节点(即,当前桶中数据已经被迁移)。碰到 FWD 节点,说明此时散链表正在进行扩容,这时候需要当前线程也加入进去,去协助散链表扩容(helpTransfer(tab, f);协助扩容是为了尽量减少扩容所花费在数据迁移上的时间)。
- 当前线程加入到协助扩容中后,ConcurrentHashMap 会根据全局的transferIndex字段去给当前线程分配迁移工作任务(需要负责迁移旧散链表的桶位区间)。例如,让当前线程负责迁移旧散链表中 【0-4】桶位上的数据到新散链表。
- 一直到当前线程分配不到要负责迁移的任务时,则退出协助扩容,即扩容结束。这时候,当前线程就可以继续执行写入数据的逻辑了!
9、扩容期间,扩容工作线程如何维护sizeCtl的低16位呢?
- 每一个执行扩容任务的线程(包含协助扩容),它在开始工作之前,都会更新 sizeCtl的低 16 位,即让低 16 位 +1,说明又加入一个新的线程去执行扩容。
- 每个执行扩容的线程都会被分配一个迁移工作任务区间,如果当前线程所负责的任务区间迁移工作完成了,没有再被分配迁移任务区间,那么此时当前线程就可以退出协助扩容了,这时候更新 sizeCtl的低 16 位,即让低 16 位 -1,说明有一个线程退出并发扩容了。
- 如果 sizeCtl 低 16 位-1后的值为 1,则说明当前线程是最后一个退出并发扩容的线程。
- 最后一个退出的线程会做两件事:①重新检查一遍老表,看看有没有遗漏的slot,即:判断slot的值是不是fwd节点,是则跳过,不是则迁移这个slot的数据,属于一种保障机制;②将新表的引用保存到map.table字段上,然后根据新表的大小,算出下一次扩容的阈值,保存到sizeCtl字段。
10、当桶位中链表升级为红黑树,且当前红黑树上有读线程正在访问,那么如果再来新的写线程请求该怎么处理?
- 写线程会被阻塞,因为红黑树比较特殊,新写入数据,可能会触发红黑树的自平衡,这就会导致树的结构发生变化,会影响读线程的读取结果。
在红黑树上读取数据和写入数据是互斥的,具体原理分析如下:
我们知道 ConcurrentHashMap 中的红黑树由 TreeBin 来代理,TreeBin 内部有一个 Int 类型的 state 字段。当读线程在读取数据时,会使用 CAS 的方式将 state 值 +4(表示加了读锁),读取数据完毕后,再使用CAS 的方式将 state 值 -4。
如果写线程去向红黑树中写入数据时,会先检查 state 值是否等于 0,如果是 0,则说明没有读线程在检索数据,这时候可以直接写入数据,写线程也会通过 CAS 的方式将 state 字段值设置为 1(表示加了写锁)。
如果写线程检查 state 值不是 0,这时候就会park()挂起当前线程,使其等待被唤醒。挂起写线程时,写线程会先将 state 值的第 2 个 bit 位设置为 1(二进制为 10),转换成十进制就是 2,表示有写线程等待被唤醒。
- 反过来,当红黑树上有写线程正在执行写入操作,那么如果有新的读线程请求该怎么处理?
TreeBin 对象内部保留了一个链表结构,就是为了这种情况而设计的。这时候会让新来的读线程到链表上去访问数据,而不经过红黑树。
- 挂起等待的写线程后,什么时候再唤醒呢?
读线程在读取数据时,会使用 CAS 的方式将 state 值 +4(表示加了读锁),读取数据完毕后,再使用CAS 的方式将 state 值 -4。
当 state 值减去 4 后,读线程会先检查一下 state 值是不是 2,如果是 2 ,则说明有等待被唤醒的写线程在挂起等候,这时候调用 unsafe.unpark() 方法去唤醒该写线程。
11、ConcurrentHashMap如何计算集合size?
借助baseCount和countCells数组:
- 当并发量较小时,优先使用CAS的方式更新baseCount;
- 当更新baseCount冲突,则会认为进入到比较激烈的竞争状态,通过启用counterCells减少竞争,通过CAS的方式把总数更新情况记录在counterCells数组对应的位置上;
- 如果更新counterCells上的某个位置时出现了多次失败,则会通过扩容counterCells的方式减少冲突;
- 统计集合size的方式就是baseCount + counterCells内的数据