随着多核 CPU 的出现,为了更好的去压榨 CPU 资源,就需要多个线程同时运行。尤其是在 IO 操作频繁的场景,由于 IO 操作需要 CPU 进行等待,也就是 CPU 这段时间是空闲的,那么就需要更多的线程来利用这段时间的空闲出来的 CPU 资源。所以,在多核 CPU 下更高效的去使用系统的资源,就是多线程的重要使用目的。

为什么会有多线程

在单 CPU 的年代多任务是共享一个 CPU 的,当一个任务占用 CPU 运行时,其他任务就会被挂起,当占用 CPU 的任务的时间片用完之后,就会把 CPU 交给其他的任务来使用,所以在单 CPU 的时代多线程编程是没有太大意义的,因为线程间频繁的上下文切换还会带来额外的开销。

多 CPU 时代的到来打破了单核 CPU 对多线程效能的控制。多核 CPU 意味着每个线程可以使用自己的 CPU运行,这减少了线程上下文切换的开销。那么,多线程能够为我们的程序带来什么收益呢?

  1. 更好的资源利用。在 IO 操作过程中主动释放 CPU,为另一个任务的执行提供资源
  2. 简化程序设计。不再需要将所有代码逻辑写在同一个线程中,可以划分模块,将复杂的,或者大型的任务分发到另一线程去执行,简化程序设计
  3. 提高程序响应速度。将耗时任务或 IO 操作放到另一个线程去执行,方便当前线程的快速响应

但是,也不要为了多线程而多线程。多线程即使能够带来一定的收益,但是滥用的话也会出现一些问题:

  1. 对临界资源的访问难以控制
  2. 上下文切换次数过高。当线程切换时,需要保存现场,代价高昂
  3. 增加资源消耗。管理更多的资源,需要更多的内存

线程间通信机制

在多线程环境中,线程之间是如何来交换信息的呢?目前有两种方式:

  1. 共享内存
  2. 消息传递

共享内存

在 JVM 结构模型中,堆和方法区是线程共享区域,也就是说所有的线程都可以访问到堆空间或者方法区中的数据,Java 中实现线程通信就是利用这个机制,通过访问和更新这个共享内存区域的数据来达到通信的目的。但是这种机制因为存在多个线程对同一分资源进行访问的情况,就容易出现并发问题,需要使用同步或加锁的手段去保证对临界资源的访问的正确性。

消息传递

消息传递机制是不同线程之间通过发送消息来达到交互的目的。线程之间没有公共状态,线程之间必须通过明确的消息来显示进行通信。由于消息的发送必须发生在消息的接收之前,因此同步时隐式进行的。

CPU 物理缓存结构

在计算机快速发展的历史中,一直有一个问题存在,那就是 CPU、主存以及 IO 设备三者之间的速度差异过大。由于 CPU 的速度过快,往往需要等待主存的响应,这样导致了 CPU 大部分时间不是在做运算,而是在等待和主存的 IO 操作。为了解决这个问题,操作系统一般会在 CPU 和主存之间添加高速缓存,称之为 L1,L2,L3 三级缓存,用来缓解两者之间的巨大速度差异。

每个 CPU 核心都有独立的 L1,L2 缓存,其中 L1 还分为 L1i(存储指令)和 L1d(存储数据),L1 缓存较小,但速度最快;其次是 L2 缓存,它不区分指令和数据,速度次之;最后是 L3,L3 是最大的缓存,且是所有核心公用的,同时速度也是缓存中最慢的。因此得到一个结论:CPU > 寄存器 > L1 > L2 > L3 > 内存 > 磁盘。越靠近 CPU 的速度越快,存储容量越小,单位成本越高,离 CPU 越远的速度越慢,存储容量越大,单位成本越低

image.png
图 - CPU 高速缓存结构

在增加了 CPU Cache 之后,CPU 将数据从主存中读到了 Cache 中,这样就不用每次都从主存中读取数据,减少了 CPU 与 主存之间的 IO 操作,提高了性能。CPU 每次从主存中读取一块内容到 Cache 中,这个内容块称之为 Cache Line。Cache Line 的大小一般都是 2 的 N 次方,在 16 ~ 256 byte 之间。当 CPU 访问没有命中 Cache 中的数据时,称之为 Cache Dismiss。

:::info Cache Line 得益于时间局部性和空间局部性原理,即访问了当前地址的数据,那么下一个需要访问的数据很有可能是下一个地址的数据。所以 Cache Line 每次才需要读一块内容,也就是将当前访问的数据相邻地址的数据读到 Cache 中,每次读的数据块就是一个 Cache Line。 :::

CPU 内核读取数据时,先从 L1 高速缓存中读取,如果没有命中,再到 L2、L3 高速缓存中读取,假如这些高速缓存都没有命中,它就会到主存中读取所需要的数据。高速缓存大大缩小了高速 CPU 内核与低速主存之间的速度差距。以三层高速缓存架构为例:

  • L1 高速缓存最接近 CPU,容量最小(如32KB、64KB等)、存取速度最快,每个核上都有一个 L1 高速缓存
  • L2 高速缓存容量更大(如256KB)、速度低些,在一般情况下,每个内核上都有一个独立的 L2 高速缓存
  • L3 高速缓存最接近主存,容量最大(如12MB)、速度最低,由在同一个 CPU 芯片板上的不同 CPU 内核所共享

CPU 通过高速缓存进行数据缓存进行数据读写有以下优势:

  1. 写缓冲区可以保证指令流水线持续运行,可以避免由于CPU停顿下来等待向内存写入数据而产生的延迟
  2. 通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用

MESI 缓存一致性协议

由于每个线程可能会运行在不同的 CPU 内核中,因此每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 内核中,在不同 CPU 内核中运行的线程看到同一个变量的缓存值就会不一样,就可能发生内存的可见性问题。

:::info 为了解决内存可见性问题,CPU 主要提供了两种解决方案:总线锁和缓存所。 :::

总线锁

操作系统提供了总线锁机制。前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号,通过地址总线发送地址信号指定其要访问的部件,通过数据总线实现双向传输。

CPU 访问内存数据都需要经过总线。在 CPU 访问共享内存数据的时候向总线上发出一个 LOCK# 信号,这个信号可以阻塞其他想要通过总线来访问共享内存数据的处理器。也就是说,消息总线被锁住了,在未解锁之前,其他 CPU 一概不能访问共享内存数据。该机制由于是锁总线,开销较大,不合适使用。

总线锁的缺陷是:某一个 CPU 访问主存时,总线锁把 CPU 和主存的通信给锁住了,其他 CPU 不能操作其他主存地址的数据,使得效率低下,开销较大

总线锁的粒度太大了,最好的方法就是控制锁的保护粒度,只需要保证被多个 CPU 缓存的同一份数据一致即可。所以引入了缓存锁(如缓存一致性机制),后来的 CPU 都提供了缓存一致性机制,Intel 486 之后的处理器就提供了这种优化。

缓存锁

相比总线锁,缓存锁降低了锁的粒度。为了达到数据访问的一致,需要各个 CPU 在访问高速缓存时遵循一些协议,在存取数据时根据协议来操作。最常见的就是 MESI 协议。

就整体而言,缓存一致性机制就是当某 CPU 对高速缓存中的数据进行操作之后,通知其他 CPU 放弃存储在它们内部的缓存数据,或者从主存中重新读取。

为了提高处理速度,CPU不直接和主存进行通信,而是先将系统主存的数据读到内部高速缓存(L1、L2或其他)后再进行操作,但操作完不知道何时会写入内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向 CPU 发送一条带 lock 前缀的指令,将这个变量所在缓存行的数据写回系统主存。

但是,即使写回系统主存,如果其他CPU高速缓存中的值还是旧的,再执行计算操作也会有问题。所以,在多 CPU 的系统中,为了保证各个 CPU 的高速缓存中数据的一致性,会实现缓存一致性协议,每个 CPU 通过嗅探在总线上传播的数据来检查自己的高速缓存中的值是否过期,当 CPU 发现自己缓存行对应的主存地址被修改时,就会将当前 CPU 的缓存行设置成无效状态,当 CPU 对这个数据执行修改操作时,会重新从系统主存中把数据读到 CPU 的高速缓存中。

