如何保证集合是线程安全的 ConcurrentHashMap如何实现高效地线程安全?

news2024/11/15 20:01:55

第10讲 | 如何保证集合是线程安全的? ConcurrentHashMap如何实现高效地线程安全?

在这里插入图片描述

我在之前两讲介绍了 Java 集合框架的典型容器类,它们绝大部分都不是线程安全的,仅有的线程安全实现,比如 Vector、Stack,在性能方面也远不尽如人意。幸好 Java 语言提供了并发包(java.util.concurrent),为高度并发需求提供了更加全面的工具支持。

今天我要问你的问题是,如何保证容器是线程安全的?ConcurrentHashMap 如何实现高效地线程安全?

典型回答

Java 提供了不同层面的线程安全支持。在传统集合框架内部,除了 Hashtable 等同步容器,还提供了所谓的同步包装器(Synchronized Wrapper),我们可以调用 Collections 工具类提供的包装方法,来获取一个同步的包装容器(如 Collections.synchronizedMap),但是它们都是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。

另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了:

各种并发容器,比如 ConcurrentHashMap、CopyOnWriteArrayList。

各种线程安全队列(Queue/Deque),如 ArrayBlockingQueue、SynchronousQueue。

各种有序容器的线程安全版本等。

具体保证线程安全的方式,包括有从简单的 synchronize 方式,到基于更加精细化的,比如基于分离锁实现的 ConcurrentHashMap 等并发实现等。具体选择要看开发的场景需求,总体来说,并发包内提供的容器通用场景,远优于早期的简单同步实现。

考点分析

谈到线程安全和并发,可以说是 Java 面试中必考的考点,我上面给出的回答是一个相对宽泛的总结,而且 ConcurrentHashMap 等并发容器实现也在不断演进,不能一概而论。

如果要深入思考并回答这个问题及其扩展方面,至少需要:

理解基本的线程安全工具。

理解传统集合框架并发编程中 Map 存在的问题,清楚简单同步方式的不足。

梳理并发包内,尤其是 ConcurrentHashMap 采取了哪些方法来提高并发表现。

最好能够掌握 ConcurrentHashMap 自身的演进,目前的很多分析资料还是基于其早期版本。

今天我主要是延续专栏之前两讲的内容,重点解读经常被同时考察的 HashMap 和 ConcurrentHashMap。今天这一讲并不是对并发方面的全面梳理,毕竟这也不是专栏一讲可以介绍完整的,算是个开胃菜吧,类似 CAS 等更加底层的机制,后面会在 Java 进阶模块中的并发主题有更加系统的介绍。

知识扩展

  1. 为什么需要 ConcurrentHashMap?

Hashtable 本身比较低效,因为它的实现基本就是将 put、get、size 等各种方法加上“synchronized”。简单来说,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。

前面已经提过 HashMap 不是线程安全的,并发情况会导致类似 CPU 占用 100% 等一些问题,那么能不能利用 Collections 提供的同步包装器来解决问题呢?

看看下面的代码片段,我们发现同步包装器只是利用输入 Map 构造了另一个同步版本,所有操作虽然不再声明成为 synchronized 方法,但是还是利用了“this”作为互斥的 mutex,没有真正意义上的改进!


private static class SynchronizedMap<K,V>
    implements Map<K,V>, Serializable {
    private final Map<K,V> m;     // Backing Map
    final Object      mutex;        // Object on which to synchronize
    // …
    public int size() {
        synchronized (mutex) {return m.size();}
    }
 // … 
}

所以,Hashtable 或者同步包装版本,都只是适合在非高度并发的场景下。

2.ConcurrentHashMap 分析

我们再来看看 ConcurrentHashMap 是如何设计实现的,为什么它能大大提高并发效率。

首先,我这里强调,ConcurrentHashMap 的设计实现其实一直在演化,比如在 Java 8 中就发生了非常大的变化(Java 7 其实也有不少更新),所以,我这里将比较分析结构、实现机制等方面,对比不同版本的主要区别。

早期 ConcurrentHashMap,其实现是基于:

