关于后端:谈谈内存模型happenbefore讲的什么

45次阅读

共计 5303 个字符,预计需要花费 14 分钟才能阅读完成。

大家好我是易安!

明天我要讲述的是 Java 内存模型中的 happen-before。

Java 语言在设计之初就引入了线程的概念,以充分利用古代处理器的计算能力。多线程机制既带来了弱小、灵便的劣势,也带来了线程平安等令人混同的问题。在这种状况下,Java 内存模型(Java Memory Model,JMM)为咱们提供了一个在缭乱之中达成统一的领导准则。
理解 happen-before 之前,咱们先来看看几个与之相干的开胃菜

什么是 Java 内存模型?

Java 内存模型是一种用于标准 Java 程序中多线程拜访共享内存的行为的标准。它形容了 Java 虚拟机(JVM)如何治理和操作内存,以及在多线程环境中如何保障线程平安。

为什么须要 Java 内存模型?

在多线程编程中,因为多个线程可能同时拜访共享内存,可能会导致一些非预期的后果。这些后果可能包含数据竞争、内存可见性问题等。Java 内存模型为咱们提供了一些规定和束缚,以帮忙咱们编写线程平安的程序。

Java 内存模型的规定和束缚

Java 内存模型次要包含以下规定和束缚:

  • 原子性:Java 内存模型保障了根本数据类型和援用的读写操作是原子性的,即在读写操作中不会产生数据的不统一或中断。然而,对于 64 位的 long 和 double 类型,读写操作不是原子性的,可能须要应用 synchronized 关键字或者 AtomicLong 等原子类来保障线程平安。
  • 可见性:Java 内存模型保障一个线程批改了共享变量的值后,另一个线程可能立刻看到批改后的值。为了实现可见性,Java 内存模型应用了“happens-before”关系。简略来说,如果操作 A happens-before 操作 B,那么操作 B 可能看到操作 A 的后果。如果没有 happens-before 关系,那么操作 A 和操作 B 的执行程序是不确定的。
  • 有序性:Java 内存模型保障程序执行的程序依照咱们编写代码的程序执行。然而,在多线程环境下,JVM 能够从新排序操作,以优化程序执行效率。为了保障有序性,能够应用 synchronized 关键字或者 volatile 关键字来禁止 JVM 对操作进行重排序。
    简略理解了 java 内存模型,咱们来谈谈 happen-before 准则

简略答复

Happen-before 关系,是 Java 内存模型中保障多线程操作可见性的机制,也是对晚期语言标准中含混的可见性概念的一个准确定义。

它的具体表现形式包含,但远不止于咱们直觉中的 synchronized、volatile、lock 操作程序等方面。以下是一些具体的表现形式:

  • 线程内执行的每个操作,都保障 happen-before 前面的操作,这就保障了根本的程序程序规定,这是开发者在书写程序时的根本约定。
  • 对于 volatile 变量,对它的写操作,保障 happen-before 在随后对该变量的读取操作。
  • 对于一个锁的解锁操作,保障 happen-before 加锁操作。
  • 对象构建实现,保障 happen-before 于 finalizer 的开始动作。
  • 甚至是相似线程外部操作的实现,保障 happen-before 其余 Thread.join()的线程等。

这些 happen-before 关系是具备传递性的。如果满足 a happen-before b 和 b happen-before c,那么 a happen-before c 也成立。

后面我始终用 happen-before 而不是简略说前后,是因为它不仅仅是对执行工夫的保障,也包含对内存读、写操作程序的保障。仅仅是时钟程序上的先后,并不能保障线程交互的可见性。

问题拆解

明天的问题是一个常见的考查 Java 内存模型基本概念的问题,我后面给出的答复尽量抉择了和日常开发相干的规定。

JMM 是面试的热点,能够看作是深刻了解 Java 并发编程、编译器和 JVM 外部机制的必要条件,但这同时也是个容易让初学者莫衷一是的主题。对于学习 JMM,我有一些集体倡议:

  • 明确目标,克服住技术的引诱。除非你是编译器或者 JVM 工程师,否则我倡议不要一头扎进各种 CPU 体系结构,纠结于不同的缓存、流水线、执行单元等。这些货色尽管很酷,但其复杂性是超乎设想的,很可能会无谓减少学习难度,也未必有实际价值。
  • 克服住对“秘籍”的引诱。有些时候,某些编程形式看起来能起到特定成果,但分不清是实现差别导致的“体现”,还是“标准”要求的行为,就不要依赖于这种“体现”去编程,尽量遵循语言标准进行,这样咱们的利用行为能力更加牢靠、可预计。

