指令重排序
指令重排序是指在程序执行过程中,为了提高性能,编译器或处理器会对指令的执行顺序进行重新排列。
指令重排序导致可见性消失
在多线程环境下,每个线程都有自己的工作内存,线程对变量的操作是在工作内存中进行的,而不是直接操作主内存中的变量。当线程将主内存中的变量读取到工作内存后,如果发生指令重排序,可能会导致该线程对变量的修改在其他线程看来不可见。这是因为指令重排序可能会使变量的修改在时间上被推迟,而其他线程在这段时间内读取的仍然是旧值,从而出现可见性问题
示例
public class VisibilityProblem {private static boolean flag = false;private static int data = 0;public static void main(String[] args) throws InterruptedException {// 线程1负责修改flag和data的值Thread thread1 = new Thread(() -> {data = 10;flag = true;});// 线程2在flag为true时,打印data的值Thread thread2 = new Thread(() -> {while (!flag) {// 线程2在此处自旋等待flag变为true}System.out.println("data = " + data);});thread1.start();thread2.start();thread1.join();thread2.join();}
}
按照正常的逻辑,线程 1 先将data
赋值为 10,然后将flag
设置为true
,线程 2 在flag
变为true
后,应该打印出data
的值为 10。然而,由于指令重排序,线程 1 中对data
和flag
的赋值操作可能会被重排序,导致线程 2 可能先看到flag
变为true
,而此时data
的值可能还没有被更新为 10,从而打印出错误的结果(可能是 0)。
解决方案
- 使用 volatile 关键字:对共享变量使用
volatile
关键字修饰,它可以保证变量的内存可见性,即当一个线程修改了volatile
变量的值,其他线程能够立即看到这个修改,同时禁止指令重排序,确保volatile
变量的读写操作按照程序中的顺序执行。如将上述代码中的flag
声明改为private static volatile boolean flag = false;
,就可以解决可见性问题。 - 使用锁机制:通过
synchronized
关键字或者ReentrantLock
等锁机制来保证同一时刻只有一个线程能够访问共享资源,这样可以避免指令重排序带来的可见性问题。因为在获取锁和释放锁的过程中,会有相应的内存屏障来保证内存的可见性和禁止指令重排序。例如,将线程 1 中修改data
和flag
的代码放在synchronized
块中,也能解决可见性问题。
指令重排序解决翻案
虽然因为流水线技术导致了指令重排序产生可能的错误,但它提高了性能。所有我们可以禁止一定数量的指令重排序。比如说有200行代码,我们需要这两行不要指令重排序,按顺序执行,那么我们只设计局部。防止指令重排序就行了。
-
使用内存屏障(Memory Barrier)
- 原理:内存屏障是一种指令,它可以阻止处理器在内存屏障之前的指令和之后的指令之间进行重排序,并且确保在执行到内存屏障时,之前所有的写操作都已经完成并对其他处理器可见,之后的读操作将获取到最新的值。
- 作用:通过在适当的位置插入内存屏障,可以保证多线程环境下内存操作的顺序性和可见性,从而避免因指令重排序导致的问题。例如,在 Java 中,
volatile
关键字的实现就利用了内存屏障,当对volatile
变量进行写操作时,会在写操作之后插入一个写内存屏障,确保写操作对其他线程可见;当对volatile
变量进行读操作时,会在读操作之前插入一个读内存屏障,确保读到的是最新的值。
-
利用原子操作和同步机制
- 原理:原子操作是指在执行过程中不会被中断的操作,它要么完全执行,要么完全不执行。在多线程环境下,使用原子操作可以保证对共享资源的访问是原子性的,避免了指令重排序和数据竞争问题。同时,同步机制如锁(
synchronized
关键字、ReentrantLock
等)可以保证在同一时刻只有一个线程能够访问被保护的代码块或资源。 - 作用:在获取锁和释放锁的过程中,会有相应的内存屏障来保证内存的可见性和禁止指令重排序。例如,当一个线程获取锁时,会清空自己的工作内存,从主内存中重新读取共享变量的值;当释放锁时,会将工作内存中的修改刷新到主内存中,确保其他线程能够看到最新的值。
- 原理:原子操作是指在执行过程中不会被中断的操作,它要么完全执行,要么完全不执行。在多线程环境下,使用原子操作可以保证对共享资源的访问是原子性的,避免了指令重排序和数据竞争问题。同时,同步机制如锁(
-
采用线程局部存储(Thread - Local Storage,TLS)
- 原理:线程局部存储是一种为每个线程提供独立存储空间的机制,每个线程可以在自己的局部存储中存储和访问数据,而不会影响其他线程。
- 作用:通过将共享变量复制到线程局部存储中,每个线程只操作自己的副本,避免了多线程对共享变量的并发访问,从而消除了指令重排序和数据竞争的可能性。不过,使用线程局部存储需要注意数据的生命周期管理,确保在使用完毕后及时清理,避免内存泄漏。
内存屏障
- 类型:
storeFence()
:禁止写操作重排序(写屏障)。loadFence()
:禁止读操作重排序(读屏障)。fullFence()
:禁止所有内存操作重排序(全屏障)。
- 适用场景:在需要保证顺序的两行代码前后插入屏障,确保它们不会被重排序。
指令重排序规则
规则(Load-Load/Load-Store....)上一行代码和下一行代码相邻的两行代码。第一行是XX操作,第二行是XX操作。
常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO和X86拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。
数据依赖
数据依赖是指两条指令之间存在对同一数据的操作,且后一条指令的执行结果依赖于前一条指令的执行结果。它是指令重排序的核心约束条件之一,决定了指令能否被重新排列。
a = 10; // 指令1:写入a
b = a + 5; // 指令2:读取a的值,依赖指令1的结果
禁止重排序。若交换指令 1 和指令 2 的顺序,指令 2 将读取到未初始化的 a(或旧值),导致结果错误。
happens-before
是一个核心概念,用于定义多线程环境下操作之间的顺序关系,确保前一个操作的结果对后续操作可见,并禁止不合理的指令重排序。它是判断多线程程序是否正确的重要依据,而非实际执行顺序的 “时间先后”,而是一种 逻辑上的因果关系。注意两个操作之间具有 happen before 关系,并不意味着前一个操作必须在后一个操作前执行,它仅仅要求前一个操作执行的结果。对后可见。且前一个操作按照顺序排在第二个操作后面
happens-before 的核心规则
JMM 定义了以下 6 条基本规则(另有传递性规则),满足这些规则的操作对,前者的结果对后者可见,且两者之间不会发生指令重排序:
1. 程序顺序规则(Program Order Rule)
- 同一线程内,按照代码顺序,前面的操作 happens-before 后面的操作。
单线程内,后续操作必然能看到前面操作的结果(编译器 / 处理器不会重排序破坏真依赖)。int a = 1; // 操作1 happens-before 操作2 int b = a; // 操作2
2. 监视器锁规则(Monitor Lock Rule)
- 解锁操作(
unlock
)happens-before 后续对同一锁的 加锁操作(lock
)。
锁的释放会将工作内存的数据刷新到主内存,加锁会清空工作内存并重新读取主内存数据。synchronized (lock) {x = 10; // 写操作,unlock happens-before 后续的 lock } // 解锁 // 其他线程获取锁后: synchronized (lock) {int y = x; // 能看到 x=10(因 unlock happens-before 此次 lock) }
3. volatile 变量规则(Volatile Variable Rule)
- 对 volatile 变量的写操作 happens-before 后续对该变量的 读操作。
volatile int flag = 0; // 线程A: flag = 1; // 写volatile,happens-before 线程B的读操作 // 线程B: int i = flag; // 能看到 flag=1(禁止读写操作重排序,且保证内存可见性)
volatile 通过内存屏障禁止重排序,并强制读写时刷新主内存。
4. 线程启动规则(Thread Start Rule)
- 主线程中调用
thread.start()
happens-before 该线程内的 第一个操作。
启动线程后,线程内的操作必然能看到启动前的可见状态。Thread thread = new Thread(() -> {x = 10; // 线程内第一个操作,保证在 thread.start() 之后可见 }); thread.start(); // happens-before 线程内的所有操作
5. 线程终止规则(Thread Termination Rule)
- 线程内的 最后一个操作 happens-before 其他线程通过
thread.join()
返回 或检测到线程终止(如isAlive()
为 false)。Thread thread = new Thread(() -> {x = 10; // 线程内最后一个操作 }); thread.start(); thread.join(); // join() 返回时,保证能看到线程内 x=10 System.out.println(x); // 输出 10
join () 会等待线程执行完毕,确保线程内所有操作的结果对主线程可见。
6. 线程中断规则(Thread Interruption Rule)
- 对线程的
interrupt()
调用 happens-before 被中断线程检测到中断事件(如interrupted()
或isInterrupted()
返回 true)。Thread thread = new Thread(); // 线程A: thread.interrupt(); // happens-before 线程B检测到中断 // 线程B: boolean isInterrupted = thread.isInterrupted(); // 能正确检测到中断
7. 对象终结规则(Finalizer Rule)
- 对象的 构造函数执行完毕 happens-before 其
finalize()
方法的开始。
传递性规则(Transitivity)
如果操作 A happens-before 操作 B,且操作 B happens-before 操作 C,则 操作 A happens-before 操作 C。
// 规则组合示例:
// 1. 线程1解锁锁 L(A happens-before B)
// 2. 线程2获取锁 L(B happens-before C)
// 3. 线程2读取变量 x(C happens-before D)
// 传递后:线程1对x的修改(在解锁前)happens-before 线程2对x的读取(D)
happens-before 与指令重排序的关系
- 保证可见性:满足 happens-before 的操作对,前者的结果对后者可见(无需担心缓存未刷新或重排序导致的旧值)。
- 限制重排序范围:JVM 允许编译器 / 处理器对不违反 happens-before 规则的操作进行重排序,以优化性能。
- 例如:无 happens-before 关系的操作(如不同线程的无依赖操作),可能被重排序;
- 有 happens-before 关系的操作,其顺序在内存语义上不可被打破。
happen before 传递性
如果 操作 A happens-before 操作 B,且 操作 B happens-before 操作 C,则 操作 A happens-before 操作 C。
传递性的本质是将多个独立的 happens-before 关系 “串联” 起来,形成更长的因果链,确保最终的操作结果可见性。
public class TransitivityExample {static int x = 0;static final Object lock = new Object();// 线程1:修改x并释放锁static class Thread1 extends Thread {public void run() {synchronized (lock) { // 加锁1(操作A)x = 10; // 写x(操作B)} // 释放锁(操作C)}}// 线程2:获取锁并读取xstatic class Thread2 extends Thread {public void run() {synchronized (lock) { // 加锁2(操作D)int y = x; // 读x(操作E)System.out.println(y); // 输出10}}}public static void main(String[] args) throws InterruptedException {Thread1 t1 = new Thread1();Thread2 t2 = new Thread2();t1.start();t2.start();t1.join();t2.join();}
}
- 基本规则应用:
- 线程 1 内:操作 B(写 x)happens-before 操作 C(释放锁)(程序顺序规则)。
- 锁规则:操作 C(释放锁)happens-before 操作 D(获取锁)(监视器锁规则)。
- 线程 2 内:操作 D(获取锁)happens-before 操作 E(读 x)(程序顺序规则)。
- 传递性推导:
操作 B → 操作 C → 操作 D → 操作 E,通过传递性,操作 B happens-before 操作 E,确保线程 2 读取到 x=10。
重排序
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间 就存在数据依赖性。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
数据依赖性的定义与条件
当两个操作访问同一个变量,且至少有一个操作是写操作时,这两个操作之间存在数据依赖性。具体包括以下三种场景(均满足 “至少一个写操作”):
- 真依赖(写→读):前一个操作写变量,后一个操作读该变量(如
a=1; b=a;
)。 - 反依赖(读→写):前一个操作读变量,后一个操作写该变量(如
b=a; a=1;
)。 - 输出依赖(写→写):两个操作都写同一个变量(如
a=1; a=2;
)。
关键约束:
- 数据依赖性仅针对 单个线程内的操作序列 或 单个处理器中执行的指令序列。
- 编译器和处理器只考虑单线程内的数据依赖,跨线程、跨处理器的数据依赖被忽略(即不保证多线程间的操作顺序和可见性)。
数据依赖性对指令重排序的影响(单线程 vs 多线程)
1. 单线程场景
- 编译器和处理器 必须遵守数据依赖性,不会对存在数据依赖的操作进行重排序(尤其是真依赖)。
- 例如:
a=1; b=a+1;
中,真依赖存在,重排序会改变结果,因此禁止重排序。 - 反依赖和输出依赖允许重排序,但通过寄存器重命名等技术保证单线程结果正确性(如
a=1; a=2;
重排序为a=2; a=1;
无意义,但最终结果以最后一次写入为准)。
- 例如:
2. 多线程场景
- 跨线程的数据依赖性不被编译器和处理器考虑!即使两个线程操作同一变量(且至少一个是写操作),编译器 / 处理器可能对跨线程的操作进行重排序,导致以下问题:
- 可见性问题:线程 A 写入变量后,线程 B 可能读取到旧值(因未刷新主内存或指令重排序)。
- 伪数据依赖问题:跨线程的操作看似无依赖(如线程 A 写
x
和线程 B 写y
),但实际可能通过共享变量隐含依赖(如依赖x
和y
的写入顺序)。
顺序一致性
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语 言的内存模型都会以顺序一致性内存模型作为参照。就是同步操作。
Java 中 64 位变量(long/double)写操作的原子性问题及多线程风险
核心问题
-
Java 内存模型(JVM)的规定:
- 对 64 位的
long
和double
类型变量的写操作,不保证原子性(读操作同理,除非使用volatile
)。 - 即:写一个
long
变量(64 位)可能被拆分为 两次 32 位的写操作(高 32 位和低 32 位)。
- 对 64 位的
-
多线程场景下的风险:
- 若两个线程同时修改同一个
long
变量,可能出现 “半更新” 现象:- 线程 A 写入前 32 位,尚未写入后 32 位时,线程 B 被调度,写入后 32 位,最终变量值为线程 A 的前 32 位 + 线程 B 的后 32 位(数据错位)。
- 或线程 A 的写操作未完成时,线程 B 读取变量,得到前 32 位是新值、后 32 位是旧值的 “拼凑结果”。
- 若两个线程同时修改同一个
-
底层硬件原因:
- CPU 内存总线宽度不足(如 32 位总线):传输 64 位数据需分两次完成,无法保证一次性写入。
- 即使在 64 位处理器上,Java 规范也未强制要求原子性(允许平台优化),导致跨平台不一致性。
问题本质
- 原子性缺失:64 位数据的写操作被拆分为两次独立的 32 位操作,两次操作之间可能发生线程切换,导致数据不一致。
- 可见性与顺序性问题:即使不考虑线程切换,非原子写操作也可能因指令重排序或缓存延迟,让其他线程看到中间状态。
解决方案
-
使用
volatile
关键字:- 对
volatile long
或volatile double
的写操作,JVM 强制保证原子性(底层通过内存屏障实现)。 - 示例:
private volatile long value; // 写操作原子性有保障
- 对
-
使用原子类
AtomicLong
(或AtomicReference<Long>
):AtomicLong
提供get()
、set()
、compareAndSet()
等原子操作方法,确保 64 位数据的读写原子性。- 示例:
private AtomicLong value = new AtomicLong(0); value.set(100L); // 原子写 long v = value.get(); // 原子读
-
使用同步机制(
synchronized
或显式锁):- 通过加锁保证同一时刻只有一个线程执行写操作,避免线程切换导致的半更新问题。
- 示例:
private long value; private final Object lock = new Object();public void setValue(long v) {synchronized (lock) {value = v; // 加锁后写操作具有原子性} }
总结建议
- 优先使用
AtomicLong
:比volatile
更灵活(支持原子更新操作),比synchronized
性能更好(无锁或轻量级锁实现)。 - 避免依赖平台特性:即使某些 64 位处理器保证
long
写原子性,Java 规范未强制要求,必须通过显式机制(volatile
/ 原子类 / 锁)保证跨平台一致性。 - 理解原子性边界:原子性仅保证单次操作的完整性,复合操作(如 “先读再写”)仍需额外同步(如 CAS 或锁)。
事务
事务是一个批次的操作,要么都成功,要么都失败的意思。一个事务可能会有很多步骤,这里关键是总线会同步,总线会同步试图并发使用总线的事务。竞争总线的这些事物会排队在一个处理器的执行总线事务时间,总线会禁止其他的处理器和 IO 设备执行内存的读写,也就说呢,其中一个使用总线的时候,其他就得等着。是物理信号不允许。
总线事务。总线所有的步骤没处理完,我不会释放总线我们最怕的什么呢?我们最怕的,比如说我们对内层操作有两步,执行完一步的时候中断了其他的线程来执行了。我没处理完。我一直站着走路线,其他的用不了。
volatile特性
volatile阻止指定重排序,它能够阻止重排序,它保障线程可见性就是它能保证了你此时此刻能读到最准确的。
它修饰的变量在后边被操作的过程中读或者写的前后受到影响(上下两行)。离得远的行数不受影响。
单例模式
关键设计点
-
私有构造函数:防止外部通过
new
创建实例,确保单例性。 -
静态 volatile 变量:
volatile
关键字确保变量的写操作先行发生于后续的读操作(禁止指令重排序),避免其他线程看到 "半初始化" 状态的对象。- 在 JDK 5 及以后版本,
volatile
提供了内存屏障功能,保证初始化对象的过程不会被重排序。
-
双重检查机制:
- 第一次检查(无锁):多数情况下实例已经创建,直接返回,避免进入同步块,提高性能。
- 同步块:仅当第一次检查发现实例为 null 时才进入,确保只有一个线程创建实例。
- 第二次检查(同步块内):防止多个线程同时通过第一次检查后,重复创建实例。
为什么需要 volatile?
volatile
关键字的作用是禁止指令重排序,确保初始化对象的过程按顺序完成:
- 分配内存空间
- 初始化对象
- 将引用指向内存空间
如果没有volatile
,可能发生指令重排序,顺序变为:1→3→2。当线程 A 执行了 1 和 3 但还未执行 2 时,线程 B 检查singleton
不为 null,直接返回未完全初始化的对象,导致错误。
优点
- 线程安全:通过双重检查和同步机制,确保只有一个实例被创建。
- 高效性:大多数情况下无需进入同步块,性能开销小。
- 延迟加载:实例在第一次使用时才被创建,避免资源浪费。
构造方法,私有无法通过它 new1个对象出来 。构造方法私有无法通过,无法new出对象。没有对象怎么调方法,没有对象能调静态方法,所以方法必须是静态。
方法为什么也是静态的,因为静态方法只能对静态变量进行操作,静态方法无法对非静态变量进行操作。
这加上锁的意思呢?就是说。创建对象的时候,只能有一个对它进行操作。多线程操作的时候,只能有一个线程竞争所对它进行操作。
所以我们用volatile它的时候才加锁,用完之后这个锁就不存在了,因为代码就进不来了,其他线程对吧,调这个方法直接 return 了,只能同时拷贝一个方法的。
Volatile 内存语义实现:
概念含义 :内存语义的实现即探讨 volatile 如何设计、需遵循的规则及具备的功能。
重排序规则 :不同代码行的普通变量与 volatile 变量的读写操作组合有不同重排序规则,如普通变量读写与 volatile 写、volatile 读与其他代码行、volatile 写与普通变量写或 volatile 写操作等搭配,9 种组合中阻止 6 种指令重排序,影响范围为前后两行。
屏障类型 :有写读、读写等屏障,分别表示不允许特定两个操作指令重排序,最终优化只需一个写读屏障。
对变量操作定义 :根据变量在等号两侧及是否被 volatile 修饰确定是读操作还是写操作,若一行中有 volatile 则以其为主,两侧都是则读写都在。
与普通变量重排序限制 :旧内存模型允许 long 变量与普通变量重排序,新规则为提供轻量级线程间通信机制,禁止 volatile 变量与普通变量重排序,因重排序可能破坏语义。
功能性能特点 :保证单个变量读写原子性,功能不如锁强大,如 cyclic 锁更霸道,volatile 允许同时读,锁不允许;但 volatile 在可伸缩和执行性能上有优势,相对轻量级。
锁的内存语义 :
synchronize 相关 :锁的释放获取存在 happens-before 关系,即加锁前上一个锁需已释放;线程获取锁时将本地内存设置无效,从主内存读取共享变量,释放锁时将本地内存共享变量刷新到主内存。
底层通知机制 :线程竞争锁,获取成功的线程释放锁时通知阻塞队列所有线程竞争进入就绪队列,并非单独通知某一线程。
公平锁(Fair Lock)和非公平锁(Nonfair Lock)
特性 | 公平锁 | 非公平锁 |
---|---|---|
获取锁顺序 | 严格按照等待队列顺序(先到先得) | 不保证顺序,允许抢占(可能插队) |
等待队列 | 必须检查队列头部是否有前驱节点 | 直接尝试抢占,不检查队列 |
线程饥饿 | 较少发生(按顺序获取) | 可能发生(新线程频繁抢占导致旧线程等待) |
上下文切换 | 更多(每次唤醒队列头部线程) | 更少(可能直接由刚释放锁的线程重新获取) |
吞吐量 | 较低(频繁线程挂起 / 恢复) | 较高(减少上下文切换开销) |
适用场景 | 需严格公平(如资源分配、避免饥饿) | 追求性能(大多数并发场景) |
AQS
state 是指加锁次数 ,非公屏锁性能更好,注意非公平性能更好,公屏锁性能不好,公平锁和非公平锁,哪个是主流呢?非公平所是主流性能更好,性能更好。,任务时间短的先执行,你很快执行完了,你执行完之后我下次不用选你了,我性能更好了
我释放锁之后呢,我会轮流去通知这里边队列,比如说这里边有十个线程,我十个线程的话呢,我并不是只通知第一个线程。
我是都同志,我都通知你第一个如果竞争成功了,你去加锁,如果你不是第一个竞争成功的,你不加锁。
这样浪费 CPU 了,就是你通知的时候属于全都通知的,通知的都是属于全都通知,但是呢,你只有第一个竞争成功的时候才加锁其他的优先竞争成功了,属于白竞争。
会判定你是不是第一个不是第一个的话?你就再歇回去。白白浪费 CPU 性能。而非公平的话呢,就是说。我在队列一边有先后顺序我会按照先后顺序
获取当前线程