为什么并发编程容易出现问题?

为什么并发编程容易出现问题?

我们的 CPU、内存、I/O 等硬件设备不断发展,但是这其中有一个无法调和的矛盾:三者之间的速度差异巨大。这种速度差异可以形象的描述为:CPU 天上一天,内存地上一年(假如 CPU 执行一条指令需要一天,内存读写内存得一年时间);内存如果是天上一天,I/O 基本是地上十年了。这种速度差异也导致了即使我们的 CPU 发展的再牛逼,整体的处理速度却取决于最慢的那位。为了均衡三者之间的性能差异,使我们的 CPU 能更高效的使用,计算机体系机构、操作系统、编译程序主要做了以下处理:

  • CPU 增加缓存,均衡和内存之间的速度差异;
  • 操作系统增加了线程、进程,分时复用 CPU ,均衡 CPU 和 I/O 的速度差异;
  • 编译程序优化指令执行次序,使缓存能更加合理的利用。

虽然这些处理给我们带来了巨大的性能提升,但同时,它们也是导致并发编程出现问题的源头。

一、缓存导致的可见性问题

在单核 CPU 时代,所有的线程都是共享一个 CPU 的缓存的,所以,一个线程对于缓存的操作对于别的线程一定是可见的。如下图:
为什么并发编程容易出现问题?_第1张图片
这里就引出了可见性的概念:一个线程对于共享变量的修改,别的线程立刻就能看到,称为可见性

多核时代,可见性的问题就保证不了了。多个线程操作的是不同 CPU 的缓存,如图:
为什么并发编程容易出现问题?_第2张图片
,线程A对于变量 V 的操作与线程B对于变量V的操作就不具备可见性,就是说线程A修改了变量V,线程B不是可以看到的。下面这段代码可以验证这个问题:

public class Test {
    private long count = 0;

    public static void main(String[] args) {
        System.out.println(new Test().getCount());
    }

    private void add() {
        int index = 0;
        while (index++ < 10000) {
            count ++;
        }
    }

    private long getCount() {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                add();
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                add();
            }
        });
        //启动线程
        thread1.start();
        thread2.start();
        try {
            // 等待两个线程执行结束
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        return count;
    }
}

这段代码很简单,但是从运行结果可以看到可见性导致的不可预测性。add 方法就是把 count 从 0 加到 10000。然后两个线程执行,按照我们的期望,是想让 count 从 0 加到 20000。但是运行结果和我们期望的并不一样,结果可能是 20000,也可能是10000-20000 之间的任意数字。这是为什么呢?假如两个线程同时开始执行,第一次都会将 count=0 读到各自的 CPU 缓存中去,然后执行 +1 操作后,count=1,两个缓存中的 count 都是1,然后各自写入内存,这就导致了count的结果是1,而不是执行两次加1操作后我们期待的2。

二、线程切换导致的原子性问题

由于 IO 太慢,早起的操作系统发明了多进程。操作系统会允许一个进程执行一段时间,过了这个时间会选择另一个进程执行,这个时间称为时间片。在一个时间片内,如果一个进程需要进行 IO 操作,它会释放 CPU 的使用权,CPU 可以执行别的进程,得到更高效的使用,IO 操作执行完成之后可以重新获得 CPU 的使用权。早期的操作系统是基于进程调度 CPU 的,不同进程之间是不共享内存地址的,切换进程就要切换内存映射地址;而同一个进程不同线程是共享内存地址的,所以线程做任务切换的成本更低。现在的操作系统都是基于线程来调度任务的,任务切换大多数都是在时间片结束的时候。而这也导致了各种诡异的bug。

Java 是高级语言,一条语句对应着多条 CPU 指令,比如:

count += 1;

需要三条 CPU 指令来完成:

  1. 把变量 count 从内存加载到 CPU 寄存器;
  2. 在寄存器中执行 +1 操作;
  3. 将结果写入内存(缓存机制可能写入的是 CPU 缓存,而不是内存)

而操作系统的任务切换可能发生在任意一条 CPU 指令结束之后,注意这里是 CPU 指令,而不是我们写的高级语言的语句。这里引出了原子性的概念:一个或多个操作在 CPU 执行过程中不被中断的特性称为原子性;原子性会导致什么问题呢?如下图:
为什么并发编程容易出现问题?_第3张图片

线程A 和线程B 执行 count+=1 操作,在线程A执行完指令1后,任务切换到线程bB,线程B依次执行完3条指令后,任务切换到线程A,线程A继续执行指令2,3,最终导致两个线程写入内存的 count 都是1。

三、编译优化带来的有序性问题

有序性指的的:程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变我们程序语句的先后执行顺序,但是不影响执行结果。比如:

a = 8;
b = 9;

编译器优化后可能变成:

b = 9;
a = 8;

这也是导致问题的源头之一。
在 Java 领域有个经典的例子:双重检查创建单例对象。

public class Singleton {
    private static Singleton instance;
    private Singleton() {
        
    }
    public static Singleton getInstance() {
        if(instance == null) {
            synchrozed(Singleton.class) {
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instace;
    }
}

这种方式亮点在于做了两次判空检查:第一次判断是避免不必要的同步操作,第二次则保证实例对象的唯一性和准确性。目前的写法有什么问题呢?我们分析下:

instance = new Singleton();

并不是一个原子操作。它可以分为下面三个步骤:

  1. 给Singleton 的实例分配内存;
  2. 调用 Singleton 的构造函数,初始化成员字段;
  3. 将 instance 对象指向分配的内存空间。
    但是由于Java编译器允许处理器乱序执行以及 JDK 1.5 之前的 Java 内存模型中 Cache、寄存器到主内存回写顺序的规定,2 和 3 的顺序无法保证。即执行顺序可能是1-2-3,也可能是1-3-2.如果是后者,在线程 A 已经执行完3的时候切换到了线程B,线程B的判断就是 instance 非空,所以线程B会直接使用该实例对象,这个时候就会出错。这就是DCL失效问题。我们怎么避免这个问题呢?在JDK1.5之后,我们可以使用 valatile 关键字:
private volatile static Singleton instance = null;

为什么并发编程容易出现问题?_第4张图片
缓存带来了可见性问题,线程切换带来了原子性问题,编译优化带来了有序性问题。但其实,缓存、线程切换、编译优化和我们写并发程序的目的是一样的,都是为了提高性能。但同时也带来了一些麻烦,而我们需要知道麻烦的来源,然后解决麻烦。

说明:以上内容总结于《java并发编程实战》:
为什么并发编程容易出现问题?_第5张图片

你可能感兴趣的:(并发编程)