java 并发变量_[Java并发-1]变量可见性问题

变量可见性问题

1.什么是可见性?

一个操作的结果,对于另一个操作是可见的。从Java程序的角度来说,是跨线程操作之间的可见性。举个例子,A线程存在一个写a变量的操作,B线程存在一个读a变量的操作,那么可见性就是指,写操作的结果,对于读操作是可见的。

我们用一段代码,来模拟可见性问题的场景

import java.util.concurrent.TimeUnit;

public class TestVolatile {

private boolean flag = true;

public static void main(String[] args) throws InterruptedException {

TestVolatile testVolatile = new TestVolatile();

Thread th1 = new Thread(new Runnable() {

int i = 0;

@Override

public void run() {

while(testVolatile.flag == true){

i = i + 1;

}

System.out.println("退出while循环,i=" + i);

}

});

th1.start();

TimeUnit.SECONDS.sleep(1);

testVolatile.flag = false;

System.out.println("flag设置为false了");

th1.join();

System.out.println("主线程结束");

}

}

事实上,这段代码在我的电脑上运行,是不会退出while循环的,这就出现了可见性问题:主线程对变量flag作出的修改操作,子线程th1看不到。理由有2点,第一是因为在多核CPU的硬件环境下,不同线程可能占用不同的CPU资源,每个CPU有自己的缓存,主线程对flag的修改结果是先写入主线程所在的CPU缓存,再写入JVM堆内存的,从写入缓存->写入主内存->th1刷新新的数据到缓存,这期间数据不一致,因此子线程th1可能暂时看不到最新的flag变量值(缓存一致性协议保证最终能看到);第二是因为出现了指令重排序(导致无限循环的罪魁祸首)。P.S.可见性问题是综合性问题,可能是CPU缓存导致的,也可能是硬件优化导致的(有些内存读写可能不会立刻触发,而是进入一个硬件队列),还可能是指令重排序、编译器优化导致的。

1b3fd8afcc7477041d7f155d6431a31a.png

HotSpot虚拟机有2种编译形式,一种是解释执行,逐条将字节码编译成机器码并执行,一种是即时编译(Just In Time Compilation,JIT),将一个方法中所有的字节码翻译成机器码并执行。前者的优势在于,不需要等待编译,后者的优势在于,实际执行速度更快。HotSpot虚拟机默认采取混合模式,先解释执行字节码,然后对热点代码,以方法为单位进行即时编译。

实际上,百分之20的代码占据了80%的计算资源,对于大部分不常用的代码,无需耗费时间即时编译,而是采取解释执行的方式运行。对于小部分热点代码(例如循环),我们可以将其编译为机器码,提高效率。HotSpot内置多个即时编译器:C1、C2和Graal。Graal是Java10正式引入的,这里不讲。C1又称为Client编译器,针对的是对启动性能有要求的客户端GUI程序,优化手段相对简单,编译时间较短。C2又称为Server编译器,针对的是对峰值性能有要求的服务端程序,优化手段相对复杂,编译时间较长。从Java7开始,HotSpot默认采取分层编译的方式:热点方法被C1编译,热点中的热点,被C2编译。即时编译是放在额外的线程中进行的,HotSpot会根据CPU的数量设置编译线程的数目,按照1:2的比例配置给C1和C2编译器。在计算资源充足的情况下,字节码的解释执行和即时编译可以同时进行,编译完成后的机器码,会在下次调用该方法时生效,替换原来的解释执行。

回到例子,在示例代码的while循环处,因为i = i + 1执行次数很多,属于热代码,C2编译器会进行进一步的优化,将指令重排序,变成下面的样子。(如果运行参数为Client编译模式,在这里就不会出现重排序)

if(flag){

while(true){

i = i + 1;

}

}

2.怎么解决上述问题?

关闭重排序优化(JIT优化),告诉编译器,我不需要重排序。

df264204bdba45736190d671dabadeda.png

也可以利用volatile关键字。根据Java内存模型的规定,对一个volatile变量的写操作,happens before后续对这个volatile变量的读操作,我们可以给flag变量加上volatile关键字,这样只要某个线程更新了flag,其他线程立马能够看到最新的值。P.S.为了满足Java内存模型的规定,实现volatile关键字的功能,需要做到两点:1.volatile变量禁止缓存,任何线程对volatile变量的读,都得保证是最新的值。2.禁止重排序,volatile变量不做任何重排序的操作。

运行结果:

6c2c309b2be28cdcaa23b9eca9ff1d51.png

3.可见性是由什么决定的

可见性是由Java内存模型决定的。内存模型决定了程序在某个点,能够读取到什么值。

4.volatile关键字的底层原理

如果一个字段设置为volatile,Java内存模型保证所有线程看到的值都是一致的。那volatile是如何保证的呢?实际上,volatile修饰的共享变量在进行写操作的时候,汇编指令中会带有lock前缀的指令。lock前缀的指令在多核处理器下,引发两件事情。1.将当前CPU缓存行的数据,全部写回主内存。 2.这个写操作使其他CPU里缓存了该内存地址的数据无效。

因为CPU和内存之间存在速度的差异,为了提高程序运行效率,在CPU和内存之间加了一层缓存(L1、L2或其他),CPU不直接对内存做写操作,而是先写入缓存,再由缓存写入内存,同样的,CPU读内存数据,也是先将内存数据读到CPU缓存里。如果对声明为volatile的变量进行写操作,JVM就会往CPU发送一条带有lock前缀的指令,将这个变量所在缓存行的数据,写入主内存。但是如果其他CPU缓存里的数据还是旧的,那就出现了数据不一致,因此多核处理器一般都会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据,来检查自己CPU缓存数据是否过期,当CPU发现自己缓存行上缓存的数据被修改了,就会把缓存行设置为无效的状态。如果CPU再次操作该内存地址的数据,就会重新从主内存中读到缓存。

再补充几点。

1.lock前缀的指令,引发CPU缓存写入主内存的操作,该指令在执行时,会发出LOCK信号,在多核CPU环境下,LOCK信号导致CPU锁住总线,其他CPU不能访问总线,意味着不能访问共享内存区域,换句话说,发出LOCK信号的CPU独占共享内存,这样的开销太大了,最近的处理器一般不锁总线,而是锁缓存。如果访问的内存区域,已经缓存在CPU内部了,那么就不发出LOCK信号,而是锁定这块内存区域的缓存,并写回主内存。缓存一致性协议保证这个修改操作的原子性,并阻止同时修改由2个以上CPU缓存的内存区域。换句话说,CPU-A缓存了共享变量x,CPU-B也缓存了共享变量x,那么这2个CPU不能同时锁各自的缓存,写回主内存。

2.缓存写回主内存的操作,会让其他CPU的缓存失效。MESI(修改、独占、共享、无效)控制协议负责维护CPU内部缓存,与其他CPU缓存的一致性。一旦内部缓存被设置为无效状态,在下次CPU访问相同的内存地址时,强制执行缓存行填充(CPU读取内存数据到缓存)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值