写点什么

并发高?可能是编译优化引发有序性问题

  • 2021 年 11 月 22 日
  • 本文字数:2323 字

    阅读完需:约 8 分钟

​​摘要:CPU 为了对程序进行优化,会对程序的指令进行重排序,此时程序的执行顺序和代码的编写顺序不一定一致,这就可能会引起有序性问题。

 

本文分享自华为云社区《【高并发】解密导致并发问题的第三个幕后黑手——有序性问题》,作者:冰 河 。 

有序性


有序性是指:按照代码的既定顺序执行。


说的通俗一点,就是代码会按照指定的顺序执行,例如,按照程序编写的顺序执行,先执行第一行代码,再执行第二行代码,然后是第三行代码,以此类推。如下图所示。



指令重排序


编译器或者解释器为了优化程序的执行性能,有时会改变程序的执行顺序。但是,编译器或者解释器对程序的执行顺序进行修改,可能会导致意想不到的问题!


在单线程下,指令重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在问题。


如果发生了指令重排序,则程序可能先执行第一行代码,再执行第三行代码,然后执行第二行代码,如下所示。



例如下面的三行代码。


int x = 1; int y = 2;int z = x + y;
复制代码


​CPU 发生指令重排序时,能够保证 x=1 和 y = 2 这两行代码在 z = x+ y 这行代码的上面,而 x = 1 和 y = 2 的顺序就不一定了。在单线程下不会出现问题,但是在多线程下就不一定了。

有序性问题


CPU 为了对程序进行优化,会对程序的指令进行重排序,此时程序的执行顺序和代码的编写顺序不一定一致,这就可能会引起有序性问题。


在 Java 程序中,一个经典的案例就是使用双重检查机制来创建单例对象。例如,在下面的代码中,在 getInstance()方法中获取对象实例时,首先判断 instance 对象是否为空,如果为空,则锁定当前类的 class 对象,并再次检查 instance 是否为空,如果 instance 对象仍然为空,则为 instance 对象创建一个实例。


package io.binghe.concurrent.lab01;
/** * @author binghe * @version 1.0.0 * @description 测试单例 */public class SingleInstance {
private static SingleInstance instance;
public static SingleInstance getInstance(){ if(instance == null){ synchronized (SingleInstance.class){ if(instance == null){ instance = new SingleInstance(); } } } return instance; }}
复制代码


​如果编译器或者解释器不会对上面的程序进行优化,整个代码的执行过程如下所示。



注意:为了让大家更加明确流程图的执行顺序,我在上图中标注了数字,以明确线程 A 和线程 B 执行的顺序。


假设此时有线程 A 和线程 B 两个线程同时调用 getInstance()方法来获取对象实例,两个线程会同时发现 instance 对象为空,此时会同时对 SingleInstance.class 加锁,而 JVM 会保证只有一个线程获取到锁,这里我们假设是线程 A 获取到锁。则线程 B 由于未获取到锁而进行等待。接下来,线程 A 再次判断 instance 对象为空,从而创建 instance 对象的实例,最后释放锁。此时,线程 B 被唤醒,线程 B 再次尝试获取锁,获取锁成功后,线程 B 检查此时的 instance 对象已经不再为空,线程 B 不再创建 instance 对象。


上面的一切看起来很完美,但是这一切的前提是编译器或者解释器没有对程序进行优化,也就是说 CPU 没有对程序进行重排序。而实际上,这一切都只是我们自己觉得是这样的。


在真正高并发环境下运行上面的代码获取 instance 对象时,创建对象的 new 操作会因为编译器或者解释器对程序的优化而出现问题。也就是说,问题的根源在于如下一行代码。


instance = new SingleInstance();
复制代码


​对于上面的一行代码来说,会有 3 个 CPU 指令与其对应。

1.分配内存空间。

2.初始化对象。

3.将 instance 引用指向内存空间。


正常执行的 CPU 指令顺序为 1—>2—>3,CPU 对程序进行重排序后的执行顺序可能为 1—>3—>2。此时,就会出现问题。


当 CPU 对程序进行重排序后的执行顺序为 1—>3—>2 时,我们将线程 A 和线程 B 调用 getInstance()方法获取对象实例的两种步骤总结如下所示。


【第一种步骤】

(1)假设线程 A 和线程 B 同时进入第一个 if 条件判断。

(2)假设线程 A 首先获取到 synchronized 锁,进入 synchronized 代码块,此时因为 instance 对象为 null,所以,此时执行 instance = new SingleInstance()语句。

(3)在执行 instance = new SingleInstance()语句时,线程 A 会在 JVM 中开辟一块空白的内存空间。

(4)线程 A 将 instance 引用指向空白的内存空间,在没有进行对象初始化的时候,发生了线程切换,线程 A 释放 synchronized 锁,CPU 切换到线程 B 上。

(5)线程 B 进入 synchronized 代码块,读取到线程 A 返回的 instance 对象,此时这个 instance 不为 null,但是并未进行对象的初始化操作,是一个空对象。此时,线程 B 如果使用 instance,就可能出现问题!!!


【第二种步骤】

(1)线程 A 先进入 if 条件判断,

(2)线程 A 获取 synchronized 锁,并进行第二次 if 条件判断,此时的 instance 为 null,执行 instance = new SingleInstance()语句。

(3)线程 A 在 JVM 中开辟一块空白的内存空间。

(4)线程 A 将 instance 引用指向空白的内存空间,在没有进行对象初始化的时候,发生了线程切换,CPU 切换到线程 B 上。

(5)线程 B 进行第一次 if 判断,发现 instance 对象不为 null,但是此时的 instance 对象并未进行初始化操作,是一个空对象。如果线程 B 直接使用这个 instance 对象,就可能出现问题!!!


在第二种步骤中,即使发生线程切换时,线程 A 没有释放锁,则线程 B 进行第一次 if 判断时,发现 instance 已经不为 null,直接返回 instance,而无需尝试获取 synchronized 锁。


我们可以将上述过程简化成下图所示。



总结


导致并发编程产生各种诡异问题的根源有三个:缓存导致的可见性问题、线程切换导致的原子性问题和编译优化带来的有序性问题。我们从根源上理解了这三个问题产生的原因,能够帮助我们更好的编写高并发程序。


点击关注,第一时间了解华为云新鲜技术~

发布于: 1 小时前阅读数: 6
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
并发高?可能是编译优化引发有序性问题