历史文章推荐:
1.ConcurrentHashMap中有十个提升性能的细节,你都知道吗?
2. HashMap面试,看这一篇就够了
3. 七种方式教你在SpringBoot初始化时搞点事情
4. Java序列化的这三个坑千万要小心
5. Java中七个潜在的内存泄露风险,你知道几个?
6. JDK 16新特性一览
7. 啥?用了并行流还更慢了
Java 7
的ConcurrenHashMap
的源码我建议大家都看看,那个版本的源码就是Java
多线程编程的教科书。在Java 7
的源码中,作者对悲观锁的使用非常谨慎,大多都转换为自旋锁加volatile
获得相同的语义,即使最后迫不得已要用,作者也会通过各种技巧减少锁的临界区。在上一篇文章中我们也有讲到,自旋锁在临界区比较小的时候是一个较优的选择是因为它避免了线程由于阻塞而切换上下文,但本质上它也是个锁,在自旋等待期间只有一个线程能进入临界区,其他线程只会自旋消耗CPU
的时间片。Java 8
中ConcurrentHashMap
的实现通过一些巧妙的设计和技巧,避开了自旋锁的局限,提供了更高的并发性能。如果说Java 7
版本的源码是在教我们如何将悲观锁转换为自旋锁,那么在Java 8
中我们甚至可以看到如何将自旋锁转换为无锁的方法和技巧。
把书读薄
图片来源:https://www.zhenchao.org/2019/01/31/java/cas-based-concurrent-hashmap/
在开始本文之前,大家首先在心里还是要有这样的一张图,如果有同学对HashMap
比较熟悉,那这张图也应该不会陌生。事实上在整体的数据结构的设计上Java 8
的ConcurrentHashMap
和HashMap
基本上是一致的。
Java 7
中ConcurrentHashMap
为了提升性能使用了很多的编程技巧,但是引入Segment
的设计还是有很大的改进空间的,Java 7
中ConcurrrentHashMap
的设计有下面这几个可以改进的点:
Segment
在扩容的时候非扩容线程对本Segment
的写操作时都要挂起等待的- 对
ConcurrentHashMap
的读操作需要做两次哈希寻址,在读多写少的情况下其实是有额外的性能损失的 - 尽管
size()
方法的实现中先尝试无锁读,但是如果在这个过程中有别的线程做写入操作,那调用size()
的这个线程就会给整个ConcurrentHashMap
加锁,这是整个ConcurrrentHashMap
唯一一个全局锁,这点对底层的组件来说还是有性能隐患的 - 极端情况下(比如客户端实现了一个性能很差的哈希函数)
get()
方法的复杂度会退化到O(n)
。
针对1和2,在Java 8
的设计是废弃了Segment
的使用,将悲观锁的粒度降低至桶维度,因此调用get
的时候也不需要再做两次哈希了。size()
的设计是Java 8
版本中最大的亮点,我们在后面的文章中会详细说明。至于红黑树,这篇文章仍然不做过多阐述。接下来的篇幅会深挖细节,把书读厚,涉及到的模块有:初始化,put
方法, 扩容方法transfer
以及size()
方法,而其他模块,比如hash
函数等改变较小,故不再深究。
准备知识
ForwardingNode
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
// MOVED = -1,ForwardingNode的哈希值为-1
super(MOVED, null, null, null);
this.nextTable = tab;
}
}
除了普通的Node
和TreeNode
之外,ConcurrentHashMap
还引入了一个新的数据类型ForwardingNode
,我们这里只展示他的构造方法,ForwardingNode
的作用有两个:
- 在动态扩容的过程中标志某个桶已经被复制到了新的桶数组中
- 如果在动态扩容的时候有
get
方法的调用,则ForwardingNode
将会把请求转发到新的桶数组中,以避免阻塞get
方法的调用,ForwardingNode
在构造的时候会将扩容后的桶数组nextTable
保存下来。
UNSAFE.compareAndSwap***
这是在Java 8
版本的ConcurrentHashMap
实现CAS
的工具,以int
类型为例其方法定义如下:
/**
* Atomically update Java variable to <tt>x</tt> if it is currently
* holding <tt>expected</tt>.
* @return <tt>true</tt> if successful
*/
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
相应的语义为:
如果对象
o
起始地址偏移量为offset
的值等于expected
,则将该值设为x
,并返回true
表明更新成功,否则返回false
,表明CAS
失败
初始化
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0) // 检查参数
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel)
initialCapacity = concurrencyLevel;
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size); // tableSizeFor,求不小于size的 2^n的算法,jdk1.8的HashMap中说过
this.sizeCtl = cap;
}
即使是最复杂的一个初始化方法代码也是比较简单的,这里我们只需要注意两个点:
concurrencyLevel
在Java 7
中是Segment
数组的长度,由于在Java 8
中已经废弃了Segment
,因此concurrencyLevel
只是一个保留字段,无实际意义sizeCtl
这个值第一次出现,这个值如果等于-1则表明系统正在初始化,如果是其他负数则表明系统正在扩容,在扩容时sizeCtl
二进制的低十六位等于扩容的线程数加一,高十六位(除符号位之外)包含桶数组的大小信息
put
方法
public V put(K key, V value) {
return putVal(key, value, false);
}
put
方法将调用转发到putVal
方法:
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;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 【A】延迟初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 【B】当前桶是空的,直接更新
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 【C】如果当前的桶的第一个元素是一个ForwardingNode节点,则该线程尝试加入扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 【D】否则遍历桶内的链表或树,并插入
else {
// 暂时折叠起来,后面详细看
}
}
// 【F】流程走到此处,说明已经put成功,map的记录总数加一
addCount(1L, binCount);
return null;
}
从整个代码结构上来看流程还是比较清楚的,我用括号加字母的方式标注了几个非常重要的步骤,put
方法依然牵扯出很多的知识点
桶数组的初始化
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
// 说明已经有线程在初始化了,本线程开始自旋
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// CAS保证只有一个线程能走到这个分支
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc >