高效并发下的高速缓存和指令重排

1. 前言

    关于计算机系统处理器资源的高效使用,计算机系统设计就引入高速缓存以解决CPU 运算速度与主内存存储速度之间的速度不匹配问题;引入指令重排来提升 CPU 内部运算单元的执行利用效率。

    提升计算机处理器的运算能力,最简单、最有效的手段是让计算机支持多任务处理,可以充分利用处理器的运算能力。当然计算机操作系统的运算能力不单单取决于处理器,还需考虑系统中并行化与串行化的比重,磁盘I/O读写速度,网络通信,数据库交互等。

2. 高速缓存

2.1 高速缓存与缓存一致性

在这里插入图片描述

2.1.1 高速缓存

    计算机处理器运算速度远远超出计算机存储设备的读写速度。一定程度上存储设备的读写速度限制了计算机系统的运算能力,引入高速缓存作为处理器和存储设备之间的一层缓冲。高速缓存的存储速度接近处理器的运算速度,处理器无需等待主内存缓慢的读写操作,使得处理器高效的工作。

2.1.2 缓存一致性

  • 缓存一致性问题
    引入高速缓存很好的处理了主内存读写速度与处理器运算速度相差几个数量级的问题。
    但多处理器计算机系统下,存在某个时刻下,主内存中某个数据在不同处理器高速缓存中的数据不一致的情况。
  • 处理方案
    (1)处理器都是通过总线来和主存储器(主内存)进行交互的,所以可以通过给总线加锁,解决缓存一致性问题;

(2)可以通过引入缓存一致性协议,来处理缓存一致性问题。

    总线,总线英文标识为 Bus,公共汽车,总线是连接多个设备或者接入点的数据传输通路,处理器所有传出的数据都要通过总线交互主存储器。

    缓存一致性协议,要求处理器要遵循这些协议,这些协议规定了读写操作的规范来保证缓存一致性。

    Inter 处理器一般采用的是 MESI 协议。MESI(Modified Exclusive Shared Or Invalid)(也称为伊利诺斯协议,是因为该协议由伊利诺斯州立大学提出)是一种广泛使用的支持写回策略的缓存一致性协议,该协议被应用在Intel奔腾系列的CPU中。

2.2 工作内存与主内存

在这里插入图片描述

    理解了高速缓存,工作内存相似的,高速缓存是从处理器角度出发,工作内存是从线程角度出发。

    所有的变量存储在主内存中,每条线程有自己的工作内存。此处主内存仅是虚拟机内存的一部分,与 Java 内存模型(程序计数器、Java 堆、非堆、虚拟机栈、本地方法栈) 没有关联。

  • 工作内存中保存了当前线程使用到变量的主内存拷贝,
  • 线程对变量所有的操作都在工作内存中进行。
  • 不同线程之间无法直接访问对方工作内存的变量
  • 线程间变量值的传递均需通过主内存来完成,工作内存交互主内存。

2.3 线程间工作内存交互主内存

    每个线程都对应自己的工作内存,修改共享变量的值后,从当前工作内存保存并写入到主内存。同样的,共享变量被其他线程修改后的新值,当前线程需要从主内存读取并载入到当前工作内存,才能进行使用。

    Java 内存模型定义了以下八种原子操作来作用于线程工作内存与主内存的交互。

操作 名称 作用内存 操作说明
lock 锁定 主内存 标识某个变量为线程独占
unlock 解锁 主内存 释放某个被线程独占的变量
read 读取 主内存 变量的值从主内存传输到工作内存
load 载入 工作内存 把读取到的值放入工作内存的变量副本
use 使用 工作内存 把变量值传给执行引擎
assign 赋值 工作内存 把执行引擎接收到的值赋给变量
store 存储 工作内存 把工作内存变量的值传输到主内存
write 写入 主内存 变量值放入到主内存的变量中

3. 原子性、可见性、有序性