此文,我会联合例子梳理上面两点:

  • 为什么须要 JMM,它试图解决什么问题?
  • JMM 是如何解决可见性等各种问题的?相似 volatile,体现在具体用例中有什么成果?

留神,文中 Java 内存模型就是特指 JSR-133 中从新定义的 JMM 标准。在特定的上下文里,兴许会与 JVM(Java)内存构造等混同,并不存在相对的对错,但肯定要分明面试官的本意,有的面试官也会特意考查是否分明这两种概念的区别。

引申

为什么须要 JMM,它试图解决什么问题?

Java 是最早尝试提供内存模型的语言,这是简化多线程编程、保障程序可移植性的一个飞跃。晚期相似 C、C++ 等语言,并不存在内存模型的概念(C++ 11 中也引入了规范内存模型),其行为依赖于处理器自身的 内存一致性模型,但不同的处理器可能差别很大,所以一段 C ++ 程序在处理器 A 上运行失常,并不能保障其在处理器 B 上也是统一的。

即使如此,最后的 Java 语言标准依然是存在着缺点的,过后的指标是,心愿 Java 程序能够充分利用古代硬件的计算能力,同时放弃“书写一次,到处执行”的能力。

然而,显然问题的复杂度被低估了,随着 Java 被运行在越来越多的平台上,人们发现,过于泛泛的内存模型定义,存在很多不置可否之处,对 synchronized 或 volatile 等,相似指令重排序时的行为,并没有提供清晰标准。这里说的指令重排序,既能够是 编译器优化行为,也可能是源自于古代处理器的 乱序执行 等。

换句话说:

  • 既不能保障一些多线程程序的正确性,例如最驰名的就是双检锁(Double-Checked Locking,DCL)的生效问题,具体能够参考我在 第 14 讲 对单例模式的阐明,双检锁可能导致未残缺初始化的对象被拜访,实践上这叫并发编程中的平安公布(Safe Publication)失败。
  • 也不能保障同一段程序在不同的处理器架构上体现统一,例如有的处理器反对缓存一致性,有的不反对,各自都有本人的内存排序模型。

所以,Java 迫切需要一个欠缺的 JMM,可能让一般 Java 开发者和编译器、JVM 工程师,可能 清晰地 达成共识。换句话说,能够绝对简略并精确地判断出,多线程程序什么样的执行序列是符合规范的。

所以:

  • 对于编译器、JVM 开发者,关注点可能是如何应用相似 内存屏障(Memory-Barrier)之类技术,保障执行后果合乎 JMM 的推断。
  • 对于 Java 利用开发者,则可能更加关注 volatile、synchronized 等语义,如何利用相似 happen-before 的规定,写出牢靠的多线程利用,而不是利用一些“秘籍”去糊弄编译器、JVM。

我画了一个简略的角色档次图,不同工程师分工合作,其实所处的层面是有区别的。JMM 为 Java 工程师隔离了不同处理器内存排序的区别,这也是为什么我通常不倡议过早深刻处理器体系结构,某种意义上来说,这样本就违反了 JMM 的初衷。

JMM 是怎么解决可见性等问题的呢?

JVM 外部的运行时数据区,真正程序执行,理论是要跑在具体的处理器内核上。你能够简略了解为,把本地变量等数据从内存加载到缓存、寄存器,而后运算完结写回主内存。你能够从上面示意图,看这两种模型的对应。

看上去很美妙,然而当多线程共享变量时,状况就简单了。试想,如果处理器对某个共享变量进行了批改,可能只是体现在该内核的缓存里,这是个本地状态,而运行在其余内核上的线程,可能还是加载的旧状态,这很可能导致一致性的问题。从实践上来说,多线程共享引入了简单的数据依赖性,不论编译器、处理器怎么做重排序,都必须尊重数据依赖性的要求,否则就突破了正确性!这就是 JMM 所要解决的问题。

JMM 外部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的形式,提供内存可见性保障,也就是实现了各种 happen-before 规定。与此同时,更多复杂度在于,须要尽量确保各种编译器、各种体系结构的处理器,都可能提供统一的行为。

