并发问题是怎样造成的

我们的程序要运行,离不开CPU、内存、IO设备,但是他们三者之间的执行速度是有差异的。

CPU 的执行速度最快,内存的速度次之,IO设备的速度最慢。

为什么会有内存缓存

CPU 执行一条指令非常快,但是他从内存中读取某个数据时,就需要等待很长的时间,为了弥补速度上的巨大差异,让 CPU 不被内存拖垮,所以在 CPU 上增加了缓存。

当 CPU 请求内存中的数据时,会先查看缓存中是否有该数据,如果存在则直接返回该数据;如果不存在,则要先把内存中的数据载入缓存,然后再返回给 CPU。

所以我们的程序在执行时,往往就需要将数据从内存中读取出来载入到缓存中,然后进行处理,处理完成之后再将数据回写到内存中去。

除此以外,现代的计算机都是多CPU、多核的,程序也不再只运行在单一线程中,而是有多个线程在运行。

每个线程都会维护一份自己的内存副本,也就是 CPU 缓存,所以线程之间一定会存在数据一致性的问题。

一般来说,导致并发问题的根源不外乎以下这几个原因:

可见性:一个线程对共享变量的修改,另一个线程是否可见?

原子性:一个或多个操作在 CPU 执行的过程中是否会被中断?

有序性:程序编译后的指令是否会按照代码原本的顺序执行?

遗憾的是,以上三个问题的答案都是不确定的,正因为这些不确定所以才会存在并发下的各种问题。

什么是可见性

如果我们的程序是在单个 CPU 上执行的,那么对于一个变量的原子性操作,无论如何都是不会出现问题的,不管是由一个线程还是多个线程来操作该变量,对结果都不会造成影响,因为内存的副本只有一个。

并发问题是怎样造成的

在单个 CPU 上操作虽然不会有问题,但是要强调一点,就是这个操作必须是原子性的。

比如线程A 设置变量 V 的值为10,那线程B获取到该变量的值就是10,不会出现问题。

但是我们的程序是不可能只在单个 CPU 上运行的,而是要在多个 CPU 上运行的,在多个 CPU 上执行时,就会出现问题。

并发问题是怎样造成的

如线程A 在CPU1 中对变量 V 设置了一个新的值,但是线程B是在 CPU2 中,而 CPU1 对缓存进行了修改,并不会通知到 CPU2,所以这时线程B 拿到的变量 V 的值还是原来的老的值,也就是脏数据。

所以这就是导致并发问题的第一个原因,在一个线程中对共享变量的更改,对其他的线程是不可见的。

一个不可见性的示例

private static int counter;
private static boolean stop;
private static class Reader implements Runnable {
    private int newestCounter;
    @Override
    public void run() {
        while (!stop) {
            if (newestCounter != counter) {
                newestCounter = counter;
                System.out.println("Reader has read a new value=" + newestCounter);
            }
        }
        System.out.println("Reader stopped at:" + System.currentTimeMillis());
    }
}
private static class Writer implements Runnable {
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            counter = i;
            System.out.println("writer has write a new value to counter=" + counter);
            // 等待 Reader 去读取 counter 的变化
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        stop = true;
        System.out.println("Writer set stop at:" + System.currentTimeMillis());
    }
}

有两个线程,一个 Reader 线程,一个 Writer 线程,并且有两个共享变量:counter 和 stop 标志位。

启动完两个线程之后,打印出如下结果:

writer has write a new value to counter=1
Reader has read a new value=1
writer has write a new value to counter=2
writer has write a new value to counter=3
writer has write a new value to counter=4
writer has write a new value to counter=5
Writer set stop at:1553871839283

Writer 线程每隔一秒更新一次 counter 的值, Reader 线程只读取到第一次 counter 的变化后的值,后面的值变更,都没有读取到,因为此时 Reader 线程已经将 counter 的值缓存在本地的内存副本中了, Writer 线程再怎么修改 counter 的值, Reader 线程也不会知道的,所以说 Writer 线程对于 counter 的修改,对 Reader 线程是不可见的。

同样的, Reader 线程启动后,读取到 stop 变量的值为 false,在后续 Writer 线程将 stop 的值更新为 true 之后, Reader 线程也不会感知到,所以该程序会一直运行下去,因为 Reader 线程中的 stop 状态永远是 false。

如果我们将 Writer 线程中的休眠1s的代码注释掉,那么 Reader 线程可能会读取到 stop 为 true。

为了解决这个问题,Java 给我们提供了一个 volatile 关键字,用来保证可见性。

当一个变量被 volatile 修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。

将上述的代码中 counter 改为如下所示:

private static volatile int counter;

即可返回正确的结果,Writer 线程每次对 counter 所做的修改,Reader 线程都能感知到,也就是说 Writer 对变量 counter 做的修改,对 Reader 线程是可见的。

