目录
1、可见性问题
2、可见性
2.1、硬件层面
2.1.1、CPU高速缓存
2.1.2、总线锁
2.1.3、缓存锁
2.1.4、Store Buffer
2.1.5、指令重排序
2.1.6、内存屏障
2.1.7、不同架构
2.2、JAVA层面
2.2.1、JAVA内存模型
2.2.2、volatile关键字
3、Happens-Before模型(可见性模型)
3.1、程序顺序规则(as-if-serial语义)
3.2、传递性规则
3.3、volatile变量规则
3.4、监视器锁规则
3.5、start规则
3.5、JOIN规则
执行程序,会发现程序无法自动结束,因为修改的stop值无法被线程thread感知到,这就是不同线程共享变量的可见性问题。
public class Test {
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(()->{
int i = 0;
while (!stop){
i++;
}
System.out.println("rs:"+i);
});
thread.start();
Thread.sleep(1000);
stop = true;
}
}
可见性即在多线程条件下,线程A修改的数据线程B不可见,导致这种情况出现的原因有两方面。
在操作系统的硬件层面中,最宝贵的资源是CPU,它的执行速度最快,所以CPU与内存交互的时候
产生的IO速度很慢,将极大地浪费CPU的资源,所以操作系统使用了一些优化方案。
CPU的高速缓存分为三块,L1,L2,L3,其中L1,L2是CPU核心独占的,L3是共享的。
L1分为L1 d-,缓存数据,L1 i-缓存命令,距离CPU越远,缓存的性能就越差,缓存空间就越大。
当CPU需要一个数据的时候,它将在高速缓存中寻找,如果不存在,则会从内存中加载到高速缓存中。假如它需要修改这个数据,那么它会修改高速缓存中的这个数据,然后高速缓存会在合适的时机将这个数据同步到内存中。此时另外一个CPU的高速缓存中存储的是这个数据的旧数值,新的数值还没有同步过来,这样就造成了高速缓存不一致的状况。
总线锁可以在某个CPU访问内存中的某个值之后将这个值锁住,防止其他CPU进行调用,这样就解决了数据不一致问题。
总线锁造成的是整个系统的数据互斥,因此为了提升性能降低粒度,在高速缓存中增加缓存锁,只有数据进入缓存行后才能开启缓存锁以提升性能。
缓存锁保持缓存一致性的方式基于缓存一致性协议,常见的缓存一致性协议有MSI,MESI,MOSI等。
MESI协议表示缓存数据有四种状态
共享状态的数值失效由CPU发起,到其他CPU,其他CPU会给予ACK确定收到失效消息,但此时发起的CPU如果进入阻塞状态等待ACK显然是对CPU资源的浪费,因此引入store buffer进行异步处理
store buffer是一种异步处理器,它负责转发失效消息,接收失效ACK并修改缓存值,它让CPU可以不用等待ACK。但是异步处理带来了另外一个问题,指令重排序。
以下代码是伪代码。
int a = 0;
int b = 0;
a = 1;
b = 1;
while(b==1){
assert(a==1);
}
其中一种执行情况是assert(a==1)
为falsewhile(b==1)
为true
之所以出现这种判断结果,就是因为发生指令重排序。
假设CPU的高速缓存中存在b = 0,CPU1的高速缓存中存在a = 0,现在CPU执行a = 1,b = 1,。
b = 1只存在于CPU的高速缓存中,所以是独享状态,修改无需通知,直接就可以修改缓存内容。
a = 1只存在于CPU1的高速缓存中,所以是分享状态,修改需要通知,将失效消息交给store buffer发送。
此时失效通知还没有到达,CPU1执行while(b==1)
和assert(a==1)
,此时b已经被修改为1,a还没有接到失效通知,因此还是0,这才出现a不等于1,b等于1的情况。
假如让优化失效,就不会出现指令重排序问题。
内存屏障分为读屏障,写屏障和全屏障,内存屏障可以阻止指令重排序,简单说来就是强制同步。
写屏障可以强制写入数据同步完毕再执行其他指令。
读屏障可以强制读取数据来自于内存已经完成同步的数据。
不同的CPU架构有不同的解决方案,上述例子介绍的仅限于MESI缓存一致性协议,但原理是相通的,所以不同架构下的内存屏障指令都不同。
java依靠JVM支持全平台运行,因此为了适配不同操作系统的内存屏障指令,java提出了自己的内存模型。
java并不存在这样一个实际的内存模型,而是仿照系统的内存模型,并且针对不同的体统有不同的处理。
简单来说,jvm定义了多线程情况下读写操作的行为规范,在虚拟机中将共享变量储存到内存以及从内存中取出共享变量的底层实现细节,通过这些细节来规范内存的读写操作从而保证指令的正确性,它解决了cpu多级缓存,处理器优化和指令重排序导致的内存问题,保证了并发条件下的可见性和有序性。
volatile关键字的原理就是通过内存屏障指令,避免了指令重排序与数据可见性问题,保证了数据的可见性。
当有多个线程访问同一个变量的时候,就需要增加volatile保证数据可见性。比较典型的例子就是单例模式下的双重检查锁写法,单例类需要增加volatile关键字,因为创建对象的指令有三条,这三条指令允许重排序,因此会导致创建对象的时候出现半对象,出现错误。
那么哪些情景本身就具有可见性,无需增加volatile关键字呢?
在java中,有一些场景是不需要添加volatile关键字就能保证没有可见性问题的。
1、不能改变程序的执行结果(在单线程环境下,执行的结果不变)
假如在单线程情况下,设置a = 1,b = 1,那么无论a和b如何重排序,都不会互相影响。
2、依赖问题,如果两个指令存在依赖关系,不允许重排序。
假如在单线程情况下,设置a = 1,b = 1,c = a+b,那么c的结果需要依赖a和b的结果,所以禁止重排序。
假如a的结果对b可见,b的结果对c可见,那么a的结果对c也可见。
volatile修饰的变量的写操作,一定对happens-before后续对于volatile变量的读操作,即对于volatile修饰的变量,其写操作的结果一定对后续读操作的结果可见。
volatile修饰的写操作与普通写操作是不能进行指令重排序。
在以下代码中,执行结果为i=1。
执行结果过程是a=1先于flag=true,volatile修饰的写操作与普通写操作是不能进行指令重排序
flag的写操作先于if(flag)读操作,volatile修饰的变量的写操作,一定对happens-before后续对于volatile变量的读操作
if(flag)读操作先于i = a写操作,依赖问题,如果两个指令存在依赖关系,不允许重排序。
a=1操作先于i = a,a的结果对b可见,b的结果对c可见,那么a的结果对c也可见。
private int a = 0;
private volatile boolean flag = flag
public void writer() {
a=1;
flag=true;
}
public void reader() {
if(flag) {
int i = a;
}
}
一个锁释放后的资源对后续所有线程都可见
以下程序,执行结果一定为x=40
int x = 10;
synchronized(this)[
x = 40
}
在线程start之前赋予变量的值,在线程中也能获取到。
以下程序执行的结果为x = 30
int x=0;
Thread thread = new Thread(()->{
System.out.println(x);
})
x = 30;
thread.start();
在线程内赋予变量的值,在线程join之后也能获取到
以下程序执行的结果为x = 30
int x=0;
Thread thread = new Thread(()->{
x = 30;
})
thread.start();
thread.join();
System.out.println(x);
DCL是指在单例模式的双重检查锁模式下,会产生类创建一半的问题,导致类被提前引用,失去单例,这同样与指令重排序有关
一般创建类的过程不是原子性的,它分为三个步骤:
但在指令重排序的情况下,它会将2,3步骤调换:
这将导致类被提前引用,破坏了单例,为了放置这种事情发生,会在创建对象时增加volatile关键字。