Java并发编程有序性详解 - Go语言中文社区

Java并发编程有序性详解


提示:以下内容是对《Java多线程编程实战指南》的分析与总结,有截选《实战Java高并发程序设计》。

一.有序性问题

程序在执行过程中,可能会进行指令重排序,重排序后的指令与原指令的顺序未必一致。

二.什么是重排序?

重排序是对内存访问有关操作所做的一种优化,可以在不影响单线程程序正确性的情况下提升程序性能。

这里我们知道,重排序是为了优化程序的执行效率,并且在单线程下能够保证程序的正确执行。

三.什么情况下会发生重排序

先来介绍相关的几个概念知识:

  • 源代码顺序:.java文件中指定的顺序。
  • 程序顺序:.class文件中指定的顺序。
  • 执行顺序:处理器实际执行顺序。

什么时候会发生指令重排序:

  • 程序顺序与源代码顺序不一致(javac编译器将.java文件编译成.class文件,基本不会发生)
  • 执行顺序与程序顺序不一致(JIT编译器将.class编译为机器码、处理器对指令进行的重排序)

由于javac编译器将.java文件编译成.class文件发送的指令重排序基本不会发生,这里就不介绍了。主要介绍下面这两种。

四.编译器指令重排序

通过一个例子来分析指令重排序的过程。

Object obj = null;
obj = new Object();
obj = new Object()这个动作使用伪代码可分解为以下几个操作

1)分配Object实例所需的内存空间,并获取一个指向该空间的引用。
objRef = allocate(Object.class);

2)调用Object类的构造器初始化objRef引用指向的Object实例。
invokeConstructor(objRef);

3)将Object实例引用objRef赋值给实例变量obj。
obj = objRef;

如果JIT编译器按照源代码顺序生成相应的机器码,其逻辑应和上述伪代码逻辑一致。而以JIT编译器的视角来看应当是如下的:
步骤(1)一定是先于步骤(2)(3)之前执行的,步骤(2)和(3)之间没有依赖关系,因此可以对(2)和(3)进行指令重排序,至于谁先谁后就看哪条指令先加载。所以就可能出现步骤(3)先于步骤(2)执行的这种情况。

分析上面重排序后会出现什么问题。
假如一个线程执行了步骤(1),由于重排序导致步骤(3)先执行了,这时另一个线程需要使用到obj变量。比如他执行的代码如下,由于此时obj变量已经不为null(但未完成初始化),因此会调用下面打印函数,最终结果就是报错。

if (obj != null) {
	System.out.println(obj.toString());
}

五.处理器对指令进行的重排序

什么是处理器的乱序执行?

现代处理器为了提高指令执行效率,往往不是按照程序顺序逐一执行指令的,而是动态调整指令的顺序,做到哪条指令就绪就先执行哪条指令,这就是处理器对指令进行的重排序,也称为处理器的乱序执行。

处理器执行程序的过程:

  1. 按程序顺序读取
  2. 乱序执行(对于写操作的结果先存入重排序缓冲器(ROB,Recorder Buffer))
  3. 按程序顺序提交

比如我们要做A、B、C三件事(假设都是写操作),那么即使C先于B做了,那么C也得先到重排序缓冲器里等着,等最终做完了再按A、B、C顺序提交。

正由于执行结果是按程序顺序进行提交,因此处理器的指令重排序并不会对单线程从程序的正确性产生影响。

处理器的乱序执行还采用了一种被称为猜测执行的技术,猜测执行可能导致if语句体先于条件语句执行。
那为什么还使用猜测执行?
这就要使用到上面的重排序缓冲器了,如果if语句体先于条件语句执行,那么先把结果存到重排序缓冲器就行了,如果后面判断为true,就将重排序缓冲器中的值刷新到主存或高速缓存中,否则直接抛弃。

上面介绍了编译器和处理器在执行程序的过程中可能对执行进行重排序,我们也知道了重排序是对程序的一种优化手段,对于什么时候会发生重排序我们是不得而知的,但对什么时候不能进行重排序,确实有规定的。

