深入理解并发内存模型
cpu多核并发与缓存架构
早期cpu和主内存是直接打交道的,cpu越来越快,主内存几乎不变,所以加了缓存,
JMM内存模型
Java多线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-itwDZja1-1634785473089)(深入理解JVM虚拟机.assets/1634729695571.png)]
有一个主内存,有多个线程要同时读取主内存的数据,假如主内存的数据为int a = 1,多个线程都要操作主内存的值是不对的,他会把变量在各个工作内存放一个副本,为了提速,实际上操作的是工作内存上的副本,操作完以后会把这个值刷回主内存。
JMM数据原子操作
首先看代码:
package com.ryh.jvm;
/**
* @author renyuhua
* @date 2021年10月20日 19:48
*/
public class VolatileVisibilityTest {
/**
* volatile
*/
private static volatile boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread( () -> {
System.out.println("wait data......");
while (!initFlag) {
}
System.out.println("======success----------");
}).start();
Thread.sleep(2000);
new Thread(VolatileVisibilityTest::prepareData).start();
}
public static void prepareData() {
System.out.println("prepare data....");
initFlag = true;
System.out.println("prepare data end........");
}
}
输出:
此时线程一是感受不到的,它还会不断地for循环,线程之间是不可以同步数据的。线程之间同步数据是需要主内存做中转的。
JMM缓存不一致问题
在普通情况下,将变量修改为true是不确定什么时候修改的,反正会在线程结束之前修改为true。
Volatile可见性底层原理
将hsdis-amd64.dll放在jdk/jre/bin目录下。
然后打开运行配置
命令:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileVisiblityTest.prepareData
生成java代码底层的汇编语言:
// 此处汇编语言特别多,只截取部分
接下来继续看:
指令重排序与内存屏障
-
并发编程三大特性:可见性,有序性,原子性
-
volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制
-
指令重排序:在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指今重排序优化
-
-
重排序会遵循as-if-serial与happens-before原则
首先来写一段代码:
package com.ryh.jvm;
import java.util.HashSet;
import java.util.Set;
/**
* @author renyuhua
* @date 2021年10月20日 22:19
*/
public class VolatileSerialTest {
private static int x,y,a,b;
public static void main(String[] args) throws InterruptedException {
Set<String> set = new HashSet<>();
set.add("a=" + a + ",b=" + b);
for (int i = 0; i < 100000000; i++) {
x = 0;
y = 0;
a = 0;
b = 0;
// cpu有时候为了让我们的程序运行比较高效,可能会对我们的代码进行重排序
Thread one = new Thread(() -> {
a = y; // 3
x = 1; // 1
});
Thread other = new Thread(() -> {
b = x; // 4
y = 1; // 2
});
one.start();
other.start();
one.join();;
other.join();
set.add("a=" + a + ",b=" + b);
System.out.println(set);
}
}
}
可能会出现a和b都等于1的情况。
那么重排序会遵循as-if-serial与happens-before原则
as-if-serial
- as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
- 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
- java语义分析只能在单线程内部做语义分析来判断这两行代码有没有依赖关系。没有办法跨线程做语义分析。
happens-before原则
- 只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:
- 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。假设有一个锁的对象,执行lock操作,然后执行unlock操作,再进行lock操作,再进行unlock操作。第二次加锁一定要在第一次释放锁之前。
- volatile规则: volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的。
双重检测锁DCL对象版初始化问题
先看单例代码:
package com.ryh.jvm;
/**
* @author renyuhua
* @date 2021年10月21日 9:58
*/
public class DoubleCheckLockSingleton {
private static DoubleCheckLockSingleton instance = null;
private DoubleCheckLockSingleton() {
}
public static DoubleCheckLockSingleton getInstance() {
// 为空才要初始化
if (instance == null) {
// 其他线程在进入到锁代码块时,就再判断是否为空,不然就又重新赋值了,这就不是单例模式了
synchronized (DoubleCheckLockSingleton.class) {
// 避免多次实例化
if (instance == null) {
instance = new DoubleCheckLockSingleton();
}
}
}
return instance;
}
public static void main(String[] args) {
DoubleCheckLockSingleton instance = DoubleCheckLockSingleton.getInstance();
}
}
在这里我们去商店下载一个jclasslib插件。然后把项目编译。
打开jclasslib
就会出现它的字节码文件
我们复制出来一段代码,这段代码对应上面的synchronized代码块。
10 monitorenter // 获得对象的锁,用于同步方法或同步块
11 getstatic #2 <com/ryh/jvm/DoubleCheckLockSingleton.instance> //获取静态变量
14 ifnonnull 27 (+13) // 判空
17 new #3 <com/ryh/jvm/DoubleCheckLockSingleton> // new对象
20 dup
21 invokespecial #4 <com/ryh/jvm/DoubleCheckLockSingleton.<init>> // init方法:初始化
// 假如这两行交换,不会影响结果
// 如果交换,就是把赋值先做了,然后再初始化它的构造方法,可能会出现先赋值,但是此时它赋值对应的这个对象的成员变量还没有真正的赋值,那这个对象已经不为空了,其他线程就可以使用了,其他线程使用的时候,拿到的这个对象,这个对象还没有初始化完------->半初始化
// 如果cpu重排序了,就会出现这样的问题,是在单线程内部
// 如果想让代码不重排序,那么就在变量前加volatile修饰
24 putstatic #2 <com/ryh/jvm/DoubleCheckLockSingleton.instance> // 对静态变量赋值
27 aload_0 // 将第一个引用类型本地变量推送至栈顶
28 monitorexit // 释放对象的锁,用于同步方法或同步块
补充:对象的创建过程
执行init方法:底层是c++实现的,执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
内存屏障
假设我们现在有a = c,b = c,都是load读操作,如果不想让发生重排序,就在他们之间增加一个LoadLoad内存屏障。
-
不同cpu硬件对于JVM的内存屏障规范实现指令不一样
-
Intel CPU硬件级内存平衡屏障实现指令(下面是汇编指令)
- lfence:是一种Load Barrier读屏障,实现LoadLoad屏障
- sfence:是一种Store Barrier写屏障,实现StoreStore屏障
- mfence:是一种全能型的屏障,具备lfence和sfence的能力,具有所有屏障能力
-
JVM底层简化了内存屏障硬件指令的实现
- lock前缀:lock指令不是一种内存屏障,但是它能完成类似内存屏障的功能
下面为底层实现部分代码展示:
下面看fence()的实现。