JVM内存模型
今天我们来看JVM的核心部分:运行时数据区
概念:就像相当于一个行号指示器一.PC寄存器(程序计数器)
- JVM中的PC寄存器是对物理PC寄存器的一种抽象模型,该区域非常小,几乎可以忽略不计
- 每一个线程都有自己独有的PC寄存器
- 用来存储下一条指令的地址,执行引擎每次读取指令时都先从PC寄存器得到下一条指令(字节码指令)的地址,然后找到指令进行执行
- 是唯一的一个在JVM规范中没有任何内存溢出(OutOtMemoryError)情况的区域,同时也没有GC
作用:
-
程序执行到哪了?因为CPU需要不停的切换各个线程,这时候切换回来,就得知道接着从哪执行。
-
下一步执行哪个指令?JVM的字节码解释器需要通过PC寄存器的值来明确下一条应该执行什么样的字节码文件
PC寄存器为什么被设置为线程私有?
-
PC寄存器主要是为了在多线程下,CPU不停的做任务切换的时候,保证可以准确的执行所有的指令。
-
如果在单线程下,所有指令都是按照顺序执行的,就不存在cpu做任务切换时导致指令混乱。而多线程就需要每个线程独立记录自己执行的情况,从而不会出现互相干扰。
二.栈
概念:
- 栈管运行,堆管存储 ,栈解决程序运行问题,即程序该如何执行,如何处理数据。 堆解决数据存储的问题,即数据往哪放,该怎么放。
- 每一个线程都会创建一个虚拟机栈(独有),内部保存为一个一个栈帧,对应一个一个java方法,它保存方法的局部变量,部分结果,并参与方法的调用和返回。
- 存在OOM,不存在GC
栈帧的内部结构
- 局部变量表:是一个数字数组,主要用来存储方法参数和局部变量
- slot(变量槽):在局部变量表中,每一个局部变量都放在一个slot中。 32位以内的类型只占一个slot,64位的类型占两个slot(比如double,long占两个slot)
-
操作数栈:主要用于保存计算过程的 中间结果/临时变量的存储空间
-
方法返回地址:存放方法返回的 地址(pc寄存器的值)
-
方法返回时,会将调用者的pc寄存器的值作为返回地址,让方法执行完之,得到下一条指令的地址
-
-
动态链接:指向运行时常量池的方法引用
- 在字节码文件中,所有的变量和方法引用都作为"符号引用"保存在常量池中。
- 比如:调用一个方法时,就是通过常量池中指向方法的符号引用来表示。
- 那么,动态链接的作用就是把这些符号引用转换为调用方法的直接引用。
-
附加信息:例如:对程序调试提供支持的信息等...
三.本地方法栈
- 虚拟机栈用于管理java相关的方法。本地方法栈用于管理本地(C/C++)相关的方法
- 调用本地方法时使用关键字 native
- 并不是所有的JVM都支持本地方法,因为jvm虚拟机规范并没有明确的要求本地方法栈。 只是当前的hotspot有本地方法栈。
四.堆
概述:
- 栈管运行,堆管存储。说白了,堆就是用来存放new出来的对象/数组
- 一个进程就对应一个JVM的实例,一个JVM实例只存在一个堆(堆是共享的,但是也有私有的空间(TLAB))
- 在JVM规范中规定,堆在物理内存中可以是不连续的,但在逻辑上应该视为连续的
堆的内存结构
- java对象可以分为两类:
- 生命周期短的对象,这类对象创建和消亡都很快
- 生命周期长的对象,在某些极端的情况下,能够与JVM的生命周期保持一致
- 关于新生代中 Eden/Survivor0/Survivor1:虽然官方说默认为8:1:1,但是实际测试中却不是。需要手动设置
- 几乎所有的对象都是在Eden区被new出来的
- 80%对象的销毁都是在新生代中进行。(朝生夕死)
为对象分配内存的过程: 为对象分配内存是一件非常复杂和严谨的任务。不仅要考虑内存的分配,还得考虑垃圾回收
一般流程:
- 对象进来申请空间,先放在Eden中。(细节:每个线程今天自己的TLAB,放不下才直接进入Eden大空间)
- 当Eden满时,触发YGC/Minor GC 进行垃圾回收,刷新Eden区。有用的对象放在Survivor0中
- 在Survivor中给每个对象一个年龄值,当Eden满了,触发垃圾回收,会把S0也进行垃圾回收(YGC/Minor GC 回收两个区域)。 此时Eden和S0的所有有用的对象都会重新分配到S1中。SO清空,变为to
- 重复步骤3,直到对象的年龄值>15时,将S区中满足条件的对象Promotion到老年代
- 在养老区,相对休闲。当养老区满的时候,触发Major GC进行垃圾回收
- 如果清理完垃圾后,养老区还是满,就进行Full GC,还不行就报OOM
注意:
- YGC是在Eden满的时候被触发,刷新Eden内的所有对象。同时YGC顺带把s0,s1的对象进行刷新
- Survivor满的时候不会触发YGC,如果S区满了,Eden还没有触发YGC,那么S区会将一些特殊的对象直接放在养老区(特殊的对象下面说)
- Promotion是晋升的意思,15就是临界值,也可以自己设置
- S区口诀:复制之后有交换,谁空谁是to
特殊:在特殊时候,就会满足内存分配策略
- 对象优先分配到Eden
- 大对象直接分配到老年代
- 长期存活的对象分配到老年代
- 对象年龄动态判断: 如果S区有很多对象年龄相同(大于一半),S0,S1来回倒腾的太费事,就认为它们也是可以进入老年代的对象。 就不需要等到年龄>15才晋升
- 空间分配担保(一种空间分配保障机制)
关于大对象:
- 就是需要连续的内存空间
- 比如:一些大的数组,长的字符串等
- 代码编写的过程中应该避免大对象,尤其是那些只用一次的大对象 因为更痛苦的是,不仅是大对象还是朝生夕死的。这样就减少了效率
关于空间分配担保:
- 在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
- 如果大于,此次Minor GC就是安全的
- 如果小于,就会开启空间分配担保策略
- 继续检查老年代最大的可用连续空间是否大于历次晋升到老年代的对象的平均大小
- 如果大于,就尝试进行Minor GC,但是此次的Minor GC是有风险的
- 如果小于,就不进行Minor GC,进行Full GC
- 继续检查老年代最大的可用连续空间是否大于历次晋升到老年代的对象的平均大小
垃圾回收的原则:频繁收集新生代,很少收集老年代,几乎不动永久代(方法区) (JVM在进行垃圾回收时,不是每次都对"新生代","老年代","方法区"进行回收,绝大部分是对新生区进行回收)
GC的分类:
- 部分收集:
- 新生代收集(Minor GC/YGC):只收集新生代
- 老年代收集(Major GC/Old GC):只收集老年代 目前只有CMS GC会有单独收集老年代的行为
- 混合收集(Mixed GC):收集整个新生代以及部分老年代 G1 GC会有这种行为
- 整堆收集(Full GC):收集整个JVM堆和方法区的垃圾
注意:
- 所有的GC都会引发STW,Minor GC时间最短,Major GC比Minor GC多十倍以上的时间
- 一般出现了Major GC,都至少伴随一次Minor GC 。但也不是绝对, Parallel Scavenge的收集策略就有直接进行Major GC的选择
- 程序在报OOM之前,会触发一次Full GC 。回收完空间任然不够,才报OOM
- Major GC 和 Full GC都会在老年代满的时候触发,一定要区分是部分回收还是整堆回收
Full GC触发机制:
- 调用System.gc()时,系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区复制时,s0区向s1区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
什么需要把堆进行分代?不分代就不能正常工作了吗?
- 不同对象的生命周期不同。经过研究,java中绝大部分的对象都是临时对象。只有少部分是长期对象 所以分代管理,会让堆中的数据更加高效。
- 不分代也是完全可以的,分代的唯一理由就是优化GC性能。(因为GC工作时,会引起STW) (STW就是当垃圾回收线程执行的时候,用户线程就停止执行,等待GC线程执行完再执行)
- 没有分代的话,GC回收的时候,要扫描所有的对象,不能精准扫描。(就像疫情期间,分为高风险,中风险,低风险等一个道理)
TLAB(线程本地分配缓存区)
为什么会有TLAB?
- 我们知道,堆是共享的空间,并且对象实例的创建在JVM中频繁发生。 那么所有的线程都来向堆申请空间时,就会引发多线程问题。
- 我们常规的办法是加锁,但是会影响性能。 那么TLAB就出来
什么是TLAB?
- 它是Eden中的一个很小的空间,默认只占1%的Eden空间
- 它专门给每个线程分配一个私有的缓存区域。每个线程来申请空间,优先在TLAB中进行创建, 当TLAB空间不够时,才会分配到Eden的其他大空间中
- 我们称这种分配方式为快速分配策略
好处: 避免多线程冲突问题,提升内存分配的吞吐量
逃逸分析
什么是逃逸分析?
- Hotspot编译器能够分析出一个新的对象的使用范围,从而决定是否要将这个对象分配到堆上。
- 也就是说,我们常规理解的,new的对象都放在堆上。但是现在通过逃逸分析,可以把未逃逸的对象 分配在栈上。分配到栈上就无需GC了,大大提高了性能
如何快速的判断是否发生逃逸分析?
- 大家就看new 的对象实体是否可能在方法外被使用。
- 没被使用:未逃逸,可以放在栈上
- 被使用:逃逸,放在堆中
好处
- 使用局部变量,当栈帧被弹出的时候,局部变量引用的对象过一段时间就会被GC
- 使用逃逸分析,直接将对象分配到栈上,随着栈帧的弹出,对象也被弹出
使用逃逸分析,对代码的一些优化方法
- 栈上分配:将堆分配转换为栈上分配
- 同步省略(消除)(锁消除):
- 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步, 那么就大大提高性能。(如果你手动加锁,就可以给你消除锁)
- 标量替换:
- 将一个聚合量替换为标量。允许将对象打散分配到栈上
- 聚合量:可以分解的数据,比如对象
- 标量:不能分解的数据,比如基本数据类型
- 标量替换其实本质还是希望栈上分配
五.方法区
概述:
- 共享区域,在JVM启动时被创建,实际物理内存可以不连续
- 主要存放:(经典款,后面有变化)
- 类型信息:类(属性,方法),接口,枚举,注解
- 常量
- 静态变量
- 编译后的代码缓存
演变
- jdk7.0及之前,习惯上把方法区成为永久代 保存在虚拟机内存中
- jdk8.0及之后,习惯上把方法区成为元空间 保存在本地内存中
为什么永久代要被元空间替换?
- 因为把JRockit和hotsport进行整合,JRockit没有永久代,hotsport就也去掉永久代 (官方的回答)
- 永久代设置空间大小是很难确认的。比如一个web工程,功能点多,在不断的动态加载很多类,很难确认大小。而元空间的大小是由本地内存确定的
- 对永久代进行调优是很困难的
为什么字符串常量(StringTable)要调整?
- 因为永久代的回收效率很低,只有在Full GC的时候才会触发。 而Full GC 是在老年代不足/永久代不足才会触发。而且我们也想方设法的不让发生Full GC。
- 这导致StringTable的回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低。 放在堆中,可以及时的被回收
常量池 VS 运行时常量池:常量池经过类的加载器之后,变为运行时常量池
- 常量池: 是字节码文件中的一部分,用于存放编译器生成的各种字面量(常量)和符号引用
- 字面量:值
- 符号引用:类引用,方法引用,参数引用,字符串引用
- 比如: int a =5; 5:字面量
- b = c() 调用方法c(),开始是指向方法的符号引用,比如"#5","#5" 对应c()
- 运行时常量池:
- 是方法区的一部分,就是常量池经过类加载器加载之后的那部分
- 池中的数据就像数组项一样,是通过索引访问的。比如:"#0","#1","#2"
- 相对于常量池,还有一个重要的特征: 具备动态性:在运行过程中,还可以动态的添加一些需要的信息的引用,所以一般比常量池的信息要多
为什么需要常量池?
- 一个类的字节码文件中,需要好多支持它的数据(类),通常这种数据很大,以至于不能直接存在字节码文件中。
- 换一种方式,可以存到常量池,这个字节码文件包含指向常量池的引用。通过动态链接, 将符号引用转变为直接引用
方法区的垃圾回收
有些人认为,方法区是没有垃圾回收的,其实不然。 JVM规范中,对方法区的约束非常宽松,提到可以不要求虚拟机在方法区实现垃圾回收。
主要回收两部分内容:
- 常量池中废弃的常量:
- 判断常量池中废弃的常量: 很简单,只要没有被任何地方引用,就是废弃的
- 不再使用的类型(包括类,接口..): 很苛刻,需要同时满足以下三个条件
- 该类的所有实例都已经被回收,也就是java堆中不存在该类及任何派生子类的实例
- 该类的加载器已经被回收,因为类中会存放加载该类的加载器的信息。这个条件很难达成
- 该类的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
寄语:清醒温柔知进退,努力上进且温柔