目录
六、JMM规范下多线程先行发生原则happens-before
前置
计算机硬件存储体系
计算机存储结构,从本地磁盘到主存到CPU缓存,也就是从硬盘到内存到CPU。运行速度是越来越快的。
一般对应的程序的操作就是从数据库查数据到内存然后到CPU进行计算。
为什么有这么多级的缓存呢?
因为CPU和物理主内存的速度是不一致的,CPU的运行并不是直接操作内存,而是先把内存里面的数据读到缓存,如果CPU直接读内存,因为速度是不一样的,所以内存的读和写操作的时候就会造成不一致的问题。
在不同的操作系统中,怎么保证CPU和内存在进行读和写时,一致性ok呢?
在JVM规范中试图定义一种Java内存模型,简称JMM,来屏蔽掉各种硬件和操作系统的内存访问差异。也就是说,希望不管是window还是ios还是安卓等等系统,都希望它们进行读写时都能够保持一致。不要有平台的差异。
一、什么是JMM?
JMM,即Java Memory Model,Java内存模型。本身是一种抽象的概念,并不真实存在,它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式,并决定一个线程对共享变量的写入何时以及如何编程对另一个线程可见。关键技术点都是围绕多线程的原子性、可见性和有序性开展的。
二、能干嘛?
1. 通过JMM来实现线程和主内存之前的抽象关系。
2. 屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
三、Java内存模型和JVM内存模型的区别
JVM内存模型规定了JAVA虚拟机在运行时使用的内存的各个分区及其作用。
JAVA内存模型保证了在多线程环境下,对共享变量读写的原子性、可见性和有序性的一系列规范。它是不存在的抽象概念。
四、JMM规范下的三大特性
即可见性、原子性、有序性。
4.1 可见性
定义
是指:当一个线程修改了某一个共享变量的值,其他线程应当能够立即看到修改后的值。
JMM规定了所有的变量都存储在主内存中。
为什么要设置工作内存?
系统主内存共享变量数据修改被写入的时机是不确定的,多线程并发下很可能出现”脏读“,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在线程自己的工作内存中进行,而不能直接读写主内存中的变量。线程间变量值的传递均需要通过主内存来完成。
4.2 原子性
即使有了工作内存,如果没有原子性的话,依然还是会出现脏读现象。例如:
所以说,可见性需要有原子性作为保证,否则会出现脏读现象。
定义
指同一个操作是不可打断的,即多线程环境下,操作不能被其他线程干扰。
4.3 有序性
指令重排
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但是为了提升性能,编译器和处理器通常会对指令序列进行重新排序。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果和它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫做指令重排。
指令重排的原因:
JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重新排序,使机器指令更符合CPU的执行特性,最大限度的发挥机器的性能。
指令重排不好的方面:
但是,指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致(即可能产生”脏读“),简单说,两行以上不相干的代码执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。
从源码到最终执行示意图:
单线程环境里面能够确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须考虑指令之间的数据依赖性。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保持一致性是无法确定的,结果无法预测。
案例
如上,处理器在进行指令重排的时候,可以按照语句1234的方式执行,也可以按照2134或者1324执行。但是不可以不能将语句4重排在第一条,因为处理器在进行重排序时必须考虑指令之间的数据依赖性。y和x都还没定义,显然不能执行的。
五、JMM规范下多线程对变量的读写过程
由于JMM规范中,存在缓存一致性协议或者说总线嗅探机制,只要有人改了动了主内存,马上就会通知其他线程。因此能够保证多线程下不会出现“脏读”问题。数据实时可靠。
六、JMM规范下多线程先行发生原则happens-before
简单讲就是,多线程谁先谁后执行了,互相让对方获得感知。
在JMM中,如果一个操作执行的结果需要对另一个操作可见性,或则代码重排序,那么这两个操作必须存在happens-before(先行发生)原则。
happens-before的八条规则
1. 次序规则
一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作;
2.锁定规则
一个unLock操作先行发生于后面(这里的“后面”是指时间上的先后)对同一个锁的lock操作。
直白讲就是,对于同一把锁,线程A一定先unlock同一把锁后线程B才能获取该锁。
3.volatile变量规则
对一个volatile变量的写操作先行发生于后面(这里的“后面”同样是指时间上的先后)对这个变量的读操作。
即,只要先写了,后面都可以读到这个数据。也就是前面说的解决脏读问题。
4.传递依赖规则
如果操作A先行发生于操作B,而操作B又先行于操作C,则可以得出操作A先行发生于操作C。
5.线程启动规则
Thread对象的start()方法优先发生于此线程的每一个动作。
6.线程中断规则
对于线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
7.线程终止规则
线程中的所有操作都先行发生于对此线程的终止检测。
8.对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
即,要先new一个对象才可能执行finalize()方法,然后被被垃圾回收。finalize()方法是在垃圾回收前最后执行的一点方法。
案例说明
解决办法:
volatile保证了读取操作的可见性。
七、volatile
在单线程中,这个volatile根本用不到,但是在高并发的场景下,经常使用。
7.1 volatile的两大特性
- 可见性
- 有序性
volatile的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量。
所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
因此它说它具有可见性。
volatile凭什么保证可见性和有序性?
保证有序性,就是禁止指令重排,靠的就是内存屏障(Memory Barrier)
7.2 volatile的内存屏障
再说明一下volatile的特性:
可见性:写完后立即刷新回主内存并及时发出通知,大家可以取主内存拿最新版,前面的修改对后面所有线程可见。
有序(禁重排)。
所以但凡加了volatile的,都意味着可见加禁重排。
那么怎么做到禁止重排的呢,靠的就是内存屏障。
定义
内存屏障(也称内存栅栏,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现类Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存。
内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读。
内存屏障的分类
粗分两种:
- 读屏障
- 写屏障
读屏障
在读指令之前插入读屏障,让工作内存或CPU高速缓存当中的缓存数据失效,重新回到主内存中获取最新数据。
读屏障细分为两种:
1. 在每个volatile读操作的后面插入一个LoadLoad屏障。
作用是:禁止处理器把前面的volatile读操作与后面的普通读重排序。
2. 在每个volatile读操作的后面插入一个LoadStore屏障。
作用是:禁止处理器把前面的volatile读操作与后面的普通写重排序。
写屏障
在写指令之后插入写屏障,强制把写缓冲区的数据刷会到主内存中。
写屏障细分为两种:
1. 在每个volatile写操作的前面插入一个StoreStore屏障
作用:可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
2. 在每个volatile写操作的后面面插入一个StoreLoad屏障
作用:避免volatile写与后面可能有的volatile读/写操作重排序
总结细分四大屏障:
案例说明
上述先大概记住,不能懂也没关系,下面通过演示加深理解。
总而言之,就是在每个volatile读的后面都会加LoadLoad屏障和LoadStore屏障,保证后面的普通读写都不能重排到该volatile读前面去。
在每个volatile写的前后加上StoreStore屏障和StoreLoad屏障,分别保证该volatile写操作前面的所有普通写操作都已经刷到主内存中,以及避免了与后面的读/写操作重排序。
而普通读写遇到普通读写就不管了,随便可以重排。
7.3 volatile可见性案例
package com.hssy.jucstudy.juc.jmm;
import java.util.concurrent.TimeUnit;
/**
* 通过如下代码知道:
* 首先:我们的变量flag没有设置为volatile
* 当t1线程执行后,进入while死循环中,等待2秒后,主线程将flag设置为false
* 但是t1线程并没有感知到,依然处于死循环。
*/
public class Test {
static boolean flag = true;
public static void main(String[] args) {
new Thread(()-> {
System.out.println(Thread.currentThread().getName() + "\t" + "线程进来了");
while (flag) {
}
System.out.println("flag被设置为false," + Thread.currentThread().getName() + "\t" + "线程停止了");
},"t1").start();
// 暂停一会,保证让t1线程先执行
try {TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {e.printStackTrace();}
flag = false;
System.out.println(Thread.currentThread().getName() + "\t" + "修改完成");
}
}
通过以上案例我们知道,不加volatile没有可见性,加上以后才能保证可见性。也即当一个线程修改了某一个共享变量的值,其他线程应当能够立即看到修改后的值。
更细致的讲,当写一个volatile变量时,其实是JMM把该线程对应的本地内存中的共享变量值立即刷新回主内存中。当读一个volatile变量时,JMM把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量。
所以,直观上讲就是volatile保证可见性,当一个线程修改了某一个共享变量的值,其他线程应当能够立即看到修改后的值。
7.4 volatile无原子性案例
volatile变量的复合操作不具有原子性,比如number++
/**
* 代码解释:
* 提供了10个线程,每个线程加加1000次,
* 最终的结果理论上希望number的值为10000;
* 方法上加synchronized关键字肯定可以做到。
*/
public class Test2 {
public static void main(String[] args) {
MyNumber myNumber = new MyNumber();
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
myNumber.addPlus();
}
},String.valueOf(i)).start();
}
try {TimeUnit.SECONDS.sleep(2);}catch (InterruptedException e){e.printStackTrace();}
System.out.println(myNumber.number);
}
}
class MyNumber{
volatile int number;
public synchronized void addPlus(){
number++;
}
}
当然这里加上或者不加volatile结果都一样,加上是为了提升效率。volatile具备可见性
/**
* 如果不加synchronized,则大概率会出现问题。
* 得不到最终的10000的结果。
* 这是因为volatile不具备原子性的问题。
*/
public class Test2 {
public static void main(String[] args) {
MyNumber myNumber = new MyNumber();
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
myNumber.addPlus();
}
},String.valueOf(i)).start();
}
try {TimeUnit.SECONDS.sleep(2);}catch (InterruptedException e){e.printStackTrace();}
System.out.println(myNumber.number);
}
}
class MyNumber{
volatile int number;
public void addPlus(){
number++;
}
}
为什么volatile会出现这种情况?
volatile只能保证从主内存加载到工作内存的值是最新的,就是是或数据加载时是最新的。但是多线程下,参与数据计算和数据赋值的操作可能有多次,而它们是非原子操作的,也就是说即使当我们加载到了最新的值,但是还需要我们进行计算和赋值,如果该线程正在计算的时候,这个时候被别的线程赶在前面处理完提交了,此时当前压在操作数栈顶的这个值就变成了过期的数据,因为Java里面的运算是非原子操作的,所以它会继续往下执行,并把这个计算后的值同步回主内存中,这样就导致值变小了。
详细见《深入理解Java虚拟机》这本书的第12.3.3节内容
所以,volatile不能保证原子性,要想保证原子性必须加锁处理。
总结
volatile变量不适合参与到依赖当前值的运算,如i=i+1;i++;之类的
那么依靠可见性的特定volatile可以用在哪些地方?
通常volatile用做保存某个状态的boolean值
7.5 volatile的禁重排案例
八、volatile的使用场景
单一赋值可以,但是含复合运算赋值不可以(i++之类)
状态标志,判断业务是否结束
开销低的读,写锁策略。也就是写操作还是要加锁,但是读操作我们不加锁,这样太重了,直接加volatile给读的数据即可。
DCL双端锁的发布