并发编程存在的问题
原子性,可见性,有序性,今天来深入分析一下可见性的问题是如何产生的? 又该怎么去解决? 解决可见性问题的底层原理是什么?
可见性问题代码演示
//-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*TestJDBC.refresh
public class TestVolatile {
private static boolean flag=false;
public static void refresh(){
flag=true;
System.err.println("======");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (!flag) {
}
}
});
thread.start();
Thread.sleep(500);
refresh();
}
}
运行上面代码,可以发现程序不会正常停止,原因就是线程main 对flag变量的修改对t1线程是不可见的;
造成不可见的原因是因为 JMM(java内存模型)规定,线程工作时需要使用共享内存的变量 需要拷贝其副本到自己的工作内存中;
也就是当线程 t1 启动时会拿到一个flag的副本到自己工作内存,此时flag的值时false, 后面main线程对flag执行了修改,但是t1 线程并不知道,所以程序无法停止;
我们都知道java中可是使用 volatile关键字来保证可见性(上述代码使用volatile修饰以后可以正常停止),那么它的实现原理是什么呢?
java代码在jvm,os上的执行流程;
我们通过画图来看一下上述 refresh() 方法在jvm 和OS 中的大概执行流程;
1.ClassLoader(类加载器) 加载Class文件至方法区,保存Class的元数据,并且在堆内存中创建一个TestVolatile 的Class对象;
1.1 Class的元数据包括解析Class文件可以获得的常量池信息,类和父类,接口信息, 字段信息,方法信息等; 这一块内容大家可以去查阅Class文件的组成部分,或者可以通过javap命令查看, idea也可以通过byteCode插件查看;
2.当main线程启动 调用refresh() 方法就会在main线程的栈内存中创建refresh() 方法栈帧(一个线程对应一个栈,一个方法对应一个栈帧,栈帧的出栈入栈表示方法的开始和退出); 执行refresh方法就会获得它对应的字节码指令交给字节码执行引擎去执行;
3.字节码执行引擎拿到字节码指令会去操作栈帧中的操作数栈执行压栈,出栈等操作压栈,出栈涉及到内存操作,执行引擎自己是无法完成的,最终还是要cpu去执行
4.字节码指令只有jvm才认识,os又不认识 所以需要通过解释器/编译器 进行解释/编译执行; 解释成相关的汇编执行/机器原语,再由硬件转成 0或1的二进制指令;
5.最终CPU进行调度执行;
我们来比较refresh方法添加volatile关键字之前和之后的汇编代码,再通过相关汇编代码的指令含义来刨析volatile的实现原理;
注:查看java的汇编代码需要使用到hsdis的工具包,大家可以自行下载; 有相关的问题可以给我留言;
未添加volatile的代码
[Constants]
# {method} 'refresh' '()V' in 'com/jtfu/test/TestVolatile'
# [sp+0x20] (sp of caller)
0x0000000002bf5640: mov dword ptr [rsp+0ffffffffffffa000h],eax
0x0000000002bf5647: push rbp
0x0000000002bf5648: sub rsp,10h
0x0000000002bf564c: mov edx,2ch
0x0000000002bf5651: mov r10,7ab7bd4c0h ; {oop(a 'java/lang/Class' = 'com/jtfu/test/TestVolatile')}
0x0000000002bf565b: mov byte ptr [r10+58h],1h ;*synchronization entry
; - com.jtfu.test.TestVolatile::refresh@-1 (line 9)
添加了volatile后的代码
[Constants]
# {method} 'refresh' '()V' in 'com/jtfu/test/TestVolatile'
# [sp+0x20] (sp of caller)
0x0000000002da5640: mov dword ptr [rsp+0ffffffffffffa000h],eax
0x0000000002da5647: push rbp
0x0000000002da5648: sub rsp,10h
0x0000000002da564c: mov r10,7ab7bd578h ; {oop(a 'java/lang/Class' = 'com/jtfu/test/TestVolatile')}
0x0000000002da5656: mov byte ptr [r10+58h],1h
0x0000000002da565b: lock add dword ptr [rsp],0h ;*putstatic flag
; - com.jtfu.test.TestVolatile::refresh@1 (line 9)
可以发现在执行第9行代码, flag=true 时,添加了一个 lock 指令;我们去百度看看 汇编代码lock的含义
LOCK指令前缀会设置处理器的LOCK#信号(译注:这个信号会使总线锁定,阻止其他处理器接管总线访问内存),直到使用LOCK前缀的指令执行结束,这会使这条指令的执行变为原子操作。在多处理器环境下,设置LOCK#信号能保证某个处理器对共享内存的独占使用。
LOCK指令前缀只能用于以下指令,并且要求指令目的操作数为内存操作数,如果源操作数为内存操作数,就会产生undefined opcode异常:ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B,CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG。LOCK指令前缀用于其他任何指令时,也会产生如果源操作数为内存操作数,就会产生undefined opcode异常。另外,XCHG指令默认会设置LOCK#信号,无论是否使用LOCK指令前缀。
LOCK指令前缀经常用于BTS指令,用来在共享内存进行一次read-modify-write操作。
从P6处理器家族开始,如果使用了LOCK指令前缀的指令要访问的目的地址的内容已经缓存在了cache中,那么LOCK#信号一般就不会被设置,但是当前处理器的cache会被锁定,然后缓存一致性(cache coherency )机制会自动确保操作的原子性。
如果大家稍微了解一点计算机硬件,应该清楚CPU 和 内存条并不是一个东西, 两个硬件之间的相互访问肯定需要一定的途径, 可以简单理解成总线(详细的访问方式,我认为没必要深究);
总线加锁时,其他Cpu核心将无法再从内存中获取数据,这无疑会造成巨大的性能损耗,所以这时候MESI 缓存一致性协议就派上用场了, MESI 锁的时CacheLine(缓存行)
M:修改
E:独享
S:共享
I:失效
假设现在有线程 t1,t2 同时对共享变量 a进行操作;
- t1 读取变量a的副本到工作内存, a状态为E(独享)
- t2 读取变量a的副本到工作内存, a状态为S(共享), 此时线程t1 a的状态也要变为S(共享)
- 假设t1对变量a进行修改, a的状态变为M(修改),此时要通知其他线程将变量所在的缓存行置为I(无效);
3.1 可能会存在两个线程同时对a进行了修改,然后相互通知; 这个时候就要交给总线裁决,总线决定谁无效谁就变为状态I(无效);
通过上述缓存一致性解析可以解决可见性的问题;
MESI 还存在伪共享 和 一个缓存行存不下的问题;
1.当一个数据过大时可能一个缓存行存不下,就没法使用MESI了,只能退化成总线加锁;
2.由于MESI是对缓存行进行加锁,缓存行的大小为64byte,可能存了多个数据; 比如线程t1对a修改, t2 对b修改,a与b相处与同一个缓存行,这样不管是对a,b其中任意一个的修改都会导致整个缓存行的数据无效,又得重新从内存加载;
Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:- RestrictContended 才会生效。