我们已经花了几个月的时间来稳定Plumbr中的锁定检测功能 。 在此期间,我们遇到了许多棘手的并发问题。 许多问题是独特的,但是一种特殊类型的问题一直反复出现。
您可能已经猜到了–滥用volatile关键字。 我们已经发现并解决了许多问题,其中大量使用volatile使应用程序的任意部分变慢,延长了锁定保持时间,并最终使JVM屈服。 反之亦然-授予过于宽松的访问策略会引发一些令人讨厌的并发问题。
我想每个Java开发人员都会回想起该语言的第一步。 使用手册和教程的日子日复一日。 这些教程都有关键字列表,其中volatile是最可怕的关键字之一。 随着时间的流逝,越来越多的代码不需要使用此关键字,我们很多人都忘记了volatile的存在。 直到生产系统开始以无法预测的方式破坏数据或死亡。 调试这种情况迫使我们中的一些人真正理解了这个概念。 但是我敢打赌,这并不是一个令人愉快的课程,所以也许我可以通过一个简单的例子来阐明一些概念,从而节省一些时间。
挥发作用的例子
该示例模拟了一个银行办公室。 银行办公室的类型,您可以在此从售票机中选择队列编号,然后在您前面的队列得到处理后等待邀请。 为了模拟这样的办公室,我们创建了以下示例,该示例由两个线程组成。
这两个线程中的第一个被实现为CustomerInLine。 这是一个线程,除了等待NEXT_IN_LINE中的值与客户的票证匹配外,什么也不做。 票号被硬编码为#4。 时间到了( NEXT_IN_LINE> = 4),线程将宣布等待结束并结束。 这模拟了一个有一些客户在排队的到达办公室的客户。
排队实现在Queue类中,该类运行一个循环,该循环调用下一个客户,然后通过为每个客户休眠200ms来模拟与该客户的工作。 呼叫下一个客户后,存储在类变量NEXT_IN_LINE中的值将增加1。
public class Volatility {static int NEXT_IN_LINE = 0;public static void main(String[] args) throws Exception {new CustomerInLine().start();new Queue().start();}static class CustomerInLine extends Thread {@Overridepublic void run() {while (true) {if (NEXT_IN_LINE >= 4) {break;}}System.out.format("Great, finally #%d was called, now it is my turn\n",NEXT_IN_LINE);}}static class Queue extends Thread {@Overridepublic void run() {while (NEXT_IN_LINE < 11) {System.out.format("Calling for the customer #%d\n", NEXT_IN_LINE++);try {Thread.sleep(200);} catch (InterruptedException e) {e.printStackTrace();}}}}
}
因此,运行此简单程序时,您可能希望该程序的输出类似于以下内容:
Calling for the customer #1
Calling for the customer #2
Calling for the customer #3
Calling for the customer #4
Great, finally #4 was called, now it is my turn
Calling for the customer #5
Calling for the customer #6
Calling for the customer #7
Calling for the customer #8
Calling for the customer #9
Calling for the customer #10
看来,这个假设是错误的。 取而代之的是,您将看到通过10个客户的列表进行的队列处理,并且不幸的线程模拟了#4客户,从不对其看到邀请发出警报。 发生了什么,为什么客户仍然坐在那里无休止地等待呢?
分析结果
您在这里面临的是将JIT优化应用于代码,该代码将对NEXT_IN_LINE变量的访问进行缓存。 两个线程都有自己的本地副本,并且CustomerInLine线程从不看到Queue实际增加了线程的值。 如果现在您认为这是JVM中的一种可怕的错误,那么您就不完全正确了–允许编译器这样做以避免每次都重新读取该值。 因此,您可以提高性能,但要付出代价-如果其他线程更改状态,则缓存副本的线程将不知道该状态,并使用过时的值进行操作。
对于volatile正是这种情况。 使用此关键字,编译器将被警告特定状态是易失性的,并且每次执行循环时,代码都被强制重新读取该值。 有了这些知识,我们就可以进行简单的修复-只需将NEXT_IN_LINE的声明更改为以下内容,您的客户就不会永远坐在队列中:
static volatile int NEXT_IN_LINE = 0;
对于那些只了解volatile用例而感到满意的人,您将很高兴。 只是要注意附加的成本–当您开始声明所有内容都是易失性时,您正在迫使CPU忽略本地缓存并直接进入主内存,从而减慢了代码的速度并阻塞了内存总线。
引擎盖下易挥发
对于那些希望详细了解该问题的人,请和我在一起。 要查看底层发生了什么,请打开调试以查看JIT从字节码生成的汇编代码。 通过指定以下JVM选项可以实现此目的:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
在启用和禁用volatile的情况下启用这些选项的情况下运行程序,可以为我们提供以下重要见解:
运行不带volatile关键字的代码,表明我们在指令0x00000001085c1c5a上可以比较两个值。 当比较失败时,我们继续从0x00000001085c1c60到0x00000001085c1c66,后者跳回到0x00000001085c1c60,并产生无限循环。
0x00000001085c1c56: mov 0x70(%r10),%r11d0x00000001085c1c5a: cmp $0x4,%r11d0x00000001085c1c5e: jge 0x00000001085c1c68 ; OopMap{off=64};*if_icmplt; - Volatility$CustomerInLine::run@4 (line 14)0x00000001085c1c60: test %eax,-0x1c6ac66(%rip) # 0x0000000106957000;*if_icmplt; - Volatility$CustomerInLine::run@4 (line 14); {poll}0x00000001085c1c66: jmp 0x00000001085c1c60 ;*getstatic NEXT_IN_LINE; - Volatility$CustomerInLine::run@0 (line 14)0x00000001085c1c68: mov $0xffffff86,%esi
使用volatile关键字后,我们可以看到在指令0x000000010a5c1c40上我们将值加载到寄存器中,在0x000000010a5c1c4a上将其与保护值4进行了比较。如果比较失败,我们从0x000000010a5c1c4e跳回到0x000000010a5c1c40,再次为新的值加载值校验。 这样可以确保我们看到NEXT_IN_LINE变量的值已更改 。
0x000000010a5c1c36: data32 nopw 0x0(%rax,%rax,1)0x000000010a5c1c40: mov 0x70(%r10),%r8d ; OopMap{r10=Oop off=68};*if_icmplt; - Volatility$CustomerInLine::run@4 (line 14)0x000000010a5c1c44: test %eax,-0x1c1cc4a(%rip) # 0x00000001089a5000; {poll}0x000000010a5c1c4a: cmp $0x4,%r8d0x000000010a5c1c4e: jl 0x000000010a5c1c40 ;*if_icmplt; - Volatility$CustomerInLine::run@4 (line 14)0x000000010a5c1c50: mov $0x15,%esi
现在,希望这种解释可以使您摆脱几个讨厌的错误。
翻译自: https://www.javacodegeeks.com/2014/08/understanding-volatile-via-example.html