除了 volatile 可以保证可见性之外,synchronized 关键字和 Lock 都能保证可见性,但是和 volatile 比起来,加锁的方式都太重了,涉及到线程的阻塞与唤醒。

为什么会有线程切换

我们的程序都是由非常多的线程来协作执行的,而具体的执行都是给 CPU 下达指令,让 CPU 去执行的。

那么每个线程该怎么使唤 CPU 让他为自己干活呢?CPU 又是怎样接受和处理这么多线程下发给自己的指令的呢?

由于 CPU 的执行非常快,而线程下发给他的任务有可能很快就执行完了,也可能由于其他的原因导致要执行很久。

如果一个任务执行的时间很久,是否需要一直占着 CPU 资源呢?

那 CPU 肯定不会同意的,CPU 为了更高效的处理各种任务,会为每个线程分配一段差不多长的时间用来执行他们的任务,当时间用完了之后,就去执行其他线程的任务了,这个时间就称为 “时间片” ,执行不同的任务就是线程之间的切换了。

什么是原子性

虽然 CPU 通过时间片和线程切换,提高了程序运行的效率,但是说到线程切换,就可能导致另一种问题。

那么线程切换会在什么时候发生呢,在 CPU 指令执行完成之后的任何时间点都可能发生线程切换。

所以对于非原子操作就可能,操作执行了一半,发生了线程切换,另外的操作没来得及执行,要等到下一个线程切换时,轮到自己占有 CPU 时,才能完成剩下的操作。

但是这样明显是有问题的,你执行了一半的操作,CPU 到别的地方转了一圈回来之后,你原本的操作结果很可能就不对了,为什么会不对呢,因为你在等待 CPU 的这段时间内,很可能有别的线程也执行了和你相同的事。

我们知道数据库事务中也有原子性的概念,他主要说的是事务中的多个操作,要么全部执行,要么全部不执行。

但是 Java 中的原子性,并不能保证要么全部执行,要么全部不执行,反而是很可能多个操作只执行了一部分。

说了这么多的 “操作”,Java 中的一条语句难道不就是一条 “操作” 吗?

Java 中的一条语句还真不一定是一条 “操作”,这里说的 “操作” 是对 CPU 而言的,指的是一条指令。

而我们 Java 中的一条语句可能由一条指令组成,也可能由多条指令组成,操作系统只能保证一条指令的原子性,也就是要么该条指令执行,要么该条指令不执行,但是并不能保证多条指令的原子性。

所以说虽然线程切换解决了性能问题,但是却带来了原子性的问题。

Java 中的自增运算是一个典型的非原子性的操作,为什么这么说呢?

自增运算看似是一条语句,但是实际上需要三条 CPU 指令构成,分别是:取值,值加1,回写值。

并发问题是怎样造成的

假设我们有一个变量 V,初始值是0,当两个线程都对变量 V 执行自增操作,正常情况下,我们期望的结果是最终变量 V 的值是2,但是很可能由于县城切换导致,最终被更新到内存中的变量的值是1。

线程 A 从内存中获取到变量 V 的值为0,然后还没来得及执行后续的指令,就发生了线程切换,线程 B 这时从内存中获取到变量 V 的值也为 0,然后执行了后续的指令,将值加1并把值回写到了内存中,这时内存中的变量 V 的值为1。

然后又发生了线程切换,线程 A 重新获得了 CPU 资源,继续执行未完成的指令,最终的也将变量 V 的值更新为1,然后写入到了内存中。

整个过程由于发生了线程切换,导致非原子性的操作的结果出现了问题,事实上只要线程 A 在执行玩第一步或者第二步指令之后发生了线程切换,都会导致问题的发生。

而当线程 A 在执行完了第三步指令之后,再发生线程切换的话,则不会出现问题,原因是第三步指令执行完之后,内存中的变量值已经更新为最新值了,即便发生了线程切换,其他线程也会从内存中获取到最新的值。当然啦,假如第三步指令都执行完了,那整个过程就相当于是一个原子性的过程了,那就不存在由于线程切换而导致的问题了。

一个非原子性的示例

private int increment = 10000;
private int unsafeCounter = 0;
private void unsafeIncrease() {
    int idx = 0;
    while (idx++ < increment) {
        unsafeCounter++;
    }
}
// 多个线程执行不安全的非原子性操作
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        unsafeIncrease();
    }
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("unsafeCounter=" + unsafeCounter);

执行上述代码之后,你会发现,unsafeCounter 的值是一个1000~2000之间的数字。

一个原子性的示例

private int increment = 10000;
private AtomicInteger safeCounter = new AtomicInteger(0);
private void safeIncrease() {
    int idx = 0;
    while (idx++ < increment) {
        safeCounter.incrementAndGet();
    }
}

