为什么会有重排序?

举个例子

在讲重排序之前,先来看一个例子:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.cyblogs.thread;

import java.util.HashSet;
import java.util.Set;

/**
* Created with leetcode-cn
*
* @Description: 验证重排序代码
* @Author: chenyuan
* @Date: 2021/3/26
* @Time: 15:05
*/
public class VolatileSerialCase {

static int x = 0, y = 0;
static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
// 用set来保存数据,保证不会重复
Set<String> resultSet = new HashSet<String>();

for (int i = 0; i < 10000000; i++) {
x = 0;
y = 0;
a = 0;
b = 0;

Thread one = new Thread(() -> {
a = y;
x = 1;
});


Thread two = new Thread(() -> {
b = x;
y = 1;
});

one.start();
two.start();
one.join();
two.join();
// 等待2个线程都跑完了再把结果添加到Set中去
resultSet.add("a=" + a + ",b=" + b);
System.out.println(resultSet);
}
}
}

上面一段代码是非常经典来讲CPU对指令重排序的案例。因为我们经过一段时间的Run出的结果很惊讶:

1
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]

对于a=1,b=1的出现,是会让人非常的奇怪的。出现这个情况,那代码执行的顺序可能是:

1
2
3
4
5
6
7
8
9
10
Thread one = new Thread(() -> {
a = y; // 第3步
x = 1; // 第1步
});

Thread two = new Thread(() -> {
b = x; // 第4步
y = 1; // 第2步
});
// 也就是说,在2个线程中,都出现了下面的代码执行到了上面的代码前面去了。

如果是这样子的话,那我们还敢写多线程的代码吗?如果没有一定的规范与约定,那肯定是没人可以写好代码。

其实这些约定都是在JSR-133内存模型与线程规范里面,它就像是Java的产品需求文档或者说明书。

http://static.cyblogs.com/Jietu20210327-174611.jpg

百度云盘:链接: https://pan.baidu.com/s/1cO5d95Za8lyz8dMaN0i9lA 密码: l08w ,大家可以去下载查阅,这些都比较底层,并不能几句话,几篇文章可以讲清楚。

为什么会重排序?

看完上面,你可能会有疑问,为什么会有重排序呢?

我的程序按照我自己的逻辑写下来好好的没啥问题, Java 虚拟机为什么动我的程序逻辑?

你想想 CPU 、内存这些都是非常宝贵的资源, Java 虚拟机如果在重排序之后没啥效果,肯定也不会做这种费力不讨好的事情。

Java 虚拟机之所以要进行重排序就是为了提高程序的性能。你写的程序,简简单单一行代码,到底层可能需要使用不同的硬件,比如一个指令需要同时使用 CPU 和打印机设备,但是此时 CPU 的任务完成了,打印机的任务还没完成,这个时候怎么办呢? 不让 CPU 执行接下来的指令吗? CPU 的时间那么宝贵,你不让它工作,确定不是在浪费它的生命?

重排序:重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图所示:

http://static.cyblogs.com/20180326170243607.png

  • 上述的1属于编译器重排序
  • 2和3属于处理器重排序

这些重排序可能会导致多线程程序出现内存可见性问题。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

重排序带来的问题

回到文章刚开始举的那个例子,重排序提高了 CPU 的利用率没错,提高了程序性能没错,但是我的程序得到的结果可能是错误的啊,这是不是就有点儿得不偿失了?

因为重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致

凡是问题,都有办法解决,要是没有,那就再想想。

它是怎么解决的呢? 这就需要来说说,顺序一致性内存模型JMM (Java Memory Model , Java 内存模型)

我们知道Java线程的所有操作都是在工作区进行的,那么工作区和主存之间的变量是怎么进行交互的呢,可以用下面的图来表示。

http://static.cyblogs.com/e0e01e43ly1g186enjfwfj20k80degmr.jpg
Java通过几种原子操作完成工作区内存主存的交互

  1. lock:作用于主存,把变量标识为线程独占状态。
  2. unlock:作用于主存,解除变量的独占状态。
  3. read:作用于主存,把一个变量的值通过主存传输到线程的工作区内存。
  4. load:作用于工作区内存,把read操作传过来的变量值储存到工作区内存的变量副本中。
  5. use:作用于工作内存,把工作区内存的变量副本传给执行引擎。
  6. assign:作用于工作区内存,把从执行引擎传过来的值赋值给工作区内存的变量副本。
  7. store:作用于工作区内存,把工作区内存的变量副本传给主存。
  8. write:作用于主存,把store操作传过来的值赋值给主存变量。

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。as-if-serial语义把单线程程序保护了起来,as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

happens-before

终于讲到了 happens-before ,先来看 happens-before 关系的定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果就会对第二个操作可见
  • 两个操作之间如果存在 happens-before 关系,并不意味着 Java 平台的具体实现就必须按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按照 happens-before 关系来执行的结果一直,那么 JMM 也允许这样的重排序

看到这儿,你是不是觉得,这个怎么和 as-if-serial 语义一样呢。没错, happens-before 关系本质上和 as-if-serial 语义是一回事。

as-if-serial 语义保证的是单线程内重排序之后的执行结果和程序代码本身应该出现的结果是一致的, happens-before 关系保证的是正确同步的多线程程序的执行结果不会被重排序改变。

一句话来总结就是:如果操作 A happens-before 操作 B ,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。

Java 中,对于 happens-before 关系,有以下规定:

  • 程序顺序规则:一个线程中的每一个操作, happens-before 于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁, happens-before 于随后对这个锁的加锁
  • volatile 变量规则:对一个 volatile 域的写, happens-before 与任意后续对这个 volatile 域的读
  • 传递性:如果 A happens-before B , 且 B happens-before C ,那么 A happens-before C
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

参考地址

如果大家喜欢我的文章,可以关注个人订阅号。欢迎随时留言、交流。如果想加入微信群的话一起讨论的话,请加管理员微信号:chengcheng222e,他会拉你们进群。

简栈文化服务订阅号