Java 内存模型(JMM,Java Memory Model)可以说是并发编程的基础,跟众所周知的Java内存区域(堆、栈、程序计数器等)并不是一个层次的划分;
JMM用来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果;
本篇将从JMM的结构划分、交互协议、特定类型变量的特殊规则、"并发三大特性" 等方面进行详尽说明。
理论指导实践,实践检验理论:理论+代码的方式,帮助大家更容易理解。
目录
1. 主内存与工作内存
2. 内存间交互协议
2.1 八种操作
2.2 操作规则
3. volatile 型变量的特殊规则
3.1 可见性
3.1.1 场景1 代码示例
3.1.2 场景2 代码示例
3.2 禁止指令重排
3.3 有关volatile其他补充
4. double和long类型的特殊规则
5. 先行发生原则
6.总概
1. 主内存与工作内存
在物理机上,处理器的运算速度远远超过存储设备的IO操作,所以计算机系统通常会加入一层或多层读写速度尽可能接近CPU算力的高速缓存,运算完成后同步回主内存。
这就引出了经典的高速缓存与主内存的 缓存一致性 问题。为了解决一致性问题,每个处理器读写时都要遵循一些协议(例如 MSI、MOSI、 Synapse、Firefly等)。
其实物理机遇到的并发问题跟JVM虚拟机有很大相似之处,下面结合一张图来探讨JMM。
Java内存模型说明:
以下提到的变量与写代码中常说的有所区别,主要包括实例字段、静态字段、构成数组对象的元素等,但不包括局部变量、方法参数,因为后者是私有的不会共享,自然没有竞争问题。
1)JMM规定所有的变量都存储在主内存中;
2)工作内存中保存了所使用变量的主内存副本 ;
3) 线程的所有操作都只能在属于自己私有的工作内存中进行,不能直接操作主内存;
4)线程间无法互访工作内存,变量值的传递只能通过主内存来完成。
特别说明:
- 如果局部变量是个reference类型,那引用的对象在堆中是共享的,但reference本身是栈私有
- JMM不限制即时编译器优化代码执行顺序以提高性能。
2. 内存间交互协议
本节内容 作为开发人员并不需要过分关注 (开发虚拟机的除外🤭),仅作了解即可。
2.1 八种操作
JMM中定义了8种操作来实现工作内存与主内存间的交互协议,Jvm虚拟机在实现时必须保证每种操作都是原子的,不可再分的。(double和long类型有所例外,稍后讨论)
1 |
lock
(锁定)
|
作用于主内存的变量,它把一个变量标识为一条线程独占的状态
|
2 |
unlock
(解锁)
|
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
|
3 |
read
(读取)
|
作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load
动作使用
|
4 |
load
(载入)
|
作用于工作内存的变量,它把
read
操作从主内存中得到的变量值放入工作内存的变量副本中。
|
5 |
use
(使用)
|
作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
|
6 |
assign
(赋值)
|
作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
|
7 |
store
(存储)
|
作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write
操作使用。
|
8 |
write
(写入)
|
作用于主内存的变量,它把
store
操作从工作内存中得到的变量的值放入主内存的变量中。
|
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作;
注意JMM只要求上述俩操作必须顺序执行,并不要求连续执行。
2.2 操作规则
Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则限定:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内 存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回 主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存 中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量 前,需要重新执行load或assign操作以初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个 被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
这8种内存访问操作以及上述规则限定,再加上针对volatile的一些特殊规定,已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。
备注:基于理解难度和严谨性考虑,Java团队将8八种操作简化为4种(lock、unlock、read、write) 来定义JMM的访问协议,仅仅是描述方式改变了,Java内存模型并没有改变。
3. volatile 型变量的特殊规则
用volatile修饰变量可以说是虚拟机提供的最轻量级的同步机制 (注意不是锁),正确了解volatile语义不管是平时写代码还是阅读JDK源码都至关重要。
Java内存模型为volatile专门定义了一些特殊的访问规则,当变量被volatile修饰后,将具备两个特性:可见性和禁止指令重排。
3.1 可见性
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(synchronized、java.util.concurrent中的锁或原子类)来保证原子性:场景1)运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量;场景2)变量不需要与其他的状态变量共同参与不变约束。
3.1.1 场景1 代码示例
我们用一个volatile Integer的自增来举例,下面代码保证只被一条线程修改值,执行多少次得到的计算结果都是对的。
package org.springblade.test;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.concurrent.*;
/**
* @Auther: liuzujie
* @Date: 2025/1/7 20:19
* @Desc: JMM测试类
*/
public class JMMTest {
private static ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("jmm-test-thread-%s")
.setDaemon(false).build();
private static ThreadPoolExecutor executor = new ThreadPoolExecutor(
12, 25, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2560), threadFactory, new ThreadPoolExecutor.AbortPolicy()
);
private volatile static Integer counter = 0; // 使用 volatile 修饰,保证可见性,但不保证原子性
public static void main(String[] args) {
Future<Integer> future = executor.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
for (int i = 0; i < 1000; i++) {
counter++;
}
return counter;
}
});
try {
System.out.println("只有一条线程,volatile运算原子性测试:" + future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
executor.shutdown();
}
}
结果永远是正确值1000。
启动两条线程各增50万会怎样呢?答案是f1和f2每次都是千差万别不一样
public static void main(String[] args) {
Future<Integer> f1 = executor.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
for (int i = 0; i < 500000; i++) {
counter++;
}
return counter;
}
});
Future<Integer> f2 = executor.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
for (int i = 0; i < 500000; i++) {
counter++;
}
return counter;
}
});
try {
System.out.printf("两条线程,volatile运算测试。f1:%d,f2:%d", f1.get(), f2.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
executor.shutdown();
}
由此可见像 counter++ 这种依赖当前值的操作,在并发下是不安全的,结果完全不可控。
想得到正确结果除了用原子类、加锁之类,最简单的方法就是把线程池改成只能有一条线程的单例线程池😀 不就符合单线程场景了嘛。
但这并不是我们想要的...
3.1.2 场景2 代码示例
volatile非常适合做各种标识,例如下面shutdown为true时进行资源回收。
private volatile static Boolean shutdonw = false;
public static void main(String[] args) {
executor.execute(new Runnable() {
@Override
public void run() {
while (!shutdonw) {
System.out.println("使用中.");
}
System.out.println("进行资源回收...");
}
});
executor.execute(new Runnable() {
@Override
public void run() {
try {
shutdonw = true;
System.out.println("设置关闭");
} catch (Exception e) {
e.printStackTrace();
}
}
});
executor.shutdown();
}
打印信息的先后顺序意义不大,只有保证程序执行的正确性即可。
3.2 禁止指令重排
指令重排是现代处理器为优化性能而在执行指令时重新安排指令顺序的行为,目的是提高 CPU 的流水线效率。但在并发程序中,指令重排可能会导致线程间的可见性问题和逻辑错误。
由于指令重排很难复现,这里也是举个真实的编码场景来解释该问题:线程A用来初始化配置,线程B使用配置。(代码中的注释足以说明潜在问题,控制台的NullPointerException是模拟的)
private static Boolean init = false;
private static Map<String, String> config = Maps.newHashMapWithExpectedSize(1);
public static void main(String[] args) {
//线程A初始化配置
executor.execute(new Runnable() {
@Override
public void run() { //以下两行代码有指令重排的可能
config.put("name", "张三");
init = true;
}
});
//线程B使用配置
executor.execute(new Runnable() {
@Override
public void run() {
while (!init) {
System.out.println(init);
}
System.out.println(config.get("name").trim()); // 如果线程A指令重排,此处会空指针
}
});
executor.shutdown();
}
事实上,运行上面代码时不只有空指针问题。由于没有同步,线程B得到的init可能是个失效值,而且可能永远都是个失效值false,导致死循环(活跃性问题);而且还有可能出现进入while 打印 true的情况。
一句话:失效数据不仅会导致严重的安全问题或活跃性问题,还会出现一些令人困惑的故障,比如意料之外的异常、被破坏的数据结构、不正确的计算和死循环等。
3.3 有关volatile其他补充
基本原理:volatile
通过引入内存屏障机制,禁止对变量的指令重排,确保对 volatile
变量的读写按程序的顺序执行,从而避免了指令重排导致的线程间的可见性和有序性问题。
性能对比: 某些情况下volatile同步性能要优于Java提供的各种锁,但由于JVM对锁进行了大量优化和消除,所以性能并不能成为我们选型时核心关注点;
在volatile修饰的变量和普通变量对比时,读性能几乎没有差别,写性能可能略逊于普通变量,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
局限性: volatile并不能完全代替各种锁,它仅保证可见性和禁止指令重排,不具有原子性
使用场景: 仅当volatile变量能简化代码实现以及对同步策略的验证时,才应该使用他们,如果在验证正确性时需要对可见性进行复杂判断,则不适合使用volatile变量。它通常适合用做操作完成、发生中断或各种状态的标识,比如标识一些重要的程序生命周期事件(例如初始化、关闭等)。
4. double和long类型的特殊规则
在聊特殊规则之前,先了解一个概念,最低安全性保证:
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少是由之前某个线程设置的值,而不是一个随机值。
这种安全性保证适用于绝大多数变量,但64位的数值变量double和long不适用。Java内存模型要求八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”。
在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。
5. 先行发生原则
在JMM中该原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,可以通过8条简单规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不陷入Java内存模型苦涩难懂的定义中。
具体虚拟机实现,必要确保这8条原则:
- 程序次序规则(Pragram Order Rule) 在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
- 对象锁(监视器锁)法则(Monitor Lock Rule ) 某个 管程(也叫做对象锁,监视器锁) 上的unlock动作happens-before同一个管程上后续的lock动作 。这里必须强调的是同一个锁,而”后面“是指时间上的先后。
- volatile变量规则(Volatile Variable Rule) 对某个volatile字段的写操作happens- before每个后续对该volatile字段的读操作,这里的”后面“同样指时间上的先后顺序。
- 线程启动规则(Thread Start Rule) 在某个线程对象 上调用start()方法happens- before该启动了的线程中的任意动作
- 线程终止规则(Thread Termination Rule) 某线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束(任意其它线程成功从该线程对象上的join()中返回),Thread.isAlive()的返回值等作段检测到线程已经终止执行。
- 线程中断规则(Thread Interruption Rule) 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生
- 对象终结规则(Finalizer Rule) 一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始
- 传递性(Transitivity) 如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论
为了更好的理解先行发生原则,举几个经常接触的
1) 将一个元素放入一个线程安全的容器Happens-Before从容器中取出这个元素;
2) Future 任务的所有操作Happens-Before Future.get()操作;
3) 向线程池提交任务要Happens-Before 任务执行;
先行发生原则在Java内存模型中通过定义操作的先后顺序,确保了在多线程环境中,线程间对共享变量的修改和访问能够保持一致性和可见性。理解先行发生原则有助在并发编程中避免常见的并发问题如竞态条件和内存可见性问题。
6.总概
Java内存模型就是围绕在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的;
在并发编程时提供了内存访问规则,确保不同线程之间的内存可见性和操作有序性;
通过使用如volatile
、锁等同步机制避免内存一致性错误等。正确理解JMM原理和同步机制至关重要,能够帮助我们编写更高效、更安全的并发程序。