JVM的内存自动管理

it2025-06-01  7

文章目录

1. JVM的内存区域1.1 运行时数据区域1.1.1 栈1.1.1.1 虚拟机栈1.1.1.2 本地方法栈 1.1.2 Java堆1.1.3 方法区1.1.4 直接内存 1.2 对象1.2.1 对象的创建1.2.2 对象的内存布局1.2.3 对象的访问定位 2. 垃圾收集器与内存分配策略2.1 关键技术点2.1.1 判断对象是否存活2.1.2 OoPMap2.1.3 安全点2.1.4 Rembered Set(记忆集)和 Card Table(卡表)2.1.5 写屏障2.1.6 三色标记2.1.7 引用类型 2.2 垃圾收集算法2.2.1 标记—清除算法2.2.2 标记—复制2.2.3 标记—整理 2.3 经典垃圾收集器2.3.1 Parallel Scavenge + Parallel Old2.3.1.1 Parallel Scavenge2.3.1.2 Parallel Old 2.3.2 ParNew + CMS2.3.2.1 ParNew2.3.2.2 CMS 2.3.3 G1 2.4 低延迟垃圾回收器2.4.1 ZGC 2.5 内存分配策略

1. JVM的内存区域

1.1 运行时数据区域

JVM将运行时的数据区域主要分为:

线程共享的(在虚拟机运行期间一直存在) 方法区堆 线程隔离的(与线程的生命周期一致) 程序计数器:当前线程执行字节码的行号,用以控制程序的执行流程。栈:虚拟机栈、本地方法栈

在JVM中,静态变量、常量位于方法区,实例变量位于堆,局部变量(方法)位于栈帧。

1.1.1 栈

1.1.1.1 虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型,JVM在每个方法被执行时都会同步创建一个栈帧,方法的调用过程对应着栈帧的入栈与出栈。

栈帧的大小在编译期即可确认,栈帧中主要保存着:

局部变量表操作数栈方法出口信息动态链接

局部变量表

局部变量表保存了方法的局部变量和入参,数据类型主要为基本数据类型和对象引用。局部变量表使用变量槽作为变量存储的基本单位,当一个方法被调用时,JVM首先会为当前实例对象的引用(this)和方法入参分配变量槽,随后再按照方法内部变量定义的顺序和作用域分配其余变量槽(可以被复用)。

操作数栈

用以保存指令执行的中间结果,譬如加法指令的执行过程是将操作数栈顶的两个元素出栈,相加后再重新入栈。

可抛出异常

栈深过大:StackOverflowError

栈深可扩展(HotSpot的栈容量不支持动态扩展)、首次申请栈空间,超出内存:OutOfMemoryError

1.1.1.2 本地方法栈

为本地(Native)方法服务,HotSpot将其与虚拟机栈合并。

1.1.2 Java堆

几乎所有的对象实例和数组都在堆上进行内存分配,但随着逃逸技术的日渐强大,对对于不会逃逸到其它线程的对象,可能经过标量替换后被拆散成标量类型从而进行栈上分配。

从内存回收的角度看,由于大部分的垃圾回收器基于分代收集理论设计,所以常将Java堆分为新生代、老年代。目前逐渐出现了一些不分代的垃圾回收器,比如G1逻辑分代而物理不分代,ZGC和Shenandoah在逻辑上和物理上均不分代(当前)。

从内存分配的角度看,为了提高并发内存分配的效率,堆可以事先划分出线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。当本地线程分配缓冲使用完了,才需要同步锁定以分配新的缓冲区。

1.1.3 方法区

用于存储已经被虚拟机加载的类型信息、常量、静态变量、代码等数据。在HotSpot中,JDK8之前的方法区使用永久代实现;在JDK8之后,使用利用本地内存实现的元空间(Meta-Space)实现。

运行时常量池

运行时常量池是方法区的一部分,用于存放字面量以及符号引用。运行时常量池具有 动态性,除了编译期产生的常量,运行期也可以将新的常量放入池中。比如String.intern方法。

