并发编程的根源–有序性问题

有序性

我们知道,程序在执行前,需经过编译器或解释器,翻译成机器语言,一条程序代码,会被翻译为多条机器指令。编译器或解释器为了优化程序的执行性能,有时会改变这些指令的执行顺序。而编译器或解释器对指令顺序的调整,可能会导致意想不到的问题。

在Java开发中,一个常见的程序,使用单例模式创建对象,而单例对象的创建,会通过双重检验的机制进行。

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;
    }
}

上述代码通过getInstance()方法创建单例对象时,先判断instance对象是否为空,如果为空,则针对SingleInstance类对象加锁,接着再次判断instance对象是否为空,为空时,创建一个实例对象,并赋值给静态成员变量instance。

如果解释器或编译器不针对以上代码进行指令优化,则整个代码的执行过程如下:

假设有线程A和线程B同时调用getinstance()获取instance对象,两个线程同时第一次判断instance是否为空,此时instance未创建,故instance为空。

这时线程A先获取了SingleInstance类对象锁,线程B未获取锁而处于等待锁状态。

接下来,线程A第二次判断instance对象为空,从而创建了instance对象,并赋值给instance成员变量,线程A释放锁。

这时,处理等待锁状态的线程B被唤醒,获取了锁,线程B第二次判断instance对象不为空,直接返回非空的instance对象。

图片[1]-并发编程的根源–有序性问题-编程社

上面的分析看,一切都很完美,但前提是编译器或解释器没有对指令进行指令优化,也就是CPU没有对指令进行重排顺序。

CPU指令优化

在真正的高并发场景下,创建对象的new操作时,会被编译器或解释器进行指令优化,而导致程序运行异常。

也就是说,问题的根源在于以下语句:

instance = new SingleInstance();

以上语句会有3条CPU指令:

  • 分配内存空间
  • 初始化对象
  • 将instance引用指向内存空间

正常的指令顺序为1–>2–>3

图片[2]-并发编程的根源–有序性问题-编程社

经编译器或解释器优化的执行顺序可能为: 1–>3–>2,此时可能会出现问题。

图片[3]-并发编程的根源–有序性问题-编程社

当CPU指令优化后的执行顺序为1–>3–>2时,我们把线程A和线程B调用getInstance()获取对象实例的两种步骤总结如下:

第一种步骤

  1. 线程A和线程B同时进入第一个if条件判断。
  2. 假设线程A先获取synchronized锁,进入synchronized代码块,此时因instance为空,所以执行instance=new SingleInstance()语句。
  3. 在执行instance=new SingleInstance()时,线程A会在JVM中开辟一块内存空间。
  4. 线程A将instance指向新开辟的内存空间,在还没有进行对象初始化时,发生了线程切换,线程A释放了synchronized锁,此时,CPU切换到了线程B上。
  5. 线程B进入synchronized代码块,在进行第二个if判断时,发现instance引用指向了线程A创建的空对象,不为空,但对象未进行初始化。此时线程B若使用未初始化的空对象时,可能会出问题。

第二种步骤

  1. 线程A先进行第一个if条件判断,instance为空
  2. 线程A获取synchronized锁,并进行第二次if判断,此时instance为空,则进入instance=new SingleInstance()语句。
  3. 线程A在内存中申请一块新的内存空间。
  4. 线程A将instance引用指向了新申请的内存空间,在没有进行对象初始化的时候,CPU进行了线程切换,CPU切换到了线程B上。
  5. 线程B进行一个if条件判断,发现instance对象不为空,但instance对象并对进行对象初始化,是一个空对象。此时线程B若使用未初始化的空对象时,可能会出问题。

以上过程,可以使用下图描述:

图片[4]-并发编程的根源–有序性问题-编程社

有序性问题的解决方案

通过前面的讲述,我们可以清晰的知道,因为有了CPU的编译优化,也就带来了有序性问题。

那么针对有序性问题,我们采用什么方案解决呢?

使用volatile关键字

volatile关键字是Java提供的一种轻量级的同步机制,它可以保证多线程环境中变量的可见性和禁止指令重排。

当我们将一个变量声明为volatile时,JVM会禁止对这个变量的读写操作进行重排序,并且保证每个线程都能看到这个变量的最新值。

public class SingleInstance {
    private static volatile SingleInstance instance;

    public static SingleInstance getInstance(){
        if(instance==null){
            synchronized (SingleInstance.class){
                if(instance==null){
                    instance = new SingleInstance();
                }
            }
        }
        return instance;
    }
}

例子中,instance成员变量被声明为volatile。这确保了当一个线程在同步块中创建了一个新的Singleton实例并赋值给instance后,其他线程能够立即看到这个新的实例,而不是看到旧的、null的值。

这就解决了由于编译优化导致的有序性问题。

使用过度引用

我们知道编译优化,可能会引起对象创建过程中,对象初始化晚于对象引用指向新创建的对象。

那么,我们是否可以让成员变量不要在对象创建时直接赋值,而是采用中间临时变量过度后,再赋值的方式?

public class SingleInstance {
    private static SingleInstance instance;

    public static SingleInstance getInstance(){
        if(instance==null){
            synchronized (SingleInstance.class){
                if(instance==null){
                    SingleInstance temp = new SingleInstance();
                    instance = temp;
                }
            }
        }
        return instance;
    }
}

例子中,SingleInstance temp = new SingleInstance()存在指令优化的顺序改变,但在引用temp指向新创建的对象,但未执行对象初始化时,发生了线程切换,因synchronized代码块未执行完同步代码块的指令,原线程仍持有同步代码块的锁,且此时成员变量instance还为空,其它线程即使获得了CPU时间片,也会堵塞在同步代码块的地方。

因此,中间过度的临时变量temp,完美规避了编译优化带来的有序性问题。

总结

缓存带来的可见性问题、线程切换带来的原子性问题和编译优化带来的有序性问题,是导致并发编程频繁出现诡异问题的三个源头.

我们已经详细介绍了缓存带来的可见性问题、线程切换带来的原子性问题和编译优化带来的有序性问题。

通过本文我们可以知道,有序性问题是并发编程中需要重点关注的问题之一。

通过使用volatile关键字和过度引用的临时变量,是可以规避编译优化带来的有序性问题的。

图片[5]-并发编程的根源–有序性问题-编程社
© 版权声明
THE END
喜欢就支持一下吧
点赞10赞赏 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容