Java 基础 volatile、JMM 和 jdk9 内存顺序简论

发布于 2024-11-25 07:27:20 字数 12093 浏览 21 评论 0

volatile 是个非常复杂的关键字,如果你只是好奇如何使用那么只需要读这一节,剩下的部分选读

由于多核处理器的架构问题,一个被多个线程共享的变量在被某一个线程修改后,其他线程并不能 立刻 看到这个修改操作,因此需要这个关键字修饰让它可以直接让其他线程立刻 看到 这个变量的修改

直觉上,它说明一个变量是“易变的”,这意味着读写该变量的时候,缓存上的数据都不可靠,得从内存中读写

给人的感觉就是直接从主存中读取的,即大家都是读取的同一块内存

好了 本文到此结束 您可以离开了

CPU 看到的变量和指令

这一部分内容由于不同 CPU 的架构不同,并不具有通用性

这里都是一些概念

我查到的资料也不一定是正确的,也不具有时效性的,所以敬请自己甄别

请大家不要完全相信这里的所有观点,如果有任何异议欢迎带着具体的实例反驳并留言

变量

当前 CPU 性能强劲,程序的主要的瓶颈不在计算,而在数据读写。根据 Latency Numbers Every Programmer Should Know 里的数据,2020 年,访问 L1 缓存需要 1ns,而访问内存/主存则需要 100ns。因此不管是硬件还是软件的优化,都在尝试提升缓存的命中率。(一个很有名的例子就是正序遍历数组比倒序快,这就是利用了内存局部性原理)

而破坏原子性、可见性、有序性,很大程度上也是为了充分利用缓存。

举个例子:

Core i7 内存体系结构

核内独占 L1, L2 缓存,核间共享 L3 缓存。其中 L1 分为指令高速缓存(i-cache) 和数据高速缓存(d-cache)。

在 CPU 指令需要读取内存时,会先尝试从 L1 缓存中读取,如果发现缓存中没有(称作 cache miss),则开始从 L2 中读取,依此类推,最终会从内存中读取数据。我们上面说过,缓存的访问速度与内存的访问速度天差地别,因此很多时候,无论是编译器还是 CPU 都会尽量让运行的代码能充分利用缓存。

思考一个情况:变量 a 被读取到 L1 中,core0 修改 a = 2 后 自己能看到此时 a 是 2,但是此时这个修改结果并没有同步到其他核,那么他们看到的就不可能是 2,也就是说 a 的可见性没有保证。

MESI 缓存一致性

这种只是一种很理想的状态

并发场景下(比如多线程)如果操作相同变量,如何保证每个核中缓存的变量是正确的值,这涉及到一些”缓存一致性“的协议。其中应用最广的就是 MESI 协议(当然这并不是唯一的缓存一致性协议)。

注意:下面的内容并不指定处理器架构,而是从一个比较学术派的角度来说

下面两个表格其实不看也行