Java中的基本类型(除了浮点型)和 String都是用了常量池技术,以便提高运行速度、节约内存。例如基本类型int的自动装箱(语法糖valueOf方法)会首先尝试从Integer.IntegerCache(数组)缓存中获取指定的包装对象,默认缓存 -127至128的Integer对象。再比如,双引号声明的String对象会直接在常量池中进行创建。

回收方法区

对方法区的内存回收主要针对常量池回收和对类型的卸载。

对类型的卸载比较苛刻,需要同时满足:

该类的所有实例(包括子类实例)都被回收加载该类的类加载器已被回收该类的Class对象没有被引用

1.1.4 直接内存

直接内存并不是JVM定义的内存区域,其分配不会受到Java堆大小的限制,只受到物理内存大小的限制。

NIO引入了直接分配堆外内存的能力,同时借助零拷贝可以避免在Java堆和Native堆中来回拷贝数据。

1.2 对象

1.2.1 对象的创建

当JVM遇到一条new指令时:

检查指令的参数是否能在常量池定位到一个类的符号引用(指向引用对象的字面量);检查这个符号引用代表的类是否已被加载、解析和初始化,如果没有首先执行;为对象分配内存,对象所需内存在类加载完成后便可完全确认,根据内存是否规整,分配方式有: 规整(指针碰撞):移动空闲指针,划出所需空间;不规整(空闲列表):维护空闲列表,从中找出合适空间。 初始化对象的内存空间,使得字段具有零值;设置对象头,比如GC分代年龄;执行构造函数。

第三步中的内存是否规整取决于所使用的垃圾收集器,对于Serial、ParNew等带有压缩整理过程的收集器,系统采用指针碰撞;对于CMS这种基于标记-清除算法的收集器,则采用了空闲列表。

1.2.2 对象的内存布局

主要分为3部分:对象头、实例数据、对齐补充。

对象头主要有两部分数据:

自身运行时数据:哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程IDID等等。类型指针:指向对象的类型,虚拟机通过该指针确定对象属于那个类的实例。

实例数据包括父类字段的所有字段,字段的存储顺序与虚拟机的默认分配顺序和源码的定义顺序有关,相同宽度的字段会被分配到一起存放。

对象的起始地址必须是8字节的整数倍,不足会进行填充。

1.2.3 对象的访问定位

引用如何访问到具体的对象,主流的访问方式有:

句柄池:通过句柄池将引用翻译到实际的地址,好处是对象移动时引用稳定不变,只需修改句柄池。直接指针:速度快,省去了中间的转换过程。

HotSpot采用直接指针。

2. 垃圾收集器与内存分配策略

2.1 关键技术点

2.1.1 判断对象是否存活

判断对象是否存活的算法主要有:

引用计数:无法解决相互循环引用;可达性分析:从GC Roots开始在引用链上进行搜索,不可达对象不可能再被使用;

在JVM中,固定的GC Roots对象主要有:

栈:栈帧中引用的对象方法区:类静态属性、常量引用的对象被同步锁(synchronized)持有的对象

除此之外,在存在分代收集和局部回收(Partial GC)的时候,回收区域的对象也可能被其它区域引用着(跨代引用、跨区域引用),所以需要将这部分对象(其它区域)也加入到GC Root中。这个通常使用Rembered Set实现,例如在新生代用记忆集记录引用新生代对象的老年代区域。

2.1.2 OoPMap

避免扫描整个GC Roots区域

目前为止,HotSpot中所有收集器在根节点枚举时,都需要暂停用户线程,在一个一致性的快照中执行。但是由于各种优化技巧(利用OopMap记录存放引用的内存地址从而无需扫描整个方法区等GC Roots区域),其停顿时间短暂且固定,不随堆大小的增长而增长。

2.1.3 安全点

降低OopMap的维护成本

可能导致引用关系变化(OopMap内容变化)的指令非常多,太过频繁的更新OopMap会产生较大的效率问题。

所以HotSpot并不是在用户程序执行的任何位置都可以停顿下来进行垃圾收集,而是必须执行到安全点。在安全点位置,HotSpot会记录OopMap信息。

HotSpot采用主动式中断,在即将进行垃圾回收时,会设置一个标志位。用户线程在安全点判断标志位,决定是否主动挂起。

