news 2026/5/12 5:24:54

ConcurrentHashMap详细讲解(java)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ConcurrentHashMap详细讲解(java)

文章目录

    • 前言
    • 一、 为什么用ConcurrentHashMap
      • 1.1 什么是 ConcurrentHashMap
      • 1.2 为什么用ConcurrentHashMap
    • 二、 并发和锁的基础知识
      • 2.1 缘起:硬件的“木桶效应”与 JMM 的诞生
      • 2.2 并发编程的三大核心危机
        • 2.2.1 可见性问题:CPU 缓存引发的“盲区”
        • 2.2.2 原子性问题:线程切换带来的“半途而废”
        • 2.2.3 有序性问题:编译优化的“移花接木”
      • 2.3 破局:Java 提供的底层利器
        • 2.3.1 synchronized(宏观锁)
        • 2.3.2 volatile 与 Happens-Before 原则
        • 2.3.3 CAS (Compare And Swap - 无锁化原子操作)
    • 三、 数据结构进化历程
      • 3.1 史前时代:数组与链表的“极限拉扯”
      • 3.2 第一次进化:哈希表(数组 + 链表)的诞生
      • 3.3 第二次终极进化(JDK8):红黑树的破局
    • 四、 常见面试问题
    • 五、 实战使用场景
      • 5.1 场景一:本地高并发缓存 (Local Cache)
      • 5.2 场景二:实时数据统计汇总
    • 总结

前言

参考学习视频链接:带你透彻理解ConcurrentHashMap,最新最全的面试题及解答_哔哩哔哩_bilibili


一、 为什么用ConcurrentHashMap

1.1 什么是 ConcurrentHashMap

简单来说,ConcurrentHashMap是 Java 并发包(java.util.concurrent)中提供的一个线程安全的高效哈希表

它的核心功能和HashMap一样,都是用来存储键值对(Key-Value)数据的,但它允许多个线程同时对它进行读写操作而不会把数据搞乱。

在单线程的HashMap中,key 和 value 都允许为 null。但在ConcurrentHashMap中,key 和 value 绝对不能为 null

  • 原因:这是为了防范并发环境下的“二义性(歧义)”问题。如果get(key)返回了 null,你根本无法确认是“这个 key 不存在”,还是“这个 key 的 value 被设置成了 null”。在单线程中,你可以通过containsKey(key)去验证;但在多线程中,你验证的瞬间数据可能已经被其他线程篡改了。

1.2 为什么用ConcurrentHashMap

单线程时,HashMap跑得飞快。但一旦在多线程情况下,就会暴露出数据不一致问题

归根结底,这种不一致是由Java 内存模型(JMM)导致的,在宏观上主要表现为两类冲突:

  • 写-写冲突:两个线程同时对同一个位置进行修改,导致其中一个线程的数据被无情覆盖(数据丢失)。
  • 写-读冲突:一个线程刚刚修改了数据,但由于工作内存未及时刷入主内存,另一个线程去读的时候,拿到的依然是旧数据(读到脏数据)。

为了解决数据不一致问题,最直观的解法就是加锁,这也是HashMapConcurrentHashMap演进的核心推手。

然而,粗暴的加锁会极大拖累系统性能。因此,提升高并发容器性能的关键思路在于:尽量减小锁的粒度,并尽可能找出可以“无锁化”的操作

在设计时,需要对读写操作“分而治之”:

  • 写操作:只要涉及数据的改动,必然需要加锁兜底,此时架构设计的核心考量就是尽力缩小锁的粒度(例如从锁整个 Map 缩小到只锁一个节点)。
  • 读操作:在写操作保证不出错的前提下,读操作相对好办。核心在于取舍“是否需要实时读到最新数据”:
    • 强一致性:读写双重加锁,绝对安全但性能最差(如HashTable)。
    • 顺序一致性:使用volatile关键字,不加锁但能保证数据的可见性。
    • 弱一致性:读完全不加锁,容忍极端情况下的微小延迟,追求极致性能

为了在“安全”与“性能”之间平衡,ConcurrentHashMap并没有采用单一的加锁手段,而是综合运用了以下三种核心底层方案:

  • synchronized:用于保证特定代码块的原子性可见性(使操作局部串行化,保证有序)。
  • volatile:轻量级同步机制,作用于单个共享变量。它像是一个全网广播,既能保证数据的可见性,又能禁止指令重排序
  • CAS (Compare-And-Swap):乐观锁机制,利用底层硬件指令实现针对单个共享变量的无锁原子操作

简而言之,ConcurrentHashMap正是巧妙地运用这三点,解决了HashMap的多线程问题。


二、 并发和锁的基础知识

2.1 缘起:硬件的“木桶效应”与 JMM 的诞生

