不懂 volatile, 也敢来面试?

volatile 是 Java 的关键字, 用于修饰变量, 是 Java 提供的最轻量的同步机制, 它有两大作用, 一是保证变量的可见性, 二是禁止 JVM 指令重排

在讲 volatile 之前, 我们需要了解一些 CPU 的有关知识

CPU

一个 CPU 有多个处理器 (多核 CPU) , 以下严格区分处理器和 CPU 的概念
在这里插入图片描述

一个处理器主要由这些玩意组成 :

  • 逻辑运算单元 (ALU) : 用于进行各种算术逻辑运算(如与, 或, 非等), 算术运算(如加减乘除)
  • 寄存器 (Registers) : 用来存放操作数, 中间结果和各种地址信息的一系列存储单元
  • 程序计数器 (PC) : 用于存放下条指令在主存中的地址
  • 高速缓存L1
  • 高速缓存L2

L3 高速缓存位于 CPU 中 (也有可能位于主板上), 被所有处理器共享

从CPU到 大约需要的 CPU 周期 大约需要的时间
主存 约60-80纳秒
QPI 总线传输 约20ns
L3 cache 约40-45 cycles, 约15ns
L2 cache 约10 cycles, 约3ns
L1 cache 约3-4 cycles, 约1ns
寄存器 1 cycle

CPU 高速缓存的速度, 大约是内存的 100 倍, 是磁盘的 100000 倍

超线程

买过电脑的同学应该听过, 四核八线程, 六核十二线程这样的术语. 但是我们知道单核 CPU 在某一时刻只能执行一条线程, 那么这是否矛盾?

当核心正在执行一个线程时, 该线程会使用一个 Registers 和 PC 用于计算和保存数据, 在超线程 CPU 中, 一个处理器拥有一个 ALU , 两个 Registers 和两个 PC. 这样, 就能使得一个处理器在某一时刻同时执行两个线程 (共用一个ALU)

缓存行 (cache line)

当 ALU 需要一个数据时, 它最先会从 L1 缓存中读, 如果没有就去 L2, 然后 L3, 如果还是读不到, 就会从主内存中读取. 读取的不是单一数据, 而是读取一块数据, 而这一块数据, 称为缓存行 (cache line) . 写的时候也是如此, 先写到 L1, 然后 L2, L3, 最后写回主内存

在 intel CPU 中, 一个缓存行占 64 字节, 是 CPU 高速缓存中可以分配的最小存储单元
在这里插入图片描述

缓存行对齐

CPU 高速缓存中每次读写的都是缓存行, 如果一个对象的所有数据位于同一缓存行中, 那么他们就会相互锁定, 大大影响了并发效率 , 所以使用缓存行对齐 , 避免出现这种现象

通过追加字节的方式, 将两个数据分开位于不同的缓存行之中, 这样如果修改其中某个数据时, 只会锁住那一个缓存行, 位于其他缓存行中的数据不受影响

static class Two {
// 如果使用追加字节, 那么每个 Ones 数组中的每个对象都超过64字节, 位于不同缓存行
// 当数组某个元素修改时, 只会锁住其所在缓存行, 其他缓存行不受影响
// t1, t2 不用每次从主内存中获取最新值, 大大提升了并发效率
    volatile long d2, d3, d4, d5, d6, d7, d8;
}
// 如果不继承 Two, ones 数组的两个对象大概率位于相同缓存行中
// 当 t1 线程修改 data1 时, 会锁住整个缓存行
// 由于加了 volatile, t2 线程发现缓存行数据已被修改, 必须从主内存中刷新
static class One /*extends Two */{
    volatile long data1;
}

public static void main(String[] args) throws Exception {
    One[] ones = new One[2];
    ones[0] = new One();
    ones[1] = new One();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000000; i++) {
            ones[0].data1++;
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000000; i++) {
            ones[1].data1++;
        }
    });
    long start = System.currentTimeMillis();
    t1.start();t2.start();t1.join();t2.join();
    System.out.println(System.currentTimeMillis() - start);
}

Doug Lea 在他的 java.util.concurrent 包中大量使用了缓存行对齐的方式

缓存一致性协议

为了解决在多核处理器, 各自的缓存行数据不一致问题, 需要各个处理器访问缓存时遵循一些协议, 比如 Intel 的 x86 架构采用的缓存一致性协议为 MESI, 共有四种状态

  1. Modified : 已修改
  2. Exclusive : 独占
  3. Shared : 共享
  4. Invalid : 失效

如果 MESI 不能解决数据一致性问题, 就需要锁总线, 禁止其他处理器对该块内存进行处理, 这种方式效率远远不如 MESI

伪共享问题

