谈谈内存模型happen-before讲的什么?

news2024/10/2 6:37:19

大家好我是易安!

今天我要讲述的是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的初衷。

alt

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

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

alt

看上去很美好,但是当多线程共享变量时,情况就复杂了。试想,如果处理器对某个共享变量进行了修改,可能只是体现在该内核的缓存里,这是个本地状态,而运行在其他内核上的线程,可能还是加载的旧状态,这很可能导致一致性的问题。从理论上来说,多线程共享引入了复杂的数据依赖性,不管编译器、处理器怎么做重排序,都必须尊重数据依赖性的要求,否则就打破了正确性!这就是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 多平台发布

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/428635.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

二叉查找树

目录 一、二叉查找树概念 二、结点内部类代码实现: 三、二叉查找树的插入原理​编辑 四、遍历的方式(中序遍历): 五、二叉查找树实现指定值删除对应的结点 六、main方法测试 一、二叉查找树概念 二、结点内部类代码实现&…

聚类问题的算法总结

目录 一、K-means算法 1、算法原理 2、如何确定K值 3、算法优缺点 二、DBScan聚类 1、算法原理 2、处理步骤 3、算法优缺点 聚类代码实现 聚类算法属于无监督学习,与分类算法这种有监督学习不同的是,聚类算法事先并不需要知道数据的类别标签&am…

高效释放数据价值,数智融合平台有门道

在数字经济时代,数据上升为新的关键生产要素,逐渐超越土地、资本等传统要素,成为社会经济发展和企业创新更加重要的驱动力。 但如何充分释放数据价值在当下依然是一个世界性难题。一方面,企业与组织逐渐拥有海量数据规模和丰富应…

基于CBC、ECB、CTR、OCF、CFB模式的AES加密算法

1、什么是AES加密算法 什么是加密算法?我在文章《从个人角度看什么是加密算法》中描述了我对加密算法的一些浅薄的理解。我不是信息安全领域的大神,只求有一个入门罢了! 这篇文章是文章《从个人角度看什么是加密算法》的延伸,所…

【C++初阶】:缺省函数和函数重载

c入门一.缺省函数二.函数重载1.参数类型不同2.参数个数不同3.参数顺序不同一.缺省函数 缺省函数,顾名思义就是可以在传参时不传或者少传参数的函数。这里举个例子: 完全缺省 上面的就属于全缺省,可以不传任何参数,当然也可以传参…

Codeforces Round 862 (Div. 2) -- D. A Wide, Wide Graph(树的直径 贪心 简单的树形dp)

题目如下: 题意简说: 树上两点 u,vu, vu,v,如果 u,vu, vu,v 的距离大于等于 kkk 则在图 GkG_kGk​ 上 u,vu, vu,v 有一条无向边。 求当 kkk 等于 [1, n] 的时候,图 GkG_kGk​ 的连通块数量。 思路 or 题解: 我们可以…

【服务器】Dell PowerEdge R750 安装GPU

各种教程 官方教程 https://www.dell.com/support/manuals/zh-cn/poweredge-r750/per750_ism_pub/%E5%AE%89%E8%A3%85-gpu?guidguid-6bb1c301-7595-4c6d-b631-b6a5761c6052&langzh-cn 手册PDF版: https://dl.dell.com/content/manual16153190-dell-emc-powe…

OKR与敏捷开发的结合

当你想达成某件事情时,你在内心会有一个设想的期望结果。这是一个非常简单、基本的概念,并且是从很多人小时候就培养起来的。我们可以将这种现象总结一句话: 通过 ________ 来衡量__________ 。 这就是 John Doerr 在其著作《Measure what Ma…

JavaWeb开发 —— Maven

目录 一、概述 1. 介绍 2. 安装 二、maven-idea 集成 1. 配置及创建Maven项目 2. IDEA 创建Maven项目 3. IDEA导入 Maven 项目 三、依赖管理 1. 依赖配置 2. 依赖传递 3. 依赖范围 4. 生命周期 一、概述 1. 介绍 ① Apache Maven 是一个项目管理和构建工…

