乐趣区

并发编程笔记1并发bug的成因



#### 前言:
    最近在学习并发编程的知识, 打算好好学习下并发这块, 之前有处理过并发的问题, 但是学的不够体系, 知识比较零散.
所以买了极客时间的并发课程(java 并发编程实战)和《并发编程实战》从头系统化的学一遍。这里记录一下自己的学习过程和心得之类的。

    首先咱们得知道为啥会产生并发编程的 bug,究其根本原因是因为 cpu 处理速度 >> 内存 >>I/O,这里用了 ”>>”, 是远远大于的意思,极客时间里面的例子更形象说是
天上一天,人间一年。天上指的是 cpu 对于内存,内存对于 I /O. 用来凸显其处理速度的差距。因为有了这三者的差距,cpu 的利用率就比较低,一直得等待内存,I/ O 处理完。
所以为了减少这三者的差距,做出了三种优化:
1.CPU 增加了缓存,以均衡与内存的速度差异;
2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

这三类优化相应的耶带来了三种问题,都可能会导致并发问题:

1. 缓存的可见性问题:
    上面的第一种优化,在 cpu 和内存之间增加了缓存,在多核 cpu 的情况下,每个 cpu 可能都有自己的缓存。现在假设一个 value=0,当线程 a 在 cpu1 的缓存中给 value 加 1,线程 b 在 cpu 的缓存中给 value 加 1,它们再往内存中刷新 value 的值的时候,可能顺序并不是我们想的 value 变为 2.
可能的执行过程是线程 a,b 如果都将 value 从内存中读入到自己的 cpu 缓存中,线程 a 先给 value 加了 1,然后刷新到内存中 value 变为 1,此时线程 b 也将 value 加 1,刷新到内存中,覆盖了 a 写入的值,value 还是 1. 这就是缓存的可见性问题,各自的 cpu 缓存并不知道,其他线程也对相同的值做了操作。
比较经典的例子就是,启动多个线程对同一个变量进行增加操作,得出来的值不是预想的值。

2. 线程切换导致的原子性问题:
    这个问题其实比较好理解,我们学过操作系统知道,多进程操作系统在执行任务的时候,不是串行的进程 a 执行完,再去执行进程 b,而是每个进程有一个操作系统分配的执行时间(称为是时间片),当进程 a 时间片消耗完了,
就切换到另一个进程。这样进程之间交替的执行,又因为时间片的时间非常短,用户是感知不出来这种切换的。介绍完进程的切换,咱们再说说像 java 这种的高级语言,对应的底层操作系统指令可能是好多条。
比如我们想的 count+=1,对于操作系统就不是原子操作,可能是 1,2,3… 好几条,那么有两个线程都对 count 加 1 的时候,如果进程 a 中 count 的值还没有加完,此时操作系统给进程 a 的时间片用完了,切换到进程 b,这时候进程 b 读到的还是没有增加的的值,那么计算结果就不符合我们的预期。

3. 编译优化带来的指令执行次序问题:
    我们写 java 的知道 volatile 关键字有使变量可见和禁止指令重排序的作用,其实就是针对的这个问题。编译器的优化能大大提高执行效率,但是也会带来一些问题,“int a = 1;int b = 2”这种被优化成“int b = 2;int a = 1”倒是问题不大。
这里引用极客时间的例子:

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

这是双重校验锁的单例,代码执行顺序是
1. 分配一块内存 M;
2. 在内存 M 上初始化 Singleton 对象;
3. 然后 M 的地址赋值给 instance 变量。
可能会被优化成:
1. 分配一块内存 M;
2. 将 M 的地址赋值给 instance 变量;
3. 最后在内存 M 上初始化 Singleton 对象。
这样就可能会导致下面这种情况,线程 a 调用 getInstance(),a 如果处于被优化的步骤 2,然后时间片用完了,此时线程 b 进来,执行到第一个 instance == null 这里,发现 instance 不为 null,就返回了未初始化的 instance。此时访问 instance 对象可能抛出空指针异常。

退出移动版