2.1.4 Rembered Set(记忆集)和 Card Table(卡表)

跨代引用、跨区域引用

Remembered Set用以记录引用收集区域对象的非收集区域信息,将其作为GC Roots的一部分。HotSpot中Remembered Set的具体实现是Card Table,记录精度精确到内存区域,而不是具体的对象指针。

2.1.5 写屏障

维护卡表

HotSpot利用写屏障维护卡表。写屏障类似与虚拟机层面的AOP切面,在引用类型字段赋值时产生一个环形(Around)通知,在写后屏障中完成卡表状态更新。

PS:

本章内存屏障中的读屏障,用于拦截读请求。

并发章节的内存屏障(读屏障),指防止指令重排优化导致的乱序执行。在内存屏障之后的指令,不能重排序到内存屏障之前的位置。

2.1.6 三色标记

并发标记的理论基础,解决并发扫描期间的引用关系变化导致的对象消失。

三色标记用于在可达性分析中表示对象是否被扫描过,作为并发可达性分析的理论依据:

白色:未被访问过,初始阶段均是白色,若在扫描完成后仍是白色,则该对象不可达;黑色:该对象和其所有引用均被扫描过,表示其是存活的;灰色:该对象被扫描过,但其引用没有被全部扫描过。

在并发扫描(标记)期间,发生的对象引用关系图变化,会产生两种不良后果。

一是对象消失,即错误的将黑色对象标记为白色,必须同时满足两个条件:

产生了一条黑色对象到白色对象的引用灰色对象到该白色对象的引用关系被删除

进而导致该白色对象不会被扫描到。打破其中一个条件即可避免对象消失问题,由此产生两种解决方案:增量更新和原始快照(Snapshot At Beginning,SATB):

增量更新(CMS使用):打破第一个条件,记录新增的黑色->白色对象的引用,并在并发扫描后重新扫描该黑色对象。原始快照(G1):打破第二个条件,记录灰色对象->白色对象的删除操作,并在并发扫描后重新该灰色对象。

二是浮动垃圾,即已经被扫描过的黑色或者灰色对象死亡了,这是一个可以容忍的问题。

2.1.7 引用类型

强引用:描述必须对象,比如等号引用。永不会回收。

软引用:有用但非必须,在发生内存溢出前会进行回收。

弱引用:非必须,只要发生GC就会被回收。

虚引用:不影响对象生存时间、也无法通过虚引用获取实例,只能在对象被回收时收到一个系统通知。

2.2 垃圾收集算法

大部分垃圾回收器都基于分代收集理论和跨代引用假说设计,将Java堆划分成不同的区域,常见的划分为新生代和老年代。将对象按照年龄大小分配在不同的区域中。针对不同区域对象存亡特征,设计与之相匹配的垃圾收集算法,有:标记—清除、标记—复制、标记—整理。

跨代引用假说,即存在跨代引用的对象只占少数。因而,为了解决跨代引用而将整个老年代放入GC Roots中收益甚微。维护一个Remembered Set记录老年代存在跨代引用的区域,将该区域加入GC Roots中更为高效。

为什么要分代?

对于传统的、基本的GC,为了减少堆的扫描范围、降低STW时间,所以采用了部分回收、分而治之的思路,分代收集是其中一种划分Java堆的方式,基于“大部分对象朝生夕灭“这一先验条件。

对于一些先进的增量式、部分并发甚至完全并发的GC,分代可以使得GC能够应对的内存分配速率大大提升。针对新生代进行更为频繁的收集,防止一次性在全堆上并发回收时产生大量的浮动垃圾。

2.2.1 标记—清除算法

标记—清除是最早出现和最基本的垃圾收集算法,首先标记出需要回收的对象,然后仔统一回收掉。

其缺点主要有:

碎片问题效率:效率随着可回收对象数量增长而降低,需要执行大量的标记和清除操作。

所以,标记—清除适用于老年代而不适用于新生代。

CMS采用了标记—清除算法。

2.2.2 标记—复制

