面试官:什么是并发问题?我--------

131 阅读5分钟

前言

在当今多核CPU的前景下,多线程运行成为了常见的提高CPU利用率的方法,但是与之而来的,还有大量的并发安全问题。那么什么是并发安全问题?在<<深入理解Java虚拟机>>中原文如下: 当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。

如何理解?多个对象对操作共享变量作为并发问题的本质。举个例子,A和B一起读一本书,A读完留下了自己的书签,B从书签处开始读,再等到A读时,书签的位置已经变了。也就是出现了并发问题。

怎么解决这个问题呢?“只要B读完了和A说我读到xx位置不就行了?" 下文中,我将从JMM内存模型出发,分析线程间是如何沟通的。

JMM内存模型

image.png

由于CPU中的独立的高速缓存机制,为了平衡与主存的读写速度差异,线程每次读写完成后,先把结果存入自己的工作内存,并在某个时刻将工作内存的变量副本写回到主存中去。

线程A和线程B之间要完成通信的话,要经历如下两步:

  1. 线程A从主内存中将共享变量读入线程A的工作内存后并进行操作,之后将数据重新写回到主内存中;
  2. 线程B从主存中读取最新的共享变量

从横向去看看,线程A和线程B就好像通过共享变量在进行隐式通信。这其中有很有意思的问题,如果线程A更新后数据并没有及时写回到主存,而此时线程B读到的是过期的数据,这就出现了“脏读”现象。可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次volatile变量都能够强制刷新到主存,从而对每个线程都是可见的。

重排序

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM对底层尽量减少约束,使其能够发挥自身优势。

一个典型的多线程指令重排异常实例可以是基于双重检查锁定(Double-Checked Locking)的单例模式。在早期的 Java 版本中,双重检查锁定被广泛用于实现延迟初始化的单例模式,但是由于指令重排可能导致单例对象未正确初始化的问题,这种实现方式已经被证明是不安全的。

以下是一个简化的示例,演示了双重检查锁定可能导致的问题:

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 可能会发生指令重排问题
                }
            }
        }
        return instance;
    }
}

在这个例子中,如果线程 A 进入了第一次检查并通过了条件,但是在完成第二次检查之前发生了指令重排,那么线程 B 可能会在第二次检查之前获取到未正确初始化的单例对象,从而导致程序出现异常。

为了解决这个问题,可以使用 volatile 关键字来确保 instance 变量的可见性,并且禁止指令重排。修改后的代码如下所示:

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 使用 volatile 关键字确保安全发布
                }
            }
        }
        return instance;
    }
}

通过使用 volatile 关键字,可以确保当 instance 被初始化时,所有线程都能立即看到它的值,从而避免了指令重排可能导致的问题。

面试官:讲一下volatile关键字吧

volatile的出现,解决了上述问题:

  1. 保证线程间变量的可见性。
  2. 禁止CPU进行指令重排序。

可见性

volatile修饰的变量,当一个线程改变了该变量的值,其他线程是立即可见的。普通变量则需要重新读取才能获得最新值。

volatile保证可见性的流程大概就是这个一个过程:

也就是说当主内存的共享变量被改变后,先失效其他线程的变量副本,再从主内存中读取最新值,保证了副本中变量值的一致性。

禁止指令重排序

首先要讲一下as-if-serial语义,不管怎么重排序,(单线程)程序的执行结果不能被改变。

为了使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率,只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序

重排序的种类分为三种,分别是:编译器重排序,指令级并行的重排序,内存系统重排序。整个过程如下所示:

指令重排序在单线程是没有问题的,不会影响执行结果,而且还提高了性能。但是在多线程的环境下就不能保证一定不会影响执行结果了。

所以在多线程环境下,就需要禁止指令重排序

总结

要学习并发编程,java内存模型是第一站。JUC包中有更多的并发设计,共通思想像是AQS中的设计也是操作共享变量。