七、Java内存模型详解
所谓JMM
JMM,全称 java memory model ,中文翻译为:java内存模型。在了解它之前,首先要对一些知识进行铺垫。
CPU知识普及
线程是CPU调度的最小单位。线程中的字节码都将会在CPU中执行。Java中的所有数据都是存放在主内存中的。 而CPU在执行字节码的过程中免不了要和 主内存打交道。随着CPU技术的发展,CPU的速度越来越快,而内存的读写速度跟不上CPU。
因此为了让CPU的性能最大化,达到高并发的效果。CPU中添加了高速缓存Cache的概念。
执行任务时,CPU会将运算所需数据 从 主内存复制到 高速缓存中,让运算快速进行。当运算完成之后,再将运算结果刷回主内存。这样,在运算过程中就不必这么频繁地与主内存交互了。
看似是提高了CPU的运行效率,但是问题随之而来。
每个处理器都有自己的高速缓存,而他们都在操作同一块主内存。当多个处理器同时操作主内存时,可能导致数据不一致。 这就是 缓存一致性问题。
缓存一致性问题
现在的移动设备很多都是 多个CPU,每个CPU还有多核。每个CPU都能在一个时刻运行一个线程。也就意味着,如果你的Java程序是多线程的,就有可能存在 多个线程在同一时刻被不同的cpu执行的情况。 举例说明:
以上代码展示了两个变量 x,y 被两个线程同时操作的情况。如果要在两个线程都执行完毕 之后,打印出r1
和r2
的值,那么结果会有几种呢?
如果p1先执行完毕,而后 p2执行。那么结果就是 r1=0;r2=1;
如果p2先执行完毕,而后 p1执行,那么结果是r1=2;r2=0;
以上两种情况是理想状况,可是现实中,可能有2个cpu c1和c2
,分别来执行这两个线程,那么情况就会变得复杂:
p1 中 int r1=x;
这句代码在 c1中执行完毕,并且刷回主内存,那么r1就是0. 然后紧接着 c2执行了int r2=y;
那么 r2就是0. 后续不管怎么执行,r1=0;r2=0
的结果是不受影响的.
出现以上多种情况的根本原因,就是CPU在执行完某个字节码之后,它将数据刷新回主内存的时机是不确定的。而 CPU的调度受系统策略的影响,通常不受人为控制。
指令重排
为了使CPU的内部运算单元能够被充分利用,处理器可能对 输入的字节码指令进行重新排序,这也叫 处理器优化。(比如,jvm的即时编译 JIT) 如下图所示代码:
指令重排之后的执行顺序可能就是:
也就是说,在CPU层面,代码并不会严格按照你写的顺序来执行。!!!
如果按照这种思路来看上面r1,r2的例子,那么还有第四种情况:
经过重排的线程p2如上图右边所示,如果按照这个顺序(图中红色 1,2,3,4)来执行的话, 结果就是 r1=2; r2=1;
内存模型的概念
为了解决这种一致性问题,内存模型应运而生。 所谓内存模型,就是一套共享内存系统中,多线程读写操作行为的规范。这套规范屏蔽了底层硬件和操作系统的内存访问的差异。解决了CPU多级缓存,CPU优化,指令重排等因素导致的内存访问问题。从而保证java程序,尤其是多线程java程序 在各种平台下对内存访问的结果一致性。
在java内存模型中,CPU的高速缓存,被抽象为:工作内存
。每一个线程都有自己的 工作内存
.
线程之间的共享数据
存储在 主内存
中.
先行发生原则
也就是 happends-before 原则。它是 内存模型中最重要的原则。
它用于描述两个操作的内存可见性。通过可见性,让程序免于数据竞争的干扰。
它的定义为:如果一个操作A happends-before 操作B,那么操作A的结果将对B可见
反过来理解,如果想要让操作A的结果对操作B可见,那么必须让操作A happends-before B
举例说明:
以上用 set方法和get方法对value进行读写操作。
很有可能 这两个操作出现在不同的线程中。 那么假设,set 被 线程A 执行, get被线程B执行。那么B的执行结果是多少呢?
结果显然是不确定的。
这里分两个情况
- set操作 happends-before get操作 set的结果对get可见,此时get的结果为1
- set操作 没有 happends-before get操作 set的结果对get不可见,此时,get的结果为0
那么如何判定java中的两个操作符合happends-before规则呢?
happends-before 先行发生原则
程序次序原则
单线程内部,如果一段代码的字节码顺序也隐式符合 happends-before原则,那么逻辑靠前的字节码执行结果一定是对逻辑靠后的字节码 可见。
int a = 10;
b = b + 1 ;
举例说明,如上代码中,单线程中执行了两个操作,int a = 10;
以及 b = b + 1;
, 按照此原则,a=10 这个结果,对 后面的操作,是可见的,但是,后面的b=b+1;
并没有对a
变量有依赖。此时cpu就有可能发生指令重排,将对a有依赖的操作,挪到int a=10;
的后面. 就像下面的:
int a = 10;
b = b + 1 ; // 原始顺序
a = a + 1 ;
在最终执行字节码时的顺序,可能就变成了:
int a = 10;
a = a + 1; // 指令被重排之后
b = b + 1;
而如果是这样,b = b + a,直接对a有依赖,则不会指令重排优化:
int a = 10;
b = b + a ; // 不会指令重排优化
a = a + 1 ;
锁定规则
如果一个锁,处于被锁定的状态,那么必须先执行 unlock,然后才能执行lock操作。
变量规则
volatile 关键字,保证了线程的可见性,如果一个线程写了 volatile修饰的变量,然后另一个线程去读这个变量,那么结果一定是 写操作 happends-before 读操作。
线程启动规则
线程对象的start方法,先行发生于此线程的每一个动作。
如果线程A,在执行过程中,通过B.start
方法启动了线程B, 那么在启动B之前的A对主内存中共享变量的操作操作结果,都对B可见。
解释一下:
int x = 0;
int y = 1;
new Thread(new Runnable(){
@override
run(){
x = 100;
y = 200;
new Thread(new Runnable(){
@override
run(){
System.out.println(x + " - " + y);
}
}
).start();
}
}
).start();
上面代码中,打印出的x和y的值,一定是在 100 和 200.
线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测,直到中断事件的发生。 举例说明:
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new LongRunningTask());
thread.start();
// 主线程休眠一段时间后,中断长时间执行的任务
try {
Thread.sleep(2000); // 休眠2秒钟
thread.interrupt(); // 中断线程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class LongRunningTask implements Runnable {
@Override
public void run() {
try {
System.out.println("长时间执行的任务开始...");
for (int i = 0; i < 10; i++) {
System.out.println("执行第 " + i + " 步");
Thread.sleep(1000); // 模拟耗时操作
// 检测线程是否被中断
if (Thread.currentThread().isInterrupted()) {
System.out.println("任务被中断,提前结束1");
return;
}
}
System.out.println("长时间执行的任务完成");
} catch (InterruptedException e) {
System.out.println("任务被中断,提前结束2");
}
}
}
以上代码,模拟了 主线程 中启动了子线程,并且在主线程睡眠一段时间之后中断子线程的情况。 在 先行发生原则的作用下,当主线程调用了 子线程的中断方法interrupt之后,子线程立即就检测到了自己被中断,从而停止了代码执行。
如果没有这个先行发生原则,那么就有可能在 interrupt之后,子线程继续执行一段代码之后才知道自己被中断了,而执行了多少,存在不确定性。
上面代码的打印结果为:
长时间执行的任务开始...
执行第 0 步
执行第 1 步
任务被中断,提前结束2
线程终结规则
线程中的所有操作,都发生在终止检测之前。
Thread.join()方法是 等待此线程结束。 Thread.isAlive()的返回值表示的是 此线程是否已终止。
如果线程A在执行过程中调用了线程B的join方法,那么A线程会等待B执行完,那么B对 主内存中共享变量的操作就是对A可见的。
这个很好理解。如下代码:
public class Main {
static int sharedVariable = 0;
public static void main(String[] args) throws InterruptedException {
Thread threadB = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
sharedVariable = 10;
});
Thread threadA = new Thread(() -> {
try {
System.out.println("线程A开始执行");
threadB.start();
threadB.join();
System.out.println("线程B执行完毕");
System.out.println("线程A中的 sharedVariable = " + sharedVariable);
sharedVariable = 200;
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("主线程开始");
threadA.start();
threadA.join();
System.out.println("主线程结束");
System.out.println("主线程中的 sharedVariable = " + sharedVariable);
}
}
有3个线程,main,threadA,thread。 上面的代码描述了,main启动threadA,threadA启动了threadB,并且 A中等待B执行完,main则等待A执行完,最后分批打印 共享变量 sharedVariable 的值。 执行的结果为:
主线程开始
线程A开始执行
线程B执行完毕
线程A中的 sharedVariable = 10
主线程结束
主线程中的 sharedVariable = 200
虽然有多个线程,但是,B的执行结果对A是可见的,A的执行结果对main是可见的。所以共享变量的结果都是可预测的,没有不确定性。
对象终结原则
一个对象的初始化发生在它的finalized方法之前。
happends-before的可传递性
如果操作A happends-before 操作B,操作B happends-before 操作C,那么操作A 一定 happends-before 操作C。
Java内存模型的应用
上面提到的 happends-before 原则是判断 数据是否存在竞争,线程是否存存在竞争的主要依据。
Java提供了一系列关键字,可以让原本不是 happends-before 的 情况,变得符合 happends-before原则。
volatile
以最简单的 多线程对同一个变量的get set操作为例:
如果没有volatile原则的话,多线程读取 value 的结果是不可控的。 但是一旦给value加上了 volatile ,所有线程对于 value的操作,对后续的线程都是可见的,因为每次操作完了都会将value写入到主内存。
synchronized
同样,同步关键字 synchronized,也能保证对value的操作对后续的线程可见。
总结
JMM(Java Memory Model) java内存模型 的诞生主要是因为 CPU 的 高速缓存 与 主内存的交互,指令重排等特性,会导致多线程执行结果的不可控。
JMM本身是一套规范,这个规范中有一个最重要的 先行发生原则 happends-before。
JMM的运用主要有两个关键字 volatile 和 synchronized 等, 让程序符合 happends-before原则。
转载自:https://juejin.cn/post/7281576058994032694