标记—复制解决了标记—清除在面对大量可回收对象时执行效率低下的缺陷,将数量较少的存活对象复制到另一块内存区域。如果内存中的多数对象都是存活的,标记—复制将产生大量的复制开销;但当大多数对象都是可回收时,算法需要复制的对象就是仅占少数的存活对象。

同时,由于是复制到另外一块内存区域,也就没有碎片产生。

标记—复制适用于新生代对象的存亡特征。

HotSpot实现

在HotSpot中,将新生代内存分为一块较大的Eden空间和两块较小的Survivor空间,每次只使用Eden空间和其中一块Survivor空间。垃圾收集时,将Eden和Survivor空间中存活的对象复制到另一块Survivor空间,然后清理掉Eden和使用过的Survivor空间。HotSpot中Eden : Survivor = 8 : 1。

逃生门(分配担保)

若垃圾回收时,Survivor空间不能容纳所有存活的对象,老年代为其进行分配担保。

2.2.3 标记—整理

**标记—复制算法在对象成活率较高的情况下,会产生很高的复制开销;**同时,需要有额外的空间进行分配担保,一般不适用于老年代。而标记—清除算法又会产生碎片,标记—整理算法对此进行了改进:标记完成之后不直接对垃圾对象进行回收,而是将存活对象向内存一端移动。

标记—清除和标记—整理的本质区别在于是否移动存活的对象:是否移动对象都存在弊端:

移动对象:会使得内存回收更为复杂、耗时,拉长停顿时间。不移动对象:产生内存碎片,使得内存分配更为复杂、耗时,降低吞吐量。

所以,在HotSpot中注重停顿时间的CMS收集器采用了不移动对象的标记—清除算法,只是在内存碎片程度高到影响对象分配时才采用标记—整理算法收集一次;而注重吞吐量的Parallel 则采用了标记—整理算法。

2.3 经典垃圾收集器

常见的垃圾收集器搭配有:

Parallel Scavenge + Parallel Old:这是 Java 8的默认垃圾收集器,吞吐量优先,多线程并行但不与用户线程并发;ParNew + CMS:追求停顿时间,CMS并发;G1:这是 Java 9的默认垃圾收集器,追求在可控的停顿时间下获得尽可能高的吞吐量,并发。

PS

收集类型:

部分收集(Partial GC): YoungGCOld GC:只用CMS会有单独收集老年代的行为 混合收集(Mixed GC):整个新生代 + 部分老年代,只有G1。整堆收集(Full GC)

2.3.1 Parallel Scavenge + Parallel Old

2.3.1.1 Parallel Scavenge

Parallel Scavenge是一款基于标记—复制算法的新生代多线程并行垃圾收集器,是一款吞吐量优先收集器,追求达到一个可控的吞吐量。

Parallel Scavenge可以配置自适应调节策略,让GC自动调节新生代大小、Eden/Survivor比例等参数,以达到用户设定的最大停顿时间、吞吐量等目标。但是Parallel Scavenge降低停顿时间的方式是缩小新生代空间,副作用会导致GC更加频繁,降低了吞吐量。

2.3.1.2 Parallel Old

Parallel Old是Parallel Scavenge的老年代版本,基于标记—整理的多线程并行收集器。

Java 8中默认新生代 : 老年代的比例是1 : 2。

2.3.2 ParNew + CMS

2.3.2.1 ParNew

ParNew 是 Serial 的多线程并行版本,同Parallel Scavenge一样使用多个线程进行垃圾收集,在收集期间不能和用户线程并发执行,会STW暂停所有用户线程。ParNew是除Serial外唯一可以和CMS进行搭配的新生代收集器。

2.3.2.2 CMS

算法:三色标记+增量更新+标记—清除

CMS是一款追求最短停顿时间老年代收集器,为此选用了在回收期间不移动对象的标记—清除算法,以降低内存回收的时间复杂度,这同时也降低了系统的吞吐量。

CMS的收集步骤

