一、 JAVA内存模型(JMM)
JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
JMM体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
二、可见性
1. 引例
```java package panw.memory;
import lombok.extern.slf4j.Slf4j;
@Slf4j public class Test { static Boolean flag = true; public static void main(String[] args) { new Thread(()->{ while (flag){ } log.debug(“t1 end …”);
},"t1").start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
flag=false;
}
}
<a name="LKAuw"></a>
#### 以上代码运行时是无法退出的,这是为啥呢?
- 初始状态,t1线程刚开始从主内存中读取的flag的值到自己的工作内存
![](https://cdn.nlark.com/yuque/0/2022/jpeg/28810082/1653875125843-562d56ec-8d28-4059-bd4b-7b55e723c2da.jpeg)
- 因为t1线程要频繁从主内存读取flag,JIT编译器会将flag值缓存到自己的工作内存中的告诉缓存中,下次就直接读取缓存中flag的值,减少对主内存的访问。
![](https://cdn.nlark.com/yuque/0/2022/jpeg/28810082/1653875646252-9f5e9ccb-ac6f-4e68-83c2-e4d602b25e60.jpeg)
- 然后主线程修改了flag的值,并将flag的值同步到主存中,但是t1线程读取的是自己的高速缓存,不会去读主存中最新的值。
![](https://cdn.nlark.com/yuque/0/2022/jpeg/28810082/1653875862194-a57a6b00-8dcf-42b6-ad74-9abf2c932cb2.jpeg)
<a name="ZP4Pl"></a>
### 2. 解决方法
- 使用volatile关键字修饰**成员变量**和**静态成员变量**(放在主存中的变量),他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是**直接操作主存**
```java
package panw.memory;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class Test {
static volatile Boolean flag = true;
public static void main(String[] args) {
new Thread(()->{
while (flag){
}
log.debug("t1 end ...");
},"t1").start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
flag=false;
}
}
3. 可见性与原子性
注意volatile只可以保证线程之间的可见性,但是不可以保证操作原子性,只适用于一写多读的情况
synchronized
- synchronized 语句块既可以保证可见性也可以保证原子性。
- 但缺点是 synchronized 是属于重量级操作,性能相对更低。
4. 两阶段终止模式优化
```java package panw.model;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
public class Test { public static void main(String[] args) throws InterruptedException {
Monitor monitor = new Monitor();
monitor.start();
TimeUnit.SECONDS.sleep(4);
monitor.stop();
}
} @Slf4j class Monitor{ private Thread monitor; private boolean stop = false;
public void start(){
monitor = new Thread(()->{
while (true){
if (stop){
log.debug("stop = true, 善后");
break;
}
log.debug("继续运行");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
log.debug("被打断了");
}
}
});
monitor.start();
}
public void stop(){
monitor.interrupt();
stop = true;
}
}
<a name="jmDIx"></a>
### 5.同步模式之犹豫模式
Balking (犹豫)模式用在一个线程发现另一个线程或本线程**已经做了某一件相同**的事,那么本线程就无需再做 了,**直接结束返回**
- 用一个标记来判断该任务是否已经被执行过了
- 需要避免线程安全问题
```java
package panw.model;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
public class Test {
public static void main(String[] args) throws InterruptedException {
Monitor monitor = new Monitor();
monitor.start();
monitor.start();
monitor.start();
TimeUnit.SECONDS.sleep(4);
monitor.stop();
}
}
@Slf4j
class Monitor{
private Thread monitor;
private boolean stop = false;
private boolean starting = false;
public void start(){
synchronized (this){
if (starting){
log.debug("已经启动了,无需再次启动");
return;
}
starting=true;
}
monitor = new Thread(()->{
while (true){
if (stop){
log.debug("stop = true, 善后");
break;
}
log.debug("继续运行");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
log.debug("被打断了");
}
}
});
monitor.start();
}
public void stop(){
monitor.interrupt();
stop = true;
}
}
三、有序性
1.指令重排
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就带来问题。
2.数据依赖
主要指不同的程序指令之间的顺序是不允许进行交互的,即可称这些程序指令之间存在数据依赖性。
有以下例子:
写后读 | a = 1; b = a; | 写一个变量后,再读 |
---|---|---|
写后写 | a = 1; a = 2; | 写一个变量后,再写 |
读后写 | a = b; b =1; | 读一个变量后,再写 |
这里每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果。
编译器将不会对存在数据依赖性的程序指令进行重排,这里的依赖性仅仅指单线程情况下的数据依赖性;多线程并发情况下,此规则将失效。
3.指令重排带来的问题
单例模式失效
来看一个经典的懒汉式双重校验单例模式:
public class Singleton {
private static Singleton instance = null;
private Singleton() { }
public static Singleton getInstance() {
if(instance == null) {
synchronzied(Singleton.class) {
if(instance == null) {
instance = new Singleton(); //非原子操作
}
}
}
return instance;
}
}
instance = new Singleton();
这一句其实并不是一个原子操作,可以抽象为:
memory =allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance =memory; //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:
memory =allocate(); //1:分配对象的内存空间
instance =memory; //3:设置instance指向刚分配的内存地址
ctorInstance(memory); //2:初始化对象
可以看到指令重排之后,instance
指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。
在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance
引用不为null,然后就将其返回使用,导致出错。
解决办法:
上面提到的volatile既可以保证线程之间的可见性,又可以禁止指令重排,禁止的是加volatile关键字变量之前的代码被重排序。
4.内存屏障
- 可见性
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
- 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中新数据
有序性
对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
但是不能解决指令交错问题
- 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证读跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序