内存模型详解

news/2024/11/30 5:57:21/

目录

JMM

背景:

目标

内存间交互

内存间交互操作

必须满足如下规则:

特殊规则

JMM重排序

处理器的重排序规则

 JMM内存屏障

happens-before

as-if-serial语义

volatile

JVM内存模型

程序计数器(Program Counter Register)

异常

虚拟机栈(VM Stack)

局部变量表

异常

本地方法栈( Native Method Stack)

堆(Heap)

异常

逃逸分析

方法区(Method Area)

异常

方法区在jdk1.7和jdk1.8之间区别

为什么移除永久代?

运行时常量池

异常


JMM

物理机内存交互关系

原文:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html 

背景:

在多核系统中,处理器一般有一层或者多层的缓存,这些的缓存通过加速数据访问(因为数据距离处理器更近)和降低共享内存在总线上的通讯(因为本地缓存能够满足许多内存操作)来提高CPU性能。缓存能够大大提升性能,但是它们也带来了许多挑战。例如,当两个CPU同时检查相同的内存地址时会发生什么?在什么样的条件下它们会看到相同的值?这样就引出了缓存一致性(Cache Coherence)问题。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主存,如上图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。

在处理器层面上,内存模型定义了一个充要条件,“让当前的处理器可以看到其他处理器写入到内存的数据”以及“其他处理器可以看到当前处理器写入到内存的数据”。有些处理器有很强的内存模型(strong memory model),能够让所有的处理器在任何时候任何指定的内存地址上都可以看到完全相同的值。而另外一些处理器则有较弱的内存模型(weaker memory model),在这种处理器中,必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作或者让其他处理器能看到当前处理器的写操作。这些内存屏障通常在lock和unlock操作的时候完成。内存屏障在高级语言中对程序员是不可见的。

在强内存模型下,有时候编写程序可能会更容易,因为减少了对内存屏障的依赖。但是即使在一些最强的内存模型下,内存屏障仍然是必须的。设置内存屏障往往与我们的直觉并不一致。近来处理器设计的趋势更倾向于弱的内存模型,因为弱内存模型削弱了缓存一致性,所以在多处理器平台和更大容量的内存下可以实现更好的可伸缩性

“一个线程的写操作对其他线程可见”这个问题是因为编译器对代码进行重排序导致的。例如,只要代码移动不会改变程序的语义,当编译器认为程序中移动一个写操作到后面会更有效的时候,编译器就会对代码进行移动。如果编译器推迟执行一个操作,其他线程可能在这个操作执行完之前都不会看到该操作的结果,这反映了缓存的影响。

此外,写入内存的操作能够被移动到程序里更前的时候。在这种情况下,其他的线程在程序中可能看到一个比它实际发生更早的写操作。所有的这些灵活性的设计是为了通过给编译器,运行时或硬件灵活性使其能在最佳顺序的情况下来执行操作。在内存模型的限定之内,我们能够获取到更高的性能。

Java内存模型描述了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了“程序中的变量“ 和 ”从内存或者寄存器获取或存储它们的底层细节”之间的关系。Java内存模型通过使用各种各样的硬件和编译器的优化来正确实现以上事情。

Java包含了几个语言级别的关键字,包括:volatile, final以及synchronized,目的是为了帮助程序员向编译器描述一个程序的并发需求。Java内存模型定义了volatile和synchronized的行为,更重要的是保证了同步的java程序在所有的处理器架构下面都能正确的运行。

引自:https://ifeve.com/memory-model/

目标

JMM目标:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节(这里变量指代的是实例字段、静态字段和构成数组对象的元素)

内存间交互

内存间交互操作

    JMM定义了8种操作(原子操作),虚拟实现时保证这8中操作均为原子操作,以下为8中操作的介绍以及执行顺序:

    (1)lock(锁定):作用于主内存的变量,把一个变量标志为一个线程占有状态(锁定)

    (2)unlock(解锁):作用于主内存的变量,把一个变量从一个线程的锁定状态解除,以便其他线程锁定

    (3)read(读取):作用于主内存的变量,将变量从主内存读取到线程的工作空间,以便后续load操作使用

    (4)load(载入):作用于工作空间的变量,将load操作从主内存得到的变量放入工作内存变量副本中

    (5)use(使用):作用于工作空间的变量,将工作空间中的变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将执行这个操作。

    (6)assign(赋值):作用于工作内存的变量,把一个从执行引擎接收的值赋给工作空间的变量

    (7)store(存储):作用于工作内存的变量,把工作空间的一个变量传到主内存,以便后续write操作使用

    (8)write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量值放入主内存的变量中

必须满足如下规则:

不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。(但不要求连续执行:read与load之间、store与write之间是可插入其他指令的)

不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回 主内存。

不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存 中。

一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。

一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。