3.1.1 性质

  • 原子性
    众所周知,原子操作是不可再拆分的操作,即原子性操作是并发安全的;

    原子操作包含 read、load、assign、use、store、write。

    lock 和 unlock 操作支持我们对一个更大范围操作提供原子性保证。直观来说,synchronized 关键字,被该关键字修饰的代码块具有原子性,使用该关键字能保证代码块的线程安全。

    synchronized 反映到字节码指令,包含 monitorenter 和 monitorexit 指令,这两个指令隐式调用了 lock、unlock 操作。

  • 有序性
    在某个线程中所有的操作都是有序的。Java 程序中在另一个线程观察当前线程的操作,都是无序的。

    volatile 和 synchronized 都可保证线程之间操作的有序性。

    volatile 具备禁止指令重排序的能力。

    synchronized 具备 lock、unlock 能力,支持一个变量在同一时刻只许一个线程对其进行 lock 锁定操作。

  • 可见性
    可见性表现在多线程之间,一个线程修改了某个共享变量(线程间共享变量)的值,其他线程可以立即得到这个修改,即新值对其他线程是实时可见的。

    volatile 关键字修饰的共享变量,线程写入新值,线程间是可见的。

    volatile 变量与普通变量的区别,在于 volatile 变量的新值会立即 store 存储到主内存中,在使用 volatile 变量时会先从主内存 read 读取新值 load 载入到当前工作内存。而普通变量使用时不会立即从主内存刷新,当前工作内存若存在,则直接使用工作内存中变量的值。

3.1.2 可见性演示实例

    关于可见性,郭婶(郭霖)举了一个栗子,有助理解,这边就直接拿来了。

/**
 * @className: VisibilityDemo 
 * @description: 可见性演示实例
 **/
public class VisibilityDemo {
    private static volatile boolean flag = true;

    public static void main(String... args) {
        Thread thread1 = new Thread(() -> {
            while (true) {
                if (flag) {
                    flag = false;
                    System.out.println("Thread1 set flag to false");
                }
            }
        }, "Thread-01");
        Thread thread2 = new Thread(() -> {
            while (true) {
                if (!flag) {
                    flag = true;
                    System.out.println("Thread2 set flag to true");
                }
            }
        }, "Thread-02");
        // 分别启动两个线程
        thread1.start();
        thread2.start();
    }
}
  • 当共享变量 flag 为普通变量 private static boolean flag 时,程序中两线程会交替打印信息到控制台,一段时间后,两线程内部分支条件不再满足,将不再打印信息到控制台;
...
Thread2 set flag to true
Thread1 set flag to false
Thread1 set flag to false
Thread1 set flag to false
Thread2 set flag to true
Thread2 set flag to true
  • 当共享变量由 volatile 修饰时 private static volatile boolean flag,程序中两线程会持续交替打印信息到控制台;
...
Thread2 set flag to true
Thread1 set flag to false
Thread1 set flag to false
Thread1 set flag to false
Thread2 set flag to true
Thread2 set flag to true
...

3.1.3 可见性演示实例问题分析

    由于线程工作内存与主内存存在缓存延时问题

    一个普通的线程共享变量private static boolean flag,在上例中存在,随着程序的运行,在某个时刻线程 Thread-01 的 flag 为 false,线程 Thread-02 的 flag 为 true,此时两者都不会进入分支结构体,不再执行赋值操作,不再刷新工作内存数据到主内存。两个线程都会停止输出信息到控制台。

    声明为 volatile 变量private static volatile boolean flag,会保证共享变量每次赋值都会即时存储到主内存,每次使用共享变量时,会从主内存读取并载入到当前线程工作内存再使用。使用关键字后的程序,两线程会持续交替输出信息到控制台。

4. 指令重排

4.1 就你TMD叫指令重排啊

    在当前线程观察 Java 程序,所有操作是有序的,但在其他线程观察当前线程的操作是无序的。即线程内表现为串行的语义,多线程间存在工作内存与主内存同步延时及指令重排序现象。