初始标记:利用OopMap快速标记GC Roots对象,包括栈、方法区以及Remembered Set等区域。根结点的枚举在目前的所有垃圾收集器中都需要暂停用户线程。并发标记:从GC Roots对象开始并发扫描整个对象引用图。这部分耗时较长,但是可以和用户线程并发。重新标记:并发标记的理论基础是三色标记,指出了并发标记期间对象引用关系变化导致的对象消失必须同时满足两个条件,CMS选择打破其中一个:产生了黑色对象到白色对象的引用。具体实现是增量更新,记录这部分新产生的引用,在并发标记结束后,对这部分增量进行重新标记。并发清理:并发清除掉已经死亡的对象,无法清理浮动垃圾。

缺点

最耗时的两个阶段并发标记和并发执行都是和用户线程一起执行的,能够大大缩短GC时的STW时间。CMS是一款优秀的垃圾收集器,但也并不完美,也没能成为默认垃圾收集器,主要以下问题:

内存碎片:标记—清理算法的弊端,碎片过多时会导致大对象找不到连续空间而无法分配。浮动垃圾:在并发期间已经被标记过的对象死亡了,这部分浮动垃圾只能等到下一次GC时回收。空间预留:并发期间会有新对象生成,所以不能等到老年代被填满时再进行收集。处理器资源敏感(并发的程序对处理器资源都比较敏感),导致用户程序变慢,降低总吞吐量;

晋升失败和并发失败

内存碎片过多会导致Young GC时新生代需要晋升到老年代的大对象无法分配,产生晋升失败(Promotion Failed),此时不得不提前触发一次Full GC。默认参数下,每次Full GC时都会进行碎片整理(无法进行并发清理,拉长停顿时间),可以调节频率。

浮动垃圾过多、预留空间较小会导致CMS并发失败(Concurrent Mode Failure),即在收集期间无法满足新对象的分配需要,进而需要使用Serial GC进行完全STW的Full GC。可以增大老年代的预留空间,即降低触发老年代CMS收集的堆使用阈值-XX:CMSInitiatingOccupancyFraction。

2.3.3 G1

算法:三色标记+原始快照(SATB)+标记—复制

G1是垃圾收集器的里程碑,其设计导向不再追求一次性把Java堆清理干净,而是追求能够应付应用的内存分配速率——只要垃圾回收速度能够跟的上对象的分配速度,那么系统就可以运行的很完美。

G1开创了基于Region的内存布局和面向局部收集的设计思路;能够指定期望停顿时间(合理值在100~300ms)、按收益制定回收计划,可以使得G1在不同场景下取得吞吐量和延迟的最佳平衡。

G1是在延迟可控的情况下尽可能的提高吞吐量,而不是纯粹的追求停顿时间。

为此,G1建立了停顿模型:在指定的时间片段M内,消耗在垃圾收集上的时间不大于N毫秒。基于Region的堆内存布局,并将其作为回收的最小单元,优先处理回收价值最高的Region,是实现这个目标的关键。在G1之前的垃圾收集器都是面向代进行收集的,而G1将堆内存划分成多个大小相同的独立区域(Region),每个区域都可以根据需要扮演不同的空间(Eden、Survivor、老年代)。所以,G1在逻辑上分代,在物理上不分代,新生代和老年代都是一系列不连续空间的集合。

难点

跨区域引用:Remmbered Set,每个区域都使用双向卡表;

并发期间的对象消失:原始快照(STAB);

空间预留:每个Region划分出一部分空间用于并发期间的对象分配。同CMS一样,对象分配速度过快,会导致并发失败进而冻结用户线程。

G1的收集步骤

初始标记:利用OopMap快速标记GC Roots对象,包括栈、方法区以及Remembered Set等区域。根结点的枚举在目前的所有垃圾收集器中都需要暂停用户线程。并发标记:从GC Roots对象开始并发扫描整个对象引用图。这部分耗时较长,但是可以和用户线程并发。最终标记:并发标记的理论基础是三色标记,指出了并发标记期间对象引用关系变化导致的对象消失必须同时满足两个条件,G1选择打破其中一个:灰色对象到白色对象的引用消失了。具体实现是原始快照,记录这部分被删除的引用,在并发标记结束后,处理这部分引用发生变化的对象。筛选回收:按照回收价值和成本对Region进行排序,并根据用户期望的停顿时间进行回收:将决定回收Region中存活的对象复制到空的Region中。由于涉及到对象复制,所以需要暂停用户线程。从整体上看与标记—整理效果相同——不会产生碎片。