状态描述
M(Modified)代表该缓存行中的内容被修改了,并且该缓存行只被缓存在该 CPU 中。这个状态的缓存行中的数据和内存中的不一样,在未来的某个时刻它会被写入到内存中(当其他 CPU 要读取该缓存行的内容时。或者其他 CPU 要修改该缓存对应的内存中的内容时
E(Exclusive)代表该缓存行对应内存中的内容只被该 CPU 缓存,其他 CPU 没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的内容和内存中的内容一致。该缓存可以在任何其他 CPU 读取该缓存对应内存中的内容时变成 S 状态。或者本地处理器写该缓存就会变成 M 状态
S(Shared)该状态意味着数据不止存在本地 CPU 缓存中,还存在别的 CPU 的缓存中。这个状态的数据和内存中的数据是一致的。当其他 CPU 修改该缓存行对应的内存的内容时会使该缓存行变成 I 状态
I(Invalid)代表该缓存行中的内容是无效的

那么不同的核是怎么知道发生了变化的呢?自然是有个消息机制(总线嗅探)

消息类型请求/响应描述
Read请求通知其他处理器和内存,当前处理器准备读取某个数据。该消息内包含待读取数据的内存地址
Read Response响应该消息内包含了被请求读取的数据。该消息可能是主内存返回的,也可能是其他高速缓存嗅探到 Read 消息返回的
Invalidate请求通知其他处理器删除指定内存地址的数据副本(缓存行中的数据)。所谓“删除”,其实就是更新下缓存行对应的 FLAG(MESI 那个)
Invalidate Acknowledge响应接收到 Invalidate 消息的处理器必须回复此消息,表示已经删除了其高速缓存内对应的数据副本
Read Invalidate请求此消息为 Read 和 Invalidate 消息组成的复合消息,主要是用于通知其他处理器当前处理器准备更新一个数据了,并请求其他处理器删除其高速缓存内对应的数据副本。接收到该消息的处理器必须回复 Read Response 和 Invalidate Acknowledge 消息
Writeback响应消息包含了需要写入内存的数据和其对应的内存地址

MESI state transition diagram

你可以看看这个网站做做测试 VivioJS MESI help (tcd.ie)

Store Buffer

如果一个核发出一个消息说自己的某个缓存被修改了,此时要是这个核还在等待别的核发送确认那么就会降低了 CPU 工作效率,毕竟 CPU 的各项设计都是尽可能并行,尽可能异步,希望由外界通知而不是主动等待。所以有些 CPU 引入了 Store Buffer(写缓存器)技术,也就是在 CPU 和 cache 之间又加了一层 buffer,在 CPU 执行写操作时直接写 StoreBuffer,然后就忙其他事去了,等其他 CPU 都置为 I 之后,CPU1 才把 buffer 中的数据写入到缓存行中。

Invalidate Queue

那么其他 CPU 接到消息后也是写入到缓存中才发消息吗?显然为了加快整个流程它不会这么做,他们会写入一个 Invalidate Queue(无效化队列),还没把缓存置为 I 状态就发送响应了。后续 CPU 会异步扫描 Invalidate Queue,将缓存置为 I 状态。和 Store Buffer 不同的是,在 CPU1 后续读变量 x 的时候,会先查 Store Buffer,再查缓存。而 CPU0 要读变量 x 时,则不会扫描 Invalidate Queue,所以存在脏读可能。 还记得最开始的话吗? 本文并不指定处理器架构 ,比如说其实 x86 没有 Invalidate Queue

指令

记得之前的文章吗?我很多地方都强调了,假设当前的代码实际执行和书写顺序一致,而实际上并不是这样。我们的编译器(java 的 c1,c2),CPU 都会根据因果一致性的前提进行指令的重排

这里插入一下 什么是因果一致性?

1 a=1;
2 b=1;
3 c = a+1;

对于 1,2 这两者并没有依赖关系 即编译器和 CPU 可以任意重排顺序执行(或者你可能看见过这样一个词 as-if-serial )。而 1 3 有依赖关系,所以这两句不能重排列,即这个指令的顺序可能是 1 2 3 或者 2 1 3。这种情况下单线程是看不出差距的,但是多线程情况下,就可能导致另外一个线程看到 b=1 但是 a!=1 的情况了。

注意:下面的内容并不指定处理器架构,而是从一个比较学术派的角度来说

cpu 为了提高流水线的运行效率,会做出比如:

1) 对无依赖的前后指令做适当的乱序和调度;

2) 对控制依赖的指令做分支预测;

3) 对读取内存等的耗时操作,做提前预读;

等等。以上总总,都会导致指令乱序的可能。

因为乱序导致出问题其实是普遍的 举个例子 glibc2.13 的 qsort 在并发时有概率会 coredump

11655 – qsort() not thread safe, results to division by zero (sourceware.org)

总结

以上不过是些名词解释,用于方便理解各种边界条件,你会发现不同的 CPU 架构都需要不同的解决方案,这就是 JMM 的意义所在,屏蔽差异。

屏障

什么是屏障?就是用来阻挠某些东西出来的设施,我们可以用来防止编译器或者 CPU 对指令进行重排

对于编译器屏障 就是告诉编译器以这一行为界,不要让两边代码乱序,比如说上面的代码不许乱序到下面,下面代码不许排序到上面

比如说你可以在你的源码中内嵌__asm volatile("" ::: "memory"),gcc 就会意识到你插入了一个编译器屏障

对于内存屏障,就是确保两个操作的前后顺序不乱序,在谈及 CPU 时,通常会把变量的读操作称为 load,变量的写操作称为 store。两两组合因而会出现 4 类读写操作:

  • LoadLoad 屏障:保证前面的 Load 在后面的 Load 之前完成
  • StoreStore 屏障:保证前面的 Store 在后面的 Store 之前完成
  • LoadStore 屏障:保证前面的 Load 在后面的 Store 之前完成
  • StoreLoad 屏障:保证前面的 Store 在后面的 Load 之前完成。

注意:由于不同 CPU 架构不同,并不是所有的 CPU 都需要这几种屏障,比如说 x86 一致性比较好,所以只需要 storeload 屏障,其他三个操作都能保证不乱序

CPU 的内存屏障如果只是保证指令顺序不会乱,也未必会让程序执行符合预期。因为 MESI 为了提升性能,引入了 Store BufferInvalidate Queue。所以内存屏障还有其他功能:

