学习ConcurrentHashMap使用和并发基础(java)
学习ConcurrentHashMap使用和并发(java)
前言

一、 为什么用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) 导致的,在宏观上主要表现为两类冲突:
写-写冲突: 两个线程同时对同一个位置进行修改,导致其中一个线程的数据被无情覆盖(数据丢失)。
写-读冲突: 一个线程刚刚修改了数据,但由于工作内存未及时刷入主内存,另一个线程去读的时候,拿到的依然是旧数据(读到脏数据)。

为了解决数据不一致问题,最直观的解法就是加锁,这也是 HashMap 向 ConcurrentHashMap 演进的核心推手。
然而,粗暴的加锁会极大拖累系统性能。因此,提升高并发容器性能的关键思路在于:尽量减小锁的粒度,并尽可能找出可以“无锁化”的操作。
在设计时,需要对读写操作“分而治之”:
写操作: 只要涉及数据的改动,必然需要加锁兜底,此时架构设计的核心考量就是尽力缩小锁的粒度(例如从锁整个 Map 缩小到只锁一个节点)。
读操作: 在写操作保证不出错的前提下,读操作相对好办。核心在于取舍“是否需要实时读到最新数据”:
强一致性: 读写双重加锁,绝对安全但性能最差(如
HashTable)。顺序一致性: 使用
volatile关键字,不加锁但能保证数据的可见性。弱一致性: 读完全不加锁,容忍极端情况下的微小延迟,追求极致性能

为了在“安全”与“性能”之间平衡,ConcurrentHashMap 并没有采用单一的加锁手段,而是综合运用了以下三种核心底层方案:
synchronized: 用于保证特定代码块的原子性和可见性(使操作局部串行化,保证有序)。
volatile: 轻量级同步机制,作用于单个共享变量。它像是一个全网广播,既能保证数据的可见性,又能禁止指令重排序。
CAS (Compare-And-Swap): 乐观锁机制,利用底层硬件指令实现针对单个共享变量的无锁原子操作。
简而言之,ConcurrentHashMap 正是巧妙地运用这三点,解决了 HashMap 的多线程问题。
二、 语法讲解
理解了为什么需要 ConcurrentHashMap 之后,回到日常编码:它本质上仍然是一个 Map,只是把并发读写的安全性和性能优化封装好了。使用时重点记住三点:
key和value都不能为null。单次
put/get/remove是线程安全的。“先查再改”这类复合逻辑,要优先用
putIfAbsent、compute、computeIfAbsent、merge这类原子 API。
2.1 常用 API 速查
下面这段代码把日常最常见的 put、get、putIfAbsent、replace、remove、compute、merge 都放在一起,适合当成语法模板看:
1 | import java.util.Map; |
小提醒:
ConcurrentHashMap的线程安全只覆盖它自己的结构更新。如果value本身是一个可变对象,比如ArrayList或自定义对象,修改对象内部字段时仍然要额外考虑线程安全。
2.2 场景一:本地高并发缓存 (Local Cache)
在一些无需动用庞大 Redis 集群的轻量级场景下,它可以作为服务本地的热点数据缓存,抗住极高的并发读取。
1 |
|
2.3 场景二:实时数据统计汇总
非常适合用作多线程环境下的计数器,例如统计 API 接口的实时访问次数、视频的实时弹幕数等。通常需要配合 AtomicInteger 或更加高效的 LongAdder 一起使用。
1 | import java.util.concurrent.ConcurrentHashMap; |
如果只是做简单的整数合并,也可以直接用 merge:
1 | import java.util.concurrent.ConcurrentHashMap; |
三、 并发和锁的基础知识
3.1 java内存模型
计算机的核心硬件在发展过程中存在巨大的速度鸿沟:CPU 最快,内存次之,I/O 设备(硬盘)最慢。为了不让 CPU 闲着等数据,计算机体系结构、操作系统和编译器分别做出了妥协与优化,但这恰恰是并发问题的根源:
CPU 增加了高速缓存(Cache),以均衡与内存的速度差异。
操作系统增加了进程/线程,以分时复用 CPU(任务切换),均衡 CPU 与 I/O 的差异。
编译器优化指令执行次序,使得缓存能够得到更合理的利用。

