尚硅谷(56-70)
JMM
引入一些大厂的面试题
- Java内存模型JMM是什么
- JMM与volatile之间的关系是什么
- JMM有哪些特性or它的三大特性是什么
- 为什么要有JMM,它为什么出现?功能和作用是什么?
- happens-before 先行发生原则是什么
Java内存模型 Java Memory Model
CPU与内存之间的缓存关系,是线程和主内存之间的抽象关系,是一种约束和规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式,决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见。屏蔽硬件瓶盖和操作系统的内存访问差异。
原则:JMM的关键技术点都是围绕多线程的原子性、可见性和有序性展开的。
JMM的三大特性
可见性
当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道变更。JMM规定了所有的变量都存储在主内存中。
系统主内存共享变量数据修改被写入的时机是不确定的,多线程并发下很可能出现“脏读”,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必需在线程自己的工作内存中进行,而不能直接读写主内存中的变量。 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
线程A需要将i加1 —> 把i从主内存中读到线程A的工作内存中 —> 做+1 —> 写回主内存中
及时可见,即时通信,一旦修改就要通知其他线程。
原子性
指一个操作是不可打断的,也就是在多线程环境下,操作不能被其他线程干扰
有序性:指令重排序
对于一个线程在执行的时候,我们总认为他是从上到下有序执行。但是为了提升性能,编译器和处理器通常会对指令进行重排序。
JVM内部位置顺序话语义,即只要程序的最终结果与它的顺序化写过执行相等,那么指令执行顺序可以和代码顺序不一致,此过程叫指令重排序。JVM可以根据处理器特性,适当对机器指令进行重排序,使机器指令更符合CPU的执行特性,最大限度的发挥机器性能。
但是,在多线程中语义指令重拍是不能保证一致性的(可能会出现脏读)
代码执行示意图
源代码 | 编译器优化重排 | 指令并行重排 | 内存系统的重排 | 最终执行指令 |
---|
多线程读写过程
- 我们定义的所有共享变量都储存在物理主内存中
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
- 线程对共享变量所有的操作都必须先在线程自己的工作内存中进行后写回主内存,不能直接从主内存中读写(不能越级)
- 不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行(同级不能相互访问)
多线程先行发生原则happens-before
在JMM中,如果一个操作的执行结果需要对另一个操作可见性或者代码重排序,那么这两个操作之间必需存在 happens-before(先行发生) 原则。逻辑上的先后关系
happens-before的总原则:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
- 两个操作之间存在happens-before关系,并不意味着一定按照happens-before原则指定的顺序来执行。如果两个重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法
happens-before 之8条
- 次序规则:一个线程内,按照代码顺序,写在前面的操作**先行发生于写在后面的操作。也就是前一个操作的结果**可以被后续的操作获取。
- 锁定规则:一个unlock操作**先行发生于**后面(这里的“后面”指的是时间上的先后)对同一个锁的lock操作。
- volatile变脸规则:对一个volatile变量的写操作**先行发生于**后面对这个变量的读操作,前面的写对于后面的读是可见的。
- 传递规则:A先于B,B先于C,A先于C
- 线程启动规则(Thread Start Rule):Thread对象的start方法先行发生于此线程的每一个动作之前
- 线程中断规则(Thread Interruption Rule):对线程interrupt方法的调用 先行发生于 被中断线程的代码检测到中断时间的发生;可以通过Thread.interrupted()检测到是否发生中断。(也就是说要先调用interrupt方法设置过的中断标志,才能检测到中断发送)
- 线程终止规则(Thread Termination Rule):线程中的所有操作都 先行发生于 对此线程的终止检测,我们可以通过isAlive等手段检测线程是否终止执行
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造器函数执行结束)先行发生于 它的finalize方法开始(也就是先出生后死亡)
JMM小结
在Java语言里面,Happens-Before 的语义本质上是一种 可见性
A Happens-Before B 意味着A发生过的事情对B来说是可见的,无论A事件和B事件是否发生在同一个线程里。
JMM的设计分为两部分:
一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解happens-before规则,就可以编写并发安全的程序了。
另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束从而提高性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。我们只需要关注前者就好了,也就是理解happens-before规则即可,其它繁杂的内容有JMM规范结合操作系统给我们搞定,我们只写好代码即可。
private volatile int value = 0;
Volatile
volatile 两大特征
volatile的特点:可见性和有序性(排序有要求,禁止重排)。
volatile的内存语义:
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量
- 所以volatile的写内存语义直接刷新到主内存中,读的内存语义是直接从主内存中读取
- 系统底层会有一个缓存行,用来缓存变量。当主内存中的共享变量被更新了,所有相关的缓存行内的数据都会被清空。
volatile 为什么可以保证可见性和有序性? —— 答:内存屏障 Memory Barrier
内存屏障**
内存屏障(也称内存栅栏,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步炮,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatle实现了Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存,
内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在缓存(store bufferes)中的数据同步到主内存。也就是说当看到Store屏障指令,就必须把该指令之前所有写入指令执行完毕才能继续往下执行。
读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在Load屏障指令之后就能够保证后面的读取数据指令一定能够读取到最新的数据。
粗分:
- 读屏障:在读指令之前插入读屏障,让工作内存或者CPU告诉缓冲区中的缓存数据失效,重新回到主内存中获取最新版
- 写屏障:在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中
- 全屏障:读屏障+写屏障
细分:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证Load1的读操作在Load2即后续读取操作之前读取 |
StoreStore | Store1;StoreStore;Store2 | 在 Store2及其之后的写操作执行之前,保证Store1的写操作已经刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 在 Store2 及其后的写操作执行前,保证Load1的操作已读取结束 |
StoreLoad | Store1;StoreLoad;Load2 | 在Store1的写操作已经刷新到主存后,load2及其后的读操作才能执行 |
保证有序性?禁重排;如何禁重排?内存重排
volatile 变量规则
当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。保证了volatile 读之后 的操作不会被重排到volatile读之前。
当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。保证了volatile 写之前 的操作不会被重排到volatile写之后。
当第一个操作为volatile写时,第二个操作是为volatile读时,都不能重排序。
volatile 特性
保证可见性
用一段代码作为示例
public class MyVolatile {
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
System.out.println("======Thread t1 is running======");
while (flag){
}
System.out.println("======flag is setetd to false======");
},"t1").start();
TimeUnit.SECONDS.sleep(2);
flag = false;
System.out.println(Thread.currentThread().getName()+"======over======");
}
}
/**
======Thread t1 is running======
main======over======
注:程序还在运行
*/
我们在13行已经把flag改为false了,但是在t1的线程方法中,并没有将flag的值修改过来,此时t1用的还是原值
当为flag加上volatile修饰后,volatile static boolean flag = true;
/**
======Thread t1 is running======
main======over======
======flag is setetd to false======
*/
程序运行结束。
上述代码的原理解释:
线程t1中为何看不到被主线程main修改为false的flag的值?
问题可能:
- 主线程修改了flag之后没有将其刷新到主内存,所以t1线程看不到。
- 主线程将flag刷新到了主内存,但是t1一直读取的是自己工作内存中flag的值,没有去主内存中更新获取flag最新的值。
我们的诉求:
- 线程中修改了自己工作内存中的副本之后,立即将其刷新到主内存;
- 工作内存中每次读取共享变量时,都去主内存中重新读取,然后拷贝到工作内存。
解决:
使用volatile修饰共享变量,就可以达到上面的效果,被volatile修改的变量有以下特点:
- 线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
- 线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存
- read:作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
- load:作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
- use:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
- assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
- store:作用于工作内存,将赋值完毕的工作变量的值写回给主内存
- write:作用于主内存,将store传输过来的变量值赋值给主内存中的变量
- 由于上述6条只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令
- lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
- unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
没有原子性
上代码实现一下
class Sum{
private int num = 0;
public synchronized void add(){
this.num++;
}
public int getNum(){
return num;
}
}
public class VolatileNoAtomic {
public static void main(String[] args) throws InterruptedException {
Sum sum = new Sum();
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
sum.add();
}
},String.valueOf(i)).start();
}
TimeUnit.SECONDS.sleep(2);
System.out.println("num的值是:"+sum.getNum());
}
}
/**
num的值是:10000
*/
去掉 synchronized
后输出:num的值是:9683
加上 volatile
后输出:num的值是:8469
所以是没有原子性的。多个线程同时读了当前数据,在写回通知其他线程时,线程当前执行的++操作失效,造成数据丢失
禁止重排序
什么是重排序?
编译器未有优化程序性能而对指令序列进行重排序的一种手段,有时候会改变程序的语句的先后顺序。
- 不存在数据依赖关系,可以重排序
- 存在数据依赖关系,禁止重排序
注意:重排序后的指令绝对不能改变原有串行语义!
数据依赖性:若两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
禁止重排序的三种情况:读后写,写后读,写后写。
四大屏障插入情况:
- 在每一个volatile写操作前面插入一个StoreStore屏障:StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中
- 在每一个volatile写操作后面插入一个StoreLoad屏障:StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
- 在每一个volatile读操作后面插入一个LoadLoad屏障:LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
- 在每一个volatile读操作后面插入一个LoadStore屏障:LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
用以下代码为例:
public class volatileTest {i}
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2;
flag = true;
}
public void read(){
if(flag){
System.out.println("---i =" + i);
}
}
}
以上代码如果不加 volatile
会存在什么问题呢?在write方法中,由于i = 2;flag = true;
之间的执行顺序是有可能被调整的,比如flag = true;
此时,如果有另一个线程调用read,就可能是的输出的i为0 。而按我们的顺序执行或者预期的是2。
那么加了 volatile
之后会怎么解决呢?
使用volatile的时机
-
单一赋值可以,但是含有复合运算不可以。
volatile x = 0;
-
状态标志,判断业务是否结束
-
低开销的读操作,写锁策略
-
DCL双端锁的发布(在单例模式中可能获取到null值)