JAVA并发根源问题的讨论与思考
JAVA并发根源问题的讨论与思考
我认为并发根源问题出现的有三个部分:可见性、原子性、有序性。
了解这三个问题,以及背后的原因和处理机制,可以有效的梳理整个并发过程
可见性(CPU缓存)
可见性是由CPU缓存引起的,在了解CPU缓存之前,应该先了解JMM内存模型。
JMM模型
内存模型如下:
JMM 是 Java 对多线程环境下内存访问行为的抽象模型,它定义了线程如何与**主内存(共享内存)及本地内存(工作内存&线程私有)**交互。
解释一下
- 主内存:所有线程共享的内存区域,存储变量原始值
- 工作内存:每个线程私有的内存区域,存储主内存中变量的副本
有两个点注意一下:
- 线程对变量的所有操作(读/写)必须在工作内存中进行,不能直接操作主内存
- 线程间通信需通过主内存(隐式或显式同步)
那么刚刚说了JMM他是一个抽象模型,他抽象的是什么?本质上抽象的是硬件架构(这就是语言跨平台的魅力)
他是对不同CPU的架构(如 x86-TSO、ARM-弱内存模型)的抽象,屏蔽底层差异。(这里又感觉可以和架构的单一职责有关,注意点要分离)
CPU 缓存一致性协议 && MESI内存嗅探机制
好的,我们扩展的再讨论一下。那么你肯定很奇怪,这个JMM模型,是怎么实现的呢?这句话就是如何保证多核CPU缓存一致性,实现细节就是MESI嗅探机制。
就是这张图
状态 | 含义 |
---|---|
Modified | 缓存行被修改,与主内存不一致。 |
Exclusive | 缓存行独占,与主内存一致。 |
Shared | 缓存行被多个核心共享,与主内存一致。 |
Invalid | 缓存行无效(需重新加载)。 |
当某核心写缓存时,通过总线广播使其他核心的对应缓存行失效(Invalid),强制其重新加载最新值
嗯,差不多到这里就可以了,不需要特别深入,嗯,在深入下去我也不会了。(当然第一次认知到这个MESI的时候,我就觉得他很难,然后就差不读一直只了解到了这一层)
Store Buffers缓存队列
大概示意图就长这样
很明显这里面,搞了一个store buffer的东西,因为cpu底层他不希望来一个指令,我就刷一下内存。要知道批量刷盘,要好过一条条数据刷。所以他搞了一个buffer缓冲池,读取的时候优先读取buffer,然后定时批量刷到工作内存。
嗯,如果仅仅只是思考到这一步,我觉得是不可以的。
看到这个模型,我第一反应是什么?这个很像我的mysql的buffer缓冲池。
第二反应是什么?我记得redis的RDB在备份的时候,也有一个fork子线程,在进行冲刷备份中的数据。
所以,这个本质是什么?
缓冲与异步刷盘
核心目标
- 减少高频操作的开销:避免直接操作慢速设备(如磁盘、网络、内存总线)。
- 提升吞吐量:批量处理数据,减少碎片化操作。
- 解耦生产与消费:允许生产者和消费者以不同速度工作(如写操作与持久化)
这就是一些典型的实现
系统/场景 | 缓冲机制 | 刷盘策略 | 一致性保证 |
---|---|---|---|
CPU Store Buffer | 暂存写操作,异步刷回缓存 | 内存屏障触发刷写 | 最终一致性(需显式同步) |
MySQL Buffer Pool | 缓存数据页,减少磁盘I/O | Checkpoint、LRU淘汰、事务提交刷盘 | ACID(依赖redo log、双写缓冲等) |
Redis RDB | Fork子进程写快照,主进程继续 | COW(写时复制)+ 增量AOF缓冲 | 最终一致性(RDB期间增量数据存AOF) |
Kafka Producer | 批量聚合消息,减少网络请求 | 时间/大小阈值触发发送 | At-Least-Once(需ACK机制) |
好的,不要觉得这个玩意很虚,我们落地一下,这个过程体现了设计的思路:“通过缓冲和异步化提升性能,再通过同步机制(如屏障、日志、锁)解决一致性副作用”。
而这种东西,我们可以将其应用到具体的软件工程设计中呀。内存态与硬件持久化,接口短RT要求,TCC下的BASE理论,都体现了这样的设计哲学。
我觉得,思考应该挖掘,能够应用最好。
那么可见性问题总结一下就是,A 线程对变量的修改未及时同步到主内存(或者B线程未从主内存(或其他线程的缓存)读取最新值)
单核CPU可见性问题的思考
那么其实在理解完这个过程中,单核CPU到底是否会存在可见性问题,应该是显而易见的。
会有的,因为有StroeBuffer会未及时刷新到主内存,导致其他线程数据未获取到最新的(他们可能会从主内存或者缓存中获取,但是此时线程1还没来得及刷,只是暂存于storeBuffer)。
要明白,工作内存的维度是线程级别的,是线程私有的。
场景 | 多核 CPU | 单核 CPU |
---|---|---|
可见性根源 | Store Buffer + 缓存一致性协议延迟 | Store Buffer + 寄存器缓存未同步 |
触发频率 | 高(并行写操作) | 低(依赖线程切换时机) |
解决手段 | 必须显式同步(内存屏障、锁) | 理论无需同步,但代码优化可能导致问题 |
如何解决(Happens-Before)
可见性问题的原因,我们之前也讲了。那如何处理它,这里面其实可以想一下,不就是其他线程读取不到吗?
那只要我“有序的”读取不就可以了吗?当然这里的有序,并不是真正的有序,而是一种思考方式。
于是就出来了Happens-Before 原则:他的核心逻辑是:如果操作 A Happens-Before 操作 B,那么 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。
这里面需要强调,HB 并不要求实际执行顺序与代码顺序一致(允许指令重排序),但必须保证可见性。
常见 Happens-Before 规则
- 程序顺序规则:单线程内代码顺序天然 HB。
volatile
变量规则:- 对
volatile
变量的写操作 HB 于后续对它的读操作。
- 对
- 锁规则:
- 解锁操作 HB 于后续的加锁操作。
- 线程启动规则:
- 线程的
start()
调用 HB 于该线程内的任何操作。
- 线程的
- 传递性规则:
- 若 A HB B,B HB C,则 A HB C。
内存屏障
如果说Happens-before是基于JMM的抽象模型,那么内存屏障就是他的底层实现机制。
他是通过限制指令重排序和强制内存可见性,确保 HB 规则的语义。JMM 定义了以下四类屏障:
屏障类型 | 作用 | 典型场景 |
---|---|---|
LoadLoad | 禁止屏障后的读操作重排序到屏障前的读操作之前。 | volatile 读后插入,防止后续读操作重排序。 |
StoreStore | 禁止屏障后的写操作重排序到屏障前的写操作之前。 | volatile 写前插入,防止之前的写操作重排序。 |
LoadStore | 禁止屏障后的写操作重排序到屏障前的读操作之前。 | 较少直接使用,通常由其他屏障隐含。 |
StoreLoad | 强制刷新所有 Store Buffer 到内存,并加载最新值(全能屏障,开销最大)。 | volatile 写后插入,确保写操作对其他线程可见。 |
原子性(分时复用)
分时复用的解释
这个名词看起来很牛啊,其实本质上就是cpu资源是有限的,但是任务又很多,所以cpu就搞了一个标识位,一直切过来切过去进行干活。
像极了我,正在处理A任务的时候,领导插活,这个很急,这个明天就要,于是我就开始干这个急的活。等到了明天,开始loading,我代码写到哪里了?
对这就是分时复用
比较官方的解释
首先原子性问题是这样的:一个或多个操作要么全部执行且不被中断,要么完全不执行。
问题根源:
- 线程切换:CPU 分时复用导致操作中途被其他线程打断(如
i++
的非原子性)。 - 非原子指令:看似单行的代码可能对应多条底层指令(如读取-修改-写入三步)。
解决方案
解决这个问题的思路极其简单,我把他变成原子性的不就可以了吗?就像redis搞了一个Lua脚本,简单,粗暴,但丑陋(主要复杂的业务可读性太差)
但实际上,这个简单解决思路,实现起来却又不是那么的简单。究其本质是我需要考虑,哪些东西属于非原子性。
锁
他的核心思想就是通过互斥锁将非原子操作变为原子操作。
// synchronized 示例
public synchronized void increment() {i++;
}// Lock 示例
private final Lock lock = new ReentrantLock();
public void increment() {lock.lock();try {i++;} finally {lock.unlock();}
}
锁机制深入:synchronized vs Lock
(1) synchronized 的锁升级
- 无锁 → 偏向锁:
首个线程访问时标记偏向,避免 CAS 开销。 - 偏向锁 → 轻量级锁:
发生竞争时,通过 CAS 自旋尝试获取锁。 - 轻量级锁 → 重量级锁:
自旋失败后升级为操作系统级互斥锁(Mutex)。
Lock 的灵活性与 AQS
-
优势:
- 可中断锁(
lockInterruptibly()
)。 - 超时获取锁(
tryLock(timeout)
)。 - 公平锁(
new ReentrantLock(true)
)。
- 可中断锁(
-
AQS(AbstractQueuedSynchronizer):
- 核心组件:CLH 队列(双向链表)管理等待线程。
- 实现原理:通过
state
变量表示锁状态,CAS 操作控制竞争。
注意一下 AQS有两个队列我们常常说的是CLH虚拟双端队列,还有个Condition Queue,他就是一个单链表了,就是CLH里面塞不下了,就往里面扔。所以本质上这种数据结构就能支持公平锁与非公平锁,不过我记得好像默认实现方式是非公平锁。
其实讲道理,AQS和synchronized可以写很多很多,包括我也有很多图,但是我觉得没有必要,只要大致了解就可以了。提纲挈领的知道各个关键节点的思路是什么就可以了,这就像写程序一样,重要的是骨架,是思路,是如何把线下的场景在线上实现。细节的东西没必要聊太多,而且网上到处都是。
可重入锁(ReentrantLock)
- 核心特性:同一线程可重复获取同一把锁。
- 作用:防止递归或嵌套调用导致的死锁。
- 实现:通过计数记录重入次数(
state++
)。
原子类(Atomic Classes)
利用 CAS硬件指令与自旋操作,实现无锁原子操作。
很好,你一定有这样的疑问,我既然已经有了锁,为啥要搞Atomic,讲道理我直接复用不就可以了吗?
嗯,是的,但本质上,它涉及到一个杀鸡焉用牛刀的问题,同时你不应该整体的看待锁,就以synchronized为例,他还有锁升级。
这里额外插入一句,我觉得无锁并发编程才是我们需要努力的目的,锁只是没有办法的情况下,保证资源的有序执行。
-
典型类:
AtomicInteger
、AtomicReference
LongAdder
(分段 CAS 优化高并发场景)。
-
ABA 问题:
- 现象:变量从 A 改为 B 再改回 A,CAS 误认为未变化。
- 解决方案:
AtomicStampedReference
(附加版本号)。
-
自旋开销:
- 问题:CAS 失败时循环重试可能导致 CPU 占用飙升。
- 优化:限制自旋次数或退化为锁(如
synchronized
)。
并发容器的原子性设计
首先是ConcurrentHashMap
版本 | 实现机制 | 优缺点 |
---|---|---|
JDK 1.7 | 分段锁(Segment) | 降低锁粒度,但段数固定,高并发下仍可能竞争激烈。 |
JDK 1.8 | CAS + synchronized 锁节点 | 更细粒度锁(链表头节点),链表转红黑树优化查询效率(O(n) |
使用场景对比
- Hashtable:全表锁,并发性能差。
- Collections.synchronizedMap:包装类锁整个实例。
- ConcurrentHashMap:高并发场景首选。
总结:原子性问题的应对策略
场景 | 解决方案 | 适用层级 |
---|---|---|
简单原子操作(如计数器) | Atomic 类 | 无锁,高性能 |
复杂业务逻辑 | synchronized /Lock | 代码块级互斥 |
高并发容器操作 | ConcurrentHashMap | 细粒度锁优化 |
灵活锁需求(超时、公平) | ReentrantLock | 显式锁控制 |
一些额外的思考
- 关于分布式场景下的锁:
单独的锁,现在其实没有太大意义,就好像你单独说事务也没有太大意义。
分布式场景下,redis锁是首选,zk锁也能实现(但我没用过)。redis锁的时候,建议使用Redission,毕竟红锁可以自动看门狗续期。
忽然想起来,你说,mq的有序队列能不能作为锁的一个补充?
想来是可以的,比如对于任务型的,并发要求没有那么高的,但是对执行顺序要求强有序的,我觉得可以使用mq。
特性 | Redis 分布式锁 | ZooKeeper 临时节点 | MQ 有序队列 |
---|---|---|---|
实时性 | 高(基于内存) | 高(Watcher 机制) | 中(依赖消费速度) |
公平性 | 依赖 RedLock 或排队逻辑 | 天然有序(临时顺序节点) | 天然有序(FIFO 队列) |
可靠性 | 依赖持久化配置 | 高(CP 设计) | 依赖 MQ 的持久化和 HA |
复杂度 | 中(需处理锁续期) | 高(需维护 Session) | 高(消息生命周期管理) |
适用场景 | 高频短期锁 | 低频长期锁 | 异步任务调度、批量锁分配 |
- 无锁编程
通过 ThreadLocal、不可变对象(Immutable,final)避免竞争。
- 性能权衡
CAS 在低竞争下高效,高竞争时可能劣于锁。
有序性(指令重排序)
嗯,之前讨论到关于并发根源问题的时候,讲到运行期指令还会重排序。嗯,比如JIT编译期间。
于是执行顺序的混乱,最终导致了数据的异常。
那他的解决思路和可见性一模一样,让指令“有序的”执行不就可以了吗
于是问题的处理方案 回到了可见性上,也就是HB与内存屏障
那么差异呢?
(1) 可见性问题
- 本质:线程对变量的修改未及时对其他线程可见(Store Buffer、缓存未同步)。
- 解决思想:通过内存屏障强制刷新缓存,确保修改后的值对其他线程可见。
(2) 有序性问题(指令重排序)
- 本质:编译器或处理器优化导致代码执行顺序与程序顺序不一致(如
a=1; b=1;
可能先执行b=1
)。 - 解决思想:通过内存屏障限制重排序,保证关键操作的执行顺序。
Happens-Before再讨论
Happens-Before(HB)不仅是可见性规则,也是有序性规则,它通过以下方式统一解决两类问题:
- 定义操作顺序:若操作 A HB 操作 B,则 JVM 必须保证:
- A 的结果对 B 可见(解决可见性)。
- A 的执行顺序在 B 之前(解决有序性)。
- 指导编译器和处理器:HB 规则约束编译器和 CPU 的优化行为,禁止破坏 HB 关系的重排序。
volatile int x = 0;
int y = 0;void write() {y = 1; // 普通写操作x = 1; // volatile 写操作(插入 StoreStore + StoreLoad 屏障)
}void read() {if (x == 1) { // volatile 读操作(插入 LoadLoad + LoadStore 屏障)assert y == 1; // 由于 HB 规则,y 的值一定为 1}
}
内存屏障再讨论
内存屏障是 HB 规则的具体实现手段,针对不同重排序类型插入屏障:
重排序类型 | 示例 | 屏障类型 | 作用 |
---|---|---|---|
写-写重排序 | a=1; b=1; → b=1; a=1; | StoreStore | 禁止屏障前的写重排序到屏障后的写之后。 |
读-读/读-写重排序 | load a; load b; → load b; load a; | LoadLoad + LoadStore | 禁止屏障后的读/写重排序到屏障前的读之前。 |
写-读重排序 | a=1; load b; → load b; a=1; | StoreLoad | 强制刷写 Store Buff |
JIT指令重排序
JIT(Just-In-Time)编译器在优化时可能重排序代码,但必须遵守 HB 规则:
- 单线程规则:单线程内不改变程序语义的重排序是允许的(As-If-Serial 语义)。
- 多线程规则:跨线程的重排序若破坏 HB 关系,则必须通过内存屏障禁止。
经典案例:双重检查锁定(DCL)
public class Singleton {private static Singleton instance; // 错误:未用 volatilepublic static Singleton getInstance() {if (instance == null) {synchronized (Singleton.class) {if (instance == null) {instance = new Singleton(); // 可能重排序!}}}return instance;}
}
对象初始化的底层步骤
instance = new Singleton()
在 JVM 中实际分为以下三步:
memory = allocate(); // 1. 分配对象的内存空间
ctorInstance(memory); // 2. 调用构造方法初始化对象
instance = memory; // 3. 将引用指向内存地址
关键问题:步骤 2(初始化)和步骤 3(赋值引用)可能被 重排序。
问题的产生
假设线程 A 执行 instance = new Singleton()
时发生重排序:
- 先执行步骤 3(
instance
指向未初始化的内存)。 - 此时线程 B 调用
getInstance()
,发现instance != null
,直接返回该引用。 - 线程 B 使用未初始化的
Singleton
对象(可能触发空指针异常或逻辑错误)。
解决方案
通过 volatile
修饰 instance
变量,禁止步骤 2 和步骤 3 的重排序:
volatile
写操作前插入StoreStore
屏障,禁止之前的写重排序。volatile
写操作后插入StoreLoad
屏障,强制刷新缓存。
一种更好的方案
静态内部类:利用类加载机制保证线程安全。
public class Singleton {private static class Holder {static final Singleton INSTANCE = new Singleton();}public static Singleton getInstance() {return Holder.INSTANCE;}
}
枚举单例:天然线程安全且防反射攻击。
public enum Singleton {INSTANCE;
}
关于As-If-Serial
As-If-Serial(看上去像是顺序执行的) 是 Java 内存模型(JMM)对单线程程序行为的保证:
-
无论编译器和处理器如何重排序指令(优化执行顺序),单线程程序的执行结果必须与代码顺序执行的结果一致。
-
这种语义的目的是让开发者无需关心单线程内的指令重排序,只需关注代码逻辑的正确性。
As-If-Serial 允许重排序,但必须满足以下条件:
- 数据依赖性:若两个操作存在数据依赖(如写后读、写后写、读后写),则它们不能被重排序。
- 结果一致性:重排序后的执行结果必须与代码顺序执行的结果完全一致。
As-If-Serial 与多线程的差异
场景 | 单线程(As-If-Serial) | 多线程 |
---|---|---|
重排序自由度 | 允许无数据依赖的任意重排序 | 可能破坏跨线程操作的可见性,需内存屏障限制 |
开发者关注点 | 无需关注执行顺序 | 需通过 volatile 、锁等机制显式控制顺序 |
优化目标 | 提升单线程性能 | 平衡性能与线程安全性 |
嗯,差不多了,我想下班了,就这些吧。
然后整个过程中,我没有很细致的聊细节,因为网上到处都是,我也是在刷面试题的时候,灵机一动,要不完整的思路写一下,于是就有了这一篇文章。
我始终是更加相信无锁并发编程的,其实还可以扯一些有的没的,比如ForkJoinPool与ThreadPoolExecutor的差异,这里面又体现了分治算法与workingStealing的工作窃取。
比如协程,进程间的通讯,JUC还是很庞大的一个东西,好了不扯了,下班下班。