如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个 被其他线程锁定的变量。

对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

特殊规则

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性, 但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。
(题外话: 如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取 和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变 量”的数值。不过这种读取到“半个变量”的情况是非常罕见的,经过实际测试,在目前主流平台下商 用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32 位x86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。从JDK 9起, HotSpot增加了一个实验性的参数-XX:+AlwaysAtomicAccesses(这是JEP 188对Java内存模型更新的 一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。而针对double类型,由于现代中央处 理器中一般都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit,FPU),用来专门处理 单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题,实际测试也 证实了这一点。笔者的看法是,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写 代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。

JMM重排序

重排序

从Java源代码到最终实际执行的指令序列,会经过三种重排序。但是,为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。对于编译器的重排序,JMM会根据重排序规则禁止特定类型的编译器重排序;对于处理器重排序,JMM会插入特定类型的内存屏障,通过内存的屏障指令禁止特定类型的处理器重排序。这里讨论JMM对处理器的重排序,为了更深理解JMM对处理器重排序的处理,先来认识一下常见处理器的重排序规则:

注:不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

处理器的重排序规则

重排序屏障

 JMM内存屏障

由于处理器的重排序规则(如写-读重排序)会影响到程序正常运行,基于这点,JMM会在适当的位置插入内存屏障指令来禁止特定类型的处理器的重排序。内存屏障指令一共有4类:

  • LoadLoad Barriers:确保Load1数据的装载先于Load2以及所有后续装载指令
  • StoreStore Barriers:确保Store1的数据对其他处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及所有后续存储指令的装载
  • LoadStore Barriers:确保Load1数据装载先于Store2及所有后续存储指令刷新到内存
  • StoreLoad Barriers:确保Store1数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行无效)先于Load2及所有后续装载指令的装载。该指令会使得该屏障之前的所有内存访问指令完成之后,才能执行该屏障之后的内存访问指令。

happens-before

        介绍这种定义的一个等效判断原则——先行发生原则(Happens-Before),用来确定一个操作在并发环境下 是否安全的。

happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。
单线程:as-if-serial
对volatile变量的写,happen-before于后续对这个变量的读。 --编译器和cpu不能重排序
对synchronized的解锁,happen-before于后续对这个锁的加锁。
对final变量的写,happen-before于final域对象的读,happen-before于后续对final变量的读。

as-if-serial语义

as-if-serial语义的意思指:管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

volatile

详见 :https://blog.csdn.net/weixin_38681369/article/details/119752994

volatile是JVM中最轻量的同步机制。

作用:

  1. 64位写入的原子性。(不是传统的原子性:复合操作不具有原子性)
  2. 内存可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,这个新值对于其他线程来说是立即可见的。
  3. 禁止重排序(实现有序性)。

JVM内存模型

Java内存模型的操作简化为read、write、lock和unlock四种。

(1)JMM的主内存对应JVM中的堆内存对象实例数据部分

(2)JMM的工作内存对应JVM中栈中部分区域

程序计数器(Program Counter Register)

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型里(概念模型,各种虚拟机可能会通过一些更高效的方式实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、跳转、循环、异常处理、线程恢复等基础操作都会依赖这个计数器来完成。每个线程都有独立的程序计数器,用来在线程切换后能恢复到正确的执行位置,各条线程之间的计数器互不影响,独立存储。所以它是一个“线程私有”的内存区域。此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域

异常

虚拟机栈(VM Stack)

JVM栈是线程私有的内存区域。它描述的是java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至完成的过程,都对应着一个栈帧从入栈到出栈的过程。每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法。就像是组成动画的一帧一帧的图片,方法的调用过程也是由栈帧切换来产生结果。

局部变量表

局部变量表存放了编译器可知的各种基本数据类型(int、short、byte、char、double、float、long、boolean)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一跳字节码指令的地址)。其中64 位长度的long 和double 类型的数据会占用2 个局部变量空间(Slot),其余的数据类型只占用1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

异常

在JVM规范中,对这个区域规定了两种异常情况:如果线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈( Native Method Stack)

它和虚拟机栈之间的区别不过是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

堆(Heap)

Heap是OOM故障最主要的发源地,它存储着几乎所有的实例对象(如果实例对象没有发生线程逃逸行为,就会被存储在线程栈中),堆由垃圾收集器自动回收,堆区由各子线程共享使用;通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间;堆的内存空间既可以固定大小,也可运行时动态地调整,

通过参数-Xms设定初始值、-Xmx设定最大值。

异常

OOM

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域。
如果一个对象在方法中被定义,但是对象的使用仅是在当前方法中,而且对象本身比较简单,那么对象就有可能被存储在线程栈中。

使用逃逸分析,编译器可以对代码做如下优化:

同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可 以不考虑同步。
将堆分配转化为栈分配:如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

