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

深入理解Java并发中的有序问题及解决方法

时间:2023-03-16 21:46:10 科技观察

问题Java并发总是会遇到各种意想不到的问题,比如下面的代码:intnum=0;布尔就绪=假;//线程1执行这个方法publicvoidactor1(I_Resultr){if(ready){r.r1=num+num;}else{r.r1=1;}}//线程2执行这个方法publicvoidactor2(I_Resultr){num=2;准备好=真;}如果在线程1中发现ready=true,则r1的值等于num+num,否则等于1,然后将结果保存到I_Result对象中,在线程2中先修改num=2,然后设置ready=true,你觉得I_Result中r1的值可能是多少?r1值等于4,大家可以这么想,CPU先执行线程2,然后执行线程1。r1值等于1,这个也很好理解。CPU先执行线程1,再执行线程2。如果我说r1的值有可能等于0,你可能会觉得离谱,不信我们来验证一下。压测验证结果由于出现并发问题的概率比较低,我们可以使用openjdk提供的jcstress框架进行压测,各种可能出现的情况。jcstress:全称TheJavaConcurrencyStresstests,是一个实验工具,也是一套测试工具,用来帮助研究JVM、类库和硬件中并发支持的正确性。详细使用可以参考文章:https://www.cnblogs.com/wwjj4811/p/14310930.html1.生成压测工程mvnarchetype:generate-DinteractiveMode=false-DarchetypeGroupId=org.openjdk.jcstress-DarchetypeArtifactId=jcstress-java-test-archetype-DarchetypeVersion=0.5-DgroupId=com.alvin-DartifactId=juc-order-Dversion=1.0生成的工程代码如下:2.填充测试内容方法actor1是第一个线程的压力测试。将结果保存到I_Result中。方法actor2是压力测试第二个线程的工作类前面的@Outcome注解,用来展示验证结果,尤其是我们感兴趣的id="0"3.运行压力测试项目mvncleaninstalljava-jartarget/jcstress.jar4。检查运行结果。运行结果如下图所示:出现0的结果有4000多个。大多数情况下,结果还是1和4,你还在迷茫吗?其实这是并发执行的一些陷阱,下面我们解释一下原因。原因分析如果r1的值先等于0,那么有可能0+0=0,那么num=0。你可能会想num怎么可以等于0,代码逻辑是先设置num=2,然后修改ready=true,最后达到num+num的逻辑....在并发的世界里,我们不'受限于固有思维,难不成num=2和ready=true的执行顺序变了。如果你考虑一下,它非常接近事实。原因:在JAVA中,当指令没有依赖时,会调整顺序。这种现象称为指令重排序,是JIT编译器在运行时的一些优化。这也是0的根本原因。指令重排不会影响单线程执行的结果,但是在多线程的情况下,就可能有问题了。理解指令重排序前面提到的问题的原因是因为指令重排序。您可能仍然不了解指令重排序是什么以及它的作用。那我就用一个鱼罐头的故事来帮助大家理解。我们可以把worker看作CPU,把fish看作指令。一个工人加工一条鱼需要50分钟。如果按顺序处理一条鱼,那不是更慢吗?没办法优化,不然我喝西北风。我发现每个鱼罐头的加工过程有5个步骤:去鳞清洗10分钟,蒸制沥水10分钟,加汤料10分钟,杀菌10分钟,真空封口10分钟。每一分钟的每一步都使用不同的工具,那么是否可以并行化?如下图所示:我们发现中间的很多步骤是并行完成的,大大提高了效率。但是在并行处理鱼的过程中,会有顺序调整,比如先做第二条鱼的某个步骤,然后再做第一条鱼的步骤。现代CPU支持多级指令流水线。几乎所有的冯·诺依曼型计算机CPU都可以将其工作分为五个阶段:取指令、解码指令、执行指令、访问数据和写回结果。它可以称为五级指令流水线。CPU可以在一个时钟周期内同时运行5条指令的不同阶段(每个线程不同阶段)。从本质上讲,流水线技术并不能缩短单条指令的执行时间,但却变相地提高了指令的吞吐量。当处理器执行重新排序时,它必须考虑指令之间的数据依赖性。单线程环境也有指令重排序。由于依赖关系,最终的执行结果与代码序列的结果是一致的。在多线程环境下,线程是交替执行的,因为编译器优化重排,会在不同阶段从其他线程获取指令,同时执行volatile关键字。那么如何解决上述问题呢?使用volatile关键字。volatile的底层实现原理是内存屏障。内存栅栏(MemoryFence)会在写指令到volatile变量后添加一个写屏障。在读取volatile变量的指令之前,将添加一个读取屏障。内存屏障本质上是一条CPU指令。只是一道栅栏,堵在那里,无法跨越。内存屏障分为写屏障和读屏障。有什么?1.Guaranteedvisibility写屏障保证在屏障之前对共享变量的改变同步到主存。读屏障保证在屏障之后,共享变量的读加载主存中的最新数据2.Guaranteedorderwritebarrier将保证当指令重新排序时,写屏障之前的代码不会在写屏障之后排队.读屏障将确保当指令被重新排序时,读屏障之后的代码不会排在读屏障之前。前面的问题,如果在ready中加上volatile,那么num=2就不能走到后面了,读取也是一样,如上图所示。final底层也是通过内存屏障实现的,这点和volatile是一样的。在final变量的写指令中加入写屏障。也就是在类初始化赋值的时候会加一个writebarrier。为final变量的读指令添加一个读屏障。将最终变量的最新值加载到内存中。综上所述,JAVA并发中的有序问题其实很难理解。本文通过一个例子来验证在并发下会出现有序问题,从而导致意想不到的结果。这样做的主要原因是为了提高性能,指令会重新排序。为了解决这类问题,我们可以使用volatile关键字来修饰变量,这样可以保证顺序和可见性,但是不能保证原子性。如果以后遇到一些成员变量或者静态变量,需要特别注意,需要分析并发下会出现什么问题。