❤️volatile的变量可见性问题探讨❤️
作者:互联网
文章目录
对于未被volatile修饰的变量的可见性可以用一句玄乎的话表达:不加volatile是不一定保证但不代表一定不可见
申明本文演示的现象仅仅是当前这些代码的语境和HotSpot现有的JIT编译器实现产生的,不能确保在其他JVM与所有CPU架构上都一定相同(肯定是不一样的)。
变量的可见性
不加volatile
对于这种情况有一种说法:
每个CPU对共享的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还只是存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值,这就是其中一种可见性问题。
我也认同这种说法,是由于CPU0修改本地变量的值没有写回主内存,导致CPU1从主内存读取的 还是修改前的值。
那么接下来我们验证一下吧。
代码1:
@Slf4j(topic = "d.VolatileForeverLoop")
public class VolatileForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
log.debug("stop to true");
},"t1").start();
foo();
}
private static void foo() {
int i = 0;
while (!stop) {
i++;
}
log.debug("stop 循环次数{}", i);
}
}
运行结果
运行无法停止
主线程 从主内存中读取stop为false到本地内存 ,线程t1 将stop改为true , 执行while()循环,主线程无法感知到stop已经变为true的
这是貌似是符合之前的那种说法:如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还只是存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值
代码2:
这里再加入一个线程t2
@Slf4j(topic = "d.VolatileForeverLoop")
public class VolatileForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
log.debug("stop to true");
}, "t1").start();
new Thread(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("{}}", stop);
}, "t2").start();
foo();
}
private static void foo() {
int i = 0;
while (!stop) {
i++;
}
log.debug("stop 循环次数{}", i);
}
}
运行结果
可以看到t2线程是能够读到t1将stop改为true的,说明t1已经将修改后的值写回主内存了。
如果再加一个t3也一样能够读到,但是为什么只有主线程读不到stop为true?
这时候我的脑袋已经懵了,怎么和刚才的说法不一样了。
原因:JIT在搞鬼
CPU-0读取物理内存的stop值为false,那么线程1的while条件满足进入下一次循环,一直读取false一直循环,这样while循环读取的次数是非常多的,
正常编译字节码使用的是解释器,
当循环到达一定次数 ,此时JIT编译器对于热点的代码(频繁调用,反复执行),JIT直接把这个循环编译成了while(!false),
导致线程2给stop赋值改成true,即使写回主内存,线程1也无法感知到stop的变化
在jdk1.8之后,引入了分层编译的策略,在运行初期开启C1编译器编译,随时间的推移执行频率高的代码会再次被C2编译器编译:
- 第0层:程序解释执行,解释器不开启性能监控功能,
- 第1层,C1编译,将字节码编译为本地代码,进行简单、可靠的优化,有必要的话加入性能监控的逻辑
- 第2层,C2编译,将字节码编译为本地代码,会启用一些编译耗时长的优化,甚至激进优化。
证明
VM配置参数-Xint
表示只用解释器进行编译,不用JIT优化
还是之前的代码
@Slf4j(topic = "d.VolatileForeverLoop")
public class VolatileForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
log.debug("stop to true");
}, "t1").start();
foo();
}
private static void foo() {
int i = 0;
while (!stop) {
i++;
}
log.debug("stop 循环次数{}", i);
}
}
运行结果如下,
20:41:23 [main] d.VolatileForeverLoop - stop 循环次数9351508
20:41:23 [t1] d.VolatileForeverLoop - stop to true
此时主线程能够读到stop = true ,100millis循环了900W次。此时已经证明是JIT从中作梗了。
此时VM配置参数-Xint
禁用JIT这是一种暂时来看可以解决变量不可见的情况。
当然还有一种情况,当t1线程执行的时间非常短,没有达到成为热点代码的次数,JIT就不会对代码进行优化
将VM配置去除,将t1睡眠时间改为1millis
@Slf4j(topic = "d.VolatileForeverLoop")
public class VolatileForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
log.debug("stop to true");
}, "t1").start();
foo();
}
private static void foo() {
int i = 0;
while (!stop) {
i++;
}
log.debug("stop 循环次数{}", i);
}
}
运行结果
20:42:35 [t1] d.VolatileForeverLoop - stop to true
20:42:35 [main] d.VolatileForeverLoop - stop 循环次数194721
即使有JIT的存在,主线程也感知到了t1线程将stop改为了true
JIT优化的对象和触发条件
JIT编译的热点代码有两类
- 多次调用的方法
- 多次执行的循环体
那么这个多次是多少次呢?这就需要进行热点探测。目前主要的热点探测方式有以下两种:
(1)基于采样的热点探测
采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
(2)基于计数器的热点探测
采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。
HotSpot采用基于计数器的热点探测方法,他为每个方法准备了方法调用计数器和回边计数器:
方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法调用次数仍然不足以让它提交给即时编译器,那这个方法的调用计数器就会衰减一般,这个过程称为方法调用计数器热度的衰减。
回边计数器统计的是一个方法中循环体代码执行的次数,它没有热度衰减。建立回边计数器统计的目的是为了触发OSR编译,OSR即栈上替换,也就是编译发生在方法执行过程之中。
OSR编译:某段循环执行的代码还在不停的循环中,如果在某次循环之后,计数器达到了某一阈值,这时JVM已经认定这段代码是热点代码,此时编译器会将这段代码编译成机器语言并缓存后,但是这段循环仍在执行,JVM就会把执行代码替换掉,那么等循环到下一次时,就会直接执行缓存的编译代码,而不需要必须等到循环结束才进行替换,这个就是所谓的栈上替换
此次代码属于多次执行的循环体,那么HotSpot应该使用回边计数器统计:
某段循环执行的代码还在不停的循环中,如果在某次循环之后,计数器达到了某一阈值,这时JVM已经认定这段代码是热点代码,此时编译器会将这段代码编译成机器语言并缓存后,但是这段循环仍在执行,JVM就会把执行代码替换掉,那么等循环到下一次时,就会直接执行缓存的编译代码,而不需要必须等到循环结束才进行替换,这个就是所谓的栈上替换
显然这里并没有提及真正的阈值是多少,我经过不严谨测试(t1睡眠1millis多次执行),发现主线程有时(大多数情况)能够感知到stop的变化-即没有JIT的激进优化,有时无法感知stop的变化-即JIT进行了激进优化。 目前对于能够打印输出i的最高值是40W
在《Java性能权威指南》一书中找到对OSR触发阈值的公式
总结
在不启用分层编译的情况下,触发 OSR 编译的阈值是由参数 -XX:CompileThreshold 指定的阈值的倍数。
该倍数的计算方法为:
CompileThreshold*((OnStackReplacePercentage - InterpreterProfilePercentage)/100)
其中 -XX:InterpreterProfilePercentage 的默认值为 33,当使用 C1 时 -XX:OnStackReplacePercentage 为 933,当使用 C2 时为 140。
也就是说,默认情况下,C1 的 OSR 编译的阈值为 13500,而 C2 的为 10700。
在启用分层编译的情况下,触发 OSR 编译的阈值则是由参数 -XX:TierXBackEdgeThreshold 指定的阈值乘以系数。
TierXBackEdgeThreshold*((OnStackReplacePercentage - InterpreterProfilePercentage)/100)
上述数据来源参考
经过JVM参数查询
intx BackEdgeThreshold = 100000 intx Tier2BackEdgeThreshold = 0
intx Tier3BackEdgeThreshold = 60000
intx Tier4BackEdgeThreshold = 40000
好了进入计算吧:
在启用分层编译的情况下,TierXBackEdgeThreshold=2000
((OnStackReplacePercentage - InterpreterProfilePercentage)/100) = 1.07
结果为214,000
到了这里数目相差一倍,根据上面提及当启用分层编译时,JVM默认开启多个client和server线程,对于8核CPU对应着两个server
那么这个数目应该是差不多了
到此了解到
第一 设置VM参数-Xint:只是用解释器,禁用JIT编译器
第二循环体执行的次数不要达到阈值(即修改变量的代码执行的时间要足够短)
这两种情况都可以使主线程感知到stop变量的变化
首先执行时间是不可控的,
其次禁用JIT编译器会导致整体性能变低,JIT编译器能够提升数十倍的编译效率,不能因为一个变量而去降低整体的性能。
所以hotspot的最终的解决方案还是使用volatile (声明这两条原因无依据)
加volatile
@Slf4j(topic = "d.VolatileForeverLoop")
public class VolatileForeverLoop {
static volatile boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
log.debug("stop to true");
}, "t1").start();
foo();
}
private static void foo() {
int i = 0;
while (!stop) {
i++;
}
log.debug("stop 循环次数{}", i);
}
}
运行结果
22:04:55 [t1] d.VolatileForeverLoop - stop to true
22:04:55 [main] d.VolatileForeverLoop - stop 循环次数155742943
我想这就不用证明volatile的特性了吧。
其他情况
当i为全局的volatile变量,而stop为非volatile变量的情况:
@Slf4j(topic = "d.VolatileForeverLoop2")
public class VolatileForeverLoop2 {
static boolean stop = false;
public static volatile int i = 0;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
log.debug("stop to true");
}, "t1").start();
foo();
}
private static void foo() {
// int i = 0;
while (!stop) {
i++;
}
log.debug("stop {} 循环次数{}",stop, i);
}
}
运行结果
22:12:20 [t1] d.VolatileForeverLoop2 - stop to true
22:12:20 [main] d.VolatileForeverLoop2 - stop true 循环次数2849970
显然此时JIT又被禁止了,那么明明stop变量没有声明volatile,是何种原因让JIT放弃对stop变量下手的呢?
原来循环中有对static volatile类型变量i的访问,JIT编译器认为不能使用前面那种激进的优化策略,于是while中判断stop的逻辑被保留了下来。
这里应该是JIT认为变量i与变量stop是存在关联了,所以才不对stop进行激进优化,因为将i移除while循环外、方法内的任意位置,JIT都会对stop进行激进优化。
volatile变量的读写保证可见性
对于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了8种操作来完成主内存与工作内存的读写交互,虚拟机实现保证每一种操作都是原子的,不可再分的
read(读取)→load(加载)→use(使用)→assign(赋值)→store(存储)→write(写入)→lock(锁定)→unlock(解锁)
read: 作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存
write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:
lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
volatile主要是对其中部分指令做了处理
**要求要use(使用)一个变量的时候必需load(载入),要载入的时候必需从主内存read(读取)**这样就解决了读的可见性。
写操作是把assign和store做了关联(在assign(赋值)后必需store(存储))。store(存储)后write(写入)。
也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存。
就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性
Java内存模型这2个操作必须顺序执行,但不保证连续执行,即在指令之间可以插入其它指令
但是Java内存模型规定了一些必要的规则
-
不允许read 、load 、 store、 write单独出现,即不允许一个变量读取到工作内存,但没有变量接收的情况
-
不允许一个线程丢弃它的assign操作,即变量在工作内存改变必须同步回主内存
-
不允许一个线程无原因(没有发生assgin赋值操作)把数据从线程的工作内存同步会主内存
-
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用未被初始化的变量
-
一个变量同一时刻只允许一条线程对其进行Lock锁定,但Lock操作可以被同一线程重复执行
-
如果对一个变量执行Lock锁定,会清空工作内存中该副本的值,即执行引擎使用该值会重新load assgin操作初始化该值
-
如果一个变量事先没有被Lock锁定,那就不允许进行Unlock操作,也不允许Unlock其它线程锁定的变量
-
对一个变量执行Unlock操作,必须先把此变量值同步回主内存(store write操作)
总结
以上都是针对当前这些代码的语境和HotSpot现有的JIT编译器实现的,不适用在其他JVM与CPU架构上。
首先对刚开始的说法:每个CPU对共享的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还只是存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值,这就是其中一种可见性问题。
这种说法并没有错误,这的确是可见性问题的其中一种,因为不同JVM,不同CPU架构的实现是不一样的,我们不能以偏概全。
所以最后还是那一句话:加了volatile是肯定保证内存的可见性和禁止指令重排的,不加是不一定保证但不代表一定不可见
如果错误,请指正,我会立刻改正的。
参考
内存可见性,指令重排序,JIT。。。。。。从一个知乎问题谈起
标签:变量,探讨,stop,编译,JIT,volatile,内存,true 来源: https://blog.csdn.net/m0_37450089/article/details/120573409