深刻理解Java并发中的有序性问题和解决之道

开发 前端
JAVA并发中的有序性问题其实比较难理解,本文通过一个例子验证了并发情况下会出现有序性的问题,从而引发意想不到的结果。这个主要的原因是为了提高性能,指令会发生重排序导致的。为了解决这样的问题,我们可以使用volatile这个关键字修饰变量,它能够保证有序性和可见性,但是无法保证原子性。

​问题

Java并发情况下总是会遇到各种意向不到的问题,比如下面的代码:

int num = 0;

boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
  • 线程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:全名The Java Concurrency Stress tests,是一个实验工具和一套测试工具,用于帮助研究JVM、类库和硬件中并发支持的正确性。详细使用可以参考文章:https://www.cnblogs.com/wwjj4811/p/14310930.html

1.生成压测工程


mvn archetype: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.运行压测工程

mvn clean install java -jar target/jcstress.jar

4.查看运行结果

运行结果如下图所示:

图片

  • 有4000多次出现了0的结果
  • 大部分情况的结果还是1和4

你是不是还是很困惑,其实这就是并发执行的一些坑,我们下面来解释下原因。

原因分析

如果先要出现r1的值等于0​,那么有一个可能0+0=0​,那么也就是num=0。

你可能想num怎么可能等于0,代码逻辑明明是先设置num=2​,然后才修改ready=true​, 最后才会走到num+num 的逻辑啊....

在并发的世界里,我们千万不要被固有的思维限制了,那是不是有可能num=2和ready=true的执行顺序发生了变化呢。如果你想到这里,也基本接近真相了。

原因: JAVA中在指令不存在依赖的情况下,会进行顺序的调整,这种现象叫做指令重排序,是 JIT 编译器在运行时的一些优化。这也是为什么出现0的根本原因。

指令重排不会影响单线程执行的结果,但是在多线程的情况下,会有个可能出现问题。

理解指令重排序

前面提到出现问题的原因是因为指令重排序,你可能还是不大理解指令重排序究竟是什么,以及它的作用,那我这边用一个鱼罐头的故事带大家理解下。

我们可以把工人当做CPU,鱼当做指令,工人加工一条鱼需要 50 分钟,如果一条鱼、一条鱼顺序加工,这样是不是比较慢?

图片

没办法得优化下,不然要喝西北风了,发现每个鱼罐头的加工流程有 5 个步骤:

  • 去鳞清洗 10分钟
  • 蒸煮沥水 10分钟
  • 加注汤料 10分钟
  • 杀菌出锅 10分钟
  • 真空封罐 10分钟

每个步骤中也是用到不同的工具,那能否可以并行呢?如下图所示:

图片

我们发现中间用很多步骤是并行做的,大大的提高了效率。但是在并行加工鱼的过程中,就会出现顺序的调整,比如先做第二条的鱼的某个步骤,然后在做第一条鱼的步骤。

现代 CPU 支持多级指令流水线,几乎所有的冯•诺伊曼型计算机的 CPU,其工作都可以分为 5 个阶段:取指令、指令译码、执行指令、访存取数和结果写回,可以称之为五级指令流水线。CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(每个线程不同的阶段),本质上流水线技术并不能缩短单条指令的执行时间,但变相地提高了指令地吞吐率。

图片

处理器在进行重排序时,必须要考虑指令之间的数据依赖性

单线程环境也存在指令重排,由于存在依赖性,最终执行结果和代码顺序的结果一致

多线程环境中线程交替执行,由于编译器优化重排,会获取其他线程处在不同阶段的指令同时执行

volatile关键字

那么对于上面的问题,如何解决呢?

使用volatile关键字。

图片

volatile​ 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对volatile 变量的写指令后会加入写屏障
  • 对volatile 变量的读指令前会加入读屏障

内存屏障本质上是一个CPU指令,形象点理解就是一个栅栏,拦在那里,无法跨越。

内存屏障分为写屏障和读屏障,有什么有呢?

1.保证可见性

  • 写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

2.保证有序性

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

图片

回到前面的问题,如果对ready​加了volatile以后,那么num=2就无法到后面去了,同样读取也是,如上图所示。

final底层也是通过内存屏障实现的,它与volatile一样。

  • 对final变量的写指令加入写屏障。也就是类初始化的赋值的时候会加上写屏障。
  • 对final变量的读指令加入读屏障。加载内存中final变量的最新值。

总结

JAVA并发中的有序性问题其实比较难理解,本文通过一个例子验证了并发情况下会出现有序性的问题,从而引发意想不到的结果。这个主要的原因是为了提高性能,指令会发生重排序导致的。为了解决这样的问题,我们可以使用volatile这个关键字修饰变量,它能够保证有序性和可见性,但是无法保证原子性。如果以后遇到一些成员变量或者静态变量就要特别注意了,需要分析并发情况下会有哪些问题。

责任编辑:武晓燕 来源: JAVA旭阳
相关推荐

2024-02-27 17:46:25

并发程序CPU

2017-01-13 08:52:46

HDFS机制Then

2024-06-24 08:31:42

2016-11-10 18:57:19

双十一高并发

2012-12-31 14:59:58

Android开发Layout_weig

2024-05-21 08:44:43

MySQLB+Tree内存

2011-04-18 19:36:10

HSRP协议

2011-05-18 09:47:39

spring

2009-10-10 15:26:11

资产管理

2022-01-14 08:08:11

Java依赖冲突

2011-03-14 13:11:07

Oracle数据库

2009-04-08 10:31:01

2016-11-03 08:57:02

javascriptjquerynode.js

2018-06-08 10:45:18

云计算成本评估应用程序

2019-11-27 10:28:11

公共安全大数据数据联系

2016-12-22 09:02:35

Linux误删文件

2020-03-04 08:25:18

有序性并发结构

2021-03-28 21:33:07

Redis热点key

2020-09-20 22:14:14

编程PythonJava

2015-07-16 23:10:19

dynatrace应用性能管理
点赞
收藏

51CTO技术栈公众号