JUC(7) : JMM & Volatile | 死磕内存模型
前言
1、你知道什么是 Java 内存模型 JMM 吗
2、JMM 与 Volatile 它们两个之间的关系?
3、JMM 有哪些特性 or 它的三大特性是什么?
4、为什么要有 JMM,它为什么出现?作用和功能是什么?
5、happens-before 先行发生原则你有了解过吗?
一、JMM 入门
1.1 概述
硬件体系中存在多级的缓存,越往寄存器,则运算速度越快。
CPU 的运行并不是直接操作内存而是先把内存里边的数据读到缓存,而内存的读和写操作就会造成不一致的问题。
因为每个人用的操作系统不同,JVM 规范中试图定义一种 Java 内存模型(JMM)来屏蔽各种硬件和操作系统的内存访问差异。以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。所以,我们需要知道 JMM 。
处理器出来的数据放入高速缓存中。我们可以在主内存和高速缓存中制定一个 缓存一致性协议。
1.2 定义作用
JMM(Java 内存模型 Java Memory Model)本身是一种抽象的概念,并不真实存在。它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入何时对另外一个线程可见。
关键技术点都是围绕多线程的原子性、可见性和有序性展开的 。
能干么呢?
1、通过 JMM 来实现线程和主内存之间的抽象关系
2、屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的 内存访问效果。
1.3 多线程对变量的读写过程
- 主内存:线程的共享数据区域,主要存储的是 Java 实例对象,所有线程创建的实例对象都存放在主内存中
- 工作内存:每个线程对应一个私有工作内存,主要存储当前方法的所有本地变量信息(共享数据的副本),也可以理解为本地内存
JMM定义了线程和主内存之间的抽象关系
① 线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)
② 每个线程都有一个私有的本地工作内存,本地工作内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)
③ 线程对共享变量所有的操作都必须先在线程自己的工作内存中进行后写回主内存,不能直接从主内存中读写(不能越级)
④ 不同线程之间也无法直接访问其他线程的工作内存的变量,线程间变量值的传递需要通过主内存来进行(同级不能相互访问)
总线窥探:
总线窥探是缓存中的一致性控制器监视或窥探总线事务的一种方案,其目标是在分布式共享内存系统中维护缓存一致性。当主内存的数据被修改后,需要将所有有该数据副本的工作内存的数据变更,这个数据变更通知可以通过总线窥探来完成,所有的窥探者都在监视总线上的每一个事务,如果一个修改共享数据的事务出现在总线上,所有的窥探者都会检查自己的副本是否有相同数据副本,若有则修改。
1.4 八种内存交互
- read:作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存;
- load:作用于工作内存,将 read 从主内存传输的变量值放入工作内存变量副本中,即数据加载
- use:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当 JVM 遇到需要该变量的字节码指令时会执行该操作。
- assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当 JVM 遇到一个给变量赋值字节码指令时会执行该操作;
- store:作用域工作内存,将赋值完毕的工作变量的值写回给主内存;
- write:作用于主内存,将 store 传输过来的变量值赋值给主内存的变量;
由于上述6条不能保证多条指令组合的原子性,没有大面积加锁
- lock:作用于主内存:将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
- unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用。
二、JMM 三大特性
并发编程Bug 的源头:可见性、原子性和有序性问题。
2.1 可见性
2.1.1 案例
修改变量的值。
public class Demo1 {
private boolean flag =true;
private int count = 0;
public void update(){
flag =false;
System.out.println(Thread.currentThread().getName()+"修改flag");
}
public void load() {
while (flag){
count++;
}
System.out.println("结束线程,count = "+count);
}
public static void main(String[] args) throws InterruptedException {
Demo1 demo1 = new Demo1();
new Thread(()->{
demo1.load();
},"线程A").start();
// 等待三秒,看另外一个线程修改后,能否进入循环
Thread.sleep(3000);
new Thread(()->{
demo1.update();
},"线程B").start();
}
}
控制台输出,线程B修改后,线程 A 无法终止,即无法读取到变量 flag 被其他线程修改了。
2.1.2 可见性
当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。
JMM 规定了所有的变量都存储在主内存中。
每个线程都从主内存中读取共享变量的副本,然后修改后提交给主内存,确实像 git。
系统主内存共享变量数据修改被写入的时机是不确定的,多线程并发下很可能出现 ”脏读“,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝。线程间变量值的传递均需要通过主内存来完成。
2.1.3 如何保证可见性
- 通过 volatile 关键字保证可见性
-
- java 中被 volatile 修饰的变量,在转变汇编指令后会添加一个 lock 前缀,lock 前缀的指令在多核处理器下做两件事:
-
-
- 1、将当前处理器缓存行的数据写回系统内存
- 2、写回内存后,其他cpu缓存了该内存地址的数据无效
-
- 通过内存屏障保证可见性
- 通过 synchronized 关键字保证可见性
-
- 加锁线程会获得锁,清空工作内存,从主存拷贝共享变量最新的值到工作内存,源码使用内存屏障实现可见性
- 通过 lock 保证可见性
- 通过 final 关键字保证可见性
-
- final 修饰的量不可变
总结:解决方案可以分为两类:
- 线程上下文切换:让出cpu时间片,线程切换回导致当前线程本地内存失效;
- 内存屏障:jvm 层面的 storeLoad 内存屏障(下节讲解)
2.2 有序性
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序。
指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致。即可能产生”脏读“,简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下的顺序执行,执行顺序会被优化。
JVM 能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合 CPU 的执行特性,最大限度的发挥机器性能。
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重排序时必须要考虑指令之间的数据依赖性,多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
在某些情况下可以禁止指令重排。
如何保证有序性?
- 通过 volatile 关键字保证有序性
- 通过内存屏障保证有序性
- 通过 synchronized 关键字保证有序性
- 通过 Lock 保证有序性
2.3 原子性
指一个操作是不可打断的,即多线程环境下,操作不能被其他线程干扰。例如对于一个静态变量 i =0,线程 A 对它赋值 1,线程 B 对它赋值 -1,那么它要么是 1 要么是 -1,这就是原子性。
如何保证原子性:
- 通过 synchronized 关键字保证原子性
- 通过Lock保证原子性
- 通过 CAS 保证有序性
三、happens-before(多线程先行发生原则)
在JMM 中,如果一个操作执行的结果需要对另一个操作可见性或者代码重排序,那么这两个操作之间必须存在 happens-before 关系。(逻辑上的先后关系)
3.1 入门案例
由这个入门案例可知,第一条语句和第二条语句不能进行排序,否则了能会出现错误。
happens-before 原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。
3.2 先行发生原则说明
如果 Java 内存模型中所有的有序性都仅靠 volatile 和 synchronized 来完成,那么很多操作都将变得非常啰嗦。
但是我们在编写 Java 并发代码的时候并没有察觉到这一点。
我们没有时时、处处、次次,添加 Volatile 和 synchronized 来完成程序,这是因为 Java 语言中 JMM 原则下有一个”先行发生“(Happens-Before)的原则限制和规矩,给你定好了规矩。
这个原则非常重要:
- 它是判断数据是否存在竞争,线程是否安全的非常有用的手段。
- 依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的底层编译原理之中。
3.3 总原则(面试答)
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作 可见 ,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原则制定的 顺序 来执行。如果重排序之后的执行结果与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法。
3.4 8条
3.4.1 次序规则
一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作;
前一个操作的结果可以被后续的操作获取。说白了前面一个操作将变量 X 赋值1,那么后面一个操作肯定能知道 X 已经变成 1.
3.4.2 锁定规则
一个unLock操作先行发生于后面((这里的“后面”是指时间上的先后))对同一个锁的lock操作;
public class HappenBeforeDemo
{
static Object objectLock = new Object();
public static void main(String[] args) throws InterruptedException
{
//对于同一把锁objectLock,threadA一定先unlock同一把锁后B才能获得该锁, A 先行发生于B
synchronized (objectLock)
{
}
}
}
3.4.3 volatile 变量规则
对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的“后面”同样是指时间上的先后。
3.4.4 传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
3.4.5 线程启动规则
Thread对象的start()方法先行发生于此线程的每一个动作
3.4.6 线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
可以通过Thread.interrupted()检测到是否发生中断,也就是说你要先调用 interrupt()方法设置中断标志位,我才能监测到中断发送。
3.4.7 线程终止规则
线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、 Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
3.4.8 对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始对象没有完成初始化之前,是不能调用finalized()方法的
3.5 案例分析
假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值 是什么?
我们就这段简单的代码一次分析happens-before的规则(规则5、6、7、8 可以忽略,因为他们和这段代码毫无关系):
1 由于两个方法是由不同的线程调用,不在同一个线程中,所以肯定不满足程序次序规则;
2 两个方法都没有使用锁,所以不满足锁定规则;
3 变量不是用volatile修饰的,所以volatile变量规则不满足;
4 传递规则肯定不满足;
所以我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B指定,但就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?
- 把getter/setter方法都定义为synchronized方法
- 把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景
3.6 总结
在Java 语言里面,Happens-Before 的语义本质上是一种可见性。
A Happens-Before 意味着 A 发生过的事情对 B 来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。
JMM 的设计分为两部分:
- 一部分是面向我们程序员提供的,也就是 happens-before 规则,它通俗易懂的向我们程序员阐述了强内存模型,我们只要理解 happens-before 规则,就可以编写并发安全的程序了.
- 另一部分是针对 JVM 实现的,为了尽可能少的对编译器和处理器做约束从而提高性能,JMM 在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。我们只需要关注前者就好了,也就是理解 happens-before 规则即可,其他繁杂的内容有 JMM 规范结合操作系统给我们搞定,我们只写好代码即可。
四、Volatile
4.1 Volatile 关键字保证的可见性
volatile 字面意思是易变的,不稳定的,在 Java 中是个关键字,作为一个类型修饰符,使用方式如下
static volatile int i=0;
其目的是告诉我们,该变量是极有可能多变的,不能随意变动目标指令,并保证该变量上操作的原子性。
- volatile 修饰的变量有可见性,其含义是变量被修改后,应用程序范围内的所有线程都能够直到这个改动
- volatile 是非排他的,常常用于多线程的共享变量,在一定条件下,它比锁更合适,性能开销比锁更少
特点:具备可见性、有序性,不具备原子性。
JMM 下 Volatile 的内存语义是怎样的?
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新回到主内存中
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
- 所以,volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取的。
4.2 内存屏障
volatile 是通过内存屏障保证它的可见性和有序性的。
那么什么是屏障呢?生活中的例子就是一个栅栏,一个红绿灯通过这个控制人流,不允许我们随意乱窜,从而保证人流车辆的顺序。
4.2.1 重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序。
例如:
int i = 0;
boolean flag = true;
i = 1; //语句1
flag = fasle; //语句2
语句1和语句2的执行顺序有可能是先 1后2,也可能是先2 后1 ,这样因为它们数据没有依赖性,重排序后的指令绝对不能改变原有的串行语义。
【注意】:
① 如果存在数据依赖关系,例如语句1定义 i=1;语句2定义 i=2,那么就不能重排序。
② 在多线程下,对存在控制依赖的操作重排序,可能会改变程序执行结果, 这时候需要内存屏障来保证可见性。
【拓展】
重排序的分类和执行流程
编译器优化的重排序: 编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序
指令级并行的重排序: 处理器使用指令级并行技术来讲多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行
数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
4.2.2 内存屏障
内存屏障,也称内存栅栏,是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以执行此点之后的操作,避免代码重排序。
内存屏障其实就是一种 JVM 指令,Java 内存模型的重排规则会要求 Java 编译器在生成 JVM 指令时插入特定的内存屏障指令,通过这些内存屏障指令,Volatile 实现了 Java 内存模型中的可见性和有序性
内存屏障之前的所有写操作都要回到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性) 。
写屏障(Store Memory Barrier):在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中
读屏障(Load Memory Barrier):在读指令之前插入读屏障,让工作内存当中的缓存数据失效,重新回到主内存中获取最新数据
4.2.3 分类
volatile 变量规则:
屏障插入策略
- 1、在每个 Volatile 写操作的前面插入一个 StoreStore 屏障
-
- 禁止前面的普通写和下面的volatile 写重排序
- 2、在每个 Volatile 写操作的后面插入一个 StoreLoad 屏障
-
- 防止上面的 volatile 写与下面可能有的 volatile 读\写重排序
- 3、在每一个 Volatile 读操作后面插入一个 LoadLoad 屏障
-
- 禁止下面的所有普通读操作和上面的 volatile 读重排序
- 4、在每个 volatile 读操作后面插入一个 LoadStore 屏障
-
- 禁止下面所有的普通写操作和上面的volatile 读重排序
4.3 volatile 特性
4.3.1 保证可见性
保证不同线程对某个变量完成操作后结果及时可见,即该共享变量一旦改变了就能通知到其他线程获取主内存的值。
案例:
- 不加 volatile,没有可见性,程序无法停止
- 加了 volatiel,保证可见性,程序可以停止。
public class VolatileTest {
static volatile boolean flag =true;
public static void main(String[] args) {
new Thread(()->{
System.out.println("come in");
while (flag){
}
System.out.println("flag to false");
}).start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag=false;
System.out.println("main flag ="+flag);
}
}
4.3.2 不保证原子性
对于 volatile 变量具备可见性,JVM 只是保证从主内存加载到线程工作内存的值是最新的,也仅是数据加载时是最新的。但是多线程环境下,”数据计算“ 和数据赋值操作可能多次出现,若数据在加载之后,若主内存 volatile 修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存最新值,操作出现丢失问题。即各个线程私有内存和主内存公共内存中的变量不同步,进而导致数据不一致。
对于多线程修改主内存共享变量的场景必须使用加锁同步。
对于 volatile 变量,JVM 只是保证从主内存加载到线程工作内存的值是最新的,也只是数据加载时是最新的。
如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,也就造成了线程安全问题。
4.3.3 通过禁止指令重排保证有序性
对于 volatile 修饰的变量的读写操作,都会加入内存屏障。
- 每个 volatile 写操作前面都会加 storeStore 屏障,禁止上面的写与它重排序
- 每个 volatile 写操作后面都会加 StoreLoad 屏障,禁止下面的读与它重排序
- 每个 volatile 读操作后面都会加 LoadLoad 屏障,禁止下面的读与它重排序
- 每个 volatile 读操作后面都会加 LoadStore 屏障,禁止下面的写与它重排序
4.4 使用场景
1、单一赋值可以,复合运算不行(i++)
volatile boolean flag=false;
2、状态标志,判断业务是否结束
/**
*
* 使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
* 理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换
* 例子:判断业务是否结束
*/
public class UseVolatileDemo
{
private volatile static boolean flag = true;
public static void main(String[] args)
{
new Thread(() -> {
while(flag) {
//do something......
}
},"t1").start();
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(2L); } catch (InterruptedException e) { e.printStackTrace(); }
new Thread(() -> {
flag = false;
},"t2").start();
}
}
3、开销较低的读、写锁策略
当读远多于写
public class UseVolatileDemo
{
/**
* 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
* 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
*/
public class Counter
{
private volatile int value;
public int getValue()
{
return value; //利用volatile保证读取操作的可见性
}
public synchronized int increment()
{
return value++; //利用synchronized保证复合操作的原子性
}
}
}
4、DCL 双端锁的发布
双重检查锁定
public class SafeDoubleCheckSingleton
{
private static SafeDoubleCheckSingleton singleton;
//私有化构造方法
private SafeDoubleCheckSingleton(){
}
//双重锁设计
public static SafeDoubleCheckSingleton getInstance(){
if (singleton == null){
//1.多线程并发创建对象时,会通过加锁保证只有一个线程能创建对象
synchronized (SafeDoubleCheckSingleton.class){
if (singleton == null){
//隐患:多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取
singleton = new SafeDoubleCheckSingleton();
}
}
}
//2.对象创建完毕,执行getInstance()将不需要获取锁,直接返回创建对象
return singleton;
}
}
五、小结
1、由于 CPU 并非直接操作内存,而是将内存的数据放到高速缓存区中,通过高速缓冲区解决了主内存与 CPU 之间的一个速率读取问题。但因为高速缓存区和主存各有一个数据,带来了缓存不一致问题。
2、为了解决缓存不一致问题,Java 推出了 JMM 规范,目的是解决由于多线程通过共享内存通信时,产生的主内存和工作内存数据不一致性问题、编译器对代码的重排序等问题。
3、那么什么是 JMM 规范呢?它是通过一组规则来决定一个线程对共享变量的写入何时对另一个线程可见。它有三大特性:可见性、有序性、原子性。
- 可见性即修改的变量对另一个线程可见,
- 有序性是由于 JVM 会对机器指令进行重排序,多线程下回出现乱序现象
- 原子性是一个线程的操作不能被另一个线程打断
4、那为了解决这三大问题,是不是我们都要通过加锁或 volatile 等方式解决呢?答案不是的,JMM 给我们提供了 happens-before 原则来辅助我们保证程序的原子性、可见性、有序性问题,它能够判断数据是否存在竞争、线程是否安全的依据。
5、而 volatile 它能够保证可见性和有序性,它通过内存屏障实现有序性,通过汇编指令后的 lock 前缀,将数据刷新回主存实现可见性,但不能保证原子性。
以上就是 JMM 与 Volatile 的相关内容,感谢阅读。
转载自:https://juejin.cn/post/7201398466630008893