4.2 指令重排的线程安全问题

  • 多线程下指令重排的线程安全问题
    我们知道处理器在指令集层面,会做一定的指令排序优化,来提升处理器运算速度。在单线程中可以保证对应高级语言的程序执行结果是正确的,即单线程下保证程序执行的有序性(及程序正确性);多线程情况下,在某个线程中观察其他线程的操作是无序的(存在线程共享内存时,则无法保证程序正确性),这就是多线程下指令重排的线程安全问题。

4.2.1 指令重排演示实例

import lombok.SneakyThrows;

/**
 * @description: 指令重排:线程内表现为串行语义
 * @author: niaonao
 **/
public class OrderRearrangeDemo {
    static boolean initFlag;
    public static void main(String... args) {
        Runnable customRunnable = new CustomRunnable();
        new Thread(customRunnable, "Thread-01").start();
        new Thread(customRunnable, "Thread-02").start();
    }

    static class CustomRunnable implements Runnable {
        // @SneakyThrows 是 lombok 包下的注解
        // 继承了 Throwable 用于捕获异常
        @SneakyThrows
        @Override
        public void run() {
            initFlag = false;
            Integer number = null;
            number = 1;
            initFlag = true;
            // 等待初始化完成
            while (!initFlag) {
            }
            System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
        }
    }
}

    上面这个例子,在实际并发场景中很少出现线程安全问题,但存在指令重排引起线程安全问题的风险。

  • 一般情况下执行结果为
name: Thread-01, number: 1
name: Thread-02, number: 1

Process finished with exit code 0
  • 指令重排存在的风险结果可能为
name: Thread-01, number: 1
name: Thread-02, number: null

Process finished with exit code 0

4.2.2 指令重排演示实例问题分析

    线程内保证程序的有序性,多线程下处理器指令重排优化存在的情况如下(这里从高级语言来快速理解,其实指令我也做不到啊),下面并没有列出所有情况。

    // 情况-01
    initFlag = false;
    Integer number = null;
    number = 1;
    initFlag = true;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    // 情况-02
    initFlag = false;
    Integer number = null;
    initFlag = true;
    number = 1;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    // 情况-03
    initFlag = false;
    initFlag = true;
    Integer number = null;
    number = 1;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情况-04
    Integer number = null;
    initFlag = false;
    number = 1;
    initFlag = true;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情况-05
    Integer number = null;
    initFlag = false;
    initFlag = true;
    number = 1;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    // 情况-06
    Integer number = null;
    number = 1;
    initFlag = false;
    initFlag = true;
    while (!initFlag) {
    }
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情况-07
    Integer number = null;
    initFlag = false;
    initFlag = true;
    while (!initFlag) {
    }
    number = 1;
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);
    
    // 情况-07
    Integer number = null;
    initFlag = false;
    initFlag = true;
    while (!initFlag) {
    }
    number = 1;
    System.out.println("name: " + Thread.currentThread().getName() + ", number: " + number);

    线程共享变量 initFlag 在线程 Thread-01 中已经执行 initFlag = true 操作后,在线程 Thread-02 中读取到 initFlag 为 true,就会跳出 while 循环,此时由于指令重排,number 可能还没有赋值为 1,程序打印到控制台的信息会是name: Thread-02, number: null

4.3 禁止指令重排序

    指令重排有线程安全风险,怎么避免呢?

    欸,问得好niaonao同学,请坐。Java 提供 volatile 关键字具备两个特性,一是可见性,一是禁止指令重排。如4.2.1 指令重排演示实例,就用 volatile 修饰共享变量 static boolean initFlag 即可。

    可见性就不再赘述了。关于禁止指令重排的原理是通过 volatile 修饰的共享变量,会添加一个内存屏障,处理器在做重排序优化时,无法将内存屏障后面的指令放在内存屏障前面。

Powered By niaonao