分离锁,也就是将内部进行分段(Segment),里面则是 HashEntry 的数组,和 HashMap 类似,哈希相同的条目也是以链表形式存放。

HashEntry 内部使用 volatile 的 value 字段来保证可见性,也利用了不可变对象的机制以改进利用 Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。

你可以参考下面这个早期 ConcurrentHashMap 内部结构的示意图,其核心是利用分段设计,在进行并发操作的时候,只需要锁定相应段,这样就有效避免了类似 Hashtable 整体同步的问题,大大提高了性能。

在这里插入图片描述

在构造的时候,Segment 的数量由所谓的 concurrencyLevel 决定,默认是 16,也可以在相应构造函数直接指定。注意,Java 需要它是 2 的幂数值,如果输入是类似 15 这种非幂值,会被自动调整到 16 之类 2 的幂数值。

具体情况,我们一起看看一些 Map 基本操作的源码,这是 JDK 7 比较新的 get 代码。针对具体的优化部分,为方便理解,我直接注释在代码段里,get 操作需要保证的是可见性,所以并没有什么同步逻辑。


public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key.hashCode());
       //利用位操作替换普通数学运算
       long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        // 以Segment为单位,进行定位
        // 利用Unsafe直接进行volatile access
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
           //省略
          }
        return null;
    }

而对于 put 操作,首先是通过二次哈希避免哈希冲突,然后以 Unsafe 调用方式,直接获取相应的 Segment,然后进行线程安全的 put 操作:


 public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        // 二次哈希,以保证数据的分散性,避免哈希冲突
        int hash = hash(key.hashCode());
        int j = (hash >>> segmentShift) & segmentMask;
        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);
    }

其核心逻辑实现在下面的内部方法中:


final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            // scanAndLockForPut会去查找是否有key相同Node
            // 无论如何,确保获取锁
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        // 更新已有value...
                    }
                    else {
                        // 放置HashEntry到特定位置,如果超过阈值,进行rehash
                        // ...
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

所以,从上面的源码清晰的看出,在进行并发写操作时:

ConcurrentHashMap 会获取再入锁,以保证数据一致性,Segment 本身就是基于 ReentrantLock 的扩展实现,所以,在并发修改期间,相应 Segment 是被锁定的。

在最初阶段,进行重复性的扫描,以确定相应 key 值是否已经在数组里面,进而决定是更新还是放置操作,你可以在代码里看到相应的注释。重复扫描、检测冲突是 ConcurrentHashMap 的常见技巧。

我在专栏上一讲介绍 HashMap 时,提到了可能发生的扩容问题,在 ConcurrentHashMap 中同样存在。不过有一个明显区别,就是它进行的不是整体的扩容,而是单独对 Segment 进行扩容,细节就不介绍了。

另外一个 Map 的 size 方法同样需要关注,它的实现涉及分离锁的一个副作用。

试想,如果不进行同步,简单的计算所有 Segment 的总值,可能会因为并发 put,导致结果不准确,但是直接锁定所有 Segment 进行计算,就会变得非常昂贵。其实,分离锁也限制了 Map 的初始化等操作。

所以,ConcurrentHashMap 的实现是通过重试机制(RETRIES_BEFORE_LOCK,指定重试次数 2),来试图获得可靠值。如果没有监控到发生变化(通过对比 Segment.modCount),就直接返回,否则获取锁进行操作。

下面我来对比一下,在 Java 8 和之后的版本中,ConcurrentHashMap 发生了哪些变化呢?

总体结构上,它的内部存储变得和我在专栏上一讲介绍的 HashMap 结构非常相似,同样是大的桶(bucket)数组,然后内部也是一个个所谓的链表结构(bin),同步的粒度要更细致一些。

其内部仍然有 Segment 定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构上的用处。

因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。

数据存储利用 volatile 来保证可见性。

使用 CAS 等操作,在特定场景进行无锁并发操作。

使用 Unsafe、LongAdder 之类底层手段,进行极端情况的优化。

先看看现在的数据存储内部实现,我们可以发现 Key 是 final 的,因为在生命周期中,一个条目的 Key 发生变化是不可能的;与此同时 val,则声明为 volatile,以保证可见性。


 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
        // … 
    }

