JMM三大特性与原子操作、happens-before原则
JMM规范下三大特性
在 Java 内存模型(JMM)规范下,有三大特性,分别是原子性、可见性和有序性。
一、原子性
- 概念:原子性是指一个操作或者一系列操作要么全部执行,要么全部不执行,不会被中断。在 Java 中,对基本数据类型变量的读取和赋值操作是原子性的,例如将一个整数赋值给另一个整数变量。
- 实现方式:
synchronized
关键字:通过使用synchronized
关键字修饰方法或者代码块,可以保证在同一时刻只有一个线程访问被修饰的代码区域,从而实现原子性操作。java.util.concurrent.atomic
包:这个包中提供了一些原子类,如AtomicInteger
、AtomicLong
等,它们通过使用底层的硬件支持,如处理器的原子指令,来实现对变量的原子操作。
二、可见性
- 概念:可见性是指当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改。在多线程环境中,如果没有可见性保证,一个线程对共享变量的修改可能不会被其他线程及时察觉。
- 实现方式:
volatile
关键字:使用volatile
关键字修饰的变量,当一个线程修改了这个变量的值后,会立即将新值刷新到主内存中,并且使其他线程中的该变量缓存无效,从而其他线程在下次使用这个变量时会从主内存中重新读取最新的值。synchronized
关键字和final
关键字:使用synchronized
关键字修饰的方法或者代码块,在释放锁之前会将线程工作内存中的变量刷新到主内存中,从而保证了变量的可见性。而被final
关键字修饰的变量,一旦被初始化后就不能被修改,所以在构造函数中对final
变量的初始化操作对于其他线程是可见的。
三、有序性
- 概念:有序性是指程序执行的顺序按照代码的先后顺序执行。在单线程环境中,程序的执行顺序是确定的,但是在多线程环境中,由于编译器和处理器可能会对代码进行重排序,所以程序的执行顺序可能会与代码的先后顺序不一致。
- 实现方式:
volatile
关键字:使用volatile
关键字修饰的变量,禁止了指令重排序,从而保证了变量的读写操作按照代码的先后顺序执行。synchronized
关键字:使用synchronized
关键字修饰的方法或者代码块,保证了同一时刻只有一个线程访问被修饰的代码区域,从而也保证了代码的执行顺序。happens-before
原则:Java 内存模型定义了happens-before
原则,即如果一个操作 A happens-before 另一个操作 B,那么 A 的结果对 B 可见,并且 A 的执行顺序在 B 之前。通过遵循happens-before
原则,可以保证程序的有序性。
数据同步八大原子操作
一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成
lock
(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。unlock
(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。read
(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load
动作使用。load
(载入):作用于工作内存的变量,把read
操作从主内存中得到的变量值放入工作内存的变量副本中。use
(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。assign
(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store
(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write
操作。write
(写入):作用于主内存的变量,它把store
操作从工作内存中得到的变量值放入主内存的变量中。
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作;如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行JMM
对这八种指令的使用,制定了如下规则:
- ①不允许
read
和load
、store
和write
操作之一单独出现。即:使用了read
必须load
,使用了store
必须write
; - ②不允许线程丢弃他最近的
assign
操作,即工作变量的数据改变了之后,必须告知主存; - ③不允许一个线程将没有
assign
的数据从工作内存同步回主内存; - ④一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施
use、store
操作之前,必须经过assign
和load
操作; - ⑤一个变量同一时间只有一个线程能对其进行
lock
。多次lock
后,必须执行相同次数的unlock
才能解锁; - ⑥如果对一个变量进行
lock
操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load
或assign
操作初始化变量的值; - ⑦如果一个变量没有被
lock
,就不能对其进行unlock
操作。也不能unlock
一个被其他线程锁住的变量; - ⑧对一个变量进行
unlock
操作之前,必须把此变量同步回主内存;
JMM对这八种操作规则和对volatile
的一些特殊规则,就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析,所以一般我们也不会通过上述规则进行分析。更多的时候,会使用JMM
中的happens-before
规则来进行分析。
happens-before
假如在多线程开发过程中,我们需要通过加锁或volatile
来解决这些问题的话,那么编写程序的时候会非常麻烦,而且加锁本质上是让多线程的并行执行变为了串行执行,这样会大大的影响程序的性能,那么其实真的需要嘛?不需要,因为在JMM
中还为我们提供happens-before
原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before
原则内容如下:
- 一、程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行;
- 二、锁规则:解锁(
unlock
)操作必然发生在后续的同一个锁的加锁(lock
)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁); - 三、volatile规则:
volatile
变量的写,先发生于读,这保证了volatile
变量的可见性。简单的理解就是:volatile
变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值; - 四、线程启动规则:线程的
start()
方法先于它的每一个动作,即如果线程A
,在执行线程B
的start
方法前修改了共享变量的值,那么当线程B
执行start
方法时,线程A
变更过的共享变量,对线程B
可见; - 五、传递性优先级规则:
A
先于B
,B
先于C
,那么A
必然先于C
; - 六、线程终止规则:线程的所有操作先于线程的终结,
Thread.join()
方法的作用是等待当前执行的线程终止。假设在线程B
终止之前,修改了共享变量,线程A
从线程B
的join
方法成功返回后,线程B
对共享变量的修改将对线程A
可见; - 七、线程中断规则:对线程
interrupt()
方法的调用,先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()
方法检测线程是否中断; - 八、对象终结规则:对象的构造函数执行,结束先于
finalize()
方法。
演示:
一、次序规则
在一个线程内,代码的执行顺序决定了操作的先后关系。这确保了线程内的操作按照程序员预期的顺序执行,并且前一个操作的结果可以被后续操作获取。
例如,在以下代码中:
1 | int x = 0; |
按照次序规则,先执行x = 1
的赋值操作,然后后续的int y = x
操作能够获取到x
被赋值为1
后的结果,即y
的值为1
。
二、锁定规则
这个规则确保了多线程环境下对锁的正确使用顺序。当一个线程释放了一个锁(unlock
操作)后,另一个线程才能获取这个锁进行lock
操作。
例如:
1 | synchronized (obj) { |
线程 1 执行完同步代码块并释放锁后,线程 2 才能获取锁进入同步代码块。
三、volatile 变量规则
对于被volatile
关键字修饰的变量,写操作会先行发生于后续对这个变量的读操作。这保证了对volatile
变量的修改能够立即被其他线程看到。
例如:
1 | volatile boolean flag = false; |
四、传递规则
如果操作 A 先行发生于操作 B,操作 B 又先行发生于操作 C,那么可以得出操作 A 先行发生于操作 C。这个规则确保了 “先行发生” 关系的可传递性,有助于在复杂的多线程场景中进行推理。
例如:
1 | int a = 0; |
如果操作 1 先行发生于操作 2,操作 2 先行发生于操作 3,那么根据传递规则,操作 1 先行发生于操作 3。
五、线程启动规则
Thread
对象的start()
方法先行发生于线程的每一个动作。这意味着在调用start()
方法启动线程后,线程中的所有操作才能开始执行。
例如:
1 | Thread t = new Thread(() -> { |
六、线程中断规则
对线程的interrupt()
方法的调用先发生于被中断线程的代码检测到中断事件的发生。可以通过Thread.interrupted()
方法检测是否发生中断。
例如:
1 | Thread t = new Thread(() -> { |
七、线程终止规则
线程中的所有操作都先行发生于对此线程的终止检测。这意味着在检测线程是否终止时,能够看到线程中之前执行的所有操作的结果。
例如:
1 | Thread t = new Thread(() -> { |
八、对象终结规则
对象在没有完成初始化之前,是不能调用finalized()
方法的。这确保了对象在被垃圾回收之前,其终结方法能够正确地执行,并且对象的状态是完整的。
例如:
1 | class MyObject { |
在创建MyObject
对象后,只有当对象不再被引用且满足垃圾回收条件时,finalize()
方法才会被调用,并且在调用之前,对象必须已经完成了初始化。
作用和意义:
- 保证内存可见性:通过 “happens-before” 原则,开发人员可以确定在多线程环境下哪些操作之间存在可见性保证,从而避免了数据竞争和不一致的问题。例如,使用 volatile 变量或同步代码块可以确保对共享变量的修改对其他线程可见。
- 确保程序有序性:虽然在多线程环境中,编译器和处理器可能会对代码进行重排序,但 “happens-before” 原则限制了重排序的范围,确保了某些操作之间的执行顺序符合预期。这有助于开发人员理解和预测多线程程序的行为。
- 简化多线程编程:“happens-before” 原则为多线程编程提供了一套明确的规则,使得开发人员可以在不深入了解底层硬件和编译器实现的情况下,编写正确的多线程程序。这降低了多线程编程的难度,提高了开发效率。