写类型的内存屏障 还能触发内存的强制更新,让 Store Buffer 中的数据立刻回写到内存中。 读类型的内存屏障 会让 Invalidate Queue 中的缓存行在后面的 load 之前全部标记为失效。

用一张图来说明各个架构的乱序问题

1651317437344

java9 带来的内存顺序 api

首先是一堆新名词

Plain 就是普通的访问

Opaque 插入编译器屏障的访问 但是没有涉及任何的内存屏屏障

Coherence

即对单个内存位置的写看上去是按照与程序顺序一致的总顺序进行的。看上去有点难以理解,结合下面的例子,可以这样理解:在全局,x 由 0 变成了 1,那么每个线程中看到的 x 只能从 0 变成 1,而不会可能看到从 1 变成 0.

例子 1:

x             |   int r1 = x;
x=1;          |   int r2 = x;

那么在 Java 内存模型下,可能的结果是包括:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. r1 = 0, r2 = 0

其中第三个结果很有意思,从程序上理解即我们先看到了 x = 1,之后又看到了 x 变成了 0.当然,通过前面的分析,我们知道实际上是因为 编译器乱序 。如果我们不想看到这个第三种结果,我们所需要的特性即 coherence。

Causality(因果性)与 Acquire/Release

首先看个例子

一个线程给 x,y 赋值 ,注意下这里我们不考虑 x,y 在同一个缓存行的情况,即一个缓存失效不会导致另外一个缓存失效

另一个线程执行(r1, r2 为本地变量):

x=1;                    |   int r1 = y;
y=1;                    |   int r2 = x;

若是 plain 情况则是有这些情况:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. r1 = 0, r2 = 0

第三个结果也是很有趣的,第二个线程先看到 y 更新,但是没有看到 x 的更新,还记得之前说的,内存屏障还能保证刷新缓存的功能吗?我们需要像这样加内存屏障才能避免第三种情况的出现,即:

x=1;                    |   int r1 = y;
write_barrier();        |    read_barrier();
y=1;                    |   int r2 = x;

线程 1 执行 x = 1 之后,在 y = 1 之前执行了写屏障,保证 store buffer 的更新都更新到了缓存,y = 1 之前的更新都保证了不会因为存在 store buffer 中导致不可见。线程 2 执行 int r1 = y 之后执行了读屏障,保证 invalidate queue 中的需要失效的数据全部被失效,保证当前缓存中不会有脏数据。这样,如果线程 2 看到了 y 的更新,就一定能看到 x 的更新。

我们把写屏障以及后面的一个 Store(即 y = 1)理解为将前面的更新(x=1)打包,然后将这个包在这点发射出去,读屏障与前面一个 Load(即 int r1 = y)理解成一个接收点,如果接收到发出的包,就在这里将包打开并读取进来

在发射点,会将发射点之前(包括发射点本身的信息)的所有结果打包,如果 在执行接收点的代码的时候接收到了这个包,那么在这个接收点之后的所有指令就能看到包里面的所有内容,即发射点之前以及发射点的内容 。Causality(因果性),有的地方也叫做 Casual Consistency(因果一致性),它在不同的语境下有不同的含义,我们这里仅特指:可以定义一系列写入操作,如果读取看到了最后一个写入,那么这个读取之后的所有读取操作,都能看到这个写入以及之前的所有写入操作。

听起来是不是特别复杂?好了 我们先抛开这些教科书词汇,我们先来看个例子:

release/acquire 的 API

class A{
    static final VarHandler X;
    static final VarHandler Y;
    static {
        X = MethodHandles.lookup().findVarHandler(A.class,"x",int.class);
        Y = MethodHandles.lookup().findVarHandler(A.class,"y",int.class);
    }
    int x,y;
    public void thread0(){
        x = 1;
        Y.setRelease(this,1); //打包点
    }
    public void thread1(){
        int r1 = (int) Y.getAccquire(this); //解包点
        r2 = x;
    }
}

根据我们的打包 解包理论+因果关系,我们可以很容易推断得到

若 r1==1 则 r2 == 1,即 r1 = 1, r2 = 0 这种不可能出现了

那么再用我们之前的四种屏障来描述它是怎么实现的

我们可以通过前面我们的抽象推出来,首先是发射点。发射点首先是一个 Store,并且保证打包前面的所有,那么不论是 Load 还是 Store 都要打包,都不能跑到后面去,所以 需要在 Release 的前面加上 LoadStore,StoreStore 两种内存屏障来实现

同理,接收点是一个 Load,并且保证后面的都能看到包里面的值,那么无论 Load 还是 Store 都不能跑到前面去,所以 需要在 Acquire 的后面加上 LoadLoad,LoadStore 两种内存屏障来实现

