Java内存模型

Java线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别

image-20220323101927305

Java原子操作详解

  • read(读取):从主内存读取数据
  • load(载入):将主内存读取到的数据写入工作内存
  • use(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好的值重新赋值到工作内存中
  • store(存储):将工作内存数据写入主内存
  • write(写入):将store过去的变量值赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,标识为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

情景:

线程一:拿到initFlag后,执行 while(!initFlag) 语句

线程二:拿到initFlag后,对其进行取反

image-20220323102645963

结果:线程一死循环,线程二修改了共享变量的值


volatile

解决上面的问题可以通过对共享变量加volatile关键字解决

volatile概述

一个线程对volatile关键字修饰的共享变量副本进行修改后,会立马将修改后的值写入主内存中,其他的线程通过cpu总线嗅探机制监听到这个共享变量的值被修改过后,就会将自己线程工作内存中的共享变量副本失效掉,之后再使用的话就去主内存中取最新的值

image-20220323111142388


volatile原理

底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存,并回写到主内存

image-20220323110053121

Lock在具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞直到锁释放。最后释放锁后会把高速缓存中的脏数据全部刷新回主内存,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效

缓存行

缓存是分段(line)的,一个段对应一块存储空间,我们称之为缓存行,它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关,通常来说是64字节。当CPU看到一条读取内存的指令时,它会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,如果没有就把整个缓存段从内存(或更高一级的缓存)中加载进来。

一个问题:如果存在两个线程都修改了共享变量,都要向主内存中写入的情况如何?


volatile不能保证原子性

情景:

new出若干个线程,对volatile修饰的共享变量执行自增操作,结果并不能得到期望的值

image-20220323112531259

原因:自增操作不是原子性的(先读取,再修改,最后写回工作内存

A、B两个线程同时自增i。由于volatile可见性,因此步骤1两条线程一定拿到的是最新的i,也就是相同的i,但是从第2步开始就有问题了,有可能出现的场景是线程A自增了i并回写,但是线程B此时已经拿到了i,不会再去拿线程A回写的i,因此对原值进行了一次自增并回写,这就导致了线程非安全,也就是多线程技术器结果不对

如果线程A对i进行自增了以后cpu缓存不是应该通知其他缓存,并且重新load i么?

拿的前提是读,问题是,线程A对i进行了自增,线程B已经拿到了i并不存在需要再次读取i的场景,当然是不会重新load i这个值的。

ps:也就是线程B的缓存行内容的确会失效。但是此时线程B中i的值已经运行在加法指令中,不存在需要再次从缓存行读取i的场景。


内存屏障

查看如下代码,猜测a、b的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Test {
public static int x = 0, y = 0;
public static void main(String[] args) {
x = 0; y = 0;
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = y;
x = 1;
}
});
thread.start();

Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
int b = x;
y = 1;
}
});
thread1.start();

}
}

你可能会觉得有三种结果

  • a=1,b=0
  • a=0,b=1
  • a=0,b=0

但其实还有一种结果:a=1,b=1;这是为什么呢?

这是因为cpu为了优化代码的执行速度可能会进行指令重排的操作,例如:

1
2
a = 1;
b = 2;

这两条代码并没有产生依赖,他们的顺序并不影响代码执行,则可能重排成如下

1
2
b = 2;
a = 1;

这就是指令重排

而上述代码也可能存在指令重排,重排成如下代码

1
2
3
4
5
6
7
//线程一
x = 1;
int a = y;

//线程二
y = 1;
int b = x;

这是导致 a=1,b=1 的原因

什么样的指令可以重排序?

对cpu来说,基本上任何指令都可以实现重排序,因为这样可以提高性能,除了一些lock或禁止重排序的指令外。

对jvm来说,jvm规范中提到了happens-before原则,也就是不在下面8条原则中的指令都可以重排序:

  • 程序次序原则:在一个线程内,代码按照编写时的次序执行(jvm会对指令重排序,但是会保证最终一致性)。
  • 锁定原则:如果一个锁是锁定状态,要先unlock后才能lock。
  • volatile变量规则:对变量的写操作要先于对变量的读操作。
  • 传递规则:A先于B,B先于C,那么A先于C。
  • 线程启动规则:线程的start()方法先于run()方法运行。
  • 线程中断规则:线程收到了中断信号,那么之前一定有interrupt()。
  • 线程终结规则:线程的任务执行单元要发生在线程死亡之前。
  • 对象的终结规则:线程的初始化先于finalize()方法之前。

解决:增加volatile关键字

1
public static volatile int x = 0, y = 0;

被volatile修饰的变量在编译成字节码文件时会多个lock指令,该指令在执行过程中会生成相应的内存屏障,以此来解决可见性跟重排序的问题。

内存屏障的作用:

  • 在有内存屏障的地方,会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序。
  • 在有内存屏障的地方,线程修改完共享变量以后会马上把该变量从本地内存写回到主内存,并且让其他线程本地内存中该变量副本失效(使用MESI协议)

内存屏障是CPU指令。如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。

下面是基于保守策略的JMM内存屏障插入策略:

在每个volatile写操作的前面插入一个StoreStore屏障。

在每个volatile写操作的后面插入一个StoreLoad屏障。

在每个volatile读操作的前面插入一个LoadLoad屏障。

在每个volatile读操作的后面插入一个LoadStore屏障。

在这里插入图片描述