MENU

浅谈 "内存屏障"- 语言层面

May 11, 2023 • Read: 2517 • 学习记录

浅谈"内存屏障"-语言层面

阅读本系列带着几个问题:

  1. 了解 volatile 如何保证可见性的?
  2. CAS 是如何保证对同一个地址操作的原子性?
  3. volatile 如何避免指令重排序的?
  4. MESI 协议与内存屏障有何种关联?

0、前言

从不同角度来理解内存屏障,谈论“内存屏障”共有三篇文章,分别从开发角度、语言角度和硬件角度。

本文是系列第二篇文章,主要从语言角度了解与内存屏障(memory barrier)的关联。

这里主要是从 Java 语言角度,语言层面主要介绍其中最重要的 内存模型

1、内存模型(memory model)

1.1 系统架构为了提高执行效率,会做哪些事情?

Architectures (eg:x86、ARM) 为了提升效率,会做许多的事情,这里借用《C++ and Beyond 2012: Herb Sutter — atomic<> Weapons》的图:

1zhQz47yIaZHfxvC0wwicig.png

从上图可知,有些优化表达会影响到程序的执行结果:

  1. CPU 会调整执行的执行顺序(out-of-order execution)
  2. CPU 会从 Cache 读取资料
  3. CPU 各自有 store buffer,CPU 之前不会立即看到其他 CPU 的更新

上述操作和 Memory model) 有关,程序设计语言的 Memory model 规范 thread(或者 CPU)如何同步数据并提供 API ,让开发者在写 multi-thread 程序的时候,可以保证程序执行结果的正确性。

大部分程序设计语言都定义了 memory model,让使用该语言的和编写 compiler 的开发者可以写出跨平台multi-thread 的程序。Java 语言在 Java 5 版本完善了内存模型。

1.2 内存模型个人理解定义:

Java 内存模型是在硬件内存模型基础上面更高层的抽象,它屏蔽了各种硬件和操作系统对内存访问的差异性,从而实现让 Java 程序在各种平台上都能达到一致的并发效果。

JMM.png

2、内存屏障和内存模型的关联

内存模型定义的 happens-before 原则 是需要依赖内存屏障来实现的,比如其中的 volatile 语义 。

下面还是借用 DCL 来说明:

public class Singleton {
    private static volatile Singleton instance; // 使用 volatile 修饰
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 保证完全初始化和可见性
                }
            }
        }
        return instance;
    }
}

Java 5 之前的 volatile 关键字只能保证变量的可见性,但不能保证变量的有序性。也就是说,volatile 变量可能会被重排序,导致对象的部分初始化。Java 5 之后,volatile 关键字增加了一个内存语义,即禁止指令重排序优化。这样就可以保证 volatile 变量的有序性,从而避免对象的部分初始化。

2.1 硬件层的内存屏障

内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中的相应数据失效。

Load Barrier 和 Store Barrier 即读屏障和写屏障。

Load Barrier:在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;

Store Barrier:在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

2.2 Java 语言层面的内存屏障^参考:[5]^

由于内存屏障是硬件层面的概念,不同的硬件平台实现内存屏障的方式不一样,加上Java 是跨平台的语言,所以 Java 需要自己提供一套语言层面的内存屏障操作来屏蔽硬件层面的差异。共有 4 种型的指令:

LoadLoad barrier

对于这样的语句:

# Load1、Load2 仅仅是加载数据操作指令,后面类似指令也是。
Load1;
LoadLoad; # 内存屏障指令
Load2;

在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。

StoreStore barrier

对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。

LoadStore barrier

对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。

StoreLoad barrier

对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。

2.3 内存屏障在语言层面具体应用:volatile

volatile 的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:

在每个 volatile 写操作前插入StoreStore屏障,在写操作后插入 StoreLoad 屏障;

在每个 volatile 读操作前插入LoadLoad屏障,在读操作后插入 LoadStore屏障;

下面举一个例子来说明 volatile 变量为什么要使用这4个指令。

假设有两个线程 A 和 B,线程 A 执行以下操作:

x = 1;
volatileVar = 2;

线程 B 执行以下操作:

int y = volatileVar;
int z = x;

在这个例子中,我们希望当线程 B 读取到 volatileVar 的值为2时,z 的值也应该为 1。但是,由于编译器和处理器可能会对指令进行重排序,因此不能保证这一点。

为了解决这个问题,Java 可以在每个 volatile 写操作前插入 StoreStore 屏障,在写操作后插入 StoreLoad 屏障;在每个 volatile 读操作前插入 LoadLoad 屏障,在读操作后插入LoadStore 屏障。

在上面的例子中,线程A的代码将变为:

x = 1;
// StoreStore 屏障
volatileVar = 2;
// StoreLoad 屏障

线程B的代码将变为:

// LoadLoad 屏障
int y = volatileVar;
// LoadStore 屏障
int z = x;

首先屏障指令会禁用指令重排序,在这种情况下,由于 StoreStore 屏障保证了 x = 1 操作在 volatileVar = 2 操作之前完成,并且 x = 1 的写入操作对其他线程可见,而 StoreLoad 屏障保证了 volatileVar = 2 操作对其他线程可见。

由于 LoadLoad 屏障在读操作前面,禁止了重排序,而 LoadStore 屏障保证了 y = volatileVar 读取操作在 z = x 之前已经读取完毕

因此,当线程 B 读取到 volatileVar 的值为 2 时,z 的值也一定为 1。

参考文章

  1. 浅论Lock 与X86 Cache 一致性
  2. Java内存模型-volatile的内存语义 - 玉树临枫 - 博客园
  3. 如何理解 JAVA 中的 volatile 关键字 - 腾讯云开发者社区-腾讯云
  4. 软件角度:簡介 C++11 atomic 和 memory order
  5. 为什么jvm四种内存屏障有两种不同叫法? - 南有雅木的回答 - 知乎