Java 8 ConcurrentHashMap源码中竟然隐藏着两个BUG

本文深入分析了 Java 8 中 ConcurrentHashMap 的源码,探讨了其在并发性能上的优化策略,如桶数组初始化、自旋锁的使用以及动态扩容的实现。文章指出了源码中可能存在的问题,如扩容线程间的协作和不必要的二次确认,并讨论了方法的改进,包括无锁读写和辅助队列的设计,以提高并发性能。
摘要由CSDN通过智能技术生成

历史文章推荐:
1.ConcurrentHashMap中有十个提升性能的细节,你都知道吗?
2. HashMap面试,看这一篇就够了
3. 七种方式教你在SpringBoot初始化时搞点事情
4. Java序列化的这三个坑千万要小心
5. Java中七个潜在的内存泄露风险,你知道几个?
6. JDK 16新特性一览
7. 啥?用了并行流还更慢了

Java 7ConcurrenHashMap的源码我建议大家都看看,那个版本的源码就是Java多线程编程的教科书。在Java 7的源码中,作者对悲观锁的使用非常谨慎,大多都转换为自旋锁加volatile获得相同的语义,即使最后迫不得已要用,作者也会通过各种技巧减少锁的临界区。在上一篇文章中我们也有讲到,自旋锁在临界区比较小的时候是一个较优的选择是因为它避免了线程由于阻塞而切换上下文,但本质上它也是个锁,在自旋等待期间只有一个线程能进入临界区,其他线程只会自旋消耗CPU的时间片。Java 8ConcurrentHashMap的实现通过一些巧妙的设计和技巧,避开了自旋锁的局限,提供了更高的并发性能。如果说Java 7版本的源码是在教我们如何将悲观锁转换为自旋锁,那么在Java 8中我们甚至可以看到如何将自旋锁转换为无锁的方法和技巧。

把书读薄

image

图片来源:https://www.zhenchao.org/2019/01/31/java/cas-based-concurrent-hashmap/

在开始本文之前,大家首先在心里还是要有这样的一张图,如果有同学对HashMap比较熟悉,那这张图也应该不会陌生。事实上在整体的数据结构的设计上Java 8ConcurrentHashMapHashMap基本上是一致的。

Java 7ConcurrentHashMap为了提升性能使用了很多的编程技巧,但是引入Segment的设计还是有很大的改进空间的,Java 7ConcurrrentHashMap的设计有下面这几个可以改进的点:

  1. Segment在扩容的时候非扩容线程对本Segment的写操作时都要挂起等待的
  2. ConcurrentHashMap的读操作需要做两次哈希寻址,在读多写少的情况下其实是有额外的性能损失的
  3. 尽管size()方法的实现中先尝试无锁读,但是如果在这个过程中有别的线程做写入操作,那调用size()的这个线程就会给整个ConcurrentHashMap加锁,这是整个ConcurrrentHashMap唯一一个全局锁,这点对底层的组件来说还是有性能隐患的
  4. 极端情况下(比如客户端实现了一个性能很差的哈希函数)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;
    }
}

除了普通的NodeTreeNode之外,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; 
}

即使是最复杂的一个初始化方法代码也是比较简单的,这里我们只需要注意两个点:

  • concurrencyLevelJava 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 > 
  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值