因为高速缓存的内容是部分主存内容的副本,所以应该与主存内容保持一致。而 CPU 对高速缓存副本如何与主存内容保持一致有几种写入模式供选择,主要的写入模式有以下两种:

  1. Write-Through(直写)模式:在数据更新时,同时写入低一级的高速缓存和主存。此模式的优点是操作简单,因为所有的数据都会更新到主存,所以其他 CPU 读取主存时都是最新值。此模式的缺点是数据写入速度较慢,因为数据修改之后需要同时写入低一级的高速缓存和主存
  2. Write-Back(回写)模式:数据的更新并不会立即反映到主存,而是只写入高速缓存。只在数据被替换出高速缓存或者变成共享(S)状态时,如果发现数据有变动,才会将最新的数据更新到主存。此模式的优点是:数据写入速度快,因为发生数据变动时不需要写入主存,所以这种模式占用总线少,大多数CPU的高速缓存采用这种模式;此模式的缺点为:实现一致性协议比较复杂,因为最新值可能存放在私有高速缓存中,而不是存放在共享的高速缓存或者主存中

MSI 协议

缓存一致性协议的基础版称之为 MSI 协议,也叫作写入失效协议。如果同时有多个 CPU 要写入,总线会进行串行化,同一时刻只会有一个 CPU 获得总线的访问权。比如 CPU c1、c2 对变量 m 进行读写,采用缓存回写模式,总线操作如下表所示:

CPU操作 总线操作 c1 缓存内容 c2 缓存内容 主存 m 所在地址的内容
0
c1 读取 m 高速缓存中没有 m,从主存中读取 0
0
c2 读取 m 高速缓存中没有 m,从主存中读取 0 0 0
c1 读取 1 到 m 通知 c2,使它的高速缓存中的 m 的值失效 1 0
c2 读取 m 的值 高速缓存中没有 m,从 c1 的高速缓存中读取(采用写回模式,并且更新到主存中) 1 1 1

上表中第二次读取 m 时,c1 会将 m 的最新值返回给 c2,并且更新主存中 m 的值,c1 和 c2 的 m 值会变成共享状态。

MSEI 协议及 RFO 请求

MSEI是 MSI 的扩展 。缓存一致性协议 MESI 用于管理多个 CPU Cache 之间的数据的一致性。MESI 指的是 4 种状态中的首字母,每个 Cache Line 有 4 种状态,可用 2 个 bit 来表示,分别为:

  • M(Modified)

该缓存行的数据只在本 CPU 的私有高速缓存中进行了缓存,而其他 CPU 中没有,是被修改过的(Dirty),即与主存中的数据不一致,且没有更新到内存中。该缓存行中的内存需要在未来的某个时间点(允许其他 CPU 读取主存中相应的数据之前)写回(Write Back)主存。当被写回主存之后,该缓存行的状态会变成独享(Exclusive)状态。

简单来说,处于 Modified 状态的缓存行数据只在本 CPU 中有缓存,且其数据与内存中的数据不一致,数据被修改过。

  • E(Exclusive)

该缓存行的数据只在 CPU 的私有高速缓存中进行了缓存,而其 CPU 中没有,缓存行的数据是未被修改过的(Clean),并且与主存中的数据一致。该状态下的缓存行在任何时刻被其他 CPU 读取之后,其状态将变成共享状态。在本 CPU 修改了缓存行中的数据后,该缓存行的状态可以变成 Modified 状态。

简单来说,处于 Exclusive 状态的缓存行数据只在本 CPU 中有缓存,且其数据与内存中一致,没有被修改过。

  • S(Shared)

该缓存行的数据可能在本 CPU 以及其他 CPU 的私有高速缓存中进行了缓存,并且各 CPU 私有高速缓存中的数据与主存数据一致(Clean),当有一个 CPU 修改该缓存行时,其他 CPU 中该缓存行将被作废,变成无效状态。