Happen-Before规则(什么时候不能进行重排序)?

  • 程序顺序原则:一个线程保证语义的串行性。
  • volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性。
  • 锁规则:解锁必然发生在随后的加锁前。
  • 传递性:A先于B,B先于C,则A必然先于C。
  • 线程的start()先于它的每个动作。
  • 线程的所有操作先于线程的终结。
  • 线程的中断先于中断线程的代码。
  • 对象的构造函数执行、结束先于finalize()方法。

以上内容引用自《实战Java高并发程序设计》

总的来说就是要保证语义正确、没有锁和volatile、没有数据依赖关系、没有函数的依赖关系(原谅我这种不规范的称呼 )

  • 语义正确:生();老();死();不能是生();死();老();(这里将程序顺序原则和传递性包含进来了)
  • 没有锁和volatile:锁和volatile能够保证有序性这个就不讲了。
  • 没有数据依赖:两个操作访问同一个变量,其中一个操作为写操作,那么这两个操作之间就存在数据依赖。
  • 函数依赖(这是个人称呼,因为都和函数有关):一个函数的执行必须先/后于某个方法/某段代码执行 。(上面加粗的后四条)

注意:这里提一下控制依赖关系,一条语句的执行结果决定另一条语句能否被执行,那么这两条指令之间就存在控制依赖关系。存在控制依赖关系的语句是可以允许被重排序的。

六.扩展(存储子系统重排序)

上面介绍了编译器和处理器的指令重排序,其实还有第三种影响程序有序性的因素:存储子系统重排序。

从上面copy,希望大家还有映像:

  • 程序顺序与源代码顺序不一致(javac编译器将.java文件编译成.class文件,基本不会发生)
  • 执行顺序与程序顺序不一致(JIT编译器将.class编译为机器码、处理器对指令进行的重排序)
  • 源代码顺序、程序顺序和执行顺序保持一致,但感知顺序和执行顺序不一致。

感知顺序?什么是感知顺序,感知顺序即——处理器所看到的该处理器和其他处理器的内存访问操作发生的顺序。

这里可能并没有弄懂是什么意思,先来介绍什么是存储子系统。

我们都知道直接从主内存获取数据是相对较慢的,因此我们通常都是通过高速缓存访问主内存。现代处理器还引入了写缓冲器以提高写高速缓存操作的效率。(写缓冲器和高速缓存就可以被称为存储子系统)。

一个处理器执行了写操作A、B,但由于某些处理器的写缓冲器为了提高将内容写入高速缓存的效率而不保证写操作结果先进先出,这就导致了存储子系统的重排序。也就是我这边执行了A、B,别人那可能先看到了B!

----------------------------------------------------------------分割线----------------------------------------------------------------------

从处理器角度来说,读内存操作的实质是从指定的RAM地址加载数据到寄存器,因此读内存也称为Load。
写内存操作的实质是将数据存储到指定地址表示的RAM存储单元中,因此写内存也称为Store。

4种内存重排序:

  • LoadLoad重排序:一个处理器先后执行两个Load操作A、B,其他处理器对这两个内存操作的感知顺序可能是B、A。
  • StoreStore重排序:一个处理器先后执行两个Store操作A、B,其他处理器对这两个内存操作的感知顺序可能是B、A。
  • LoadStore重排序:一个处理器先后执行Load操作A和Store操作B,其他处理器对着两个内存操作的感知顺序可能是B、A。
  • StoreLoad重排序:一个处理器先后执行Store操作A和Load操作B,其他处理器对着两个内存操作的感知顺序可能是B、A。

在这里插入图片描述

上图引用自《Java多线程编程实战指南》

存储子系统重排序是一种现象而不是一种动作,并没有像处理器重排序以及编译器重排序一样对指令执行顺序进行调整,其重排序的对象是内存操作的结果。

以上内容如有错误,欢迎纠正!

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/dh554112075/article/details/101621597
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