public class VolatileTest {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) {
//do sth
}
System.out.printf("end");
}, "子线程").start();
Thread.sleep(1000);
flag =false;
}
}
如果没有在flag上加上volatile关键字,则主线程改变了flag的值,但是子线程中flag依然为true,一直在循环。只有加上volatile关键字,才保证了线程间的可见性。
根据Java的线程模型,flag在我们的主内存中,每个线程是运行在CPU里面的,每个线程有可能占用不同的CPU,这个CPU去读内存内容的时候,它会把这个值拷贝一份到自己的缓存中。子线程在运行时一直读缓存的值,不会主动再去内存中拿,即便主线程把flag的值写回到主内存了,不好意思,由于你没有通知其他线程,则是不会去主内存中读这个值的。因此它会一直读缓存。加上volatile关键字的意思,谁要是改了这块,马上通知其他线程,要重新读主内存,不能够读自己的缓存。
我们结合CPU和内存结构来具体看下:
假如说X被第一个CPU改为1,那我们怎么通知另外一个CPU,告诉它这个值已经改了,读的时候需要去内存再取。这就出现了缓存一致性协议。其中MESI是IntelCPU的缓存一致性协议。
Modified指这个缓存行被我改过了,Exclusive指这个缓存行被我独占,Shared指这个缓存行大家共享,Invalid指这个缓存行已经失效了,所以MESI协议其实就是这四种状态之间互相通知和转换。一个CPU改了缓存行,只要通知另外一个CPU告诉它这个缓存行失效了,要是重新想用的话,麻烦去主内存那里再读回来。
如果X和Y都加上volatile关键字了,因为它们在同一个缓存行上,当CPU1改了X,马上就会通知CPU2缓存行失效,要重新读内存;同理,当CPU2改了y,也马上通知CPU1缓存行失效,要重新读内存。这就带来了效率的降低。
一般CPU为了提高效率,会对指令进行重排,也就是所谓乱序执行。
结合对象的创建过程来说一下,
第一步new的时候,先在内存中申请一块空间,成员变量m会设置为默认值0(不是8),接着执行invokespecial,就是调用T的构造方法,只有调用构造方法后,才会把m的值设置为8,一般我们把m=0称为半初始化状态,把m=8称为全初始化状态。最后astore_1就把t和new出来的东西建立了连接。
public class SingleTest {
private static volatile SingleTest INSTANCE;
private SingleTest(){
}
public static SingleTest getInstance(){
if (INSTANCE == null) {
synchronized (SingleTest.class) {
//双重检查锁
if (INSTANCE == null) {
INSTANCE = new SingleTest();
}
}
}
return INSTANCE;
}
}
双重检查锁(Double Check Lock) 即DCL单例到底需不需要volatile?
恰好在这个时候发生指令重排,
发生指令重排,先建立了连接,t和半初始化的对象建立了连接
所以要加上volatile!
那么volatile在底层到底怎么禁止指令重排的?
1.内存屏障等系统原语
2.锁总线
内存屏障就是夹在两条指令之间的、不允许两条指令换顺序的这么一个东西。
JVM级别的内存屏障:
LoadLoad屏障指上面一个Load指令,下面一个Load指令,这两个指令不可以换。其他三个同理。
顺便提下happens-before原则(有些地方是不可以进行重排序的)
上面提的都是JVM要求的规范,那么底层怎么实现这种要求?
一种是和特定CPU相关的,有的CPU支持如下指令。
另一种就是万能的,锁总线。