Volatile

笑死 终于到 Volatile 了,简单的单变量的可见性不再赘述

我们直接看看两个变量情况的内存变化的爱恨情仇

Volatile 其实就是在 Release/Acquire 的基础上,进一步保证了 Consensus;Consensus 即所有线程看到的内存更新顺序是一致的,即所有线程看到的内存顺序全局一致

举个例子

x,y
x=1;                    |   int y = 1;
int r1 = y              |   int r2 = x;

在 plain 模式访问,同样可能有 4 种结果:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. r1 = 0, r2 = 0

第四个结果是不符合 Consensus 的,因为两个线程看到的更新顺序不一样(第一个线程看到 0 代表他认为 x 的更新是在 y 的更新之前执行的,第二个线程看到 0 代表他认为 y 的更新是在 x 的更新之前执行的)。如果没有乱序,那么肯定不会看到 x, y 都是 0,因为线程 1 和线程 2 都是先更新后读取的

如果要保证 Consensus,我们只要保证线程 1 的代码与线程 2 的代码不乱序即可,即在原本的内存屏障的基础上,添加 StoreLoad 内存屏障.

x,y
LoadStore               |   LoadStore
StoreStore              |   StoreStore
x=1;                    |   int y = 1;
StoreLoad               |   StoreLoad  
int r1 = y              |   int r2 = x;
LoadLoad                |   LoadLoad
LoadStore               |   Loadtore

Final

为什么要讲这个,这个和内存顺序关系大吗?我们先不考虑这个问题,先来看一段 c 语言初始化结构体代码

struct P{
    int a;int b;
}
void s(){
    struct P* obj = (P*)malloc(sizeof(P)); 
    obj.a = 1;obj.b;
}

实际上我们的 java 实例化也是这样的

P p = new P(1,1);
________________________________________________
P* tmp = new P();
2 tmp.a = 1;tmp.b =2;
3 p = temp;

根据之前的说明,其实这几行代码(2 和 3),是可以乱序的,即你在其他线程观测到的这个 p 时 a,b 不一定初始化完毕了

注意:大部分读者电脑都是 x86 storestore 是不会乱序的,所以你观测不到这个情况 ,根据上面的表,你可以在 aarch64 上面复现出这个情况

那么怎么改呢?直接在 2 之后加 storestore 就行了,或者 3 使用 setRelease 也可以

那么和 final 有什么关系呢?还记得我们之前讲的线程安全部分中提到的不可变类吗?

如果一个类不具有可修改性,那么可以安全地在多个线程间共享。

为了做到不可变那么 final 是不可缺少的,但是如果我们对于初始化阶段 final 变量的读写都没有做任何屏障的话会发生什么?

不同的线程可能看到了虽然初始化分配了一块内存 构造函数也执行完毕了,但是 final 变量没有初始化 ,因为发生了重排。所以 jvm 需要增强 final 的语义以确保 final 在构造结束后必须被赋值完毕。

这就是为什么 jvm 为 final 加入了 storestore 屏障的原因

Spring Bean 初始化如何保证线程安全

看了 final 这个情况,你可能会想 我们写 spring 代码的时候经常使用字段注入,既没有 final 也没有 volatie,那我们并发获取到一个 bean 的时候会存在对应的字段没有正确初始化的问题吗?

  1. Spring 的 Bean 会存储在一个 map 中( DefaultSingletonBeanRegistry::singletonObjects
  2. 每次存储或获取某个 Bean,都会显示在这个 map 上加内置锁(synchronized)
  3. 由于 JMM 的“监视器锁规则”,lock 能看到同一个监视器的 unlock 前的变化

于是,我们只要注入了某个 Bean,那么这个 Bean 的初始化的内容就是可见的,上例中,在 MyService 中看到了 myData 这个 Bean,就可以保证 myData 已经被正确初始化了。并且这里的初始化不仅仅指构造函数中的内容,而是 Spring 语境下的初始化,还包括 setter 注入,PostConstruct 初始化等。

但是要注意,这个机制要求 Bean 的初始化和获取都是通过 Spring 完成的。如果 Bean 初始化后又做了修改,或者 Bean 不是通过 ApplicationContext 或 Autowired 获取的,则没有这个可见性保证。

java - Should I mark object attributes as volatile if I init them in @PostConstruct in Spring Framework? - Stack Overflow

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

漫雪独思

暂无简介

文章
评论
25 人气
更多

推荐作者

jsonder

文章 0 评论 0

給妳壹絲溫柔

文章 0 评论 0

北笙凉宸

文章 0 评论 0

国产ˉ祖宗

文章 0 评论 0

月下客

文章 0 评论 0

梦行七里

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文