为了屏蔽各种硬件和操作系统的内存访问差异,让 Java 程序在各平台下都能达到一致的效果,Java 内存模型(JMM,Java Memory Model) 应运而生。 JMM 规定:
所有的共享变量都存储在主内存中。
每个线程都有自己的工作内存,里面保存了被该线程使用的共享变量的副本。
线程对共享变量的所有操作(读、写)都必须在工作内存中进行,绝对不能直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量。
这种架构虽然高效,但在多线程下也存在并发编程的三大危机。
3.2 并发编程的三大核心危机
3.2.1 可见性问题:CPU 缓存引发的“盲区”
定义: 一个线程对共享变量的修改,另一个线程能够立刻看到,这就叫可见性。
危机成因: 在多核心架构下,线程 A 运行在 CPU-1 上,线程 B 运行在 CPU-2 上。线程 A 修改了工作内存(对应 CPU-1 缓存)中的变量 val,但还没来得及同步到主内存;此时线程 B 去读 val,读到的依然是 CPU-2 缓存或主内存中的旧值。这就是 CPU 缓存导致的可见性盲区。
3.2.2 原子性问题:线程切换带来的“半途而废”
定义: 我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。 危机成因: 高级语言里看似简单的一条语句,往往需要多条 CPU 指令才能完成。这就是违背我们直觉的地方。
经典场景:
count += 1。在 CPU 层面,它分为三步:Load: 把变量
count从内存加载到 CPU 寄存器。Add: 在寄存器中执行
+1操作。Store: 将结果写入内存(缓存)。
操作系统的任务切换可以发生在任何一条 CPU 指令执行完之后。如果线程 A 刚做完“加法”还没写回内存,就被切换到了线程 B,线程 B 拿到的还是旧的
count值进行操作。最终两个线程各加了一次,结果却只增加了 1。
3.2.3 有序性问题:编译优化的“移花接木”
定义: 程序执行的顺序按照代码的先后顺序执行。 危机成因: 编译器和处理器为了优化性能,会对没有数据依赖性的指令进行重排序。
经典场景: 双重检查锁(DCL)实现单例模式。
instance = new Singleton();这行代码底层分为三步:分配一块内存空间 M。
在内存 M 上初始化 Singleton 对象。
将 M 的地址赋值给
instance变量。
重排序灾难: 编译器可能将执行顺序优化为
1 -> 3 -> 2。当线程 A 执行完 1 和 3 时,instance已经不是 null 了,但对象还没初始化。此时如果线程 B 进来判断instance == null为 false,直接拿走这个半成品对象去使用,就会触发空指针异常。
3.3 解决并发问题的工具
为了解决上述的可见性、原子性和有序性问题,JMM 定义了 8 种底层操作(lock, unlock, read, load, assign, use, store, write),并提供了以下并发控制工具:
3.3.1 synchronized(宏观锁)
1. synchronized是什么
synchronized 是 Java 语言提供的一个内置关键字,在多线程编程中被称为“隐式锁”或“内置锁”。
底层本质: 它基于 JVM 内部的管程(Monitor)对象实现。在底层字节码层面,它通过插入
monitorenter(获取锁)和monitorexit(释放锁)指令来实现同步。物理依托: 它的加锁动作必须依赖于一个真实的、已实例化的 Java 对象。锁的各种状态(如无锁、偏向锁、轻量级锁、重量级锁)都记录在这个对象的对象头(Mark Word)中。
2. synchronized的作用
在多线程并发环境下,synchronized 提供以下保障:
保证原子性(互斥与排队): 它确保同一时刻,只有一个线程能拿到锁并进入被保护的代码区域。其他试图进入的线程会被系统阻塞,必须排队等待。这保证了被包裹的代码逻辑(比如先读取、再计算、后写入)像一个不可分割的整体,不会被其他线程打断。
保证可见性(内存刷新): 当一个线程执行完
synchronized块并释放锁(monitorexit)时,JVM 会强制将该线程工作内存中的所有共享变量修改刷新回主内存;而下一个获取同一把锁的线程,会被强制要求从主内存中重新读取这些变量。这彻底消除了多线程间的数据滞后现象。保证有序性: 虽然在
synchronized代码块内部,编译器和 CPU 仍然可能进行指令重排序优化,但由于外界看来这块代码是“单线程串行”执行的,因此它能保证并发环境下的最终执行结果是正确且一致的。
3. 如何使用synchronized
synchronized 的使用心法只有一条:认清你锁的是哪个对象。
如果多个线程竞争的是同一个对象锁,它们就会排队;如果竞争的是不同的对象锁,它们就互不干扰,主要用法如下:
修饰代码块,锁是你指定的任何非空对象。
修饰实例方法(锁是当前实例
this)。修饰静态方法(锁是当前类的
Class对象)
1 |
|
3.3.2 volatile 与 Happens-Before 原则
1. volatile是什么
volatile 是 Java 中的一个关键字,专门用来修饰变量(包括实例变量和静态变量)。
本质: 它是 Java 虚拟机(JVM)提供的一种最轻量级的同步机制。
不加锁: 与
synchronized不同,使用volatile不会发生上下文切换,也不会让线程阻塞排队。它仅仅是在底层通过硬件级别的“内存屏障(Memory Barrier)”来干预 CPU 的读写操作。大白话比喻: 假设每个线程都有一个小本子(工作内存/CPU缓存),主存是一块公共大黑板。平时线程都是看着自己的小本子干活。如果把一个变量加上
volatile,就等于告诉所有线程:“这个数据你绝对不能抄在小本子上,每次读写必须亲自跑到公共大黑板上去看”
2. volatile的作用
volatile 主要针对并发编程中的两个痛点:
作用一:保证可见性(数据立刻刷新)
在默认情况下,线程为了追求速度,会把主存中的变量缓存到 CPU 寄存器或 L1/L2 缓存中。
如果线程 A 修改了变量,没有及时写回主存,线程 B 是看不到的(这就是“可见性问题”)。
- 加上
volatile后: 只要线程 A 修改了这个变量,JVM 会强制要求它立刻刷回主存,并且发出信号,让其他所有线程缓存中的这个变量立刻失效。其他线程下次再用时,必须重新去主存里读最新值。
⚠️ 重点避坑:你提到的“数组元素无法保证可见性”是怎么回事? 当你写下
volatile int[] arr = new int[10];时,volatile锁定的仅仅是arr这个引用地址。
- 如果你执行
arr = new int[20];(改变了引用),其他线程立刻可见。- 但如果你执行
arr[0] = 1;(改变了数组内部的元素),因为arr的引用地址没变,触发不了volatile的刷新机制,其他线程可能根本看不见arr[0]变成了 1!- 怎么解决? 这就是为什么
ConcurrentHashMap底层必须借助Unsafe类(或者 Java 9 之后的VarHandle),直接操作内存偏移量去强制读取/写入数组的内部元素,以此来保证数组元素的可见性。
作用二:保证有序性(禁止指令重排)与 Happens-Before
为了提高执行效率,编译器和 CPU 会在不改变单线程最终结果的前提下,把你的代码顺序打乱执行(这叫指令重排)。
但在多线程下,打乱顺序可能会引发灾难。
内存屏障:
volatile会在底层的汇编指令中插入“内存屏障”,告诉 CPU:“在这个变量前后的指令,谁也不许越过界限排序。”Happens-Before(先行发生原则): 它是 JMM(Java 内存模型)对程序员的承诺。其中“volatile 变量规则”是说:“对一个 volatile 变量的写操作,必定先行发生于(Happens-Before)后面对这个变量的读操作。”
- 深层含义: 这不仅意味着你能读到最新的
volatile变量,还意味着在写入volatile变量之前发生的所有其他操作,对读取该volatile变量的线程也是可见的。(这就好比volatile是一个邮戳,盖了邮戳之前的信件内容,对方全都能看到)。
- 深层含义: 这不仅意味着你能读到最新的
3. 如何使用volatile
因为 volatile 不保证原子性,所以它不能用来做计数器(不能用来修饰 count++)。它最经典的使用场景只有两个:
场景一:作为状态标志位(最常用)
用于在一个线程中控制另一个线程的执行流。这种场景下,变量的修改不依赖于它原来的值(只有 true 或 false 之间的切换)。
1 |
|
场景二:单例模式中的 DCL(双重检查锁)
这是面试中最爱考的一段代码。这里使用 volatile 的目的完全是为了禁止指令重排。
1 |
|
为什么单例模式必须加 volatile?
因为 instance = new Singleton(); 在底层并不是一步完成的,它分为三条机器指令:
分配内存空间。
在内存空间上初始化对象(调用构造方法)。
把
instance引用指向这块内存地址。
如果没有 volatile,CPU 可能会把顺序重排成 1 -> 3 -> 2。 假设线程 A 执行了 1 和 3(此时 instance 已经不等于 null 了,但对象还没初始化完)。 此时线程 B 进来了,执行最外层的 if (instance == null),发现不为空,直接拿走去用,结果因为对象还没初始化,直接报空指针异常崩溃。 加上 volatile 后,强行禁止 1-3-2 的重排,必须按 1-2-3 执行,从而保证安全。
3.3.3 CAS (Compare And Swap - 无锁化原子操作)
1. CAS 是什么
CAS 的全称是 Compare And Swap(比较并交换)。它是一种无锁化(Lock-free)的原子操作。
它的核心运作机制: CAS 操作需要三个核心参数:
V (内存地址/内存变量当前的值)
E (期望的旧值) —— 也就是你上次看到它的值。
N (要替换的新值)
动作逻辑: “我来看看内存地址 V 里的值是不是 E,如果是,说明没人动过,我就把它改成 N;如果不是 E,说明在我准备改的这段时间里,有别人把它改了,那我就什么都不做(或者重新读取最新的值再试一次)。”
底层支撑: 你可能会问:“那比较和交换这两步,中间如果有别的线程插队怎么办?”
答案是:绝不可能。 因为 CAS 并不是用 Java 代码写出来的逻辑,它是直接调用了 CPU 底层硬件的指令(在 x86 架构下是 cmpxchg 指令)。
CPU在硬件级别保证了这条指令的执行是绝对原子的,不可被打断。
2. CAS有什么作用
为什么有了 synchronized,大牛们还要挖空心思搞出 CAS 呢?因为它带来了极高的性能收益:
不阻塞线程(拒绝上下文切换):
synchronized(重量级锁状态下)会让抢不到锁的线程进入“睡眠/阻塞”状态。线程从睡眠到被唤醒,需要操作系统在内核态和用户态之间频繁切换,这个开销极其巨大!而 CAS 发现数据被别人改了,它不会去睡觉,而是立刻去读最新的值,并在原地不断地循环重试(这叫“自旋”,Spinning)。这省去了操作系统调度线程的巨大开销。
天生免疫死锁:
- 因为它根本不需要加锁,多个线程同时去 CAS 更新同一个变量,只会有一个成功,其他的失败后重试,永远不会出现两个线程互相抱着对方的锁死等的情况。
大白话比喻:
synchronized是**“去银行柜台办业务”**,必须拿号排队,别人在办,你就只能在等候区干坐着(阻塞休眠)。- CAS 是**“一群人抢一个掉在地上的钱包”**,不用排队,大家一起伸手去抓。谁手快谁拿到,手慢的人抓了个空(CAS 失败),也不用去睡觉,立刻站起来看钱包又掉哪了,继续抢(自旋重试)。
3. 如何使用
在计算机世界里,CAS 最早是一条 CPU 的汇编指令(比如 x86 架构下的 cmpxchg)。它是直接操控硬件内存的,跑得极快,且绝对保证原子性,但它根本不懂什么是 Java 代码。
java 官方为了能调用这个底层的 CPU 指令,在内部写了一个叫 Unsafe 的类。 这个类提供了一些直接操作内存和 CAS 的方法,比如 compareAndSwapInt()。
但是,直接使用这个类非常危险:你必须手动计算变量在计算机内存中的“物理偏移地址”。如果你算错了一个字节,不仅数据会错,整个 Java 虚拟机(JVM)都会直接崩溃闪退。
所以,官方给它起名 Unsafe(不安全)[doge]。
Java 官方把CAS的相关操作封装为工具类:java.util.concurrent.atomic 包下的原子类。
1 |
|
3.4 小结
并发编程的本质,其实就是我们在软件层面为底层硬件(CPU 缓存、线程调度、编译器优化)的“速度妥协”打补丁的过程。
为了应对 JMM 带来的三大危机(可见性、原子性、有序性),Java 为我们提供了不同维度的解决工具。
概括如下:
1. synchronized(悲观互斥锁):
- 基于悲观并发策略,通过管程(Monitor)实现临界区的互斥访问。
- 它能全面保证原子性、可见性与有序性,但线程阻塞与唤醒引起的上下文切换会导致较高的性能开销。
2. volatile(轻量同步原语):
- 基于底层内存屏障机制,通过强制缓存刷新与失效保证变量的可见性,并借助 Happens-Before原则禁止指令重排。
- 它轻量高效,但不具备互斥语义,无法保证复合操作的原子性。
3. CAS(乐观无锁机制):
- 基于乐观并发策略,调用底层 CPU 硬件指令完成比较与替换。
- 它通过“自旋”重试实现了无锁化的原子操作,彻底消除了线程调度开销,但复杂状态流转下需引入版本号以规避 ABA 问题。
总结
ConcurrentHashMap 是 Java 专为高并发场景设计的高效哈希表。它严格禁止 key 和 value 为 null 以避免多线程下的“数据二义性”问题,并在底层摒弃了传统的全盘加锁,转而综合运用 synchronized、volatile 和 CAS 等技术,通过缩小锁粒度和读写分离,巧妙地平衡了数据安全性与极高的并发性能。
多线程并发问题的根源在于计算机软硬件为了追求速度而做出的妥协,这在 Java 内存模型(JMM)中集中爆发为三大危机:CPU 缓存未及时同步引发的“可见性盲区”、操作系统线程切换打断执行逻辑导致的“原子性失效”,以及编译器和 CPU 为了优化而随意打乱代码顺序破坏的“有序性危机”。
为了给底层硬件的“速度妥协”打补丁,Java 提供了三种不同维度的工具:
- synchronized 作为悲观互斥锁能大包大揽地全面保证安全,但排队阻塞的性能开销较大;
- volatile 借助内存屏障,轻量级地保证了变量的全局可见性与代码的有序性;
- CAS 则直接调用底层硬件指令,以“自旋重试”实现了极速的无锁化原子操作。这三者也是构成各类并发容器的基石。