synchronized 的可见性-内存屏障-重排序-原子性-JMM
synchronized 的可见性
在 Java 中,synchronized
关键字通过 隐式内存屏障(Memory Barrier) 和 锁机制 自动保障可见性。这些操作是由 JVM(Java 虚拟机)在底层自动实现 的,开发者无需手动干预。以下是具体解释:
一、synchronized 的可见性保障机制
1. 内存屏障(Memory Barrier)的作用
内存屏障是 CPU 或 JVM 插入的指令,用于:
- 禁止指令重排序:确保屏障前后的代码按顺序执行。
- 强制内存同步:让线程的工作内存(缓存)与主内存(共享内存)的数据保持一致。
Synchronized
在获取锁(进入同步块)和释放锁(退出同步块)时,自动插入内存屏障,触发以下行为:
- 进入同步块时(加锁):强制从主内存重新加载所有共享变量的最新值到线程的工作内存。
- 退出同步块时(解锁):强制将线程工作内存中的修改刷新到主内存,使其他线程可见。
2. 示例代码说明
public class SyncDemo {
private static boolean flag = false;
private static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) { // 加锁:强制从主内存加载最新值
while (!flag) { // 此时 flag 一定是最新值
// do something...
}
System.out.println("Thread A: Flag is now true");
} // 解锁:强制刷新修改到主内存(如果有)
}).start();
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock) { // 加锁:强制从主内存加载最新值
flag = true; // 修改值
System.out.println("Thread B: Set flag to true");
} // 解锁:强制刷新修改到主内存
}).start();
}
}
效果:Thread A
能立即感知到 flag
被修改为 true
,退出循环。
二、为什么说是“系统自动执行”?
这些操作完全由 JVM 在底层实现,开发者只需正确使用 synchronized
,无需关心以下细节:
- 内存屏障的插入:JVM 在编译或运行时自动为
synchronized
块插入内存屏障指令。 - 主内存与工作内存的同步:JVM 通过锁协议(如
MESI 协议
)管理缓存一致性,确保数据同步。
三、对比其他可见性方案
机制 | 原理 | 开发者干预 | 适用场景 |
---|---|---|---|
synchronized |
JVM 自动插入内存屏障,加锁/解锁时强制同步主内存 | 无需 | 需要原子性 + 可见性的复合操作 |
volatile |
JVM 插入读写屏障,直接操作主内存 | 需声明变量 | 单一变量的可见性保障 |
Atomic 类 |
基于 volatile + CAS 实现原子操作 |
需替换变量 | 无锁化高并发场景 |
四、总结
synchronized
的可见性保障是 JVM 自动实现 的,开发者只需正确加锁。- 内存屏障和主内存同步的细节由 JVM 处理,无需手动操作。
synchronized
不仅解决可见性问题,还提供原子性保障,适合复合操作(如i++
)。
内存屏障(Memory Barrier)是一种底层同步机制,用于在多线程或多核处理器环境中强制内存访问的有序性和可见性。它通过约束编译器和处理器的指令重排序(Reordering)以及缓存刷新,确保程序的执行顺序和内存状态符合开发者的预期。以下是其核心要点:
内存屏障
一、内存屏障的诞生背景
-
指令重排序问题
编译器和处理器为提高性能,会对指令进行优化重排(如流水线并行、缓存预取),但可能破坏多线程程序的逻辑顺序。例如:// 线程1 x = 42; // 操作A ok = 1; // 操作B // 线程2 while (ok != 1); // 等待操作B完成 print(x); // 预期x=42,但若操作A被重排到B之后,可能读到旧值
若操作A与B被重排,线程2可能读到未初始化的
x
值。 -
缓存一致性问题
多核系统中,各处理器的缓存独立工作,可能导致共享变量的修改对其他处理器不可见(即“缓存不一致”)。
二、内存屏障的核心作用
-
禁止指令重排序
确保屏障前后的指令顺序不被编译器和处理器打乱,维持程序逻辑的正确性。- 编译器屏障:阻止编译器优化导致的指令重排(如Linux内核的
barrier()
)。 - CPU屏障:阻止硬件层面的乱序执行。
- 编译器屏障:阻止编译器优化导致的指令重排(如Linux内核的
-
保障数据可见性
强制将缓存中的数据刷新到主内存(写屏障),或从主内存加载最新数据到缓存(读屏障),确保多线程间共享变量的修改可见。
三、内存屏障的类型
1. 按功能分类
- LoadLoad屏障:确保当前
Load
操作之前的Load
全部完成。 - StoreStore屏障:确保当前
Store
操作之前的Store
全部完成。 - LoadStore屏障:确保
Load
操作先于后续Store
操作完成。 - StoreLoad屏障:全能型屏障,确保所有
Store
操作完成后,后续Load
才能执行(性能开销最大)。
2. 按读写行为分类
- 写屏障(Store Barrier):强制刷新缓存到主内存,确保其他线程可见写操作结果。
- 读屏障(Load Barrier):强制从主内存加载最新数据,避免读取缓存中的旧值。
四、应用场景
-
Java中的
volatile
变量
在volatile
变量的读写前后插入内存屏障,保证:- 写操作后插入StoreStore和StoreLoad屏障,强制刷新到主内存。
- 读操作前插入LoadLoad和LoadStore屏障,强制从主内存加载最新值。
-
锁机制(如
synchronized
)- 进入同步块时插入Acquire屏障(类似LoadLoad+LoadStore),确保读取最新数据。
- 退出同步块时插入Release屏障(类似StoreStore+LoadStore),强制刷新修改到主内存。
-
无锁数据结构
如无锁队列(Lock-Free Queue),通过内存屏障实现高效线程间通信,避免锁竞争。
五、实现原理
-
硬件层面
CPU通过特定的指令(如x86的mfence
、ARM的dmb
)实现内存屏障,强制缓存一致性协议(如MESI)的生效。 -
语言层面
高级语言(如Java、C++)通过关键字(volatile
、atomic
)或函数(std::atomic_thread_fence
)间接使用内存屏障。
六、总结对比
维度 | 内存屏障 | 普通内存操作 |
---|---|---|
指令顺序 | 强制顺序执行,禁止重排序 | 允许编译器和处理器优化重排 |
可见性 | 确保多核缓存一致性,数据修改立即可见 | 可能因缓存延迟导致数据不一致 |
性能开销 | 较高(尤其是StoreLoad屏障) | 无额外开销 |
适用场景 | 多线程共享变量、锁、无锁数据结构 | 单线程或线程私有数据 |
内存屏障是并发编程中保障数据一致性的基石,尤其在多核处理器和复杂同步场景中不可或缺。理解其机制有助于编写高性能且线程安全的代码。
什么是重排序
什么是重排序
- 假设我们写了一个 Java 程序,包含一系列的语句,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致。
- 但实际上,编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序。
重排序的好处:提高处理速度
- 图中左侧是 3 行 Java 代码,右侧是这 3 行代码可能被转化成的指令。
- 可以看出 a = 100 对应的是 Load a、Set to 100、Store a,意味着从主存中读取 a 的值,然后把值设置为 100,并存储回去,同理, b = 5 对应的是下面三行 Load b、Set to 5、Store b,最后的 a = a + 10,对应的是 Load a、Set to 110、Store a。
- 如果你仔细观察,会发现这里有两次“Load a”和两次“Store a”,说明存在一定的重排序的优化空间。
经过重排序之后,情况如下图所示:
-
重排序后, a 的两次操作被放到一起,指令执行情况变为 Load a、Set to 100、Set to 110、 Store a。
-
下面和 b 相关的指令不变,仍对应 Load b、 Set to 5、Store b。
-
可以看出,重排序后 a 的相关指令发生了变化,节省了一次 Load a 和一次 Store a。
-
重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。
重排序的 3 种情况
(1)编译器优化
- 编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a 的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排。不过重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。
(2)CPU 重排序
- CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。
- 所以即使之前编译器不发生重排,CPU 也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。
(3) 内存的“重排序”
- 内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。
- 由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。
- 举个例子,线程 1 修改了 a 的值,但是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,所以线程 2 看不到刚才线程 1 对 a 的修改,此时线程 2 看到的 a 还是等于初始值。但是线程 2 却可能看到线程 1 修改 a 之后的代码执行效果,表面上看起来像是发生了重顺序。
3. 潜在问题
在 多线程环境 中,指令重排序可能导致以下问题:
- 数据竞争(Data Race)
多个线程同时读写共享变量时,因重排序导致结果不可预测。例如,线程 A 修改变量后未及时同步,线程 B 读取旧值。 - 可见性问题
线程本地缓存未及时刷新到主内存,其他线程无法感知变量修改。例如,volatile
变量未声明时,多线程可能陷入死循环。 - 破坏原子性
复合操作(如i++
)被拆分为多条指令后,重排序可能导致中间状态暴露。
并发理论基础:指令重排序问题
为什么需要对指令进行重排序?
其实说到底都是源于对性能的优化,CPU运行效率 相比缓存、内存、硬盘IO之间效率有着指数级的差别,CPU作为系统的宝贵资源,那么如何更好的优化和利用这个资源就能提升整个计算机系统的性能。
其实指令重排序就是一种来源于生活的优化思想,这种思想在生活中处处可见,就像平常咱们做菜,咱们会选择在炒第一个菜的同时就在洗第二个菜了, 咱们会把熟得最慢的菜放到最开始(比如煲汤),因为在等待这些菜熟的过程中(IO等待)咱们(CPU)还可以做其它事情,这就是一种时间上的优化,在计算机领域也是一样,它也会根据指令的类别做一些优化,目的就是把CPU的资源利用起来,这样就能就能提升整个计计算机的效率。
三种重排序场景
1、编译器重排序
针对程序代码语而言,编译器可以在不改变单线程程序语义的情况下,可以对代码语句顺序进行调整重新排序。
2、指令集并行的重排序
这个是针对于CPU指令级别来说的,处理器采用了指令集并行技术来讲多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应的机器指令执行顺序。
3、内存重排序
因为CPU缓存使用 缓冲区的方式(Store Buffere )进行延迟写入,这个过程会造成多个CPU缓存可见性的问题,这种可见性的问题导致结果的对于指令的先后执行显示不一致,从表面结果上来看好像指令的顺序被改变了,内存重排序其实是造成可见性问题的主要原因所在,其原理可在上一篇可中详细了解。
指令重排序的原则(as-if-serial语义)
编译器和处理指令也并非什么场景都会进行指令重排序的优化,而是会遵循一定的原则,只有在它们认为重排序后不会对程序结果产生影响的时候才会进行重排序的优化,如果重排序会改变程序的结果,那这样的性能优化显然是没有意义的。而遵守as-if-serial 语义规则就是重排序的一个原则,as-if-serial 的意思是说,可以允许编译器和处理器进行重排序,但是有一个条件,就是不管怎么重排序都不能改变单线程执行程序的结果。
单线程重排序
比如下面这段代码来说语句2和语句1、3之间没有任何依赖关系,而语句1和语句3却有着明确的依赖关系,遇到这样的语句(换成指令也一样)编译器就认为先执行语句2再执行语句1、3对程序结果是没有任何影响的,所以可以对语句2进行重排序。
反之编译器不会对语句3重排序到语句1之前,因为语句3和语句1是有数据依赖关系的,如果对3进行重排序就有可能影响到最终的程序运行结果,这也就是as-if-serial语义所表达的,只要程序结果不会改变,那么就算我重排序了代码和指令,但从结果上来看我好像就是完全串行按顺序的把代码从头执行到尾。
a=1; //1
b=2; //2
c=a+1; //3
编译器优化后可能执行顺序如下
b=2; //2
a=1; //1
c=a+1; //3
重排序对多线程的影响
单线程的重排序很简单,因为可以通过语义分析就能知道前后代码的依赖性,但是多线程就不一样了,多线程环境里编译器和CPU指令优化根本无法识别多个线程之间存在的数据依赖性,比如说下面的程序代码如果两个方法在l两个不同的线程里面调用就可能出现问题。
private static int value;
private static boolean flag;
public static void init(){
value=8; //语句1
flag=true; //语句2
}
public static void getValue(){
if(flag){
System.out.println(value);
}
}
根据上面代码,如果程序代码运行都是按顺序的,那么getValue() 中打印的value值必定是等于8的,不过如果init()方法经过了指令重排序,那么结果就不一定了。根据as-if-serial 原则,init()方法是允许进行指令重排序,因为语句1和语句2之间没有依赖关系。 进行重排序后代码执行顺序可能如下。
flag=true; //语句2
value=8; //语句1
如果init()方法经过了指令重排序后,这个时候两个线程分别调用 init()和getValue()方法,那么就有可能出现下图的情况,导致最终打印出来的value数据等于0。
内存屏障-JMM模型
在复杂的多线程环境下,编译器和处理器是根本无法通过语义分析来知道代码指令的依赖关系的,所以这个问题只有写代码的人才知道,这个时候编写代码的人就需要通过一种方式显示的告诉编译器和处理器哪些地方是存在逻辑依赖的,这些地方不能进行重排序。
所以在编译器层面 和CPU层面都提供了一套内存屏障来禁止重排序的指令,编码人员需要识别存在数据依赖的地方加上一个内存屏障指令,那么此时计算机将不会对其进行指令优化。
不过因为不同的CPU架构和操作系统都有各自对应的内存屏障指令,为了简化开发人员的工作,避免开发人员需要去了解各种不同的底层的系统原理,所以在JAVA里面封装了一套规范,把这些复杂的指令操作与开发人员隔离开来,这套规范就是我们常说的Java 内存模型(JMM),JMM定义了几个happens before原则来指导并发程序编写的正确性。程序员可以通过Volatile、synchronized、final几个关键字告诉编译器和处理器哪些地方是不允许进行重排序的。
原子性
在 Java 并发编程中,原子性是确保多线程环境下操作正确性的核心特性之一。以下是关于原子性的详细解析:
一、原子性的定义
原子性(Atomicity) 指一个或多个操作在执行过程中不可被中断,要么全部执行成功,要么全部不执行,中间状态对外不可见。这一特性在多线程场景中尤为重要,因为线程切换可能导致未完成的操作被其他线程观察到,从而引发数据不一致问题 。
示例说明
Java 中对基本数据类型(如 int
、boolean
)的 简单读取和赋值操作是原子性的,例如:
a = 5; // 原子操作(直接赋值)
boolean flag = true; // 原子操作
而 复合操作(如自增 i++
)是非原子性的,因为它包含多个步骤:
- 读取当前值;
- 执行计算(如
i+1
); - 写回新值。
多线程并发执行时,这三个步骤可能被其他线程打断,导致最终结果错误 。
二、原子性问题的根源
-
线程切换
CPU 的时间片调度机制可能导致线程在操作中途被切换。例如,线程 A 执行i++
时,若在读取i
后发生线程切换,线程 B 也读取到旧值并进行修改,最终结果会小于预期 。 -
复合操作的分解
非原子操作被拆分为多个步骤,如i++
分解为getfield
、iadd
、putfield
三条字节码指令,无法保证整体原子性 。
三、原子性破坏的典型场景
1. 自增计数器问题
public class Counter {
private int count = 0;
public void increment() { count++; }
}
多个线程并发调用 increment()
时,count++
的复合操作可能导致结果错误。例如,5 万次自增可能实际得到 49,999 而非 50,000 。
2. 双重检查锁定(DCL)失效
在单例模式中,若未正确同步 new
操作,指令重排序可能导致其他线程获取到未完全初始化的对象 。
四、Java 中保障原子性的方法
1. 使用 synchronized
关键字
通过同步代码块或方法强制原子性:
public synchronized void increment() { count++; }
原理:
- 利用
monitorenter
和monitorexit
字节码指令实现锁机制,确保同一时刻只有一个线程执行临界区代码 。 - 锁的获取与释放伴随内存屏障,保障可见性和有序性 。
2. 使用原子类(Atomic Classes)
如 AtomicInteger
、AtomicLong
等,基于 CAS(Compare and Swap)实现无锁化原子操作:
private AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); }
原理:
- 内部通过
volatile
变量和 Unsafe 类实现原子操作,避免锁竞争,性能更高 。
3. 使用显式锁(Lock)
如 ReentrantLock
提供更灵活的锁控制:
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try { count++; }
finally { lock.unlock(); }
}
五、原子性与其他并发特性的对比
特性 | 定义 | 保障机制 | 示例场景 |
---|---|---|---|
原子性 | 操作不可分割,要么全部成功,要么全部失败 | synchronized 、原子类、显式锁 |
计数器自增、单例模式初始化 |
可见性 | 线程修改共享变量后,其他线程能立即看到最新值 | volatile 、锁机制 |
状态标志位更新 |
有序性 | 程序按代码顺序执行,避免指令重排序导致的逻辑错误 | volatile 、synchronized 、内存屏障 |
双重检查锁定、线程启动顺序 |
六、总结
- 原子性是多线程安全的基础:任何可能被多个线程共享的复合操作(如
i++
)必须通过同步机制或原子类保障原子性。 - 选择合适工具:
- 高频轻量操作优先使用原子类(如
AtomicInteger
)提升性能; - 复杂逻辑或需要显式控制时使用
synchronized
或Lock
。
- 高频轻量操作优先使用原子类(如
- 避免误区:
volatile
仅保证可见性和禁止指令重排序,无法解决复合操作的原子性问题 。
通过合理应用上述机制,可有效避免多线程环境下的原子性问题,确保程序正确性和高性能。