Java可见性、原子性与有序性问题

628 阅读3分钟

并发编程中,常见的问题主要有缓存导致的可见性问题线程切换带来的原子性问题编译优化带来的有序性问题

1、可见性问题

1.1 单核CPU时的线程可见性问题

在单核CPU时,所有线程都是在同一个CPU上执行,操作的都是同一个CPU缓存。因此,一个线程对CPU缓存的写对其他线程都是可见的。如下图中所示,线程 A 和线程 B 操作同一个 CPU 里面的缓存变量V,彼此的操作都是相互可见的。

单核时CPU缓存与内存关系.png

1.2 多CPU时缓存与内存关系

多CPU缓存与内存关系.png

在多CPU的情况下,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。如上图中所示,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-N 上的缓存。两个缓存在默认情况下是不可见的。

1.3 可见性问题Demo

public class VisibilityTest {
    private long count = 0;

    private void add10K() {
        int idx = 0;
        while (idx++ < 10000) {
            count += 1;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final VisibilityTest test = new VisibilityTest();
        // 创建两个线程,执行add()操作
        Thread threadA = new Thread(() -> {
            test.add10K();
        });
        Thread threadB = new Thread(() -> {
            test.add10K();
        });
        // 启动两个线程
        threadA.start();
        threadB.start();
        // 等待两个线程执行结束
        threadA.join();
        threadB.join();
        System.out.println("count="+test.count);
    }

}

运行结果

image.png
上述代码正常情况下,count的值为10000到20000之间的任何值。 假设上述代码中的线程A和线程B同时执行,第一次线程读取到的count值都为0。之后执行count+=1,每个线程的CPU缓存中的count值都为1。之后将count值写入内存。执行完之后 ,内存中的count值为1,而不是预期的2。因为两个线程都是基于各自的CPU缓存中的count值来进行计算的,所以最终的count结果都小于20000。

2、原子性问题

2.1 count+=1存在的问题

count+=1不是一个原子性的操作,它可以分为三个步骤:

  1. 将count变量从内存加载到CPU的寄存器。
  2. 在寄存器中将变量count的值加1。
  3. 将寄存器的count值写入内存。

在单线程的情况下,这个操作是没有任务问题的。但在多线程的情况下,因为改操作是非原子性的,就会出现问题,如下图中所示。

Count原子性问题.png

3、有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=1;b=2;”编译器优化后可能变成“b=2;a=1;”。 在Java单例模式中经常的双重锁校验就是一个经典例子。

public class SingletonClass { 

  private volatile static SingletonClass instance = null; 

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

在上述代码中,调用getInstance()方法时,会判读instance是否为空。如果为空,会通过synchronized锁定SingletonClass.class。之后再判断instance是否为空,如果还是为空,则创建对象。 但这个方式不是完美的,问题出现在new SingletonClass()。 在Java创建对象时,会分为三个步骤,

  1. 申请内存
  2. 在内存中初始化对象
  3. 将对象的内存地址赋值给变量

但是实际上上述步骤不一定是绝对的,步骤二跟步骤三可能会互换,变成

  1. 申请内存
  2. 将对象的内存地址赋值给变量
  3. 在内存中初始化对象

这就是出现问题的原因。

我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

双重检查创建单例的异常执行路径.png