写点什么

什么是线程安全?并发问题的源头

用户头像
Geek_571bdf
关注
发布于: 2021 年 05 月 05 日
  1. 什么是线程安全?

根据《Java 并发编程实战》一书中的定义,线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性,这里的状态反映在程序中其实可以看作是数据。因此,如果状态不是共享的,或者是不可修改的,那也就不存在线程安全问题。


具体说,线程安全需要保证几个特性:原子性、可见性、有序性。


  1. 线程切换带来的原子性。

我们将一个或多个操作在 CPU 上执行的过程中不被中断的特性称为“原子性”。CPU 能保证的原子性是指令级别的,而我们使用的高级语言中,一条语句往往对应多条 CPU 指令。比如 count += 1 至少需要这三条 CPU 指令:

  • 指令 1:首先,把变量 count 从内存加载到 CPU 的寄存器;

  • 指令 2:接着,在寄存器中执行 +1 操作;

  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

 

但由于发生线程切换的时机可以发生在任何一条 CPU 指令执行完(时间片结束)。那就可能出现问题了。举个例子,线程 A 在执行完指令 1 后发生线程切换(count=0),线程 B 执行完了上面 3 条指令,此时 count=1,之后切换回线程 A 继续执行指令 2 和 3。最后会发现 count=1 而不是期望的 2。


  1. 缓存带来的可见性问题。

我们将一个线程对共享变量的修改,另一个线程能立即看到的特性称为“可见性”。不过,在多核 CPU 时代,每个核上都有自己的缓存,多个线程可能并行的运行在 CPU 的不同核上,对共享变量的读写都是在缓存中。此时,就存在可见性问题。


举个例子,我们分别启动两个线程执行 add10K()

public class Test {  private long count = 0;  private void add10K() {    int idx = 0;    while(idx++ < 10000) {      count += 1;    }  }  public static long calc() {    final Test test = new Test();    Thread th1 = new Thread(()->{      test.add10K();    });    Thread th2 = new Thread(()->{      test.add10K();    });
th1.start(); th2.start();
// 等待th1、th2执行结束 th1.join(); th2.join(); return count; }}
复制代码


我们期望 count 返回值是 20000,但实际上结果是 10000 到 20000 之间的随机数。我们假设线程 a 和线程 b 同时执行,那么第一次都会将 count 读进各自 CPU 的缓存中。执行完 count+=1 之后,各自 CPU 缓存中的值都是 1,同时写入内存后,会发现内存中是 1,而不是期望的 2。之后,由于各自的 CPU 缓存中都有了 count 值,于是两个线程都是基于 CPU 缓存里的 count 值来计算的。这就最终导致 count 的值小于 20000。


  1. 编译优化带来的有序性。

编译器的优化有可能会将指令重排。下面是一段经典的双重检测创建单例对象。

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


在低版本的 Java 中,上面的写法是存在问题的(高版本的 Java 在 JDK 中解决了该问题,即将对象 new 操作和初始化操作设计为原子操作,自然就解决了重排序问题)。问题出在 new 上。我们期望的 NEW 操作应该是:

  1. 分配一块内存 M;

  2. 在内存 M 上初始化 Singleton 对象;

  3. 然后 M 的地址赋值给 instance 变量。

 

但经过编译优化的结果却是这样:

  1. 分配一块内存 M;

  2. 将 M 的地址赋值给 instance 变量;

  3. 最后在内存 M 上初始化 Singleton 对象。

 

这就会导致,假设线程 A 执行完指令 2 时发生线程切换,线程 B 进入 getInstance()方法,判断 instance!=null,于是直接返回 instance 对象。但此时的 instance 对象是还未初始化的。这个时候在引用时 instance 中的成员变量时就会抛出空指针异常。


用户头像

Geek_571bdf

关注

还未添加个人签名 2019.06.13 加入

还未添加个人简介

评论

发布
暂无评论
什么是线程安全?并发问题的源头