ConcurrentHashMap详细讲解(java)

文章目录

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

Java 复制代码
// 存储复杂的配置对象或数据字典,避免高并发下反复查库
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 一起使用。

Java 复制代码
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 高并发底层设计哲学。

相关推荐
机器学习之心1 小时前
集成BWM法、熵权法、改进博弈论组合赋权与三角直觉模糊云模型的多属性评价模型,MATLAB代码
开发语言·matlab·熵权法·三角直觉模糊云模型·bwm法·改进博弈论组合赋权·多属性评价模型
特种加菲猫1 小时前
二叉搜索树:数据世界的“快速寻路指南”
开发语言·c++
特种加菲猫2 小时前
STL关联容器:Set/Multiset与Map/Multimap详解
开发语言·c++
我滴老baby2 小时前
0基础速通Python+AI|2026热门轻量化玩法全攻略:从入门到实战,3天搞定AI应用开发
开发语言·人工智能·python
源码集结号2 小时前
基于 Spring Boot + JPA + MySQL的上门家政系统代码示例
java·前端·后端
一个天蝎座 白勺 程序猿2 小时前
Python(29)Python生成器函数深度解析:asyncio事件循环的底层实现与异步编程实战
开发语言·python
2zcode2 小时前
原创文档:基于MATLAB的线性预测编码变声器系统
开发语言·matlab·语音识别
七夜zippoe2 小时前
Python RESTful API设计终极指南:从理论到企业级实战
开发语言·python·http·pandas·restful api
lly2024062 小时前
Highcharts 配置说明
开发语言