(1)多核并发缓存架构
早期计算机先把数据(硬盘数据)加载到主内存,然后CPU再到内存中取。由于现在CPU发展很快,CPU的运算速度比主内存高得多,为了避免受主内存读取速度的影响,所以现在会在CPU中有CPU缓存,速度接近CPU,比主内存快得多,只要数据在CPU缓存,那么CPU的速度就没有太大的限制,发挥到最大
L1、L2、L3就是CPU的高速缓存
(2)JMM内存模型
Java多线程内存模型跟CPU缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽掉了低层不同计算机的区别。
假设主内存中有共享变量 int a=6,线程1把它改为7,线程2是未必能看到最新值的,因为线程和主内存之间还有一个工作内存,存储这共享变量副本,线程1会先把工作内存的值改为7,在刷到主内存中,但线程2的工作内存值还是6,未必感知得到
证明:
public class VolatileVisibilityTest {
private static boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("waiting data...");
while(!initFlag){
}
System.out.println("===============success");
}).start();
Thread.sleep(2000);
new Thread(() -> prepareData()).start();
}
public static void prepareData(){
System.out.println("prepare data...");
initFlag = true;
System.out.println("prepare data end...");
}
}
在initFlag变为了true之后,但线程1还没感知得到,仍处于死循环中
(3)volatile
只要使用了volatile修饰,就能马上知道最新值,退出while循环
private static volatile boolean initFlag = false;
(4)volatile怎么保证线程可见性
内存模型原子操作:
read(读取):从主内存读取数据
load(载入):将主内存读取到的数据写入工作内存
use(使用):从工作内存读取数据来计算
assign(赋值):将计算好的值重新赋值到工作内存中
store(存储):将工作内存数据写入主内存
write(写入):将store过去的变量值赋值给主内存中的变量
lock(锁定):将主内存变量加锁,标识为线程独占状态
unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
下面先慢慢展开
(5)缓存一致性协议(MESI)
多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制
可以感知到数据的变化从而将自己缓存里的数据失效
(6)查看volatile汇编指令
首先下载hsdis-amd64.dll
,然后复制到如C:\Program Files\Java\jdk1.8.0_261\jre\bin
中,然后在运行java程序的时候,要先配置一些jvm参数
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileVisibilityTest.prepareData
如果不同类名方法名的还得改改这里的内容
如果提示了
PrintAssembly is enabled; turning on DebugNonSafepoints to gain additional output
,说明是PrintAssembly功能以及开启,但系统不支持,把hsdis-amd64.dll
复制到如C:\Program Files\Java\jdk1.8.0_261\jre\bin\server
目录吧,亲测可行,以下就是输出的汇编信息其中*putstatic initFlag是JVM指令码,作用是给静态变量赋值。lock、mov这些都是汇编语言。
没有volatile修饰的时候,没有lock指令,volatile修饰的变量在赋值的时候会有lock。
(7)volatile缓存可见性实现原理
底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存
IA-32和Intel 64架构软件开发者手册对lock指令的解析:
Ⅰ、会将当前处理器缓存行的数据立即写回到系统内存
Ⅱ、这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)
Ⅲ、提供内存屏障功能,使lock前后指令不能重排序
其中第一点中,lock能使变量赋值完后立即执行store和write,没有lock的话是不会立即执行的,会不知道什么时候执行。所有lock会二话不说把数据刷回主内存中。立即刷回到主内存的目的是让多线程能及时感知到修改,强调及时性。
第二点,MESI,指定的是
M状态(修改)
E状态(独享)
S状态(共享)
I状态(无效)
把变量改为Invalid状态,数据就无效了。MESI内容比较多,可以自己搜索一下。
第三点后面再说。
假如面试提到volatile的原理,大概吹一下是通过汇编lock前缀指令、然后这个指令的行为(立即、MESI)就差不多了
(8)指令重排序与内存屏障
并发编程三大特写:可见性、有序性、原子性
volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制
-
指令重排序:在不影响单线程程序执行的结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化
重排序会遵循as-if-serial与happens-before原则
阿里面试题:双重检测锁DCL对象半初始化问题
(9)什么是有序性
程序在执行的时候是有顺序行的,下面看一个例子,猜一下可能会输出什么
public class VolatileSerialTest {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Set<String> resultSet = new HashSet<>();
for (int i = 0; i < 10000000; i++) {
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(() -> {
a = y;
x = 1;
});
Thread other = new Thread(() -> {
b = x;
y = 1;
});
one.start();
other.start();
one.join();
other.join();
resultSet.add("a=" + a + ",b=" + b);
System.out.println(resultSet);
}
}
}
就这么一看,ab的组合可能有00、01、10,但不会有11。
但实际上,随着程序的运行,是会出现11的情况,也就是a=1,也就是y=1,也就是要先other线程先执行完成,这样b应该是0才对,但为什么会是1,这个就是指令重排序的影响。
在最终指令执行之前,可能会出现几种情况的重排序,如编译器优化重排序、指令级并行重排序、内存系统重排序,所以出现11的情况可能是执行了
x=1; => y=1; => a=y; => b=x;
为什么会自作主张重排序,大概是cpu认为当前指令比较耗时,而后面的指令结果会在其他地方使用,就先执行了,让其他地方可以不用等这么久,然后再执行那个耗时的操作。就是编译器和处理器为了提高并行度。
这就出bug了,但不是计算机的bug,是你代码的bug,是你自己没考虑到11的情况,而不是理所当然的不可能有11,这就是并发的难点。
(10)重排序原则
操作系统不会乱排序,而是有依据的,会遵循as-if-serial与happens-before原则。
as-if-serial:不管怎么重排序,(单线程)程序的执行结果不能被改变。
假如有a=y和x=a,这个就是不能重排序的,因为有依赖关系,重排序的话x的值是变化的
像之前的程序,a=y和x=1,并没有任何关系,重排序并不影响结果,所以是允许重排序的,它不管是不是影响了其他线程,as-if-serial只管单线程
假设oracle的JDK产品经理要求“a=y和x=a”的时候不能重排序,“a=y和x=1”的时候可以重排序,那么开发人员如何实现JDK这个软件?
编译原理会进行语义分析生成语义树,判断代码之间有没有依赖关系,有依赖就不能重排序
happens-before:
①程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
②锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
obj.lock();
obj.unlock();
obj.lock();
obj.unlock();
就是第2第3行不能重排序
③volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
④线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
⑤传递性:A先于B,B先于C,那么A必然先于C
⑥线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功放回后,线程B对共享变量的修改将对线程A可见。
⑦线程终端规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
⑧对象终结规则:对象的构造函数执行,结束先于finalize()方法。
(11)阿里面试题:双重检测锁DCL对象半初始化问题
在阿里巴巴手册中,有一个推荐做法
早期的时候,双重检测锁单例模式就是这么写的,主要是解决并发的问题,避免重复初始化
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();
}
}
但它存在点问题,我们先在idea中安装jclasslib插件,看看这个类的指令码
其中synchronized对应的就是monitorenter至monitorexit
10 monitorenter
获取静态变量
11 getstatic #2 <tuling/DoubleCheckLockSingleton.instance>
判断是不是为null
14 ifnonnull 27 (+13)
是的话就new
17 new #3 <tuling/DoubleCheckLockSingleton>
20 dup
执行init方法(下面简单说明一下,和这部分内容无关)
21 invokespecial #4 <tuling/DoubleCheckLockSingleton.<init>>
给变量赋值,即instance=xx
24 putstatic #2 <tuling/DoubleCheckLockSingleton.instance>
27 aload_0
28 monitorexit
对象创建的主要流程
执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(主要,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
回到问题上,究竟是会出现什么问题?就是 invokespecial 和 putstatic 有可能重排序,因为没有违背as-if-serial与happens-before原则。
对于单线程下,重排序的结果并没有影响
重排序之后:先把17行的地址给24行,instance已经不为空了,但初始化是还没完成的,未执行完init都是未完成,这时是对象的半初始化。当其他线程拿到了这个还没完成初始化的变量时,就会出现问题。(这种问题只有极端情况才会偶尔出现)
所以要加volatile,就不会有重排序。
(12)内存屏障
volatile底层会帮我们实现内存屏障。如何理解内存屏障?
如a=y; x=1; 如果不想这两行代码重排序,在它们中间加一行标记性代码,跟重排序(CPU )做好约定,当遇到这个标记的时候就不能对它前后的代码进行重排序,那么这个标记性代码就叫做内存屏障
- JVM规范定义的内存屏障
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 保证load1的读取操作在load2及后续读取操作之前执行 |
StoreStore | Store; StoreStore; Store2 | 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore | Load1; LoadStore; Store2 | 在Store2及其后的写操作执行前,保证load1的读操作已读取结束 |
StoreLoad | Store1; StoreLoad; Load2 | 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
这些屏障是jdk规定的,就是jdk的程序员实现的,load就是加载,store就是写
LoadLoad:如b=a; c=a; 对应a来说,都是加载,只有它们之间加了LoadLoad,就不允许重排序。
- JVM规定volatile需要实现的内存屏障
a=2; //volatile写,a为volatile变量
StoreStore屏障
a=1; //volatile写
StoreLoad屏障
b=a; //volatile读
LoadLoad屏障
LoadStore屏障
也就是jdk的程序员在实现volatile的时候,就是在对它的操作前后都要加屏障,具体实现的话看看源码 openjdk - https://github.com/openjdk/jdk/blob/master/src/hotspot/share/interpreter/zero/bytecodeInterpreter.cpp
这个switch就是对不同类型的volatile进行各自的操作,最后都会执行一个 OrderAccess::storeload();,可以看看这个链接orderAccess_linux_x86.hpp
前面asm就是调用汇编语言的意思,后面的就是汇编语言,还记得这个汇编语言吗?回看上方第6点提到的
没有volatile修饰的时候,没有lock指令,volatile修饰的变量在赋值的时候会有lock。
lock前缀指令:在上方第7点中也提到
提供内存屏障功能,使lock前后指令不能重排序
,当很多的硬件看到这个lock前缀指令,就不会对前后左右的代码进行重排序,就是一个约定好的代码指令,看到就不能重排序。
- 不同CPU硬件对于JVM的内存屏障规范实现指令不一样
- Intel CPU硬件级内存屏障实现指令
- lfence:是一种Load Barrier读屏障,实现LoadLoad屏障
- sfence:是一种Store Barrier写屏障,实现StoreStore屏障
- mfence: 是一种全能型的屏障,具备lfence和sfence的能力,具有所有屏障能力
- JVM低层简化了内存屏障硬件指令的实现
- lock前缀:lock指令不是一种内存屏障,但是它能完成类似内存屏障的功能
(13)从Spring Cloud微服务框架源码看下并发编程的应用
从github下载nacos源码,后续再看