一个问题引发的思考
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
int i=0;
while(!stop){
i++;
//System.out.println("rs:"+i);
try {
Thread.sleep(0);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
Thread.sleep(1000);
stop=true;
}
问题:
- 如果不加
System.out.println("rs:"+i);
,线程不会终止,因为变量i在主线程和创建的线程中是不可见的,主线程中修改stop
的值,创建的线程中不可见。 - 如果加了
System.out.println("rs:"+i);
线程会终止。为什么呢。
活性失败. JIT深度优化
- 这里分为两个层面来解答
第一个层面
println
底层用到了synchronized
这个同步关键字,这个同步会防止循环期间对于stop值的缓存。- 因为
println
有加锁的操作,而释放锁的操作,会强制性的把工作内存中涉及到的写操作同步到主内存,可以通过如下代码去证明。
Thread thread=new Thread(()->{
int i=0;
while(!stop){
i++;
synchronized (JITDemo.class){}
}
});
第二个层面
- 第三个角度,从IO角度来说,
new File()
。同样会达到效果。
Thread.sleep(0) 也能达到上述效果
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.3
- 在这段代码中,我们增加Thread.sleep(0)也能生效,这个我认为是和cpu、以及jvm、操作系统等因素有关系。
- 官方文档上是说,Thread.sleep没有任何同步语义,编译器不需要在调用Thread.sleep之前把缓存在寄存器中的写刷新到给共享内存、也不需要在Thread.sleep之后重新加载缓存在寄存器中的值。
- 编译器可以自由选择读取stop的值一次或者多次,这个是由编译器自己来决定的。但是我认为Thread.sleep(0)导致线程切换,线程切换会导致缓存失效从而读取到了新的值。
volatile关键字(保证可见性)
通过对上述代码查看汇编指令,使用HSDIS工具,具体的使用方法详见压缩文档。
可以看到,使用volatile关键字之后,多了一个Lock指令
0x00000000037028f3: lock add dword ptr [rsp],0h ;*putstatic stop
思考:思考lock汇编指令来保证可见性问题?
什么是可见性
在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性。
硬件层面
CPU/内存/IO设备
- CPU层面增加了高速缓存;
- 操作系统,进程、线程、| CPU时间片来切换;
- 编译器的优化 ,更合理的利用CPU的高速缓存。
CPU层面的高速缓存
因为高速缓存的存在,会导致一个缓存一致性问题
总线锁&缓存锁
总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个
LOCK#
信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU
和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。
- 如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个
CPU
缓存的同一份数据是一致的就行。在P6
架构的CPU
后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要协会到主内存中的,就可以采用缓存锁来解决问题。引入缓存锁的概念。所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在
Lock
期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。
缓存一致性协议
MSI ,MESI ,MOSI
...
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常
见的协议有MSI,MESI,MOSI
等。最常见的就是MESI
协议。接下来给大家简单讲解一下MESI
MESI表示缓存行的四种状态,分别是
M(Modify)
表示共享数据只缓存在当前CPU
缓存中,并且是被修改状态,也就是缓存的数据和主内
存中的数据不一致.E(Exclusive)
表示缓存的独占状态,数据只缓存在当前CPU
缓存中,并且没有被修改
3.S(Shared)
表示数据可能被多个CPU
缓存,并且各个缓存中的数据和主内存数据一致I(Invalid)
表示缓存已经失效
引出了MESI的一个优化
Store Bufferes
Store Bufferes
是一个写的缓冲,对于上述描述的情况,CPU0
可以先把写入的操作先存储到Store Bufferes
中,Store Bufferes
中的指令再按照缓存一致性协议去发起其他CPU
缓存行的失效。而同步来说CPU0可以不用等到Acknowledgement
,继续往下执行其他指令,直到收到CPU0
收到Acknowledgement
再更新到缓存,再从缓存同步到主内存。
指令重排序
我们来关注下面这段代码,假设分别有两个线程,分别执行executeToCPU0
和executeToCPU1
,分别由两个不同的CPU
来执行。
引入Store Bufferes
之后,就可能出现 b==1
返回true
,但是assert(a==1)
返回false
。很多同学肯定会表示不理解,这种情况怎么可能成立?那接下来我们去分析一下。
executeToCPU0(){
a=1;
b=1;
}
executeToCPU1(){
while(b==1){
assert(a==1);
}
}
分析
a=1
和b =1
这两条指令是可以重排序的,如果b=1
被执行了,并且被刷新到主内存,这个时候cpu0
读到的b=1
为true
,但是a=1
这条指令可能还没执行,或者还没刷新到主内存,反正就是没让cpu1
可见,这个时候assert(a==1)
就判断为false
了
通过内存屏障禁止了指令重排序
X86
的memory barrie
r指令包括lfence
(读屏障) sfence
(写屏障) mfence
(全屏障)
Store Memory Barrier
(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes
)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或
者写是可见的Load Memory Barrier
(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏
障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的Full Memory Barrier
(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障
后的读写操作
附图:
volatile int a=0;
executeToCpu0(){
a=1;
storeMemoryBarrier()//写屏障,写入到内存
b=1;
//CPU层面的重排序
//b=1;
//a=1;
}
executeToCpu1(){
while(b==1){ //true
loadMemoryBarrier(); //读屏障
assert(a==1) //false
}
}
JMM
简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没主动限制编译器对于指令的重排序,也就是说在JMM这个模型之上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是建立在不同的操作系统和硬件层面之上对问题进行了统一的抽象,然后再Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题。
JMM是如何解决可见性和有序性问题的
- 其实通过前面的内容分析我们发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?
- 其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。
Volatile的原理
通过
javap -v VolatileDemo.class
,我们可以清楚的看到,volatile
修饰的成员变量,汇编指令多了一个ACC_VOLATILE
,后续可以通过hotspot
源码分析这个指令的作用。
public static volatile boolean stop;
descriptor: Z flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
1、可以在JVM源码中看到有一个is_volatile判断是否是volatile访问限定符修饰的,然后再看字节码解释器的部分源码
2、注意有三个细节,首先会判断是否标识了volatile,然后再判断类型,我们这个是int类型所以会调用release_int_field_put,最后插入一道屏障storeload
int field_offset = cache->f2_as_index();
if (cache->is_volatile()) { //是否标识了volatile
if (tos_type == itos) {
obj->release_int_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == atos) {
VERIFY_OOP(STACK_OBJECT(-1));
obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
} else if (tos_type == btos) {
obj->release_byte_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ltos) {
obj->release_long_field_put(field_offset, STACK_LONG(-1));
} else if (tos_type == ctos) {
obj->release_char_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == stos) {
obj->release_short_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ftos) {
obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
} else {
obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
}
OrderAccess::storeload(); //全屏障
}
Happens-Before模型
除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。
传递性规则
a happens-before b
, b happens- before c
, a happens-before c
程序顺序规则(as-if-serial语义)
- 不能改变程序的执行结果(在单线程环境下,执行的结果不变.)
- 依赖问题, 如果两个指令存在依赖关系,是不允许重排序
int a=0;
int b=0;
void test(){
int a=1; a
int b=1; b
//int b=1;
//int a=1;
int c=a*b; c
}
a happens -before c ; b happens before c
锁的happen-before原则
同一个锁的
unlock
操作happen-before
此锁的lock
操作。
线程启动的happen-before原则
同一个线程的start方法happen-before此线程的其它方法。
start 放法与其它方法可能并没有数据依赖关系,但是显而易见的,为了程序的正确性,我们必须做到这一点。start 方法造成的函数副作用必须对其它方法可见。
public class StartDemo{
int x=0;
Thread t1=new Thread(()->{
//读取x的值 一定是20
if(x==20){
}
});
x=20;
t1.start();
}
线程中断的happen-before原则
对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
interrupt 方法改变的状态必须对后续执行的检测方法可见。
线程终结的happen-before原则
线程中的所有操作都happen-before线程的终止检测。
为了安全的关闭线程,线程中的方法造成的函数副作用必须对线程关闭方法可见
对象创建的happen-before原则
一个对象的初始化完成先于他的finalize方法调用。
单线程下对象的创建于销毁存在数据依赖,该条原则强调的是多线程情况下对象初始化的结果必须对发生于其后的对象销毁方法可见。
Join规则
public class Test{
int x=0;
Thread t1=new Thread(()->{
x=200;
});
t1.start();
t1.join();
//保证结果的可见性。 //在此处读取到的x的值一定是200.
}
volatile变量规则
volatile
修饰的变量的写操作,一定happens-before
后续对于volatile
变量的读操作.
内存屏障机制来防止指令重排.
public class VolatileExample{
int a=0;
volatile boolean flag=false;
public void writer(){
a=1; 1
//修改
flag=true; 2
}
public void reader(){
//true
if(flag){ 3
//1
int i=a; 4
}
}
}
问: 1 happens-before 4 是否成立?
分析:
- 1 happens-before 2 是否成立?是 (volatile规则)
- 3 happens-before 4 是否成立? 是 (as-if-serial语义)
- 2 happens-before 3 是否成立? 是 (volatile规则)
- 1 happens-before 4 ;