垃圾回收与内存分配策略
谈及 GC 自然是需要先谈及如何触发 GC,GC 作为垃圾回收器,自然是需要定义内存中『垃圾』的,那么怎么定义自然就成了关键。总体来说有以下的思路。
标记算法
计数引用
简单思路,引用 +1,失效 -1。对于循环引用无效。比较简单且 JVM 不是使用这种方法的,所以不进行深入介绍了。
采用这类 GC 的主流语言有:Python/PHP/Perl/TCL/Objective-C。
与追踪类算法相比优势
- 可以保证对象引用为0时立马得到清理,无不确定行
- 大多数操作具有增量特性(incremental),GC 可与应用交替运行,不需要暂停应用即可完成回收功能
- 可以用来优化运行时性能。比如函数式编程中所推崇的「不可变数据结构」的更新就能收益:运行时知道某个对象的引用为1,这时对这个对象进行修改,类似
str <- str+"a"
,那么这个修改就可以在本地进行,而不必进行额外的 copy
劣势
- 无法解决循环引用。CPython 使用独特的环检测算法规避;此外也可以用弱引用的方式解决,弱引用不会保护该对象被 GC 回收。如果该对象被回收了,那么这个弱引用会被赋予一个安全值(一般为NULL)。
- 实现一个高效率的引用计数 GC 比较困难。主要包括下面两方面
- space overhead,每个对象需要额外的空间来存储其引用次数,在追踪类 GC 中,用以标注对象是否在使用中的flag 位一般放在引用变量里面
- speed overhead,在增加/减少对象的引用时,需要修改引用次数。这对于栈上的赋值(on-stack assignment,比如函数调用是的参数、函数内部变量等)影响是非常大的,因为之前只需要简单修改寄存器里面的值,现在需要一个原子操作(这涉及到加锁,会浪费几百个 CPU cycles)
- 减少一个对象的引用计数时,会级联减少其引用对象的计数,这就可能造成同时删除过多的对象。在实现中一般会把要回收的对象放入一队列,当程序申请大小为 N 的内存时,从这个队列取出总体积不小于 N 的一个或多个对象将其回收。
可达性分析(追踪)
基本思路:它将整个内存中的对象看做一个树的形状,只要抓住根节点(GC Roots
)并从根节点向下会形成无数子树(引用链(Reference Chain)),当一个对象没有与根节点相关联的子树的时候(这个对象从 GC Roots
不可达)则说明此对象不可用。
在 Java 中,可作为 GC Roots
的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI (即一般说的 Native 对象)引用的对象。
值得一说的是,Java 中对于引用也是有分级的,当内存空间不足够的时候,我们会从最弱的引用开始释放空间。
- 强引用,类似于
Object obj = new Object()
这类在代码里面常见的。永远不会回收。 - 软引用,有用但并非必需的对象。在系统将要发生内存溢出异常前,会将这些对象列入回收范围之中进行二次回收。提供了
SoftReference
类来实现软引用。 - 弱引用,只能生存到下一次垃圾收集发生之前。
WeakReference
类实现。 - 虚引用,将一个对象设置为虚引用唯一目的就是能在这个对象呗收集器回收时受到一个系统通知。
PhantomReference
类实现。
要知道一点,就算被判定为不可达对象了,也并非非死不可,这时候处于准备去死的状态吧。
要真正宣告一个对象死亡,至少要经历两次标记过程:
- 是否有必要执行(只能执行一次)
finalize()
方法,如果有则会放入一个F-Queue
队列,由一个低优先级的Finalize
线程来执行它,但不会保证执行完成(防止阻塞了F-Queue
队列导致内存回收崩溃)。 - GC 将在
F-Queue
中的对象进行第二次标记,如果对象想拯救自己,则需要在finalize
方法给自己与GC Roots
再建立上关联。
该算法的缺点有:
- 在进行 GC 期间,整个系统会被挂起(暂停,Stop-the-world),所以在一些实现中,会采用各种措施来减少这个暂停时间
- heap 容易出现碎片。实现中一般会进行 move 或 compact。(需要说明一点,所有 heap 回收机制都会这个问题)
- 在 GC 工作一段时间后,heap 中连续地址上存在 age 不同的对象,这非常不利于引用的本地化(locality of reference)
- 回收时间与 heap 大小成正比
垃圾收集算法
标记清除
标记:之前介绍的可达性算法标记。完成之后进行清除。
缺点:
- 标记和清除两个过程的效率都不高
- 产生空间碎片
优化策略
Bitmap marking
将其可达性使用一个 Bitmap
来存储,其映射到各个 Object
的内存地址。这就要求 object 进行对齐,比如:heap 大小为 65536 字节,所有的对象以 16 字节对齐,那么堆内就有 4096 个地址可以作为对象的起始地址,与之对应需要 4096 个 bit 即 512 个字节。
bitmap 还有下面两个优势:
- sweep 操作更高效,这是由于 bitmap 结构紧凑,可以一次性加载到内存中;通过整型的 ALU 操作与条件分支(conditional branch) 一次性可进行 32 位的检测
- 在类 Unix 系统中,bitmap 有利于 fork() 出来的进程与主进程进行 copy-on-write 数据共享,Ruby 2.0 就因此获得较大性能提升。
Lazy sweeping
MS 算法有以下几个特点:
- 某对象一旦被标为garbage,它永远都会是 garbage,不会被 mutator 再访问
- mutator 不能修改 mark-bit
基于以上几点,sweep 操作完全可以与 mutator 同时运行(parallel)的。
Lazy sweep 指的是把较为耗时(相对 mark 来说)的 sweep 操作放在 allocate 过程中,并且只在有足够的空间时才去真正进行回收。Ruby 1.9.3 引入 lazy sweep 获得较大性能提升。
Lazy Sweep 除了降低 sweep 阶段 mutator 的暂停时间外,还有以下优点:
- 更好的 locality。这是因为被回收的 block 会尽快地重新使用
- GC 复杂度只于可到达对象成正比
- 在大部分 heap 空间为空时效率最好
除此之外还有一些优化算法如:
- FIFO prefetch buffer [Cher et al, 2004]
- Edge marking [Garner et al, 2007]
就不累述了。
复制算法
将可用内存按容量划分为大小相等的两块(不一定会大小相等),每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点:内存占用太多。
新生代的垃圾收集
将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
标记整理算法
标记过程仍然与『标记-清除』算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
缺点:效率不高。
垃圾收集器
安全点
在 HotSpot 中有一组叫做 OopMap
的数据结构用于当 『Stop World』时候获取哪些地方存放对象引用及对象类型(用于释放内存大小)。在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。
为了减少使用 OopMap
指令的生成(占用大量空间),只需要在『特殊位置』记录了这些信息,这些位置就被称为安全点。
即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。
HotSpot 垃圾收集器
在这里只会详细介绍 G1,如果有其他垃圾收集器需求可以自行查询解决(网上充斥了大量的资料)。
Serial
重点提要:
- 新生代
- 单线程
- 简单高效,适合 Client
ParNew
重点提要:
- 新生代
- Serial 多线程版本
- CMS(老年代) 无法配合 Paraller Scavenge(新生代),所以只能选择 Serial / ParNew (为什么)
Paraller Scavenge
重点提要:
- 新生代
- 可控吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))
- 自适应调节策略(虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量)
Serial Old
重点提要:
- serial 老年代版本
- 使用“标记-整理”算法
Paraller Old
重点提要:
- Paraller Scavenge 老年代版本
- 这种组合的吞吐量甚至还不一定有ParNew加CMS的组合“给力”
- 在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器
CMS
重点提要:
- 老年代
- 并发收集、低停顿
- 对CPU资源非常敏感
- 无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次FullGC的产生
- CMS是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。
G1(Garbage-First Garbage Collector) 垃圾收集器
之所以重点说 G1 是因为在 Java 9 中 G1 已经被作为默认的垃圾收集器了,且其理念与之前的收集器有很大的区别。
G1 的特点
G1 是一个“服务器风格(server-style)”的垃圾回收器,它主要有下面的这些属性:
- 并行和并发。 G1 可以从今天最新的硬件中获得并行的能力。它能够使用所有可用的CPU(CPU多核,硬件多线程等)来加速它的 “stop-the-world” 机制(这个机制简称STW,即,在执行垃圾收集算法时,Java应用程序的其他所有除了垃圾收集帮助器线程之外的线程都被挂起)。
- 分代处理。 就像其它的HotSpot 垃圾回收器,G1 是分代的,也就是说,它在处理新分配的对象(年轻代)和已经生存了一段时间的对象(年老代)时会不同,它会更多地考虑一些新创建的对象实例,因为越新创建的就越有最大的可能性被回收,老对象只是偶尔访问一下。对于大多数的Java应用来说,这个机制可以极大地提高回收效率。
- 紧凑内存(碎片整理)。 不像CMS,G1 会对堆进行内存整理。压缩可以消除潜在的内存碎片的问题,这样程序就可以更长时间的平滑运行。
- 预见性的。 G1 比起 CMS 来有更多的预见性。G1 能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。
G1 基本思路
G1 将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。
G1 之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据运行的收集时间,优先回收价值最大的 Region(也就是 Garbage-First 名称由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
对象引用不确定在哪个 Region
Region 不是孤立的。一个对象分配在某个 Region 中,它并非只能被本 Region 中的其他对象引用,而是可以与整个 Java 堆任意的对象发生引用关系。那么做可达性分析的时候岂不是还得扫描整个 Java 堆才能保证准确性?这个问题在其他收集器中也存在,但是 G1 中尤其突出。
在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set
来避免全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remembered Set
,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set
之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set
即可保证不对全堆扫描也不会有遗漏。
G1 具体流程(不考虑维护 Remembered Set)
- 初始标记:仅仅标记 GC Roots 能关联到的对象,并且修改 TAMS(Next Top at Mark Start) 的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建对象。此处需要 STW。
- 并发标记:从 GC Roots 开始对堆中的对象进行可达性分析,找出存活对象。并行执行。
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变化的那部分标记记录。虚拟机会将这段时间的改变记录在线程
Remembered Set Logs
中,需要将它的数据合并到Remembered Set
中。需要 STW。 - 筛选回收:首先对各个 Region 的回收价值和成本进行排序,然后根据用户所期望的 GC 停顿时间来制定回收计划。并发执行。
G1 diff CMS
- G1在压缩空间方面有优势
- G1通过将内存空间分成区域(Region)的方式避免内存碎片问题
- Eden, Survivor, Old区不再固定、在内存使用效率上来说更灵活
- G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间避免应用雪崩现象
- G1在回收内存后会马上同时做合并空闲内存的工作、而CMS默认是在STW(stop the world)的时候做
- G1会在Young GC中使用、而CMS只能在OLD区使用
G1 适用场景
- 服务端多核CPU、JVM内存占用较大的应用(至少大于6G)
- 应用在运行过程中会产生大量内存碎片、需要经常压缩空间
- 想要更可控、可预期的GC停顿周期;防止高并发下应用雪崩现象
垃圾收集器参数总结
注明:此处由于是书上(JDK7版本)描述,所以有些 JVM 参数可能过时了,大家可以参照 JDK 9 常用参数(TODO)来使用
内存分配与回收策略
对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。
对象优先在 Eden 分配
重点提要:
- 当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
- 虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
- Minor GC 是指新生代发生的 GC,非常频繁;Full GC/Major GC 是指老年代发生的 GC,速度很慢。
大对象直接进入老年代
重点提要:
- 所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的byte[]数组就是典型的大对象)。
- 避免『短命』大对象
- 虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。
长期存活的对象将进入老年代
重点提要:
- 虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。
- 对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
动态对象年龄判定
重点提要:
- 为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
分配空间担保
重点提要:
- 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
GC 日志阅读实例(G1)
Young GC
1 | {Heap before GC invocations=12 (full 1): |
参考文章
《深入理解Java虚拟机》