简单来说,处于 Shared 状态的缓存行的数据在多个 CPU 中都有缓存,且与主存一致。

  • I(Invalid)

该缓存行是无效的,可能有其他CPU修改了该缓存行。

当新的数据要进入 Cache 中时候,优先选择 Invalid 状态的 Cache Line。之所以如此是因为选择其他状态的 Cache Line 会发生 Cache Line 中的数据置换,而置换后的数据被访问到很大可能会产生 Cache Miss,造成较大的开销。

假设现在有一个变量 a=1 已经加载到 CPU 的 Core1、Core 2、Core 3 的私有高速缓存中,准确地说,应该是包括变量 a 的缓存行被加载到高速缓存中,此时各内核中该缓存行的状态为S:

image.png

如果 Core 1 将变量 a 的值改为 2,那么在 Core 1 的高速缓存中,该缓存行的状态将变为 M,在 Core 2、Core 3 的高速缓存中,该缓存行的状态将变为 Invalid :
image.png

这 4 中状态的转换过程如下:

  1. 初始阶段:开始时,缓存行没有加载任何数据,所以它处于“ I状态”;
  2. 本地写**(Local Write)**阶段:如果 CPU 内核写数据到处于“ I状态”的缓存行,缓存行的状态就变成“M状态”;
  3. 本地读**(Local Read)**阶段:如果本地 CPU 读取处于“I状态”的缓存行,很明显此缓存没有数据给它。此时分两种情况:
    1. 其他 CPU 的高速缓存中也没有此行数据,那么从内存加载数据到此缓存行后,将它设成“E状态”,表示只有“我”有此行数据,其他 CPU 都没有;
    2. 其他 CPU 的高速缓存有此行数据,就将此缓存行的状态设为“S状态”(注意:处于“M状态”的缓存行,再由本地 CPU 写入/读出,状态是不会改变的);
  4. 远程读**(Remote Read)**阶段:假设我们有两个 CPU c1 和 c2,如果 c2 需要读 c1 的缓存行内容,c1 需要把它的缓存行内容通过主存控制器(MemoryController)发送给 c2,c2 接收到后将相应的缓存行状态设为“S状态”。在设置之前,主存要从总线上得到这份数据并保存
  5. 远程写**(Remote Write)**阶段:其实确切地说不是远程写,而是 c2 得到 c1 的数据后,不是为了读,而是为了写。也算是本地写,只是 c1 也拥有这份数据的拷贝,这该怎么办呢?c2 将发出一个 RFO(Request For Owner)请求,说明它需要拥有这行数据的权限,其他 CPU 的相应缓存行设为“I状态”,除了它之外,谁也不能动这行数据。这就保证了数据的安全,但处理 RFO 请求以及设置“I状态”的过程将给写操作带来很大的性能消耗。

image.png

并发编程的来龙去脉

并发编程解决的是多个线程如何安全地访问同一资源的问题。这个问题的核心在于以下3点:

  • 可见性问题
  • 原子性问题
  • 有序性问题

CPU 切换线程导致的原子性问题

Java 中,原子性表示的是一组要么成功,要么失败的操作。例如, int i = 0; 就是一个原子操作,这是由硬件支持的原子操作。这个操作一旦执行是不能够被打断的,不可打断也正是原子操作的特性。

为了提升 CPU 的效率,操作系统采用时间片分配算法对进程进行调度,每一个时间片执行完之后就换切换进程执行,保证 CPU 资源不会因为进程等待 IO 等操作而浪费。后来操作系统在 CPU 切换进程的基础上又做了进一步的优化,已更细维度的线程来切换任务,更进一步提高了 CPU 的利用率。由于 CPU 切换着不同的线程,因此每一条线程都不能原子性的执行完成,有可能执行到一半就切换到了别的线程执行,这就导致了原子性问题。

下面是一端简单的代码:

  1. public class AtomicTest {
  2. static int sum = 0;
  3. public static void main(String[] args) {
  4. sum++;
  5. }
  6. }

