第 3 章 垃圾收集器与内存分配策略
第 3 章 垃圾收集器与内存分配策略
3.1 概述
TIP
- 经典垃圾收集器
- Serial 收集器
- ParNew 收集器
- Parallek Scavenge 收集器
- Serial Old 收集器
- Parallel Old 收集器
- CMS 收集器
- Garbage First 收集器
- 选择合适的垃圾收集
- 虚拟机与垃圾收集器日志
- 垃圾收集器参数总结
另外涉及了一些内容可深入了解。
- 对象已死?
- 生存还是死亡?(finalize 方法)
- HotSpot 的算法细节实现
- 根节点枚举
- 安全点
- 安全区域
- 记忆集与卡表
- 写屏障
- 并发的可达性分析
- 低延迟垃圾收集器
- Shenandoah 收集器
- ZGC 收集器
- 选择合适的垃圾收集
- Epsilon 收集器
- 收集器的权衡
- 实战:内存分配与回收策略
- 对象优先在 Eden 分配
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
- 动态对象年龄判定
- 空间分配担保
3.2 对象已死?
3.2.1 引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一,当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
单纯的引用计数很难解决对象之间相互循环引用的问题。
Java 循环引用回收示例见:com/jvm/practice/chapter03/ReferenceCountingGC.java
,看日志明细循环引用依然被回收了。
3.2.2 可达性分析算法
通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
Java 体系中,固定可作为 GC Roots 的对象包括以下几种。
- 在虚拟机栈中(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池里的引用。
- 在本地方法栈中 JNI(即 Native 方法) 引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointException、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整“GC Roots”集合。
3.2.3 再谈引用(4 大引用)
Java 对引用的概念进行了扩充,将引用分为以下四种。
- 强引用:GC 时不会被回收。
- 软引用:内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。。
- 弱引用:具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
- 虚引用(幽灵引用/幻影引用):如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。
TIP
需要理解下这个 ThreadLocal为什么要用弱引用和内存泄露问题
3.2.4 生存还是死亡
即 finalize() 方法回收时会拯救一次自己,示例见 com/jvm/practice/chapter03/FinalizeEscapeGC.java
。
注意高版本 JDK 已经溢出了复杂的 finalize() 方法,这里就没必要深入研究了。
3.2.5 回收方法区
主要回收两部分内容:废弃的常量和不再使用的类型。
回收时,判断一个常量是否废弃还是相对简单,而要判定一个类型是否属于不再被使用的类的条件就比较苛刻了。需要同时满足下面三个条件。
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGI、JSP 的重加载等,否则通常是很难达成的。
- 该类访问的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
满足上述三个条件后,是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制等。
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGI 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3.3 垃圾收集算法
3.3.1 分代收集理论
设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。
梳理注意以下一些要点。
- 一般至少会把 Java 堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。
- 跨代引用假说。通过“记忆集”避免扫描整个老年代。
- 新生代基本采用复制算法,老年代采用标记整理算法。
- 算法快慢速度:复制算法>标记-清除算法>标记-整理算法。理解性记忆(老年代(标记-清除及标记-整理算法)垃圾回收一般是年轻代(复制算法)的 10 倍左右)。
TIP
分代的名词避免混淆,梳理如下。
- 部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分如下几种。
- 新生代收集(Minar GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有 CMS 收集器会有单独收集老年代的行为。“Major GC” 现在说法有点混淆,要按上下文区分到底是指老年代的收集还是整堆收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集器。目前只有 G1 收集器会有这种行为。
- 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。
对比加深理解:垃圾收集算法对比
3.3.2 标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:
- 标记阶段:标记出可以回收的对象。
- 清除阶段:回收被标记的对象所占用的空间。
标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。其优点如下。
- 实现简单,不需要对象进行移动。
缺点如下。
- 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除过程的执行效率都随对象数量增长而降低。
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-清除算法示意图如下。
3.3.3 标记-复制算法
为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。其优点如下。
- 按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
缺点如下。
- 可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
标记-复制算法示意图如下。
3.3.4 标记-整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键是的,需要有额外的空间进行分配担保,所以老年代一般不能直接选用这种算法。
标记整理算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
其优点如下。
- 解决了标记-清理算法存在的内存碎片问题。 缺点如下。
- 仍需要进行局部对象移动,一定程度上降低了效率。
标记-整理算法示意图如下。
TIP
CMS 是基于标记-清除算法,但是内存空间的碎片化程度大到影响对象分配时,会采用标记-整理算法收集一次,以获得规整的内存空间。
3.4 HotSpot 的算法细节实现
该小节目前仅梳理一些要点
3.4.1 根节点枚举
目前主流的 Java 虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机是有办法直接得到哪些地方存放着对象引用的。
在 HotSpot 的解决方案里,是使用一组称为 OopMap 的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要一个不漏地从方法区等 GC Roots 开始查找。
3.4.2 安全点
HotSpot 只是在“特定的位置”记录了这些信息,这些位置被称为安全点。其决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
安全点还需要注意以下要点。
- 安全点的选定。既不能太少,也不能太多,一般是在指令序列的复用(如方法调用、循环跳转、异常跳转等)时才会产生安全点。
- 如何在垃圾收集发生时让所有线程(不包括 JNI 调用的线程)都跑到最近的安全点,然后停顿下来。有两种方案:抢先式中断和主动式中断。
抢先式中断:在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机使用其来暂停线程响应 GC 事件。
主动式中断:当垃圾收集需要中断线程时,不直接对线程操作,仅仅简单的设置一个标志位,各个线程执行过程中会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
3.4.3 安全区域
程序“不执行”的时候,线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己。典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被拉伸了的安全点。
3.4.4 记忆集与卡表
记忆集是一种用于记录非收集区域指向收集区域的指针集合的抽象数据结构,主要是为了解决跨代引用的问题。
卡表是记忆集的一种实现形式。
3.4.5 写屏障
这里注意和“内存屏障要区分开”,可以参考 AOP 方式来理解。
还需要注意以下要点。
- 伪共享问题。
3.4.6 并发的可达性分析
注意下以下要点。
- 三色标记。
3.5 经典的垃圾收集器
HotSpot 各款经典的垃圾收集器之间的关系如下图,不同收集器之间的连线表示它们可以搭配使用。
3.5.1 Serial 收集器
单线程的收集器,收集垃圾时,必须 stop the world,使用复制算法。
Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
Serial 和 Serial Old 收集器运行示意图如下。
3.5.2 ParNew 收集器
是 Serial 收集器的多线程版本,也需要 stop the world,复制算法。其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致。
ParNew 和 Serial Old 收集器运行示意图如下。
3.5.3 Parallek Scavenge 收集器
又称为吞吐量优先收集器,和 ParNew 收集器类似,是一个新生代收集器。使用复制算法的并行多线程收集器。目标是达到一个可控的吞吐量。如果虚拟机总共运行 100 分钟,其中垃圾花掉 1 分钟,吞吐量就是 99%。
Parallel Scavenge 和 Parallel Scavenge Old 收集器运行示意图如下。
3.5.4 Serial Old 收集器
是 Serial 收集器的老年代版本,单线程收集器,使用标记整理算法。
3.5.5 Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本,使用多线程,标记-整理算法。
3.5.6 CMS 收集器
CMS(Concurrent Mark Sweep) 收集器是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收,在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。
CMS 是基于标记-清除算法实现的,它的运作过程分为 4 个步骤。
- 初始标记(CMS initial mark)。
- 并发标记(CMS concurrent mark)。
- 重新标记(CMS remark)。
- 并发清除(CMS concurrent sweep)。
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。
CMS 收集器运行示意图如下。
CMS 收集器的 3 个明显的缺点如下。
- 对处理器资源非常敏感。CMS 默认启动的回收线程数是(处理器核心数量 + 3)/ 4。如果处理器核心梳理不足四个时,CMS 对用户程序的影响就可能变得很大。
- 无法处理“浮动垃圾”。在并发标记和并发清理阶段,程序在运行会产生新的垃圾,也需要垃圾收集阶段预留足够内存空间提供给用户线程使用。
- 有大量空间碎片产生。CMS 可以配置内存碎片的合并整理过程,以及执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理。
注意以下问题
3.5.7 Garbage First 收集器
G1 是一款主要面向服务端应用的垃圾收集器。
G1 收集器的,它的运作过程分为 4 个步骤。
- 初始标记(Initial Marking)。
- 并发标记(Concurrent Marking)。
- 最终标记(Final Marking)。
- 筛选回收(Live Data Counting and Evacuation)。
G1 收集器运行示意图如下。
3.6 低延迟垃圾收集器
3.6.1 Shenandoah 收集器
略。
3.6.2 ZGC 收集器
略。
3.7 选择合适的垃圾收集
略。
3.7.1 Epsilon 收集器
略。
3.7.2 收集器的权衡
略。
3.7.3 虚拟机与垃圾收集器日志
JDK9 以前,HotSpot 并没有提供统一的日志处理框架,虚拟机各个功能模块的日志开关分布在不同的参数上,日志级别、循环日志大小、输出格式、重定向等设置在不同功能上都要单独解决。JDK9 时,HotSpot 所有功能都收归到了 “-Xlog” 参数上。
配置参数如下。
3.7.4 垃圾收集器参数总结
配置参数如下。
3.8 实战:内存分配与回收策略
JDK 高版本优先 GC 收集器特性没了,这里大部分都是 JDK8 的内容。
3.8.1 对象优先在 Eden 分配
略。
3.8.2 大对象直接进入老年代
略。
3.8.3 长期存活的对象将进入老年代
略。
3.8.4 动态对象年龄判定
略。
3.8.5 空间分配担保
略。