计算机的核心硬件在发展过程中存在巨大的速度鸿沟:CPU 最快,内存次之,I/O 设备(硬盘)最慢。为了不让 CPU 闲着等数据,计算机体系结构、操作系统和编译器分别做出了妥协与优化,但这恰恰是并发问题的根源:

  1. CPU 增加了高速缓存(Cache),以均衡与内存的速度差异。
  2. 操作系统增加了进程/线程,以分时复用 CPU(任务切换),均衡 CPU 与 I/O 的差异。
  3. 编译器优化指令执行次序,使得缓存能够得到更合理的利用。

为了屏蔽各种硬件和操作系统的内存访问差异,让 Java 程序在各平台下都能达到一致的效果,Java 内存模型(JMM,Java Memory Model)应运而生。 JMM 规定:

  • 所有的共享变量都存储在主内存中。
  • 每个线程都有自己的工作内存,里面保存了被该线程使用的共享变量的副本。
  • 线程对共享变量的所有操作(读、写)都必须在工作内存中进行,绝对不能直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量。

这种架构虽然高效,但在多线程下直接引爆了并发编程的“三大底层危机”。

2.2 并发编程的三大核心危机

2.2.1 可见性问题:CPU 缓存引发的“盲区”

定义:一个线程对共享变量的修改,另一个线程能够立刻看到,这就叫可见性。危机成因:在多核心架构下,线程 A 运行在 CPU-1 上,线程 B 运行在 CPU-2 上。线程 A 修改了工作内存(对应 CPU-1 缓存)中的变量val,但还没来得及同步到主内存;此时线程 B 去读val,读到的依然是 CPU-2 缓存或主内存中的旧值。这就是 CPU 缓存导致的可见性盲区。

2.2.2 原子性问题:线程切换带来的“半途而废”

定义:我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。危机成因:高级语言里看似简单的一条语句,往往需要多条 CPU 指令才能完成。这就是违背我们直觉的地方。

  • 经典场景:count += 1。在 CPU 层面,它分为三步:
    1. Load:把变量count从内存加载到 CPU 寄存器。
    2. Add:在寄存器中执行+1操作。
    3. Store:将结果写入内存(缓存)。
  • 操作系统的任务切换可以发生在任何一条 CPU 指令执行完之后。如果线程 A 刚做完“加法”还没写回内存,就被切换到了线程 B,线程 B 拿到的还是旧的count值进行操作。最终两个线程各加了一次,结果却只增加了 1。
2.2.3 有序性问题:编译优化的“移花接木”

定义:程序执行的顺序按照代码的先后顺序执行。危机成因:编译器和处理器为了优化性能,会对没有数据依赖性的指令进行重排序

  • 经典场景:双重检查锁(DCL)实现单例模式。instance = new Singleton();这行代码底层分为三步:
    1. 分配一块内存空间 M。
    2. 在内存 M 上初始化 Singleton 对象。
    3. 将 M 的地址赋值给instance变量。
  • 重排序灾难:编译器可能将执行顺序优化为1 -> 3 -> 2。当线程 A 执行完 1 和 3 时,instance已经不是 null 了,但对象还没初始化。此时如果线程 B 进来判断instance == null为 false,直接拿走这个半成品对象去使用,就会触发空指针异常。

2.3 破局:Java 提供的底层利器

为了解决上述的可见性、原子性和有序性问题,JMM 定义了 8 种底层操作(lock, unlock, read, load, assign, use, store, write),并提供了以下并发控制工具:

2.3.1 synchronized(宏观锁)

提供了更大范围的原子性保证。在底层,它通过字节码指令monitorentermonitorexit隐式地使用了lockunlock操作。

  • 它不仅保证了被锁定代码块的原子性(单线程串行执行),同时在解锁前会将变量刷新回主内存,从而也保证了可见性。当遇到数组元素为空等无法加锁的场景时,它无能为力,必须依赖其他手段。
2.3.2 volatile 与 Happens-Before 原则

volatile是一把轻量级的神兵利器,专门针对可见性和有序性:

  • 可见性:强制将修改后的值立刻刷入主存,并让其他线程的工作内存缓存失效。(注意避坑:Java 中数组是对象,volatile修饰数组时,只能保证数组引用地址的可见性,如果数组内部元素发生改变,是无法保证可见性的!这也是为什么 ConcurrentHashMap 底层需要用 Unsafe 来操作数组元素。)
  • 有序性(禁止重排):利用内存屏障防止指令重排。
  • 底层约束:JMM 定义了 8 条Happens-Before(先行发生)规则。其中“volatile 变量规则”明确指出:对一个 volatile 变量的写操作,先行发生于后面对这个变量的读操作。
2.3.3 CAS (Compare And Swap - 无锁化原子操作)