我这里就不再介绍 get 方法和构造函数了,相对比较简单,直接看并发的 put 是如何实现的。


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; K fk; V fv;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 利用CAS去进行无锁线程安全操作,如果bin是空的
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break; 
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else if (onlyIfAbsent // 不加锁,进行检查
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                 && (fv = f.val) != null)
            return fv;
        else {
            V oldVal = null;
            synchronized (f) {
                   // 细粒度的同步修改操作... 
                }
            }
            // Bin超过阈值,进行树化
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

初始化操作实现在 initTable 里面,这是一个典型的 CAS 使用场景,利用 volatile 的 sizeCtl 作为互斥手段:如果发现竞争性的初始化,就 spin 在那里,等待条件恢复;否则利用 CAS 设置排他标志。如果成功则进行初始化;否则重试。

请参考下面代码:


private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 如果发现冲突,进行spin等待
        if ((sc = sizeCtl) < 0)
            Thread.yield(); 
        // CAS成功返回true,则进入真正的初始化逻辑
        else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

当 bin 为空时,同样是没有必要锁定,也是以 CAS 操作去放置。

你有没有注意到,在同步逻辑上,它使用的是 synchronized,而不是通常建议的 ReentrantLock 之类,这是为什么呢?现代 JDK 中,synchronized 已经被不断优化,可以不再过分担心性能差异,另外,相比于 ReentrantLock,它可以减少内存消耗,这是个非常大的优势。

与此同时,更多细节实现通过使用 Unsafe 进行了优化,例如 tabAt 就是直接利用 getObjectAcquire,避免间接调用的开销。

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
}