除了并发标记阶段其它阶段均需要暂停用户线程,所以G1不是纯粹的追求停顿时间。

与CMS对比

G1并不能在所有场景下都替代CMS,更为复杂的卡表结构和原始快照使其内存占用和执行负载都高于CMS。

一般而言,在小内存上(6~8GB以下),CMS的表现要大概率优于G1。

2.4 低延迟垃圾回收器

随着硬件性能的提高,对垃圾收集器的内存占用和吞吐量的要求降低,而对延迟的要哭越来越高——堆内存的增长会使得停顿时间变长。

在三色标记的指导下,CMS和G1分别基于增量更新和原始快照实现了标记阶段的并发,但在标记之后的回收阶段处理的不够完美——CMS基于标记—清除,虽然实现了并发,但是存在空间碎片等问题;G1则需要在回收阶段停顿。

ZGC和Shenandoah几乎全部过程都可以并发,除了初始标记和最终标记,而这部分时间相对固定,与堆大小没有正比关系。因而可以实现停顿时间小于10ms的目标。

2.4.1 ZGC

算法:染色指针

ZGC是一款基于Region内存布局的,使用染色指针、读屏障和内存多重映射技术的并发标记—整理垃圾收集器,其目标是在任意大小的堆内存下都可以将垃圾收集的停顿时间控制在10ms以内。

染色指针

ZGC的标志性设计是在指针上记录标记信息。以往的垃圾收集器通常在对象头(Serial)或者BitMap(G1)之类的独立区域记录标记信息,而ZGC的染色指针直接在引用对象的指针上记录三色标记状态、是否进入重分配集、是否只能通过finalized访问到,共4比特位。

染色指针的优势:

一旦Region存活的对象被移走,该Region可以立即被释放,而无需等到所有引用被修正;减少内存屏障的使用,由于没有分代、针对整堆回收,所以不需要利用写屏障维护Remmbered Set;以后可以记录更多的额外信息。

内存映射

ZGC使用多重映射,将多个不同的虚拟地址映射到同一个物理地址上。

ZGC的收集步骤

ZGC大致分为四大阶段,每个阶段都可以并发。中间会存在短暂的停顿,比如根结点的枚举。

并发标记

并发遍历引用图作可达性分析,标记是在指针上进行的。也会经历与G1中的初始标记、最终标记类似的短暂停顿。

并发预备重分配

统计标记信息,确定重分配集,其中的存活对象将会被复制到其它Region中。ZGC针对全堆进行收集,而不是像G1那样每次只回收一部分Region。避免了跨区域引用时Remembered Set的维护成本。

并发重分配

把重分配集中的对象复制到新的Region中,并为每个Region维护转发表:记录就对象到新对象的转向关系。

ZGC利用读屏障拦截对象的访问,ZGC从指针上就可以看出访问的对象是否处于重分配集中,若是则利用转发表将访问指向新的地址,同时修正该引用。——自愈

并发重映射

修正重分配集中所有对象的旧引用。由于旧引用存在“自愈“,并发重映射并不是一个迫切的工作。

当下缺点

目前能够承受的对象分配速率不高

由于并发期间存在浮动垃圾,同时在并发期间产生的新对象也会被视为存活对象,加之当前针对全堆进行回收,并发时间较G1和CMS来的更长。在高对象分配速率下,堆空间很快会被占满。

根本方法是分代,针对新生代进行更为频繁和更快的收集。

2.5 内存分配策略

对象一般在堆上分配内存,但随着标量替换和栈上分配的发展,有些对象会拆散成标量直接在栈上分配。只要对象不会逃逸出线程,即可使用栈上分配优化,比如局部变量。

总体来说,对象分配主要存在以下策略:

TLAB(线程私用的分配缓冲区,并发分配CAS)、Eden优先分配(不足则Young GC)大对象(长字符串、长数组)、年龄较大(默认15晋升)的对象进入老年代空间分配担保(Young GC后Survivor空间无法容纳的对象进入老年代)
最新回复(0)