当多线程修改相互独立的变量时, 如果这些变量又恰好在同一个缓存行中, 就会彼此影响 (写回, 无效化 或者 同步) 而导致性能降低, 本应该是一个并行的操作,但是由于缓存一致性,却成为了串行!

可以使用两种方式解决 :

  1. 通过追加字节的方式达到缓存行对齐的目的, 是相互独立的变量位于不同缓存行
  2. 使用 @Contended 注解 : Java1.8 中提供了该注解, 但是必须加上 XX:-RestrictContended 参数
    1. 如果在类上添加 @Contended 注解,该类的每个属性都会在不同的缓存行
    2. 如果在属性上加,那么可以指定哪些属性处于同一个缓存行中
@Contended("g1")
public class Demo {
    @Contended("g2")
    private int a1;
    @Contended("g2")
    private int a2;
    @Contended("g2")
    private int a3;
    @Contended("g2")
    private int a4;
}

乱序执行

为了使处理器内部的运算单元能尽量被充分利用, 处理器可能对输入的执行进行乱序执行优化, 处理器会在计算之后将乱序执行的结果重组, 保证该结果与顺序执行的结果是一致的, 但并不能保证程序种各个语句计算的先后顺序与输入指令中的顺序一致

比如第一行代码是读取文件流, 第二行代码是数学计算, 明显第二行代码的执行时间更快, 处理器并不会等第一行代码执行完然后执行第二行代码, 处理器是有可能会乱序执行的

OS 底层如何保证有序性

  • 内存屏障, sfence (写屏障), mfence (全屏障), lfence (读屏障) 等系统原语
  • 锁总线

volatile

JVM 是如何保证其可见性的

public class Test{
    volatile Object obj;
    public static void main(String[] args){
        obj = new Object();
    }
}

使用 java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Test 可以反汇编生成汇编指令 (需要安装 HSDIS 插件), 查看 CPU 做了什么事情?

0x01a3deld: movb $0X0,0X1104800 (%esi) ;0x01a3de24: lock add1 $0x0, (%esp);

可以看到, 有一条 lock 指令, 该指令在多核 CPU 下会引发两件事情 (可查阅 IA-32 开发者手册)

  1. 将当前处理器缓存行的数据立刻写回到主内存
  2. 如果这个写回操作成功, 会使 CPU 的其他处理器缓存了该内存地址的数据失效

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

由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自已缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态, 再次从主内存中刷新出最新值

volatile 变量保证了可见性, 但是不保证原子性

这里再提一点 : JMM 对 64 位数据特别定义了一个宽松的规定 , 允许 JVM 将没有被 volatile 修饰的 64 位数据 (double, long) 划分为两次 32 位的操作来进行, 这就是 long 和 double 的非原子性访问协定

这就导致一个问题, 某些线程可能读到的数据为 “半个变量” , 在 x86 平台下的 HotSpot 对 long 类型确实存在非原子性访问的风险

所以, 如果存在明显的线程竞争, 请将 double, long 类型声明为 volatile

JVM 如何保证有序性

查看 JVM 规范可以得知 :

  • 在每个 volatile 变量写操作的前后分别插入 StoreStore Barrier, StoreLoad Barrier
  • 在每个 volatile 变量读操作的前后分别插入LoadLoad Barrier, LoadStore Barrier
    在这里插入图片描述

插入内存屏障后, 就能禁止内存屏障两边的指令重排

JVM 使用的并不是 sfence , mfence , lfence 等 CPU 原语, 而是使用了 lock 指令来达到插入内存屏障的目的

Java 中除了使用 volatile 和 synchronized 来保证两个线程之间的有序性, 还定义了一个 happen-before 原则 (以后更新)

双重检查式的单例模式写法为什要加 volatile ?

在 JDK 5之前, volatile 变量前后仍然存在指令重排, 因此 JDK 5之前的 DCL 单例模式写法存在问题 ! 在 JSR-133 才增强了 volatile 的内存语义 : 严格限制编译器和处理器对 volatile 变量与不同变量的重排序

首先需要了解, 在 new 一个对象的过程中, JVM 做了哪些事情 ,
在这里插入图片描述

上面的字节码只是简单的一个 new 对象操作 ( Object object = new Object(); )

由上可知, 首先需要再堆中分配一块内存, 存放创建出来的对象, 然后执行该类的 方法 (即执行其非静态变量, 非静态代码块, 构造方法), 最后引入与对象关联存入局部变量表中

所以, new 一个对象并不是一个原子性操作, CPU 是有可能会乱序执行的, 这样就会导致引用指向的是一个 半初始化对象 , 这在单例模式中是不允许存在的, 需要加上 volatile 修饰

猜你喜欢

转载自blog.csdn.net/Gp_2512212842/article/details/107285181