再看看,现在是如何实现 size 操作的。阅读代码你会发现(http://hg.openjdk.java.net/jdk/jdk/file/12fc7bf488ec/src/java.base/share/classes/java/util/concurrent/ConcurrentHashMap.java),真正的逻辑是在 sumCount 方法中, 那么 sumCount 做了什么呢?

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

我们发现,虽然思路仍然和以前类似,都是分而治之的进行计数,然后求和处理,但实现却基于一个奇怪的 CounterCell。 难道它的数值,就更加准确吗?数据一致性是怎么保证的?

static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

其实,对于 CounterCell 的操作,是基于 java.util.concurrent.atomic.LongAdder 进行的,是一种 JVM 利用空间换取更高效率的方法,利用了Striped64内部的复杂逻辑。这个东西非常小众,大多数情况下,建议还是使用 AtomicLong,足以满足绝大部分应用的性能需求。

今天我从线程安全问题开始,概念性的总结了基本容器工具,分析了早期同步容器的问题,进而分析了 Java 7 和 Java 8 中 ConcurrentHashMap 是如何设计实现的,希望 ConcurrentHashMap 的并发技巧对你在日常开发可以有所帮助。

一课一练

关于今天我们讨论的题目你做到心中有数了吗?留一个道思考题给你,在产品代码中,有没有典型的场景需要使用类似 ConcurrentHashMap 这样的并发容器呢?

请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。

c.LongAdder 进行的,是一种 JVM 利用空间换取更高效率的方法,利用了Striped64内部的复杂逻辑。这个东西非常小众,大多数情况下,建议还是使用 AtomicLong,足以满足绝大部分应用的性能需求。

今天我从线程安全问题开始,概念性的总结了基本容器工具,分析了早期同步容器的问题,进而分析了 Java 7 和 Java 8 中 ConcurrentHashMap 是如何设计实现的,希望 ConcurrentHashMap 的并发技巧对你在日常开发可以有所帮助。

一课一练

关于今天我们讨论的题目你做到心中有数了吗?留一个道思考题给你,在产品代码中,有没有典型的场景需要使用类似 ConcurrentHashMap 这样的并发容器呢?

请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。

你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/350782.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

智能家居项目(一)之基础概念引入

目录 一、智能家居功能整体流程 二、设计模式 三、C语言中结构体的新玩法&#xff0c;承接上一步 四、工厂模式概念引入 一、智能家居功能整体流程 1.控制区语音识别模块socket客户端 2.外设区继电器组控制灯&#xff0c;远程终端子系统&#xff0c;窗帘等&#xff0c;火灾…

数据分析面试---假设检验知识点

文章目录一、假设检验是干啥的二、假设检验基本原理/思想三、假设检验步骤显著性检验(significance test)统计显著性和置信度补充&#xff1a;第一类错误和第二类错误p值是啥链接1数理统计中的统计推断问题主要有两大类&#xff1a;参数估计问题和假设检验问题参数估计问题 所指…

[datawhale202302]CS224W图机器学习:图的基本表示及特征工程

结论速递 本章涉及了图基本表示及传统的特征工程。 图由节点和连接组成&#xff0c;节点和连接上都可以有不同的属性。根据属性的特点&#xff0c;分为几类不同的图&#xff0c;其中异质图和二分图是比较重要的特殊图。 图可以用邻接矩阵进行结构化表示&#xff0c;如果图过于…

单元测试的优势

单元测试提供了许多好处&#xff0c;包括及早发现软件错误、促进变化、简化集成、提供文档来源以及许多其他优点&#xff0c;接下来将对其进行详细介绍。 1、使流程更灵活 单元测试的主要好处之一是它使编码过程更加灵活&#xff0c;更遵循敏捷开发方法论。 当向软件中添加越来…

如何加入new bing候补名单

如何加入new bing候补名单 我们都知道现在最新版edges中已经提示我们可以加入new bing候补名单&#xff0c;但国内环境下无法正常加入new bing候补名单&#xff0c;这篇文章讲告诉你如何绕过限制加入new bing候补名单 下载配置 HeaderEditor 插件 下载地址microsoftedge.mic…

XDSpy APT组织近期针对俄罗斯国防部的攻击活动分析

一 概述 XDSpy是ESET于2020年首次披露的APT组织&#xff0c;该组织最早活跃于2011年&#xff0c;主要针对东欧和塞尔维亚地区的政府、军队、外交部及私人公司进行窃密活动。2020年9月&#xff0c;该组织在攻击活动中使用Covid-19主题诱饵下发恶意Windows脚本文件&#xff08;W…

高通平台开发系列讲解(Sensor篇)IAM20680驱动程序的使用

文章目录 一、相关编译宏二、设备树配置三、打开iio自动挂载脚本四、模块加载验证沉淀、分享、成长,让自己和他人都能有所收获!😄 📢本篇文章将介绍 IAM20680 驱动程序。 一、相关编译宏 目录:arch/arm/configs CONFIG_IIO_BUFFER=y CONFIG_IIO_KFIFO_BUF=y CONFIG_II…

Highcharts.Chart

Highcharts 是一个使用javascript 脚本来生成图表的工具&#xff0c;和jfreechart 作用类似&#xff0c;都用来生成各种图表&#xff0c;并支持图片的导出和打印。 从官网 www.highcharts.com 上下载的压缩表中的example中有各种图表的例子。 要编写生成图表的例子建议从 文…

生成小分子力场TOP

生成小分子力场TOP ATB网站 生成基于Amber力场适配gromacs格式TOP文件&#xff0c; 对于使用GaussView 计算resp电荷可以参考&#xff1a; https://blog.csdn.net/weixin_42486623/article/details/129055384 下面我们使用上面是生成的mol2文件来生成基于Amber的力场&#x…

Linux网络编程 | 彻底搞懂零拷贝(zero-copy)技术

传统文件传输的问题 在网络编程中&#xff0c;如果我们想要提供文件传输的功能&#xff0c;最简单的方法就是用read将数据从磁盘上的文件中读取出来&#xff0c;再将其用write写入到socket中&#xff0c;通过网络协议发送给客户端。 ssize_t read(int fd, void *buf, size_t …

JVM05 方法区

Person&#xff1a;存放在元空间&#xff0c;也可以说方法区 person&#xff1a;存放在Java栈的局部变量表中 new Person()&#xff1a;存放在Java堆中 1.方法区的理解 方法区主要存放的是 Class&#xff0c;而堆中主要存放的是 实例化的对象 方法区&#xff08;Method Area…

ubuntu清理挖矿病毒

0 序言 我之前搭建的hadoop用于测试&#xff0c;直接使用了8088和9870端口&#xff0c;没有放入docker&#xff0c;从而没有端口映射。于是&#xff0c;就被不法之徒盯上了&#xff0c;hadoop被提交了很多job&#xff0c;使得系统被感染了挖矿病毒&#xff0c;在前几天阿里云站…

企业实施了IM移动平台后,可以在上面做什么?

企业实施了IM移动平台后&#xff0c;可以有很多服务场景&#xff0c;其中员工服务平台是一个很好的场景组合。企业固然是以赢利为目的的组织&#xff0c;然而&#xff0c;在一个企业中同样需要有人文关怀的氛围。这种人文关怀&#xff0c;说到底&#xff0c;就是一种以员工为本…

阿里巴巴主流数据库连接池Druid入门

一.数据库连接池的必要性 (一).传统数据库连接模式的的步骤 1.在主程序中创建连接 2.进行sql操作 3.关闭数据库连接 &#xff08;二&#xff09;.传统数据库连接模式存在的问题 1.浪费时间&#xff1a;每次连接时都要验证登录和将conn加载到内存&#xff0c; 2.不能大规…

使用ThreadLocal实现当前登录信息的存取

有志者&#xff0c;事竟成 文章持续更新&#xff0c;可以关注【小奇JAVA面试】第一时间阅读&#xff0c;回复【资料】获取福利&#xff0c;回复【项目】获取项目源码&#xff0c;回复【简历模板】获取简历模板&#xff0c;回复【学习路线图】获取学习路线图。 文章目录一、使用…

【多线程与高并发】- 线程基础与状态

线程基础与状态 &#x1f604;生命不息&#xff0c;写作不止 &#x1f525; 继续踏上学习之路&#xff0c;学之分享笔记 &#x1f44a; 总有一天我也能像各位大佬一样 &#x1f3c6; 一个有梦有戏的人 怒放吧德德 &#x1f31d;分享学习心得&#xff0c;欢迎指正&#xff0c;大…

漏洞深度分析|CVE-2023-25141 sling-org-apache-sling-jcr-base存在JNDI注入漏洞

项目介绍 Apache Sling提供对可插入资源提供程序的支持。虽然这允许将自定义数据提供程序非常灵活和高效地集成到Sling中&#xff0c;但这种集成是在Sling的资源 API 级别上完成的。可能依赖于能够将资源适配到JCR节点并继续使用JCR API的遗留代码将不适用于此类资源提供者。 …

微信小程序自动化测试最佳实践(附 Python 源码)

目录&#xff1a;导读 小程序运行环境 微信小程序技术架构 使用 Chrome 调试小程序 小程序的性能测试 微信小程序的自动化测试​编辑 源码-微信小程序自动化测试 Python 版代码示例 小程序自动化测试需要跨过的几个坎 写在最后 随着微信小程序的功能和生态日益完善&…

“一号文件”聚焦三农,VR全景助农发展数字化

一号文件为何如此聚焦三农&#xff1f;因为三农是拼经济的基础&#xff0c;也是筑牢底盘的基本保证。现如今&#xff0c;农业基础还不稳固&#xff0c;而且城乡区域发展和居民收入差距依旧较大&#xff0c;为了解决城乡发展不平衡、农村发展不充分等矛盾&#xff0c;发展数字化…

Fuzz概述

文章目录AFL一些概念插桩与覆盖率边和块覆盖率afl自实现劫持汇编器clang内置覆盖率反馈与引导变异遗传算法fork server机制AFL调试准备AFL一些概念 插桩与覆盖率 边和块 首先&#xff0c;要明白边和块的定义 正方形的就是块&#xff0c;箭头表示边&#xff0c;边表示程序执行…