JMM之有序性介绍

一、基本概念

对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。

但为了提升性能,编译器和处理器通常会对指令序列进行重新排序

Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果一致,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序

指令重排序类型

(1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

(2)指令级并行的重排序:现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

(3)内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

image-20230328231244419

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题

指令重排序优缺点

  • 优点:JVM能根据处理器特性(CPU多级缓存系统、多核处理等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度发挥机器性能。
  • 缺点:指令重排序可以保证串行语义一致,但没有义务保证多线程之间的语义一致(即可能产生“脏读”)。

二、代码解读指令重排

JVM会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

1
2
3
4
5
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是

1
2
i = ...;
j = ...;

也可以是

1
2
j = ...;
i = ...;

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下吧

三、指令级并行原理

指令重排序优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回这 5 个阶段。

image-20230328232128544

术语参考:

instruction fetch (IF)

instruction decode (ID)

execute (EX)

memory access (MEM)

register write back (WB)

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,分阶段、分工正是提升效率的关键!

支持流水线的处理器

现代 CPU 支持多级指令流水线,例如支持同时执行取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了 指令地吞吐率。

image-20230328232144396

大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC>1。

四、指令重排序带来的问题

4.1 诡异的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

  • 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
  • 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
  • 情况4:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加结果为 0,再切回线程2 执行 num = 2(*情况4由于指令重排序出现里问题)*

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化

  • 这个现象需要通过大量测试才能复现:借助 java 并发压测工具jcstress(Java Concurrency Stress)
  • jmeter侧重对于接口整体的响应速度等进行测试,而JCStress框架能对某块逻辑代码进行高并发测试,更加侧重JVM,类库等领域的研究

4.2 指令重排的说明

  • 指令重排序操作不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,*由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。*
  • *重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变*。 比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

指令重排序在单线程模式下是一定会保证最终结果的正确性, 但是在多线程环境下,问题就出来了。

五、解决方法

volatile 修饰的变量,可以禁用指令重排

注:对于volatile禁止指令重排的原理见文章:volatile原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest // 标记此类为一个并发测试类
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State // 标记此类是有状态的
public class ConcurrencyTest {
int num = 0;
volatile boolean ready = false;//加上 volatile 防止 修改ready操作 之前的写指令重排
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}

@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}

}

运行结果

1
2
3
4
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.

0 matching test results.

思考:是否可以通过synchronized来解决该问题?

使用synchronized并不能解决有序性问题,但是如果是该变量整个都在synchronized代码块的保护范围内,那么变量就不会被多个线程同时操作,也不用考虑有序性问题!在这种情况下相当于解决了重排序问题!


JMM之有序性介绍
http://example.com/2023/03/28/JMM之有序性介绍/
作者
程序员小魏
发布于
2023年3月28日
许可协议