欢迎重视专栏【JAVA并发】

问题

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值或许是多少呢?

  1. r1值等于4, 这个咱们都能想到, CPU先履行了线程2,然后履行线程1
  2. r1值等于1,这个也简单了解,CPU先履行了线程1,然后履行线程2
  3. 那我假如说r1值有或许等于0,咱们或许觉得离谱,不信的话,咱们验证下。

压测验证成果

因为并发问题呈现的概率比较低,咱们可以运用openjdk提供的jcstress结构进行压测,就可以呈现各种或许的状况。

jcstress:全名The Java Concurrency Stress tests,是一个实验东西和一套测验东西,用于协助研究JVM、类库和硬件中并发支撑的正确性。详细运用可以参阅文章:www.cnblogs.com/wwjj4811/p/…

  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

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

生成的工程代码如下图:

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

  1. 填充测验内容

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

  • 办法actor1是压测榜首个线程干的活,将成果保存到I_Result中。
  • 办法actor2是压测第二个线程干的活
  • 类前面的@Outcome注解用来展示验证成果,特别是id="0"这个是咱们感兴趣的成果
  1. 运转压测工程
mvn clean install
java -jar target/jcstress.jar
  1. 查看运转成果

运转成果如下图所示:

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

  • 有4000屡次呈现了0的成果
  • 大部分状况的成果仍是1和4

你是不是仍是很困惑,其实这便是并发履行的一些坑,咱们下面来解说下原因。

原因剖析

假如先要呈现r1的值等于0,那么有一个或许0+0=0,那么也便是num=0

你或许想num怎么或许等于0,代码逻辑明明是先设置num=2,然后才修正ready=true, 终究才会走到num+num 的逻辑啊….

在并发的世界里,咱们千万不要被固有的思维约束了,那是不是有或许num=2ready=true的履行次序发生了变化呢。假如你想到这里,也根本挨近真相了。

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

指令重排不会影响单线程履行的成果,但是在多线程的状况下,会有个或许呈现问题。

了解指令重排序

前面说到呈现问题的原因是因为指令重排序,你或许仍是不大了解指令重排序终究是什么,以及它的作用,那我这边用一个鱼罐头的故事带咱们了解下。

咱们可以把工人作为CPU,鱼作为指令,工人加工一条鱼需要 50 分钟,假如一条鱼、一条鱼次序加工,这样是不是比较慢?

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

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

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

每个过程中也是用到不同的东西,那能否可以并行呢?如下图所示:

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

咱们发现中心用很多过程是并行做的,大大的提高了效率。但是在并行加工鱼的过程中,就会呈现次序的调整,比如先做第二条的鱼的某个过程,然后在做榜首条鱼的过程。

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

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

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

  • 单线程环境也存在指令重排,因为存在依赖性,终究履行成果和代码次序的成果一致
  • 多线程环境中线程交替履行,因为编译器优化重排,会获取其他线程处在不同阶段的指令一起履行

volatile关键字

那么关于上面的问题,怎么处理呢?

运用volatile关键字。

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

volatile 的底层完成原理是内存屏障,Memory Barrier(Memory Fence)

  • volatile 变量的写指令后会参与写屏障
  • volatile 变量的读指令前会参与读屏障

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

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

  1. 保证可见性
  • 写屏障保证在该屏障之前的,对同享变量的改动,都同步到主存当中
  • 读屏障保证在该屏障之后,对同享变量的读取,加载的是主存中最新数据
  1. 保证有序性
  • 写屏障会保证指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会保证指令重排序时,不会将读屏障之后的代码排在读屏障之前

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

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

final底层也是经过内存屏障完成的,它与volatile相同。

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

总结

JAVA并发中的有序性问题其实比较难了解,本文经过一个比如验证了并发状况下会呈现有序性的问题,然后引发意想不到的成果。这个首要的原因是为了提高功能,指令会发生重排序导致的。为了处理这样的问题,咱们可以运用volatile这个关键字润饰变量,它可以保证有序性和可见性,但是无法保证原子性。假如今后遇到一些成员变量或者静态变量就要特别注意了,需要剖析并发状况下会有哪些问题。

假如本文对你有协助的话,请留下一个赞吧

本文正在参与「金石方案 . 分割6万现金大奖」