目录
引言
在 Java 并发编程中,理解 Java 内存模型(Java Memory Model, JMM)以及 volatile
关键字的作用是至关重要的。它们帮助我们确保多线程环境下的可见性、有序性和原子性。本文将深入探讨 JMM 和 volatile
关键字以及指令重排,并解释它们如何影响多线程程序的执行。
1. Java 内存模型(JMM)
JMM 本身是一种抽象的概念,并不真实存在,它描述的是一组规则或者规范~
Java 内存模型定义了 Java 程序中各种变量(线程共享变量)的访问规则,以及在并发环境下如何保证这些变量的可见性、有序性和原子性。JMM 的主要目标是解决多线程环境下的内存可见性问题。
JMM 关于同步的规定:
1、线程解锁前,必须把共享变量的值刷新回主内存
2、线程加锁前,必须读取主内存的最新值到自己的工作内存
3、加锁解锁是同一把锁
1.1 主内存与工作内存
在 JMM 中,内存分为两类:
-
主内存(Main Memory):所有线程共享的内存区域,存储了所有的变量(实例字段、静态字段等)。
-
工作内存(Working Memory):每个线程都有自己的工作内存,存储了该线程使用到的变量的副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接操作主内存中的变量。
此处的主内存和工作内存跟JVM内存划分(堆、 栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部 分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存 对应的是寄存器和高速缓存。
JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所 以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写 入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个 线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了 一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。
JMM的内存模型
线程A感知不到线程B操作了值的变化!如何能够保证线程间可以同步感知这个问题呢?
只需要使用 Volatile关键字即可!volatile 保证线程间变量的可见性,简单地说就是当线程A对变量X进行了修改后, 在线程A后面执行的其他线程能看到变量X的变动,更详细地说是要符合以下两个规则 :
线程对变量进行修改之后,要立刻回写到主内存。
线程对变量读取的时候,要从主内存中读,而不是缓存。
各线程的工作内存间彼此独立,互不可见,在线程启动的时候,虚拟机为每个内存分配一块工作内存, 不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(非线程内构造的对象)的 副本,即,为了提高执行效率。
1.2 内存间的交互操作
JMM 定义了以下几种内存间的交互操作:
-
read:从主内存中读取变量的值到工作内存。
-
load:将 read 操作读取的值放入工作内存中的变量副本。
-
use:当线程执行字节码指令时,使用工作内存中的变量值。
-
assign:将工作内存中的变量值赋给一个新的值。
-
store:将工作内存中的变量值传送到主内存。
-
write:将 store 操作传送的值写入主内存中的变量。
这些操作必须按照一定的顺序执行,以确保多线程环境下的内存可见性。
1.3 可见性问题
在多线程环境下,一个线程对共享变量的修改可能不会立即对其他线程可见。这是因为每个线程都有自己的工作内存,线程对变量的操作首先发生在工作内存中,之后才会同步到主内存。如果同步不及时,其他线程可能读取到过期的数据。
1.4 有序性问题
为了提高性能,编译器和处理器可能会对指令进行重排序。这种重排序在单线程环境下不会影响程序的执行结果,但在多线程环境下可能会导致意想不到的结果。JMM 通过 happens-before 规则来保证指令的有序性。
2. volatile 关键字
volatile
是 Java 提供的一种轻量级的同步机制,用于确保变量的可见性和有序性。当一个变量被声明为 volatile
时,它具有以下特性:
2.1 可见性
volatile
变量的修改会立即被写入主内存,并且每次读取 volatile
变量时都会从主内存中读取最新的值。这确保了多个线程之间对 volatile
变量的可见性。
验证:
//Volatile 用来保证数据的同步,也就是可见性
public class JMMVolatileDemo01 {
// volatile 不加volatile没有可见性
// 不加 volatile 就会死循环,这里给大家将主要是为了面试,可以避免指令重排
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (num==0){ //此处不要编写代码,让计算机忙的不可开交
}
}).start();
Thread.sleep(1000);
num = 1;
System.out.println(num);
}
}
2.2 禁止指令重排序
volatile
变量的读写操作不会被重排序。编译器在生成字节码时,会插入内存屏障(Memory Barrier)来禁止指令重排序,确保 volatile
变量的操作按照代码的顺序执行。
2.3 volatile 的使用场景
volatile
适用于以下场景:
状态标志:当一个变量作为状态标志时,可以使用 volatile
来确保多个线程能够及时看到状态的改变。例如:
volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
// do something
}
}
单例模式的双重检查锁定(Double-Checked Locking):在单例模式中,volatile
可以确保实例的可见性和有序性。
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
2.4 volatile 不保证原子性
虽然 volatile
可以保证可见性和有序性,但它并不能保证原子性。例如,volatile
变量不适合用于计数器等需要原子操作的场景。如果需要保证原子性,可以使用 synchronized
或 java.util.concurrent.atomic
包中的原子类。
验证 volatile 不保证原子性
原子性理解: 不可分割,完整性,也就是某个线程正在做某个具体的业务的时候,中间不可以被加塞或者被分割,需要整体完整,要么同时成功,要么同时失败。
举例:
public class JMMVolatileDemo02 {
private static int num = 0;
public static void add(){
num++;
}
// 结果应该是 num 为 2万,测试看结果
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 20; i++) {
new Thread(()->{
for (int j = 1; j <= 1000; j++) {
add();
}
},String.valueOf(i)).start();
}
// 需要等待上面20个线程都全部计算完毕,看最终结果
while (Thread.activeCount()>2){ // 默认一个 main线程 一个 gc 线程
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+" "+num);
}
}
思考
这段代码的结果为什么不是20000?
代码中,num++
操作在多线程环境下并不是原子操作,因此会导致结果不符合预期。具体来说,num++
实际上分为三个步骤:
-
读取:从主内存中读取
num
的值到工作内存。 -
修改:在工作内存中对
num
的值进行加 1 操作。 -
写入:将修改后的值写回主内存。
由于多个线程可能同时执行这些操作,且没有同步机制来保证操作的原子性,因此可能会出现以下情况:
-
线程 A 读取
num
的值为 100。 -
线程 B 也读取
num
的值为 100。 -
线程 A 对
num
进行加 1 操作,得到 101,并写回主内存。 -
线程 B 也对
num
进行加 1 操作,得到 101,并写回主内存。
最终,num
的值只增加了 1,而不是预期的 2。这种情况下,多个线程的并发操作导致了数据的不一致性。
解决方案
要解决这个问题,可以使用以下几种方法:
1. 使用 synchronized
关键字
通过 synchronized
关键字来保证 add()
方法的原子性:
public class JMMVolatileDemo02 {
private static int num = 0;
public synchronized static void add() {
num++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
add();
}
}, String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) { // 默认一个 main线程 一个 gc 线程
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " " + num);
}
}
输出结果:20000
2. 使用 java.util.concurrent.atomic
包中的原子类
Java 提供了 AtomicInteger
等原子类,可以保证对 int
类型变量的原子操作:
import java.util.concurrent.atomic.AtomicInteger;
public class JMMVolatileDemo02 {
private static AtomicInteger num = new AtomicInteger(0);
public static void add() {
num.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
add();
}
}, String.valueOf(i)).start();
}
while (Thread.activeCount() > 2) { // 默认一个 main线程 一个 gc 线程
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " " + num.get());
}
}
输出结果:20000
3. 如果使用 volatile
关键字呢?
public class JMMVolatileDemo02 {
private volatile static int num = 0;
public static void add(){
num++;
}
// 结果应该是 num 为 2万,测试看结果
public static void main(String[] args) throws InterruptedException {
for (int i = 1; i <= 20; i++) {
new Thread(()->{
for (int j = 1; j <= 1000; j++) {
add();
}
},String.valueOf(i)).start();
}
// 需要等待上面20个线程都全部计算完毕,看最终结果
while (Thread.activeCount()>2){ // 默认一个 main线程 一个 gc 线程
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+" "+num);
}
}
输出结果:不到20000的随机数
volatile
关键字可以保证变量的可见性,但不能保证复合操作的原子性。因此,volatile
不能解决 num++
的原子性问题。
3.指令重排
计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须要考虑指令之间的数据依赖性。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
重排理解测试1:
public class TestHappensBefore {
public static void main(String[] args) {
int x = 11; // 语句1
int y = 12; // 语句2
x = x + 5; // 语句3
y = x * x; // 语句4
}
// 指令顺序预测: 1234 2134 1324
// 问题:请问语句4可以重排后变成第一条吗? 答案:不可以
}
重排理解测试2:
案例:
// 多线程环境中线程交替执行,由于编译器优化重排的存在
// 两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
public class TestHappensBefore {
int a = 0;
boolean flag = false;
public void m1(){
a = 1; // 语句1
flag = true; // 语句2
}
public void m2(){
if (flag){
a = a + 5; // 语句3
System.out.println("m2=>"+a);
}
}
}
指令重排小结:
volatile 实现了禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。
先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU 指令,它的作用有两个:
1、保证特定操作的执行顺序。
2、保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会告诉编译器 和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说,通过插入内存屏障禁止 在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
经过,可见性,原子性,指令重排的话,线程安全性获得保证:
工作内存与主内存同步延迟现象导致的可见性问题,可以使用 synchronized 或 volatile 关键字解决,它 们都可以使一个线程修改后的变量立即对其他线程可见。
对于指令重排导致的可见性问题 和 有序性问题,可以利用 volatile 关键字解决,因为 volatile 的另外一 个作用就是禁止重排序优化。
4. 总结
Java 内存模型(JMM)定义了多线程环境下变量的访问规则,确保可见性、有序性和原子性。volatile
关键字是 JMM 中的一种轻量级同步机制,用于保证变量的可见性和有序性,但它不能保证原子性。在多线程编程中,合理使用 volatile
可以避免一些常见的并发问题,但在需要原子操作的场景中,仍需使用其他同步机制。