从jdk1.7开始,默认开启逃逸分析  -XX:-DoEscapeAnalysis

方法区(Method Area)

方法区是被所有线程共享的内存区域,用来存储已被虚拟机加载的类信息、常量、静态变量、JIT(just in time,即时编译技术)编译后的代码等数据。运行时常量池是方法区的一部分,用于存放编译期间生成的各种字面常量和符号引用。一个别名叫做Non-Heap(非堆)

通过反射获取到的类型、方法名、字段名称、访问修饰符等信息就是从方法区获取到的。在使用到CGLib对类进行增强时,增强的类越多,就需要越大的方法区类存储动态生成的Class信息。

Java 虚拟机规范对这个区域的限制非常宽松,除了和Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。在Sun 公司的BUG 列表中,曾出现过的若干个严重的BUG 就是由于低版本的HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。

在jdk1.8中也就是Metaspace内存溢出,可以通过参数JVM参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置Metaspace的空间大小。

异常

当存放方法区数据的内存溢出时,会报OutOfMemoryError异常。

方法区在jdk1.7和jdk1.8之间区别

java7之前,方法区位于永久代(PermGen),永久代和堆相互隔离,永久代的大小在启动JVM时可以设置一个固定值,不可变;

java7中,存储在永久代的部分数据就已经转移到Java Heap或者Native memory。但永久代仍存在于JDK 1.7中,并没有完全移除,譬如符号引用(Symbols)转移到了native memory;字符串常量池(interned strings)转移到了Java heap;类的静态变量(class statics)转移到了Java heap。

java8中,取消永久代,方法存放于元空间(Metaspace),元空间仍然与堆不相连,但与堆共享物理内存,逻辑上可认为在堆中

Native memory:本地内存,也称为C-Heap,是供JVM自身进程使用的。当Java Heap空间不足时会触发GC,但Native memory空间不够却不会触发GC。

为什么移除永久代?

1、字符串存在永久代中,容易出现性能问题和内存溢出。

2、永久代大小不容易确定,PermSize指定太小容易造成永久代OOM

3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

4、Oracle 可能会将HotSpot 与 JRockit 合二为一。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant PoolTable),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java 虚拟机规范没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只能在编译期产生,也就是并非预置入Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String 类的intern() 方法。既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError 异常

异常

OOM


http://www.ppmy.cn/news/882754.html

相关文章

Java关键字interface(接口)

文章目录 接口的理解接口的声明接口的成员说明接口的使用规则类实现接口(implements) 接口的多实现接口的多继承(extends)接口与实现类对象构成多态引用使用接口的静态成员使用接口的非静态方法JDK8中相关冲突问题总结小测试接口与抽象类之间的对比练习 …

Redis数据结构 — Listpack

目录 listpack 结构 listpack 节点结构 quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。 于是,Redis 在 5.0 新设计一个数据结构叫 listpack…

洛谷P1083 [NOIP2012 提高组] 借教室

题目描述 在大学期间,经常需要租借教室。大到院系举办活动,小到学习小组自习讨论,都需要向学校申请借教室。教室的大小功能不同,借教室人的身份不同,借教室的手续也不一样。 面对海量租借教室的信息,我们…

React、Vue3中父组件如何调用子组件内部的方法

React 当父组件需要调用子组件的方法时,可以通过useImperativeHandle钩子函数实现。以下例子是ts实现方式。 在子组件中使用 useImperativeHandle 钩子,将指定的方法暴露给父组件,以便父组件可以通过子组件的引用来调用该方法。 在子组件中…

开始单反生涯

一直都想学习摄影,今天终于决定入手佳能550D。通过网络搜索,电话了解,最终决定到摄苑网买,毕竟网上购买风险大了点。 由于是新手,决定先买套机(IS18-55镜头),整机¥5450&a…

相机的 高清到底是一个什么东西

高清到底是一个什么东西?可能很多人还只能依稀的知道1080P什么的。当你在广告中看到数字电视机时,总会说支持1080i/1080P这样标准,先不提现在市面上销售的电视机有几个能达到这样一个要求。我们来看看1080i和1080P都代表着什么意思——1080i和…

京东内部 Spring Boot 全解笔记,精髓!

在使用传统的 Spring 去做 Java EE(Java Enterprise Edition)开发中,大量的 XML 文件存在于项目之中,导致 JavaEE 项目变得慢慢笨重起来,,繁琐的配置和整合第三方框架的配置,导致了开发和部署效…

在阿里云linux上安装MySql数据库

我们先远程连接服务器 然后输入 sudo yum update重新运行一下 然后 sudo yum install mysql-server安装 mysql 服务 其中有两次 y n 选择 都选y就好了 然后 运行 sudo service mysqld start启动MySql 然后 我们查看一下MySql sudo service mysqld status