【UDP报文和TCP协议特性】

目录1.UDP报文1.1报文长度1.2校验和2.TCP协议特性2.1确认应答2.2超时重传2.3连接管理2.3.1三次握手2.3.2四次挥手2.4滑动窗口2.5流量控制2.6拥塞控制2.7延时应答2.8捎带应答2.9面向字节流2.10异常情况3.小结3.1tcp小结3.2tcp和UDp应用场景的差异4.寄语1.UDP报文 udp是传输层最…

【Android】之【内存管理】

一、Android内存运行是如何运行的? 物理内存即移动设备上的ram,当启动一个android程序时,会启动一个dalvik vm进程,系统会给它分配固定的内存空间【16m,32m,64m,不定,没有统一标准,每个虚拟机会有堆内存阈…

【Minecraft开服教程】使用 MCSM 面板一键搭建我的世界服务器,并内网穿透公网远程联机

文章目录前言1.Mcsmanager安装2.创建Minecraft服务器3.本地测试联机4. 内网穿透4.1 安装cpolar内网穿透4.2 创建隧道映射内网端口5.远程联机测试6. 配置固定远程联机端口地址6.1 保留一个固定TCP地址6.2 配置固定TCP地址7. 使用固定公网地址远程联机前言 MCSManager是一个开源…

C++ float 数据的保存格式

总体上,在计算机中, float 数据的的保存按照如下转换逻辑:10进制数 >二进制数>科学计数法二进制数>c 条件下下浮点数在内存中的保存格式(这里面有个转换算法,需要理清楚)。 下面举出一个案例&…

Python+selenium自动化测试实战项目(全面,完整,详细)

前言 之前的文章说过, 要写一篇自动化实战的文章, 这段时间比较忙再加回家过清明一直没有更新,今天整理一下实战项目的代码共大家学习。(注:项目是针对我们公司内部系统的测试,只能内部网络访问,外部网络无…

使用fetch()异步请求API数据实现汇率转换器

任务8 https://segmentfault.com/a/1190000038998601 https://chinese.freecodecamp.org/news/how-to-master-async-await-with-this-real-world-example/ 跟随上面的指示,理解异步函数的编写,并且实现这个汇率转换器。 第一步:在工作区初始…

AI失业潮来袭,某些部门裁员过半

历史的车轮滚滚向前,每次生产力的大幅跃进,都会造成一批失业潮。想当年,纺纱机的出现让无数手工作坊的织布师傅失业。如今,在AI技术的催化下,同样的事正在互联网行业的各个领域重演。疯狂的裁员浪潮 “AI15秒做的&…

图神经网络汇总和总结

下面所有博客是个人对EEG脑电的探索,项目代码是早期版本不完整,需要完整项目代码和资料请私聊。 数据集 1、脑电项目探索和实现(EEG) (上):研究数据集选取和介绍SEED 相关论文阅读分析: 1、EEG-SEED数据集作者的—基线论文阅读和…

LeetCode——二叉树的层序遍历

102. 二叉树的层序遍历 I 给你二叉树的根节点 root ,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。 示例 1: 输入:root [3,9,20,null,null,15,7] 输出:[[3],[9,20],[15,7]]…

超级详解MySQL执行计划explain

1、什么是MySQL执行计划 要对执行计划有个比较好的理解,需要先对MySQL的基础结构及查询基本原理有简单的了解。 MySQL本身的功能架构分为三个部分,分别是 应用层、逻辑层、物理层,不只是MySQL ,其他大多数数据库产品都是按这种架构…

窗函数的总结

1. 为什么要加窗 每次FFT变换只能对有限长度的时域数据进行变换,因此,需要对时域信号进行信号截断。即使是周期信号,如果截断的时间长度不是周期的整数倍(周期截断),那么,截取后的信号将会存在泄…