并发编程BUG源头

背景

核心矛盾

CPU/内存/IO设备的速度差异

解决思路

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

异常根源

并发程序问题的根源便是以上解决方案。

cpu缓存导致的可见性问题

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到

硬件程序员给软件程序员挖的坑~ 不同线程可能会对应不同cpu缓存。

public class Test {

   private static long count = 0;

   public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException {
       ExecutorService executorService = Executors.newCachedThreadPool();

       CompletableFuture<Void> f1 = CompletableFuture.runAsync(Test::add100K, executorService);
       CompletableFuture<Void> f2 = CompletableFuture.runAsync(Test::add100K, executorService);

       CompletableFuture.allOf(Arrays.asList(f1, f2).toArray(new CompletableFuture[0])).get(2, TimeUnit.SECONDS);

       System.out.println(Test.count); // 134410

       executorService.shutdown();
   }

   private static void add100K() {
       for (int i = 0; i < 100000; i++)
           count++;
   }

}

值为100000-200000之间的随机数。
因为假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 200000的。这就是缓存的可见性问题。

线程切换带来的原子性问题

原子性:一个或者多个操作在执行的过程中不被中断的特性

IO太慢,所以操作系统发明来多进程。即便单核cpu也同时支持多个操作。

操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。

image

进程在等待 IO 时会释放 CPU 使用权,为了让 CPU 在这段等待时间里可以做别的事情,提升来 CPU 的使用率;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,提升来 IO 的使用率。

高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。

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

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完。假设以下情况,就可能导致数据错误。

image

CPU 能保证的原子操作是 CPU 指令级别的,故必要时,需要在高级语言层面保证保证原子性。

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

有序性:程序按照代码的先后顺序执行

编译器为了优化性能,有时候会改变程序中语句的先后顺序。

经典案例:单例模式双重检查锁。


public class Singleton {
  private 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 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:分配一块内存 M;在内存 M 上初始化 Singleton 对象;然后 M 的地址赋值给 instance 变量。但是实际上优化后的执行路径却是这样的:分配一块内存 M;将 M 的地址赋值给 instance 变量;最后在内存 M 上初始化 Singleton 对象。优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
解决办法加上volatile禁止重排序即可。如private static volatile Singleton instance;
image

在Java中,volatile关键字能够确保每个线程都访问共享变量的最新值,主要是通过禁止指令重排序和禁止处理器缓存来实现的。
1. 禁止指令重排序
  Java内存模型规定,对于一个volatile变量的读操作,在其后所有的读写操作之前必须完成。也就是说,volatile变量的读操作具有原子性,不会被重排序。这可以确保每个线程在访问共享变量时都获取到最新的值。
2. 禁止处理器缓存
  volatile关键字告诉Java虚拟机(JVM),对于这个变量,不要在处理器缓存中进行优化。这意味着每次访问这个变量时,都会直接从主内存中读取,而不是从处理器缓存中读取。这可以确保每个线程都访问到共享变量的最新值,避免出现数据不一致的问题。
posted @ 2023-03-08 19:44  kiper  阅读(18)  评论(0编辑  收藏  举报