使用 javap 命令对生成的字节码进行反汇编得到如下内容:

  1. javap -c L:\Java\project\...\com\bujian\concurrence\AtomicTest.class
  2. Compiled from "AtomicTest.java"
  3. public class com.bujian.concurrence.AtomicTest {
  4. static int sum;
  5. public com.bujian.concurrence.AtomicTest();
  6. Code:
  7. 0: aload_0
  8. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  9. 4: return
  10. public static void main(java.lang.String[]);
  11. Code:
  12. 0: getstatic #2 // Field sum:I
  13. 3: iconst_1
  14. 4: iadd
  15. 5: putstatic #2 // Field sum:I
  16. 8: return
  17. static {};
  18. Code:
  19. 0: iconst_0
  20. 1: putstatic #2 // Field sum:I
  21. 4: return
  22. }
  1. 第 15 行表示获取当前sum变量的值,并且放入栈顶
  2. 第 16 行表示将常量1放入栈顶
  3. 第 17 行表示当前栈顶中的两个值(sum的值和1)相加,并把结果放入栈顶
  4. 第 18 行表示把栈顶的结果再赋值给sum变量

如果这4个操作之间发生了线程切换,由于 i++ 不是原子操作,那么在并发环境中就会发生原子性问题。

JMM 模型缓存导致的可见性问题

一个线程对共享变量的修改能够被另一个线程立马感知到,称之为共享变量具备内存可见性。

由于 JMM(Java Memory Model,Java内存模型)规定:将所有的变量都存放在公共主存中,当线程使用变量时会把主存中的变量复制到自己的工作空间(或者叫私有内存)中,线程对变量的读写操作,是自己工作内存中的变量副本。

如果多个线程同时操作一个共享变量,就会出现可见性问题。例如:

  1. 主存中有变量 sum,初始值为 0
  2. 线程 A 计划将 sum 加 1,先将 sum=0 复制到自己的私有内存中,然后更新 sum 的值。线程 A 操作完成之后其私有内存中 sum 的值为 1,然而线程 A 将更新后的 sum 值回刷到主存的时间是不固定的
  3. 在线程 A 没有回刷 sum 到主存前,刚好线程 B 同样从主存中读取 sum,此时值为 0,和线程 A 进行同样的操作,最后期盼的 sum=2 目标没有达成,最终 sum=1

线程B没有将sum变成2的原因是:线程A的修改还在其工作内存中,对线程B不可见,因为线程A的修改还没有刷入主存。这就发生了典型的内存可见性问题。

要想解决多线程的内存可见性问题,所有线程都必须将共享变量刷新到主存,一种简单的方案是:使用Java提供的关键字volatile修饰共享变量。

指令重排序导致的有序性问题

所谓程序的有序性,是指程序按照代码的先后顺序执行。如果程序执行的顺序与代码的先后顺序不同,并导致了错误的结果,即发生了有序性问题。

什么是指令重排序呢?一般来说,CPU为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行顺序同代码中的先后顺序一致,但是它会保证程序最终的执行结果和代码顺序执行的结果是一致的。

Java 领域有一个经典的有序性问题—double check 单例因为指令重排序导致异常的问题。

  1. public class Singleton {
  2. private static Singleton instance;
  3. public static Singleton getInstance() {
  4. if (instance == null) {
  5. synchronized (Singleton.class) {
  6. if (instance == null) {
  7. instance = new Singleton();
  8. }
  9. }
  10. }
  11. return instance;
  12. }
  13. }

由于 new 操作实际上应该对应3条指令:

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

但是在指令重排序之后,执行顺序会变成 1-3-2。假设现在用 A、B 两条线程同时进入该方法,A 线程先获取到锁并按照 1-3-2 的顺序执行到了第3步,即此时 instance 变量已分配了内存,但是还未初始化,那么此时切换到了 B 线程执行,那么线程B在执行第一个判断时会发现 instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。如下图所示:

Java并发编程—多线程、MESI 以及并发特性 - 图5
图片来源:Java多线程 – 有序性问题

参考

CPU高速缓存Cache

带你了解缓存一致性协议 MESI