当前位置: 首页 > 科技观察

Volatile-JVM我警告你,我的人,不要乱动

时间:2023-03-16 10:33:39 科技观察

Volatile是面试中的高频问题。我们都知道Volatile有两个作用:禁止指令重排序和保证内存可见的指令重排序。指令重排序问题基本上是通过DCL问题来研究的。DCL,DoubleCheckLook面试通常有以下场景:面试官:你用过单例吗?你:用过。面试官:如何实现线程安全的懒惰单例你:DCL。面试官:DCL能保证线程绝对安全吗?你:添加挥发性。面试官满意地点点头。通常,面试中的这个问题到这里就结束了。但这个问题还有待探讨。我们继续沿着单例代码向下挖掘:publicclassSingleton{privatestaticvolatileSingletoninstance=null;privateSingleton(){}publicstaticSingletongetInstance(){if(instance==null){synchronized(Singleton.class){if(instance==null){instance=newSingleton();}}}返回实例;}}如果不加Volatile,会有什么问题?问题出现在下面这行代码中:instance=newSingleton();上面这行代码看起来很不起眼,就是一个赋值操作,我们还能做什么呢?我们只写了一行代码,但JVM需要做几步。那么JVM到底做了什么?也许也许也许几乎把大象放在冰箱里了。Java代码中的一条赋值语句,在JVM指令层面大致可以分为三步:分配一块内存空间,初始化并返回内存地址,我们通过字节码找出来,为了简化问题,我们将其替换使用以下代码:Objecto=newObject();编译后,通过javap-v命令,或者IDEA中的JClassLib插件,可以看到如下图内容:通过上面的字节码信息,可以更清楚的看到上面提到的三个步骤:newis用于分配一块内存空间,invokspecial调用Object的init()方法,初始化astore_1指向o指向Object实例对象的内存地址。完成赋值后,dup指令会做一些入栈操作,和大家讨论的问题关系不大,这里可以忽略。到这里,问题就更清楚了。重排序问题在第2步和第3步会出现,因为是先初始化或者对象的内存地址先赋值给o,所以没有必然的约束关系。因此,在某些情况下,此类指令会重新排序。在单线程下,这种重新排序是完全没问题的。但是在多线程场景下,可能会出现问题:线程A进入instance=newSingleton();后,由于指令重排,地址在init之前给了o。这时B线程来了,发现实例不为null,就直接使用了。不过这个时候实例还没有初始化,只是个半成品。所以,当B拿到实例去操作的时候,就会出现问题。所以instance需要用volatile修饰,禁止指令重排。说到这里,你可能想说,我用的是单例,没有加volatile,这么久都没遇到你说的reordering问题。你如何证明“重新排序”的存在?问得好,我们用一个小例子来验证一下重排序是否真的存在。privatestaticintx=0;privatestaticinty=0;privatestaticinta=0;privatestaticintb=0;publicstaticvoidmain(String[]args)throwsInterruptedException{inti=0;while(true){i++;x=0;y=0;一=0;b=0;线程一=newThread(()->{a=1;x=b;});线程二=newThread(()->{b=1;y=a;});一个.开始();二.开始();一个。加入();二.加入();if(x==0&&y==0){log.info("第{}次,x={},y={}",i,x,y);休息;}}}代码很简单,就是几个赋值操作,但是很巧妙。x、y、a、b初始都是0,两个线程分别给a、x、b、y赋值。线程一先设置a=1,然后x=b;两个线程先设置b=1,然后让y=a。如果没有重新排序,那么上面的程序只会有以下六种可能:每一列,从上到下,代表代码执行的顺序。也就是说,不重新排序,x和y不可能同时为0。而如果x和y同时为0,那么一定出现了以下六种情况之一,即发生了重排。每列从上到下代表代码执行的顺序。运行程序后,经过漫长的等待,我们得到了如下输出:可以看到,经过超过50万次的执行,我们终于抓到了一个重新排序。出现这种情况的几率很低,所以即使不使用volatile,大概率也不会出问题,但是我们以后还是要合理的使用volatile。内存可见性说完指令重排,我们再来说说内存可见性。这次直接上代码:privatestaticbooleanflag=true;privatestaticvoidjustRun(){System.out.println("线程一开始");while(flag){}System.out.println("ThreadOneEnd");}publicstaticvoidmain(String[]args)throwsInterruptedException{newThread(()->justRun(),"ThreadOne").开始();TimeUnit.SECONDS.sleep(1);flag=false;}代码很简单,在主线程中启动一个子线程,在子线程中进行一个while循环,当flag为false时,循环结束。标志的初始值为真,一秒钟后,它被主线程设置为假。按照上面的逻辑,子线程应该在程序启动后一秒停止。但是,当你运行程序的时候,你会发现这个程序就像是在吃玄麦,根本停不下来。这说明主线程对flag的修改是没有被子线程感知到的。让我们修改程序:privatestaticvolatilebooleanflag=true;给flag加上volatile修饰符,再次运行,你会发现运行后很快(大约一秒左右)程序就停止了。为什么?轩迈的药没了?哈哈,当然不是。为了获得更好的性能,线程有自己的缓存(CPU中的缓存),我们称之为工作内存或本地内存。还有一个共同的记忆,姑且称之为主从。它们的结构大致如下图所示:在主存中定义了一个标志变量,当每个线程读取它时,在线程本地缓存一份以提高性能。读取时,也是先读取本地副本的值。当flag被volatile修改后,每修改一次,其他线程中的副本就会失效,所以必须从主存中读取最新的值。因此,使用volatile后,子线程可以立即感知到flag的变化而停止。上图简化了线程(CPU)的缓存结构,其完整结构如下图所示:现代CPU有三级缓存,即:L1、L2和L3。CPU中的每个内核都有自己的L1和L2,而CPU中的多个内核共享L3。总结Volatile意味着多变、动荡、反复无常。volatile的作用就是告诉JVM,我修改的这个变量是变化无常的。你得替我留意一下。如果有什么麻烦,你必须立即通知大家;另外,不要巧妙地调整它的位置(reorderingforperformance),说翻脸就是师傅翻脸。最后留个小问题:在内存可见性程序中,即使flag没有被volatile修改,线程最多也不会第一次读到flag的修改,但是应该是读不到的每时每刻。为什么?这太违反直觉了!