高级语言中,除了基本数据类型的直接读写(longdouble除外),其他操作(比如new一个对象)绝大多数都不是原子操作。

  • CAS是直接调用 CPU 底层硬件提供的一条原子指令(cmpxchg
  • Java 中通过Unsafe类提供的方法(如compareAndSwapInt)来暴露 CAS 能力。它通过“比较并替换”的机制,在不加锁、不阻塞线程(仅自旋等待)的情况下,硬生生地保证了单个共享变量修改的原子性。这也是ConcurrentHashMap在空桶插入时能够实现无锁高并发的核心底气。

三、 数据结构进化历程

3.1 史前时代:数组与链表的“极限拉扯”

在最基础的数据结构世界里,数组和链表是两个性格迥异的极端:

  • 数组(Array):内存中连续分配。优势:拥有绝对的寻址霸权,根据下标查询的时间复杂度是绝对的O(1)劣势:增删元素时需要大面积挪动数据,极其低效。
  • 链表(Linked List):内存中分散存储,通过指针相连。优势:增删元素只需改变指针指向,时间复杂度O(1)劣势:查找数据只能从头节点顺藤摸瓜,时间复杂度高达O(n)

痛点:在高并发、大数据的业务场景中,我们既想要数组的“秒查”,又想要链表的“秒插”,这能做到吗?


3.2 第一次进化:哈希表(数组 + 链表)的诞生

为了融合两者的优点,哈希表(Hash Table)诞生了。它采用了拉链法(Separate Chaining),这也是 JDK7 及之前版本HashMapConcurrentHashMap的底层基石。

  • 结构解析:主体是一个数组。当你插入一个 Key 时,通过哈希函数计算出一个哈希值,然后对数组长度取模,定位到具体的数组下标(也就是哈希桶Bucket)。
  • 解决冲突:如果两个不同的 Key 算出了相同的下标(哈希冲突),怎么办?就把它们串成一个链表,挂在这个数组节点下面。
  • 比喻:数组就像是酒店的各个楼层,哈希函数是电梯。你瞬间到达指定楼层(O(1)),如果这层住了多个人(冲突),你再挨个敲门寻找(遍历链表)。

随着数据量激增,或者遇到恶意攻击(如 Hash DoS 攻击,故意构造大量哈希值相同的 Key),哈希表的脆弱一面暴露无遗。

  • 灾难再现:当大量的元素全部堆积在同一个哈希桶时,这个桶下面的链表会变得无限长。
  • 性能崩塌:此时,哈希表的查询效率从神坛跌落,由O(1)极速退化成了单链表的O(n)。如果你去查一个在链表末尾的元素,每一次查询都要经历漫长的遍历,系统甚至会被直接拖垮。


3.3 第二次终极进化(JDK8):红黑树的破局

为了彻底封死链表过长导致的性能退化,JDK8 对ConcurrentHashMapHashMap的数据结构进行了史诗级大换血,进化成了数组 + 链表 + 红黑树的终极形态。

当发生哈希冲突时,元素依然先追加到链表上,但加入了严苛的“变异机制”:

  • 树化触发(Treeify):当某一个哈希桶的链表长度达到 8,并且整个数组的总容量达到 64时,这条慵懒的单向链表就会瞬间变身为一棵结构严密的红黑树(Red-Black Tree)
  • 退化触发(Untreeify):当进行扩容或删除节点操作后,如果树中的节点数降到 6,红黑树又会退化回普通链表(利用 6 和 8 之间的差值,防止在 7 这个临界点发生频繁的“树化-退化”抖动)。

为什么是红黑树?红黑树是一种自平衡的二叉查找树。

  • 它的最大威力在于:无论数据怎么插入,它都能通过自身的左旋、右旋和变色,保持树的相对平衡。
  • 这样一来,即使发生最极端的哈希冲突,在同一个桶里堆积了成千上万的数据,红黑树也能将最坏情况下的查询时间复杂度死死钉在O(log n)(例如,100万条数据,最多只需查询 20 次左右),彻底杜绝了性能雪崩。

并发控制视角的升华:在 JDK8 的ConcurrentHashMap源码中,当链表转为红黑树后,桶的头节点会被替换为一个特殊的包装类TreeBin。此时,如果其他线程来修改数据,锁住的就是这个TreeBin节点。这不仅保证了读写的极高效率,还在红黑树复杂的平衡调整(左旋右旋)过程中,完美地维护了并发安全性。


四、 常见面试问题










五、 实战使用场景

理解了底层原理,我们来看看在实际编码中,ConcurrentHashMap到底能干什么:

5.1 场景一:本地高并发缓存 (Local Cache)

在一些无需动用庞大 Redis 集群的轻量级场景下,它可以作为服务本地的热点数据缓存,抗住极高的并发读取。

// 存储复杂的配置对象或数据字典,避免高并发下反复查库 private static final ConcurrentHashMap<String, ConfigObject> configCache = new ConcurrentHashMap<>(); public ConfigObject getConfig(String key) { // 利用 computeIfAbsent 保证高并发下,即使缓存击穿,也只有一个线程去执行数据库查询 return configCache.computeIfAbsent(key, k -> loadConfigFromDatabase(k)); }

5.2 场景二:实时数据统计汇总

非常适合用作多线程环境下的计数器,例如统计 API 接口的实时访问次数、视频的实时弹幕数等。通常需要配合AtomicInteger或更加高效的LongAdder一起使用。

private static final ConcurrentHashMap<String, LongAdder> apiRequestCounter = new ConcurrentHashMap<>(); public void recordApiAccess(String apiName) { // 线程安全的复合操作:如果 apiName 不存在则初始化一个 LongAdder,随后进行原子的递增操作 apiRequestCounter.computeIfAbsent(apiName, k -> new LongAdder()).increment(); }

总结

ConcurrentHashMap 的本质,是 Java 在“线程安全”与“高并发性能”之间做出的工程化平衡。

它并不像Hashtable那样对整个 Map 粗暴加锁,而是通过:

  • CAS实现无锁化竞争
  • volatile保证可见性与有序性
  • synchronized只锁局部桶节点
  • 数组 + 链表 + 红黑树的复合结构优化查询效率

从而实现:

  • 高并发下的数据安全
  • 尽可能低的锁竞争
  • 极端哈希冲突下依然稳定的性能表现

其底层设计思想,本质上就是一句话:

能无锁就无锁,必须加锁时就尽量缩小锁粒度。

同时,ConcurrentHashMap 也是 Java 并发编程思想的一个缩影,它几乎串联起了:

  • JMM(Java 内存模型)
  • 可见性 / 原子性 / 有序性
  • Happens-Before 原则
  • synchronized / volatile / CAS
  • 红黑树
  • CPU 缓存一致性

等整个 Java 并发体系中的核心知识点。

因此,学习 ConcurrentHashMap,不只是学习一个“线程安全容器”,更是在真正理解 Java 高并发底层设计哲学。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/12 5:24:34

Python Web框架flect:现代高性能异步开发实践与架构解析

1. 项目概述&#xff1a;一个现代、高性能的Python Web框架最近在Python Web开发社区里&#xff0c;一个名为flect的项目开始引起不少资深开发者的注意。这个由Chaoyingz维护的项目&#xff0c;定位非常清晰&#xff1a;它要成为一个“现代、高性能”的Python Web框架。如果你和…

作者头像 李华
网站建设 2026/5/12 5:23:31

原生PDF向量化:基于多模态嵌入的免文本提取RAG方案实践

1. 项目概述&#xff1a;告别文本提取&#xff0c;拥抱原生PDF向量化如果你也曾在构建基于PDF文档的问答系统时&#xff0c;被繁琐的文本提取、格式清洗和布局解析折磨得焦头烂额&#xff0c;那么今天分享的这个思路&#xff0c;可能会让你眼前一亮。传统的RAG&#xff08;检索…

作者头像 李华
网站建设 2026/5/12 5:08:06

别再写O(n²)的阶乘求和了!一个变量搞定,效率提升100倍

从O(n)到O(n)&#xff1a;阶乘求和算法的思维跃迁 在编程竞赛和算法学习中&#xff0c;阶乘求和是一个经典问题。表面上看&#xff0c;它似乎只需要简单的循环和乘法运算就能解决。但当你深入探究&#xff0c;会发现这个看似基础的问题背后隐藏着算法优化的精髓——如何用更简洁…

作者头像 李华
网站建设 2026/5/12 5:08:05

STM32H743项目内存不够用?试试把这7块SRAM全用上(含代码分区策略)

STM32H743项目内存不够用&#xff1f;试试把这7块SRAM全用上&#xff08;含代码分区策略&#xff09; 当你的STM32H743项目因为功能升级而遭遇内存瓶颈时&#xff0c;是否想过这颗芯片内部其实藏着7块独立的SRAM&#xff1f;这些分散在不同时钟域的内存区域总容量高达1MB以上&a…

作者头像 李华
网站建设 2026/5/12 5:06:37

RedwoodJS协调器:终极分布式协调与一致性解决方案指南

RedwoodJS协调器&#xff1a;终极分布式协调与一致性解决方案指南 【免费下载链接】redwood RedwoodGraphQL 项目地址: https://gitcode.com/gh_mirrors/re/redwood RedwoodJS协调器是RedwoodGraphQL项目中的核心组件&#xff0c;为分布式应用提供了强大的协调与一致性保…

作者头像 李华