可见性、原子性和有序性问题:并发编程Bug的源头

并发编程幕后的故事

随着设备的不断迭代,相应的速度也变得更快。但是在发展的过程中有一个矛盾一直存在,即三者的速度差异。
快慢关系:CPU > 内存 > I/O,程序整体的性能取决于最慢的操作,即读写I/O设备,所以单方面的提升CPU性能是无效的。

为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序做出了贡献,体现为:

  1. CPU增加了缓存,平衡与内存的速度差异
  2. 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用

现在所有程序都是在享受着这些成果,但是并发程序很多诡异的问题的根源也在这里。

源头一:CPU缓存导致的可见性问题

  • 什么是可见性?
    一个线程对共享变量的修改,另外一个线程能够立刻看到,称之为可见性

单核与内存的关系

所有线程都在一个CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有的线程操作的是同一个CPU的缓存,一个线程对缓存的写,另一个线程来说一定是可见的。

如下图:线程A和线程B都是操作相同的CPU缓存,所以线程A操作共享变量V的值,线程B访问V的值,一定是V的最新值(线程A写过的值)
可见性、原子性和有序性问题:并发编程Bug的源头_第1张图片

多核与内存的关系

多核时代,每个CPU都有自己的缓存,那么保证CPU缓存与内存的数据一致性问题就不怎么容易了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。

如下图:有两个CPU,对应有两个CPU缓存,线程A和线程B操作在不同的CPU上执行,这时线程A操作的V的值就会对线程B不可见,同理线程B操作的V的值对线程A也不可见。
可见性、原子性和有序性问题:并发编程Bug的源头_第2张图片

示例代码:

有一个共享变量count,两个线程t1和t2,线程t1和t2执行方法add10K(),每次add10K会循环10000次count ++操作。


public class Test1 {

    private static long count = 0;

    public static void main(String[] args) throws InterruptedException {
        final Test1 test1 = new Test1();
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                test1.add10K();
            }
        });
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                test1.add10K();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

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


执行结果是10000到20000之间的随机数
在这里插入图片描述

分析:
假设线程t1和t2同时执行,在两个线程分别执行第一次add10K的时候,CPU从内存中读到count的值0并缓存到自己的缓存中;

两个线程分别执行add10K方法中的count ++后,线程t1和t2都将count的值从0增加到1,同时刷新各自的缓存中的值,此时我们看到的count值是1,而非我们期望的2;

由于每个线程都是基于自己的缓存值进行计算的,所以最后导致count的值都是小于20000的,这就是缓存的可见性问题;

之所以在10000到20000之间,并接近20000,是由于两个线程不是同时启动的,有一个时差。

可见性、原子性和有序性问题:并发编程Bug的源头_第3张图片

源头二:线程切换带来的原子性问题

  • 什么是原子性?
    把一个或多个操作在CPU执行的过程中不被中断的特效称为原子性。CPU能保证原子性是指在CPU指令层面的,而不是高级语言的操作符。

由于读写I/O速度太慢,早期的操作系统便有了多进程,即可以在单核CPU上一边听歌,一边敲BUG。

任务切换:操作系统允许某个进程执行一小段时间后重新选择一个进程来执行,称之为任务切换。这一小段时间称之为时间片
可见性、原子性和有序性问题:并发编程Bug的源头_第4张图片
在一个时间片内如果一个进程进行一个I/O操作,当执行读写操作时,CPU会将自己标记为休眠状态并让出CPU的使用权,让CPU去执行其他任务,待这个读写操作完成时,操作系统会把这个进程唤醒,唤醒后的进程就会有竞争CPU使用权的权利了。

这里的进程让出CPU使用权,是为了在这个耗时的操作时间段内做别的事情,这样就可以提高CPU的使用率了。

如果此时有其他进程也要读写相同的文件,那么这个进程会排队,等待上一个进程执行完读写操作后,发现有排队的任务,就会立即启动下一次的读写操作,这样I/O的效率也上来了。

早期的操作系统是基于进程来调度CPU,不同进程间是不共享内存空间的。所以进程做任务切换就要切换映射地址,而一个进程创建的所有线程都是共享一个内存空间的,所以线程做任务切换的成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的"任务切换"都是指"线程切换"。

Java并发程序都是基于多线程的,也就涉及到了"任务切换",但是"任务切换"也是并发编程里诡异BUG的源头之一。

任务切换的时机大多数是在时间片结束的时候,我们现在基本都是基于高级语言编程,高级语言里的一条语句往往需要多个CPU指令来完成,例如count += 1,至少需要三条CPU指令。

  1. 指令1:首先把变量count从内存加载到CPU的寄存器
  2. 指令2:之后,在寄存器中执行+1操作
  3. 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)

操作系统做任务切换,可以发生在任何一条CPU指令执行后,而非高级语言里的一条语句。

如下图,假设count原始值为0,线程A和线程B都执行count += 1操作,当线程在指令1执行结束后做线程切换,执行线程B,当线程B执行完后切换到线程A并执行完成,但是得到的结果并不是期望中的2,而是1。
可见性、原子性和有序性问题:并发编程Bug的源头_第5张图片
由上说明CPU能保证原子性是在CPU指令级别的,而不是高级语言的操作符。

源头三:编译优化带来的有序性问题

  • 什么是有序性?
    有序性是指程序按照代码的先后顺序执行。

编译器为了优化程序性能,有时候会改变程序中语句的先后顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的BUG。

Java单例模式中有个双重检查创建单例对象:先检查singleton是否为空,如果空则锁定Singleton.class并再次检查是否为空,如果还为空,则创建Singleton的一个实例。

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和线程B同时执行getInstance方法,并同时检查到instance为null,于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功(假如是线程A获得锁,线程B则等待线程A释放锁),线程A检查instance为空,则创建Singleton的一个实例,之后释放锁,线程B获得锁,并加锁,检查instance不为空,所以不会再次创建实例了。

看上去上面代码很完美,但实际上这个getInstance方法并不完美,问题出在new操作上。我们认为new操作应该是:

  1. 分配一块内存M;
  2. 在内存M上初始化Singleton对象;
  3. 将M的地址赋值给instance变量。

但实际上优化后的执行路径却是这样的:

  1. 分配一块内存M;
  2. 将M的地址赋值给instance; // 1⃣️
  3. 在内存M上初始化Singleton对象。

优化后导致什么问题呢?假设线程A执行完指令21⃣️后切换到线程B,此时instance已经被指向了一块内存地址,线程B检查instance不为空,直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常
可见性、原子性和有序性问题:并发编程Bug的源头_第6张图片

总结:

并发编程BUG的源头有:缓存导致的可见性问题、线程切换导致的原子性问题、编译优化导致的有序性问题

对于文中双重锁问题,异常原因是由于发生了编译优化导致指令重排序,为了避免这类情况的发生,就可以用volatile声明变量,禁止指令重排序。

什么时候将CPU缓存刷新到内存中,通常是没有固定时间的,对于有volatile声明的变量,线程A执行完成后会强制将缓存刷新到内存中的,线程B会强制重新从内存中读取并放入到自己的缓存中,这就涉及到了写入屏障问题,即所谓的happen-before问题。

long类型的变量在32位机器上执行,由于long类型是64位的,所以在32位上会将指令拆分位高32位和低32位,计算时分两个指令执行,由于线程切换,无法保证原子性,所以会导致数据计算不及预期的问题。

你可能感兴趣的:(并发编程,Java并发编程实战)