深入理解Java中的Garbage Collection
前提
最近由于系统业务量比较大,从生产的GC日志(结合Pinpoint)来看,需要对部分系统进行GC调优。但是鉴于以往不是专门做这一块,但是一直都有零散的积累,这里做一个相对全面的总结。本文只针对HotSpot VM
也就是Oracle Hotspot VM
或者OpenJDK Hotspot VM
,版本为Java8,其他VM不一定适用。
什么是GC(Garbage Collection)
Garbage Collection
可以翻译为“垃圾收集” – 一般主观上会认为做法是:找到垃圾,然后把垃圾扔掉。在VM中,GC的实现过程恰恰相反,GC的目的是为了追踪所有正在使用的对象,并且将剩余的对象标记为垃圾,随后标记为垃圾的对象会被清除,回收这些垃圾对象占据的内存,从而实现内存的自动管理。
分代假说(Generational Hypothesis)
名称 | 具体内容 |
---|---|
弱分代假说(Weak Generational Hypothesis) | 大多数对象在年轻时死亡 |
强分代假说(Strong Generational Hypothesis) | 越老的对象越不容易死亡 |
弱分代假说已经在各种不同类型的编程范式或者编程语言中得到证实,而强分代假说目前提供的证据并不充足,观点还存在争论。
分代垃圾回收器的主要设计目的是减少回收过程的停顿时间,同时提升空间吞吐量。如果采用复制算法对年轻代对象进行回收,那么期望的停顿时间很大程度取决于次级回收(Minor Collection
)之后存活的对象总量,而这一数值又取决于年轻代的整体空间。
如果年轻代的整体空间太小,虽然一次回收的过程比较快,但是由于两次回收之间的间隔太短,年轻代对象有可能没有足够的时间“到达死亡”,因而导致回收的内存不多,有可能引发下面的情况:
- 年轻代的对象回收过于频繁并且存活下来需要复制的对象数量变多,增大垃圾回收器停顿线程和扫描其栈上数据的开销。
- 将较大比例的年轻代对象提升到老年代会导致老年代被快速填充,会影响整个堆的垃圾回收速率。
- 许多证据表明,对新生代对象的修改会比老年代对象的修改更加频繁,如果过早将年轻代对象晋升到老年代,那么大量的更新操作(
mutation
)会给赋值器的写屏障带来比较大的压力。 - 对象的晋升会使得程序的工作集合变得稀疏。
分代垃圾回收器的设计师对上面几个方面进行平衡的一门艺术:
- 要尽量加快次级回收的速度。
- 要尽量减少次级回收的成本。
- 要减少回收成本更高的主回收(
Major Collection
)。 - 要适当减少赋值器的内存管理开销。
基于弱分代假说,JVM中把堆内存分为年轻代(Young Generation)和老年代(Old Generation),而老年代有些时候也称为Tenured。
JVM对不同分代提供了不同的垃圾回收算法。实际上,不同分代之间的对象有可能相互引用,这些被引用的对象在分代垃圾回收的时候也会被视为GC Roots
(见下一节分析)。弱分代假说有可能在特定场景中对某些应用是不适用的;而GC算法针对年轻代或者老年代的对象进行了优化,对于具备“中等”预期寿命的对象,JVM的垃圾回收表现是相对劣势的。
对象判活算法
JVM中是通过可达性算法(Reachability Analysis
)来判定对象是否存活的。这个算法的基本思路就是:通过一些列的称为GC Roots(GC根集合)
的活跃引用为起始点,从这些集合节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain
),当一个对象到GC Roots
没有任何引用链相连时,说明该对象是不可达的。
GC Roots
具体是指什么?这一点可以从HotSpot VM
的Parallel Scavenge
源码实现总结出来,参考jdk9分支的psTasks.hpp
和psTasks.cpp
:
// psTasks.hpp |
由于HotSpot VM
的源码里面注释比较少,所以只能参考一些资料和源码方法的具体实现猜测GC Roots
的具体组成:
Universe::oops_do
:VM的一些静态数据结构里指向GC堆里的对象的活跃引用等等。JNIHandles::oops_do
:所有的JNI handle,包括所有的global handle和local handle。Threads::oops_do
:所有线程的虚拟机栈,具体应该是所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用,或者换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。ObjectSynchronizer::oops_do
:所有被对象同步器关联的对象,看源码应该是ObjectMonitor
中处于Block状态的对象,从Java代码层面应该是通过synchronized
关键字加锁或者等待加锁的对象。FlatProfiler::oops_do
:所有线程的中的ThreadProfiler
。SystemDictionary::oops_do
:System Dictionary
,也就是系统字典,是记录了指向Klass
,KEY是一个Entry,由KalssName
和Classloader
组成,实际上,YGC不会处理System Dictionary
,但是会扫描System Dictionary
,某些GC可能触发类卸载功能,可以这样理解:System Dictionary
包含了所有的类加载器。ClassLoaderDataGraph::oops_do
:所有已加载的类或者已加载的系统类。Management::oops_do
:MBean
所持有的对象。JvmtiExport::oops_do
:JVMTI
导出的对象,断点或者对象分配事件收集器相关的对象。CodeCache::scavenge_root_nmethods_do
:代码缓存(Code Cache)。AOTLoader::oops_do
:AOT加载器相关,包括了AOT相关代码缓存。
还有其他有可能的引用:
StringTable::oops_do
:所有驻留的字符串(StringTable
中的)。
JVM中的内存池
JVM把内存池划分为多个区域,下面分别介绍每个区域的组成和基本功能,方便下面介绍GC算法的时候去理解垃圾收集如何在不同的内存池空间中发挥其职责。
- 年轻代(Young Generation):包括Eden和Survivor Spaces,而Survivor Spaces又等分为Survivor 0和Survivor 1,有时候也称为from和to两个区。
- 老年代(Old Generation):一般称为Tenured。
- 元空间:称为Metaspace,在Java8中VM已经移除了永久代Permanent Generation。
Eden
伊甸园是地上的乐园,根据《圣经·旧约·创世纪》记载,神·耶和华照自己的形像造了人类的祖先男人亚当,再用亚当的一个肋骨创造了女人夏娃,并安置第一对男女住在伊甸园中。
Eden,也就是伊甸园,是一块普通的在创建对象的时候进行对象分配的内存区域。而Eden进一步划分为驻留在Eden空间中的一个或者多个Thread Local Allocation Buffer(线程本地分配缓冲区,简称TLAB),TLAB是线程独占的。JVM允许线程在创建大多数对象的时候直接在相应的TLAB中进行分配,这样可以避免多线程之间进行同步带来的性能开销。
当无法在TLAB中进行对象分配的时候(一般是缓冲区没有足够的空间),那么对象分配操作将会在Eden中共享的空间(Common Area)中进行。如果整个Eden都没有足够的空间,则会触发YGC(Young Generation Garbage Collection),以释放更多的Eden中的空间。触发YGC后依然没有足够的内存,那么对象就会在老年代中分配(一般这种情况称为**分配担保(Handle Promotion)**,是有前置条件的)。
当垃圾回收器收集Eden的时候,会遍历所有相对于GC Roots
可达的对象,并且标记它们是活对象,这一阶段称为标记阶段。
这里还有一点需要注意的是:堆中的对象有可能跨代链接,也就是有可能年轻代中的对象被老年代中的对象持有(注:老年代中的对象被年轻代中的对象持有这种情况在YGC中不需要考虑),这个时候如果不遍历老年代的对象,那么就无法通过可达性算法分析这种被被老年代中的对象持有的年轻代对象是否可达。JVM中采用了Card Marking(卡片标记)的方式解决了这个问题,这里不对卡片标记的细节实现进行展开。
标记阶段完成后,Eden中所有存活的对象会被复制到幸存者空间(Survivor Spaces) 的其中一块空间。复制阶段完成后,整个Eden被认为是空的,可以重新用于分配更多其他的对象。这里采用的GC算法称为标记-复制(Mark and Copy) 算法:标记存活的对象,然后复制它们到幸存者空间(Survivor Spaces) 的其中一块空间,注意这里是复制,不是移动。
关于Eden就介绍这么多,其中TLAB和Card Marking是JVM中的相对底层实现,大概知道即可。
Survivor Spaces
Survivor Spaces也就是幸存者空间,幸存者空间最常用的名称是from和to。最重要的一点是:幸存者空间中的两个区域总有一个区域是空的。
下一次YGC触发之后,空闲的那一块幸存者空间才会入驻对象。年轻代的所有存活的对象(包括Eden和非空的from幸存者区域中的存活对象),都会被复制到to幸存者区域,这个过程完成之后,to幸存者区域会存放着活跃的对象,而from幸存者区域会被清空。接下来,from幸存者区域和to幸存者区域的角色会交换,也就是下一轮YGC触发之后存活的对象会复制到from幸存者区域,而to幸存者区域会被清空,如此循环往复。
上面提到的存活对象的复制过程在两个幸存者空间之间多次往复之后,某些存活的对象“年龄足够大”(经过多次复制还存活下来),则这些“年纪大的”对象就会晋升到老年代中,这些对象会从幸存者空间移动到老年代空间中,然后它们就驻留在老年代中,直到自身变为不可达。
如果对象在Eden中出生并且经过了第一次YGC之后依然存活,并且能够被Survivor Spaces容纳的话,对象将会被复制到Survivor Spaces并且对象年龄被设定为1。对象在Survivor Spaces中每经历一次YGC之后还能存活下来,则对象年龄就会增加1,当它的年龄增加到晋升老年代的年龄阈值,那么它就会晋升到老年代也就是被移动到老年代中。晋升老年代的年龄阈值的JVM参数是-XX:MaxTenuringThreshold=n
:
VM参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
-XX:MaxTenuringThreshold=n |
Survivor Spaces存活对象晋升老年代的年龄阈值 | 1<= n <= 15 | 15 |
值得注意的是:JVM中设置-XX:MaxTenuringThreshold
的默认值为最大可选值,也就是15。
JVM还具备动态对象年龄判断的功能,JVM并不是永远地要求存活对象的年龄必须达到MaxTenuringThreshold
才能晋升到老年代,如果在Survivor Spaces中相同年龄的所有对象的大小总和大于Survivor Spaces的一半,那么年龄大于或者等于该年龄的对象可以直接晋升到老年代,不需要等待对象年龄到达MaxTenuringThreshold
,例如:
类型 | 占比 | 年龄 | 动作(MaxTenuringThreshold=15 ) |
---|---|---|---|
ObjectType-1 |
60% | 5 | 下一次YGC如果存活直接晋升到老年代 |
ObjectType-2 |
1% | 6 | 下一次YGC如果存活直接晋升到老年代 |
ObjectType-3 |
10% | 4 | 下一次YGC如果存活对象年龄增加1 |
可以简单总结一下对象进入老年代的几种情况:
- 多次YGC对象存活下来并且年龄到达设定的
-XX:MaxTenuringThreshold=n
导致对象晋升。 - 因为动态对象年龄判断导致对象晋升。
- 大对象直接进入老年代,这里大对象通常指需要大量连续内存的Java对象,最常见的就是大型的数组对象或者长度很大的字符串,因为年轻代完全有可能装不下这类大对象。
- 年轻代空间不足的时候,老年代会进行空间分配担保,这种情况下对象也是直接在老年代分配。
Tenured
老年代(Old Generation)更多时候被称为Tenured,它的内存空间的实现一般会更加复杂。老年代空间一般要比年轻大大得多,它里面承载的对象一般不会是“内存垃圾”,侧面也说明老年代中的对象的回收率一般比较低。
老年代发生GC的频率一般情况下会比年轻代低,并且老年代中的大多数对象都被期望为存活的对象(也就是对象经历GC之后存活率比较高),因此标记和复制算法并不适用于老年代。老年代的GC算法一般是移动对象以最小化内存碎片。老年代的GC算法一般规则如下:
- 通过
GC Roots
遍历和标记所有可达的对象。 - 删除所有相对于
GC Roots
不可达的对象。 - 通过把存活的对象连续地复制到老年代内存空间的开头(也就是起始地址的一端)以压缩老年代内存空间的内容,这个过程主要包括显式的内存压缩从而避免过多的内存碎片。
Metaspace
在Java8之前JVM内存池中还定义了一块空间叫永久代(Permanent Generation),这块内存空间主要用于存放元数据例如Class
信息等等,它还存放其他数据内容,例如驻留的字符串(字符串常量池)。实际上永久代曾经给Java开发者带来了很多麻烦,因为大多数情况下很难预测永久代需要设定多大的空间,因为开发者也很难预测元数据或者字符串常量池的具体大小,一旦分配的元数据等内容出现了失败就会遇到java.lang.OutOfMemoryError: Permgen space
异常。排除内存溢出导致的java.lang.OutOfMemoryError
异常,如果是正常情况下导致的异常,唯一的解决手段就是通过VM参数-XX:MaxPermSize=XXXXm
增大永久代的内存,不过这样也是治标不治本。
因为元数据等内容是难以预测的,Java8中已经移除了永久代,新增了一块内存区域Metaspace(元空间),很多其他杂项(例如字符串常量池)都移动了Java堆中。Class
定义信息等元数据目前是直接加载到元空间中。元空间是一片分配在机器本地内存(native memory
)的内存区,它和承载Java对象的堆内存是隔离的。默认情况下,元空间的大小仅仅受限于机器本地内存可以分配给Java程序的极限值,这样基本可以避免因为添加新的类导致java.lang.OutOfMemoryError: Permgen space
异常发生的场景。
VM参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
XX:MetaspaceSize=Xm |
Metaspace扩容时触发FullGC的初始化阈值 | - | - |
XX:MaxMetaspaceSize=Ym |
Metaspace的内存上限 | - | 接近于无穷大 |
常用内存池相关的VM参数
- -Xmx 和 -Xms
VM参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
-Xmx |
设置最大堆内存大小 | 有下限控制,视VM版本 | - |
-Xms |
设置最小堆内存大小 | 有下限控制,视VM版本 | - |
- -Xmn、**-XX:NewRatio** 和 -XX:SurvivorRatio
VM参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
-Xmn |
设置年轻代内存大小 | - | - |
-XX:NewRatio= |
设置老年代和年轻代的内存大小比值,设置为4表示年轻代占堆内存的1/5 | - | 4 |
-XX:SurvivorRatio= |
设置Eden和幸存者区域的内存大小比值,设置为8表示from:to:Eden=1:1:8 | - | 8 |
GC类型
参考R大(RednaxelaFX
)的知乎回答,其实在HotSpot VM
的GC分类只有两大种:
Partial GC
:也就是部分GC,不收集整个GC堆。Young GC
:只收集young gen的GC。Old GC
:只收集old gen的GC,目前只有CMS的concurrent collection是这个模式。Mixed GC
:收集整个young gen以及部分old gen的GC,目前只有G1有这个模式。
Full GC
:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式。
因为HotSpot VM
发展多年,外界对GC的名词解读已经混乱,所以才出现了Minor GC
、Major GC
和Full GC
。
Minor GC
Minor GC,也就是Minor Garbage Collection,直译为次级垃圾回收,它的定义相对清晰:发生在年轻代的垃圾回收就叫做Minor GC。Minor Garbage Collection处理过程中会发生:
- 当JVM无法为新的对象分配内存空间的时候,始终会触发Minor GC,常见的情况如Eden的内存已经满了,并且对象分配的发生率越高,Minor GC发生的频率越高。
- Minor GC期间,老年代中的对象会被忽略。老年代中的对象引用的年轻代的对象会被认为是GC Roots的一部分,在标记阶段会简单忽略年轻代对象中引用的老年代对象。
- Minor GC会导致Stop The World,表现为暂停应用线程。大多数情况下,Eden中的大多数对象都可以视为垃圾并且这些垃圾不会被复制到幸存者空间,这个时候Minor GC的停顿时间会十分短暂,甚至可以忽略不计。相反,如果Eden中有大量存活对象需要复制到幸存者空间,那么Minor GC的停顿时间会显著增加。
Major GC和Full GC
Major GC(Major Garbage Collection,可以直译为主垃圾收集)和Full GC目前是两个没有正式定义的术语,具体来说就是:JVM规范中或者垃圾收集研究论文中都没有明确定义Major GC或者Full GC。不过按照民间或者约定俗成,两者区别如下:
- Major GC:对老年代进行垃圾收集。
- Full GC:对整个堆进行垃圾收集 – 包括年轻代和老年代。
实际上,GC过程是十分复杂的,而且很多Major GC都是由Minor GC触发的,所以要严格分割Major GC或者Minor GC几乎是不可能的。另一方面,现在垃圾收集算法像G1收集算法提供部分垃圾回收功能,侧面说明并不能单纯按照收集什么区域来划分GC的类型。
上面的一些理论或者资料指明:与其讨论或者担心GC到底是Major GC或者是Minor GC,不如花更多精力去关注GC过程是否会导致应用的线程停顿或者GC过程是否能够和应用线程并发执行。
常用的GC算法
下面分析一下目前Hotspot VM
中比较常见的GC算法,因为G1算法相对复杂,这里暂时没有能力分析。
GC算法的目的
GC算法的目的主要有两个:
- 找出所有存活的对象,对它们进行标记。
- 移除所有无用的对象。
寻找存活的对象主要是基于GC Roots
的可达性算法,关于标记阶段有几点注意事项:
- 标记阶段所有应用线程将会停顿(也就是Stop The World),应用线程暂时停顿保存其信息在还原点中(Safepoint)。
- 标记阶段的持续时间并不取决于堆中的对象总数或者是堆的大小,而是取决于存活对象的总数,因此增加堆的大小并不会显著影响标记阶段的持续时间。
标记阶段完成后的下一个阶段就是移除所有无用的对象,按照处理方式分为三种常见的算法:
- Sweep – 清理,也就是
Mark and Sweep
,标记-清理。 - Compact – 压缩,也就是
Mark-Sweep-Compact
,标记-清理-压缩。 - Copy – 复制,也就是
Mark and Copy
,标记-复制。
Mark-Sweep算法
Mark-Sweep算法,也就是标记-清理算法,是一种间接回收算法(Indirect Collection),它并非直接检测垃圾对象本身,而是先确定所有存活的对象,然后反过来判断其他对象是垃圾对象。主要包括标记和清理两个阶段,它是最简单和最基础的收集算法,主要包括两个阶段:
- 第一阶段为追踪(trace)阶段:收集器从
GC Roots
开始遍历所有可达对象,并且对这些存活的对象进行标记(mark)。 - 第二阶段为清理(sweep)阶段:收集器把所有未标记的对象进行清理和回收。
Mark-Sweep-Compact算法
内存碎片化是非移动式收集算法无法解决的一个问题之一:尽管堆中有可用空间,但是内存管理器却无法找到一块连续内存块来满足较大对象的分配需求,或者花费较长时间才能找到合适的空闲内存空间。
Mark-Sweep-Compact算法,也就是标记-清理-压缩算法,也是一种间接回收算法(Indirect Collection),它主要包括三个阶段:
- 标记阶段:收集器从
GC Roots
开始遍历所有可达对象,并且对这些存活的对象进行标记。 - 清理阶段:收集器把所有未标记的对象进行清理和回收。
- 压缩阶段:收集器把所有存活的对象移动到堆内存的起始端,然后清理掉端边界之外的内存空间。
对堆内存进行压缩整理可以有效地降低内存外部碎片化(External Fragmentation)问题,这个是标记-清理-压缩算法的一个优势。
Mark-Copy算法
Mark-Copy算法,也就是标记-复制算法,和标记-清理-压缩算法十分相似,重要的区别在于:标记-复制算法在标记和清理完成之后,所有存活的对象会被复制到一个不同的内存区域 – 幸存者空间。主要包括三个阶段:
- 标记阶段:收集器从
GC Roots
开始遍历所有可达对象,并且对这些存活的对象进行标记。 - 清理阶段:收集器把所有未标记的对象进行清理和回收 — 实际上这一步可能是不存在的,因为存活对象指针被复制之后,原来指针所在的位置已经可以重新分配新的对象,可以不进行清理。
- 复制阶段:把所有存活的对象复制到Survivor Spaces中的某一块空间中。
标记-复制算法可以避免内存碎片化的问题,但是它的代价比较大,因为用的是半区复制回收,区域可用内存为原来的一半。
小结
JVM和GC是Java开发者必须掌握的内容,包含的知识其实还是挺多的,本文也只是简单介绍了一些基本概念:
- 分代假说。
- Minor GC、Major GC和Full GC。
- 内存池组成。
- 常用的GC算法。
后面会分析一下GC收集器搭配和GC日志查看、JVM提供的工具等等。
参考资料:
- 《深入理解Java虚拟机-2nd》
- 《The Garbage Collection Handbook》
- 知乎-RednaxelaFX部分回答
- Java Garbage Collection handbook
OpenJDK HotSpot VM
部分源码