并发编程学习(2) —— 并发编程Bug源头

前言

因为最近在极客时间中学习并发编程,由于内容比较多,涉及范围比较多,因此写下这些文章,一来方便自己日后回顾,同时能够根据自己的理解稳固知识内容,二来能够让更多朋友了解并发的知识,因为我也是刚刚开始接触,可能在某方面会有一些错误的见解,望大家能够提出不同的看法。

并发Bug源头

在之前文章提过,CPU、内存、I/O读写的速度存在非常大的差距,用通俗的语言说就是CPU一天就能干完的事,内存就要十天,I/O就要一百天。那么要等I/O和内存完全执行完,CPU得等100天,这样就非常浪费时间了。因此为了平衡这三者的速度差异,就出现了以下三种改变:
1. CPU增加了缓存,以均衡与内存的速度差异。
2. 操作系统增加了线程、进程,分时调用CPU(就是分时调度就是不同时间干不同的事),以均衡CPU与I/O的速度差异。
3. 编译程序实现优化指令执行次序,例如int a = 0; int b = 1,有时候在不影响结果的情况下编译器会对执行次序进行重排,就可能会变成int b = 1, int a = 0。

那么既然三者的速度都得到优化,那为什么还会出现很多奇怪的bug?明明代码没问题,优化都自动做好了。任何事都有利有弊,既然有了优化,就一定会存在他的缺点。

CPU缓存

以前在单核的时代,所有的线程都是在同一个CPU上执行,CPU的缓存和内存的数据一致性问题比较容易解决。一个线程在CPU缓存的写,对于其他线程来说都是可以见到。就好比两个人去雕塑同一件艺术品,A先雕塑,雕到一段时间交给B雕,B雕到一定时间给回A雕,如此反复。无论交给谁,都有一点可以确定,A或B接手的雕塑品一定是最新的,而不会存在A雕塑后交给B,B拿到的是雕塑前的情况,因为从头到尾,A、B的操作都是可见性的,A能看B雕塑后的结果,B能看到A的雕塑结果。,就如下图所示:
在这里插入图片描述
线程A和B操作的都是同一个CPU缓存,因此线程A更新了V的值,那么线程B之后访问的变量V的值一定是最新的。一个线程对共享变量的修改,另一个线程能够立刻看到,这就是可见性。

可惜的是,在多核时代,这个想法就不一样了。在多核时代里,每个CPU都有自己独立的缓存,线程A操作CPU-1上的缓存,线程B操作CPU-2,这时候线程A对变量V的操作线程B是不可见的,如下图:
在这里插入图片描述
每个CPU缓存中都有一个变量V,因此线程A,线程B对变量V的操作是不可见的,以下用一段代码解释:

public class Test {
    private long count = 0;
    public void write10(){
        int idx = 0;
        while (idx++ < 10000){
            count += 1;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        final Test test = new Test();
        Thread thread1 = new Thread(()->{
            test.write10();
        });
        Thread thread2 = new Thread(()->{
            test.write10();
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程结束,否则test.count输出为0
        thread1.join();
        thread2.join();

        System.out.println(test.count);
    }
}
// 输出结果为10645

如果是单核的CPU,那么结果就应该为20000,但是由于多核的原因,线程在各自的CPU缓存区执行,因此步骤如下: 首先A,B两个线程在内存中读取到各自的CPU缓存中,进行count += 1后,内存中的结果为1而不是2,是因为线程A、B是根据CPU缓存中的count来进行计算的。因此最终结果只会在10000到20000之间。

原子性

原子性指的是当多个操作在CPU执行的过程中不被中断的特性。倘若每个线程都有它自己的时间片(时间片指的是线程执行多少时间后就休眠),而任务切换(又叫线程切换)发生的时机大数又在时间片结束后。问题来了,我们现在使用的是高级编程,那么我们一句普通的语句在CPU中可能要好几条,这里用count += 1举例(count初始值为0):

  1. 首先,需要把变量 count =0从内存加载到 CPU 的寄存器.
  2. 将变量count 进行+1操作。
  3. 将结果存入到内存。

那么问题来了,任务切换可发生在CPU指令的任何一个时刻,就是说当线程A执行完指令2的时候就进行休眠,线程B执行同样的三个指令,当线程B将结果写入内存后并唤醒线程A,这时候内存中的count = 1,最需要注意的来了,线程A这时候的结果也是1,那么唤醒后执行指令3的时候,内存中的count会被覆盖,最后结果是1而不是2。如果觉得不明白可以看下图:
在这里插入图片描述
因此,在高级语言的层次上保持操作的原子性是很有必要的。

编译优化的有序性

啥是有序性,就是字面上的意思按顺序执行。例如 a = 0; b = 1;编译器有时候会因为优化的问题而调换一下次序,变成b = 1; a = 0。当然,这都是不影响结果的情况下。不过,有时候的优化调整就会造成一些想不到的结果,如下面这个双重检查单例对象:

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

假设线程A,B同时调用getInstance(),首先会判断instance是否为空,若为空,则用synchronized进行加锁,每次只能由一个线程访问,假设线程A加锁成功,线程B则是等待状态,线程A会new一个Singleton。完毕后线程A就会释放锁,线程B就会加锁,发现instance已经不为空了,则返回。

但是因为有了解释器和编译器的指令次序优化,就不一样了。new的操作顺序一般是这样:

  1. 分配内存M
  2. 在M中初始化Singleton对象。
  3. 然后把M的地址赋给instance。

但优化后的顺序是:

  1. 分配内存M
  2. 先把M的地址赋给instance。
  3. 最后在M中初始化Singleton对象。

这会造成什么问题呢?当线程A加锁并执行到指令2时,发生任务切换,但是这时候M中还没有初始化Singleton对象,而线程B会发现instance!= null,这就造成nullpoint异常了。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值