高效并发中的高速缓存与指令重排

1. 前言

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

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

2. 高速缓存

2.1 高速缓存与缓存一致性

在这里插入图片描述

图2-1、处理器与主内存交互缓冲图

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

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

    总线,总线英文标识为 Bus,公共汽车,总线是连接多个设备或者接入点的数据传输通路,处理器所有传出的数据都要通过总线交互主存储器。
    缓存一致性协议,要求处理器要遵循这些协议,这些协议规定了读写操作的规范来保证缓存一致性。
    Inter 处理器一般采用的是 MESI 协议。MESI(Modified Exclusive Shared Or Invalid)(也称为伊利诺斯协议,是因为该协议由伊利诺斯州立大学提出)是一种广泛使用的支持写回策略的缓存一致性协议,该协议被应用在Intel奔腾系列的CPU中。

2.2 工作内存与主内存

在这里插入图片描述

图2-2、线程工作内存与主内存图

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

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

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

    每个线程都对应自己的工作内存,修改共享变量的值后,从当前工作内存保存并写入到主内存。同样的,共享变量被其他线程修改后的新值,当前线程需要从主内存读取并载入到当前工作内存,才能进行使用。
    Java 内存模型定义了以下八种原子操作来作用于线程工作内存与主内存的交互。

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

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

3.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.2 有序性先行发生原则

  • 什么是先行发生?
        先行发生是 Java 内存模型中定义的两项操作之间的偏序关系。
        如果操作 A 先行发生于操作 B,那么 A 产生的影响都能被 B 观察到。观察到的影响包括且不限于修改内存中共享变量的值,发送消息,调用方法等。
  • 先行发生原则的重要性
        先行发生原则,是判断数据是否存在线程安全问题的主要依据
        在 Java 内存模型中所有的有序性仅仅靠 volatile 和 synchronized 来完成会很繁琐。依靠先行发生原则,可以判断并发下两个操作之间是否存在冲突,依据该原则可以解决冲突中出现的所有问题。
  • 先行发生原则
操作规则说明
程序次序规则单线程内,按控制流(考虑分支,循环结构)顺序执行,代码编写在前面的操作先于后面的操作执行
管程锁定规则一个 unlock 操作先于后面(时间上发生在后面)对同一个锁的 lock 操作
volatile 变量规则一个 volatile 变量的写操作先于后面(时间上发生在后面)对这个变量的读操作
线程启动规则Thread 的 start() 线程启动方法先于线程对象的所有操作
线程终止规则Thread 的所有操作都先于对此线程的代码检测检测到终止事件的发生
线程中断规则Thread 的 interrupt() 线程中断方法先于对此线程的代码检测检测到中断事件的发生
对象终结规则一个对象的初始化完成(构造函数执行完成)先于对象的 finalize() 方法的开始
传递性操作 A、B、C;有 A 先行发生于 B,B 先行发生于 C,则 A 先行发生于 C
表3-1、Java 内存模型先行发生原则表

3.3 先行发生原则演示实例

此例出于《深入理解 Java 虚拟机》一书。

	private int value = 0;
	public void setValue(int value) {
		this.value = value;
	}
	public int getValue() {
		return this.value;
	}

我们都知道这段代码是非线程安全的;下面从先行发生原则分析并解决这个线程安全问题;

  • 提出问题
    假设存在两个线程 Thread-01、Thread-02,
    线程 Thread-01 调用了 setValue(1),Thread-02 调用 getValiue(),
    Thread-01 的赋值操作先于(时间上先发生) Thread-02 的读取操作。线程 Thread-02 读取到的共享变量的值是 0 还是 1 ?
  • 问题分析
    此处为多线程,程序次序规则不适用;没有使用锁操作,管程锁定规则不适用;共享变量没有被 volatile 修饰,volatile 变量规则不适用;线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性和此处的操作没有关联,均不适用。
    所以无法确定程序执行时,线程 Thread-02 的读取操作与 Thread-01 的赋值操作的先行发生顺序。同时也说明了“时间先行发生”与“操作先行发生”不是必然的关联关系,前者仅仅是后者的必要非充分条件。
  • 问题解决
    可以对 setter、getter 方法加锁 synchronized 使其遵循管程锁定规则
    或者 volatile 修饰共享变量 value,使其遵循volatile 变量规则

    时间先后顺序是先行发生原则的必要不充分条件,实际衡量并发安全问题时一切以先行发生原则为准。
    下文会提到处理器的指令重排序优化,关系到程序次序原则,但并不影响先行发生原则的正确性。

3.4 可见性演示实例

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

/**
 * @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.5 可见性演示实例问题分析

    由于线程工作内存与主内存存在缓存延时问题
    一个普通的线程共享变量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

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 博客之星2020 设计师:CY__0809 返回首页