// 多个线程执行安全的原子性操作
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        safeIncrease();
    }
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("safeCounter=" + safeCounter);

执行上述代码之后,你会发现,safeCounter 的值确实是2000。

为什么使用 AtomicInteger 就能保证原子性呢,这些 Atomic* 开头的类都是为了解决原子性的问题而存在的,为什么他们就能保证原子性呢,原因是他们底层是通过 CAS 实现的。

通过 CAS 来设置某个变量的值时,会先检查该变量内存中的值是否与当前期望的值一致,如果发现不一致则会重新获取内存中的最新值,直到内存中的值与当前期望的值一致时,才将最新的值更新到内存中去,所以整个过程是原子性的。

复合原子操作是不是原子性的

现在我们知道了一个操作必须是原子性的才能保证在并发的情况下不出问题,具体可以使用原子类 Atomic* 来代替原始的变量。

但是 Atomic* 能否保证永远不出问题呢?

答案是不会,只要使用的不正确,Atomic* 也会出现问题,例如下面的代码:

private int[] nodes = new int[]{12};
private AtomicInteger nodeIndex = new AtomicInteger(0);
private void unsafeAtomic() {
    int i = 0;
    while (i++ < 100) {
        // 获取当前节点的索引,并将索引加1
        int value = nodes[nodeIndex.getAndIncrement()];
        // 如果索引值等于节点的长度了,则设置为0
        nodeIndex.compareAndSet(nodes.length, 0);
        System.out.println("Thread=" + Thread.currentThread().getName() + " current node value=" + value);
    }
}

上述代码是模拟轮询获取可用节点的功能,假设有两个节点,我们希望在多线程下能够交替返回每一个节点给调用方,这样可以做到负载均衡。

但是上述代码无法做到交替返回,原因是 getAndIncreament() 和 compareAndSet() 方法虽然都是原子操作,但是他们放在一起作为一个复合操作就不是原子的了。

为什么会有重排序

编译器或运行时环境为了优化程序性能,通常会对指令进行重新排序,所以重排序分两种,分别是编译期重排序和运行期重排序。

对于我们程序员来说,不要假设指令执行的顺序,因为我们无法预知不同线程之间的指令会以何种顺序执行。

java 会为了提升程序的性能,将指令进行重排,这又是一种导致并发环境下可能出错的情况。

什么是有序性

在程序执行过程中,按照代码的顺序先后执行,这就是有序性,但是通过上面的介绍我们知道,不采取措施的话有序性是无法保证的。

因为我们写的代码,在编译期就已经发生了变化,而在最终执行时也可能发生变化,如果我们进行干涉的话,执行的结果很可能会发生不可预知的变化。

一个有序性的示例

一个最经典的有序性的问题就是,获取单例对象时,通过双重检查来保证对象只创建了一次,具体代码如下:

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

上述的代码乍看上去是没有问题的,如果不是指令重排序的话,也确实不会出现问题,但正是由于重排序的原因导致返回的单例对象可能出现问题。

并发问题是怎样造成的

线程A来获取单例对象,这时发现instance==null,所以就进入了加锁创建单例对象的代码块。

本来正常情况下,创建了一个对象然后返回就可以了,但是因为重排序的原因,创建对象的过程被重排序了:

并发问题是怎样造成的

正常应该是先初始化对象,然后再将分配好的内存指向该对象,但是重排序后的结果变成了,先将分配好的内存指向了对象,然后再初始化对象。

问题就出在这里,当将分配好的内存指向该对象后,如果发生了线程切换,线程B来获取单例对象时,发现单例对象已经不为空了,所以直接就拿该对象去操作了,但是该对象并没有进行过初始化,所以线程B后续再执行时就会出现空指针的问题。

为了解决重排序的问题,需要我们写代码时进行人为干预,具体怎么干预呢?那就是通过 volatile 关键字,可是上面我们刚说了 volatile 是解决可见性的问题的啊。

没错 volatile 除了可以解决可见性问题,也可以解决有序性的问题,通过 volatile 修饰的变量,编译器和运行时环境不会对他进行指令重排。

并发问题是怎样造成的

通过上面的分析,我们知道了造成并发问题的原因了,这些都是操作系统或者编译期为了提升性能而做了一些努力,但是为了享受到这些性能上的优势,我们就得付出更多的代价来写出复杂的代码。

换句话说,硬件上为了最求卓越的性能,而忽略了软件实现上的复杂度,相当于硬件工程师给软件工程师挖了一个坑。

CPU上的高速缓存造成了多线程下共享变量的可见性问题,可以通过 volatile 或加锁的方式来解决。

线程切换造成了多线程下原子性的问题,可以通过原子类或加锁的方式来解决。

编译器或者运行环境为了优化程序性能造成了有序性的问题,可以通过 volatile 禁止指令重排。

发表评论

登录后才能评论
关注我们