我以 volatile 为例,看看如何利用内存屏障实现 JMM 定义的可见性?

对于一个 volatile 变量:

  • 对该变量的写操作 之后 ,编译器会插入一个 写屏障
  • 对该变量的读操作 之前 ,编译器会插入一个 读屏障

内存屏障可能在相似变量读、写操作之后,保障其余线程对 volatile 变量的批改对以后线程可见,或者本地批改对其余线程提供可见性。换句话说,线程写入,写屏障会通过相似强制刷出处理器缓存的形式,让其余线程可能拿到最新数值。

内存屏障(Memory Barrier)是一种机制,用于避免处理器和编译器对内存拜访的重排序。内存屏障能够分为两种类型:读屏障(Load Barrier)和写屏障(Store Barrier)。

读屏障保障了在该屏障之前的读操作要先于该屏障之后的读操作执行,从而保障了读操作的程序性。写屏障保障了在该屏障之前的写操作要先于该屏障之后的写操作执行,从而保障了写操作的程序性。这些屏障能够用来实现内存可见性和避免数据竞争。

内存屏障有以下三种类型:

  • LoadLoad 屏障(LL):它保障了在该屏障之前的所有读操作要先于该屏障之后的所有读操作执行。这样能够保障读操作的程序性和一致性。
  • StoreStore 屏障(SS):它保障了在该屏障之前的所有写操作要先于该屏障之后的所有写操作执行。这样能够保障写操作的程序性和一致性。
  • LoadStore 屏障(LS)和 StoreLoad 屏障(SL):它们是最重要的内存屏障类型。LS 屏障保障了在该屏障之前的所有读操作要先于该屏障之后的所有写操作执行。SL 屏障保障了在该屏障之前的所有写操作要先于该屏障之后的所有读操作执行。这样能够确保数据的一致性和可见性。

内存屏障的实现是由处理器和编译器共同完成的。处理器能够应用缓存一致性协定和总线锁等机制来实现内存屏障,而编译器则能够在生成汇编代码时插入内存屏障指令来保障内存拜访的程序性和一致性。

如果你对更多内存屏障的细节感兴趣,或者想理解不同体系结构的处理器模型,倡议参考 JSR-133 相干文档,我集体认为这些都是和特定硬件相干的,内存屏障之类只是实现 JMM 标准的技术手段,并不是标准的要求。

从利用开发者的角度,JMM 提供的可见性,体现在相似 volatile 上,具体行为是什么样呢?

我举两个例子

请看上面的代码片段,心愿达到的成果是,当 condition 被赋值为 false 时,线程 A 可能从循环中退出。

// Thread A
while (condition) {
}

// Thread B
condition = false;

这里就须要 condition 被定义为 volatile 变量,不然其数值变动,往往并不能被线程 A 感知,进而无奈退出。当然,也能够在 while 中,增加可能间接或间接起到相似成果的代码。

Brian Goetz 提供的一个经典用例,应用 volatile 作为守卫对象,实现某种程度上轻量级的同步:

Map configOptions;
char[] configText;
volatile boolean initialized = false;

// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// Thread B
while (!initialized)
  sleep();
// use configOptions

JSR-133 从新定义的 JMM 模型,可能保障线程 B 获取的 configOptions 是更新后的数值。

也就是说 volatile 变量的可见性产生了加强,可能起到守护其上下文的作用。线程 A 对 volatile 变量的赋值,会强制将该变量本人和过后其余变量的状态都刷出缓存,为线程 B 提供可见性。当然,这也是以肯定的性能开销作为代价的,但毕竟带来了更加简略的多线程行为。

咱们常常会说 volatile 比 synchronized 之类更加轻量,但轻量也仅仅是绝对的,volatile 的读、写依然要比一般的读写要开销更大,所以如果你是在性能高度敏感的场景,除非你确定须要它的语义,不然慎用。

总结

明天,我从 happen-before 关系开始,帮你了解了什么是 Java 内存模型。为了更不便了解,我作了简化,从不同工程师的角色划分等角度,论述了问题的由来,以及 JMM 是如何通过相似内存屏障等技术实现的。最初,我以 volatile 为例,剖析了可见性在多线程场景中的典型用例
如果本文对你有帮忙的话,欢送点赞分享,这对我持续分享 & 创作优质文章十分重要。感激!

本文由 mdnice 多平台公布

正文完
 0