JMM(Java Memory Model)
JMM是什么?
JMM是Java的并发采用的是共享内存模型,是不存在的东西是一种概念与约定。
关于JMM的约定有以下几点
- 线程加锁解锁都是同一把锁
- 线程加锁前必须读取主存中最新值到工作内存中
- 线程解锁前必须把共享变量刷新到主存中
JMM长啥样?
从上图可以看出JMM定义了线程与内存之间的抽象关系,线程之间的共享变量存储在内存中,每一个线程都有一个私有的本地内存,在本地内存中存储该线程的读写副本。并且线程是不直接操作内存,只能操作自己工作内存中的变量后将其刷新到内存中。内存是多个线程共享而线程自己工作内存由线程独享。当线程需要通信时需借助内存来完成。
在图中可以看到8种操作这是与内存的交互操作,每一个都是原子性的,不可再分的
- lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
- read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
- load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
- use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
- assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
- store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
- write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
- unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定
Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。
问题来了,JMM是如何解决可见性问题的?
问题1:当两个线程同时操作一个共享变量时,其中一个线程修改后另一个线程知道吗?
下面我们用代码来看看
package com.kowk.jmm;
import java.util.concurrent.TimeUnit;
/**
* @ClassName VolatileTest
* @Description
*
*思路:
* 定义两个线程与一个共享的静态变量,
* 线程A根据变量无限循环,线程B在几秒后将变量改变
* 如果程序停止说明线程A可以知道线程B对变量做出修改,如果不结束则不知道
*
* @Author kwok
* @Date 2020-09-27 20:33
* @Version 1.0
*/
public class VolatileTest01 {
// 定义一个静态变量
private static int num = 0;
public static void main(String[] args) {
// 新建一个线程进入无限循环
new Thread(()->{
while (num == 0) {
}
}).start();
// 主线程延迟两秒使效果明显
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 改变变量,使线程A中判断状态改变
num = 1;
// 输出变量,确认修改情况
System.out.println(num);
}
}
结果是输出1后程序还没结束,说明线程A不知道线程B做出了改变。
如何解决这个不可见性问题也是同步问题
这时就是我们的volatile关键字出场了
那volatile是什么?
volatile是java虚拟机提供的轻量级的同步机制
volatile有三大特点:
- 保证可见性
- 不保证原子性
- 禁止指令重排
保证可见性
普通变量与volatile变量的区别是:volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。哦豁这就相当于变量改变后就通知其他使用这个变量的线程“喂,这个变量变了,你手上的out了,快去重新领一个”。下面再测试,将普通变量变成volatile就行
private volatile static int num = 0;
再次运行完美解决。
不保证原子性
volatile既然保证了可见性但为啥不保证原子性?要知道java中只有基本操作例如num = 1;这样才是原子操作。而像i++这样的操作,其过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。所以就变成不是原子性操作,但这样有啥问题?下面用代码来说明
/**
* @ClassName VolatileTEst02
* @Description
*
* 思路:
* 用10个线程进行100次++操做后看结果,
* 如果结果如预期所料为1000即保证原子性,否则不保证
*
* @Author kwok
* @Date 2020-09-27 20:37
* @Version 1.0
*/
public class VolatileTEst02 {
// 定义一个volatile修饰的静态变量
private volatile static int num = 0;
public static void main(String[] args) {
// 用10条线程分别对num进行++操作
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int i1 = 0; i1 < 100; i1++) {
// ++操作不是原子性操作,从字节码文件可以看出其分为3步
num++;
}
}).start();
}
// 这里判断存活的线程是否只有主线程,如果不是主线程让步给其他线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
// 打印结果
System.out.println(num);
}
}
运行可得每次结果都不一样,说明volatile不保证,里面是为何?
上面说了num++操作分成3个步骤首先获取num的值,其次对num的值进行加1,最后将得到的新值写会到缓存中。当线程A获取这个num时获取最新值但还没来得及修改,这个时候阻塞了,线程B获取并修改了num并刷回内存,但是!线程A读取num的原子性操作已经结束所以线程A中num不会失效,哦豁这样问题就出现了。这里只是对这个不保证原子性做解释,如果想解决可以参考juc包中的atomic包中方法。
禁止指令重排
指令重排是什么?
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排列的一种手段。一份代码会经历下面几个阶段。
- 编译器重排序:编译器在不影响单条线程的执行结果的情况下对代码行重排排列。
- 指令重排序:计算机的操作都是由一条条指令组成,我们代码运行进行操作最终也是指令,这里与编译器同理。
- 内存:因为有读写缓存,加载存储可能是乱序的。
这里看个例子
int a = 1;
int b = 2;
a = a + 5;
b = a + a;
// 正常结果是a = 6; b = 12;
//当重排序后顺序变成1243 就变成 a = 6; b = 2;
当然单线程正常不会发生,下面看多线程
线程在自己里面改变顺序没影响自己的结果吧,但刷新后就会改变其他线程了。
这其实涉及到数据依赖性的问题。
什么是数据依赖性?
数据依赖性就是两个数据相互之间有联系如上诉例子,也可以说两个相关的操作包含了写操作就是有数据依赖性,这时就不能让他们重排序。
这里规定了一个as-if-serial 语义
- 含义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
- 编译器、runtime 和处理器都必须遵守 as-if-serial 语义
- 编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果
- 如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序
volatile怎么做的
-
保证内存的可见性,如果A事件发生在B事件后,那B一定对A可见。
-
volatile在该声明了volatile操作上下各做了一层内存屏障,通俗来说内存屏障就是告诉计算机这里的指令不能进行交换排序,这就解决了重排序可能会发生的问题
下面是内存屏障的四种类型:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 |
StoreStoreBarriers | Store1;StoreStore;Store2 | Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见 |
LoadStore Barriers | Load1;LoadStore;Store2 | 在Store2被写入前,保证Load1要读取的数据被读取完毕。 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 在Load2读取操作执行前,保证Store1的写入对所有处理器可见 |
StoreLoad Barriers是一个全能型的屏障,同时具有其他3个屏障的效果。执行该屏障的花销比较昂贵,因为处理器通常要把当前的写缓冲区的内容全部刷新到内存中(Buffer Fully Flush)。
volatile写前保证普通写可见,写后保证对后续可见
volatile读保证读取完整