文章目录
JVM P3 垃圾回收
教程:https://www.bilibili.com/video/BV1PJ411n7xZ
Java 17 文档:https://docs.oracle.com/javase/specs/jvms/se17/html
6. 垃圾回收
6.1 垃圾回收概述
6.1.1 什么是垃圾
经典三问:
- 那些内存需要回收?
- 什么时候回收?
- 如何回收?
- 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要回收的垃圾。
- 如果不及时对内存中的垃圾进行清理,那么这些垃圾所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出
6.1.2 为什么需要 GC
- 不进行垃圾回收,内存迟早会被消耗完
- 除了释放没用的对象,垃圾回收还会清理内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一段,以便 JVM 可以将整理出的内存分配给新的对象
- 没有 GC 就不能保证应用程序的正常进行
6.1.3 早期垃圾回收
- C 中的 malloc() / free()
- C++ 中的 new / delete
- 有了 Java 之后就不需要再代码层面考虑回收问题了
6.1.4 Java 垃圾回收机制
- 自动内存管理,无序开发人员手动申请和释放内存,降低内存泄露和内存溢出的风险
- 更专注于业务开发
- JVM GC 作用区域:方法区/元空间,堆
- 堆是垃圾回收重点区
- 从次数上看,频繁收集 Young 区,较少收集 Old 区
6.2 垃圾回收算法
- 标记阶段:那些内存需要回收?判断对象是否存活?
- 对象不再被任何存活的对象引用时,可以判定死亡
- 回收条件:什么时候回收?
- 清除阶段:如何回收?
6.2.1 标记阶段:引用计数算法
原理:
- 对象中添加一个引用计数器,当有一个地方引用该对象,引用计数器 + 1;当引用失效,引用计数器 - 1;当任何时刻引用计数器为 0 时代表该对象不会在被使用。
优点:
- 原理简单,判定效率高
缺点:
- 无法解决相互循环引用的问题
6.2.2 标记阶段:可达性分析算法(Java 选择)
GC Roots 对象:栈帧中的本地变量表中引用的对象,StringTable 中的引用,本地方法引用对象。。。
- 如果一个指针,保存了堆内存里面的对象,但是自己又不存放在堆内存,那么他就是一个 Root
原理:
- 以 GC Roots 根对象集合为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
- 若目标对象没有被根对象直接或者间接相连,则是不可达的,意味着该对象已经死亡,可以标记为垃圾对象
优点:
- 有效解决循环引用的问题,防止内存泄露的发生
注意:
- 可达性分析算法判断内存是否可回收,分析工作必须在一个能保障一致性的快照中进行,否则分析结果准确性无法保证
- 这一点是导致 GC 必须进行 Stop the world 的一个重要原因,枚举根节点是必须要停顿的
6.2.3 对象 finalization 机制
Java 提供了 finalization 对象终止机制来允许开发人员编写对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现对象没有在 GC Roots 的引用链上,会将该对象进行第一次标记,然后筛选,如果对象没有重写 finalize()
方法,或者 finalize()
方法已经被虚拟机调用过,那么该对象将被标记为死亡。
如果对象重写了 finalize()
方法,并且在方法中重新把自身的对象引用加上了引用链,那么该对象依然可以存活,但是在下一次系统调用 finalize()
时此对象就会直接被标记为死亡,不会再变为存活状态了(finalize()
只会被调用一次)。
6.2.4 MAT 与 JProfiler 的 GC Roots 溯源
略
6.2.5 清除阶段:标记-清除算法(Mark-Sweep)
首先进行 Stop the world,然后进行两项工作,标记和清除:
- 标记:垃圾回收器从引用根节点开始遍历,标记所有被引用的对象。并在对象头中记录 GC 记录。
- 清除:对堆内存从头到尾线性遍历,若发现某个对象的对象头没有标记为可达,则将其回收
- 实质上是将需要清除的对象地址保存在空闲的地址列表中
缺点:
- 效率不高
- 需要维护空闲列表
- 进行 GC 的时候需要停止整个应用程序,导致用户体验差
- 内存碎片化,若内存碎片过多,会导致新申请的内存无法找到连续的内存,不得不再进行一次 GC
6.2.6 清除阶段:复制算法(Copying)
将内存分为两块,每次只使用其中一块,当这一块内存使用完,就把还存活的对象复制到另一块内存中。(半区复制)
优点:
- 解决 Mark-Sweep 算法面对大量可回收对象执行效率低的问题
- 如果内存中多数对象都是可回收的,内存复制只占少数存活对象
- 不会产生内存碎片
缺点:
- 将可使用的内存缩小为原来的一半,空间浪费
- 如果内存中多数对象还存活,那么内存复制将会产生大量开销
Note:
新生代 Eden,S From,S To 区就是使用该思想
6.2.7 清除阶段:标记-压缩算法(Mark-Compact)
还可以称为标记-整理算法,标记过程和标记-清除算法一致,但后续步骤不是直接对可回收对象处理,而是让所有的存活对象向内存的一端移动,然后直接清除边界以外的内存。
优点:
- 消除复制算法内存减半的代价
- 不需要维护空闲列表
缺点:
- 对象移动需要暂停用户应用程序,Stop the world
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
Note:
CMS 收集器采用的”和稀泥“做法:多数时间采用标记-清除算法,知道内存碎片化程度大到影响对象分配的时候,再采用标记-整理算法收集一次以获得规整的内存空间。
6.2.8 小结
三种清除算法对比
6.2.9 分代收集算法
分代收集算法基于一个事实:不同对象的生命周期是不一样的。
因此,不同生命周期的对象可以采用不同的收集方式,以便提高回收效率。一般将 Java 堆分为新生代和老年代,这样可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在 Java 程序中,有些对象和业务相关,比如 Http 请求中的 Session 对象,线程,Socket 连接,这类对象和业务直接挂钩,因此生命周期较长。但是临时变量比如 String 生命周期较短,由于其不变性的特性,系统中会产生大量的这些对象,有些对象甚至只用一次即可回收。
年轻代
特点:区域较小,对象生命周期短,存活率低,回收频繁
更适合使用复制算法,速度最快。内存利用率不高的问题通过 Hotspot 中的两个 Survivor 的设计得到缓解。
老年代
特点:区域较大,对象生命周期长,存活率高,回收不及年轻代频繁
一般采用标记-清除算法或者标记-整理算法,或者两者混合使用。
- Mark 阶段的开销和存活对象数量成正比
- Sweep 阶段的开销和所管理区域的大小呈正比
- Compact 阶段的开销和存活对象的数量成正比
6.2.10 增量收集算法,分区算法
增量收集算法
增量收集算法为了解决垃圾回收过程中 Stop the world 的性能问题。
基本思想:
- 每次垃圾回收线程只收集一小片区域,接着切换到应用程序线程。依次反复,直到垃圾回收完成
- 允许垃圾回收以分阶段的方式完成标记、清理或者复制工作
缺点:
- 产生的线程切换和上下文转换的消耗,会导致垃圾回收的总体成本上升,造成系统吞吐率下降
分区算法
将一大块内存分割成多个小块 region,根据目标的停顿时间每次合理地回收若干个小区间,而不是整个堆空间,减少一次 GC 产生地停顿时间。
Note:
G1 垃圾回收器采用了该算法
6.3 垃圾回收相关概念
6.3.1 System.gc()
Runs the garbage collector in the Java Virtual Machine.
Calling the gc method suggests that the Java Virtual Machine expend effort toward recycling unused objects in order to make the memory they currently occupy available for reuse by the Java Virtual Machine. When control returns from the method call, the Java Virtual Machine has made a best effort to reclaim space from all unused objects. There is no guarantee that this effort will recycle any particular number of unused objects, reclaim any particular amount of space, or complete at any particular time, if at all, before the method returns or ever. There is also no guarantee that this effort will determine the change of reachability in any particular number of objects, or that any particular number of Reference objects will be cleared and enqueued.
The call System.gc() is effectively equivalent to the call:
Runtime.getRuntime().gc()
显式进行 Full GC
,同时对老年代和新生代进行回收。一般是默认系统自动调用,不建议手动触发。
public class InitTest {
public static void main(String[] args) {
new InitTest();
System.gc();
System.runFinalization(); // 强制调用失去引用的对象的 finalize() 方法
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize()....");
}
}
不可达对象回收行为
- 可以看到对象并没有被回收但是局部变量表中并没有该对象:
public class InitTest {
public static void main(String[] args) {
new GC().localVarGC();
}
}
class GC {
public void localVarGC() {
{
byte[] buffer = new byte[10 * 1024 *1024];
}
System.gc();
}
}
- 修改代码,可以看到垃圾进行了回收,可以推断,对象使用的作用域已经结束,系统会把该对象引用设置为可以覆盖的 Slot,当有另外一个变量覆盖这个 Slot 时,该对象才可以回收。
public void localVarGC() {
{
byte[] buffer = new byte[10 * 1024 *1024];
}
int value = 10;
System.gc();
}
- 当整个方法结束时,在主函数手动调用 System.gc() 也可以实现回收
public class InitTest {
public static void main(String[] args) {
new GC().localVarGC();
System.gc();
}
}
class GC {
public void localVarGC() {
{
byte[] buffer = new byte[10 * 1024 *1024];
}
System.gc();
}
}
6.3.2 内存溢出与内存泄露
内存溢出
内存溢出时会报 OOM ERROR,没有空闲内存,并且垃圾收集器也无法提供更多内存。
常见原因:
- 堆内存设置不够
- 代码中创建了大量大对象,并且长时间不能被垃圾收集器回收
Note:
随着元空间的引入,直接将类型信息放在直接内存,方法区内存不再那么窘迫
内存泄露
对象不再使用,但是 GC 又不能回收。
常见原因:
- 单例的生命周期和应用程序是一样长的,单例模式中若有外部对象引用时,该引用不能被回收
- 常见的连接未关闭:数据库连接,网络连接等没有手动
close()
6.3.3 Stop the world
发生 GC 时,会产生应用程序的停顿。停顿导致应用程序线程暂停。
因为可达性分析算法中枚举根节点(GC Roots)会导致所有的 Java 执行线程停顿
- 分析工作必须在一个能保证一致性的快照中进行
- 如果分析过程中对象引用关系不断变化,分析结果的准确性将无法保证
6.3.4 垃圾回收的并行与并发
- 并发(Concurrent):单核一个时间段内多个线程多次切换执行
- 并行(Parallel):多个线程同时执行
Note:
这个地方我感觉有问题,先略
6.3.5 安全点与安全区域
一般选择执行时间较长的指令作为 Safe Point,比如方法调用,循环跳转和异常跳转。
JVM 采用主动式中断:设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,若中断标志为真,则将自己进行中断挂起
安全区域是指在一段代码片段中,对象的引用不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。
- 当线程运行到安全区域的代码时,首先标记已经进入 Safe Region,若该段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程;
- 当线程离开 Safe Region 时,会检查 JVM 是否已经完成 GC,若未完成,会继续等待受到可以安全离开 Safe Region 的信号为止。
6.3.6 强-软-弱-虚-引用
除了强引用外,另外三种引用都继承了 Reference
抽象类。
强引用(StrongReference)
传统的引用类型,类似 Object obj = new Object();
这种引用关系。任何条件下,只要有强引用关系在,垃圾回收器就不会回收被引用的对象。
软引用(SoftReference)内存不足即回收
内存即将溢出之前,才会把这些对象回收,常用于缓存。
使用方法:
SoftReference<Object> softReference = new SoftReference<>(new Object());
Object obj = softReference.get();
Note:
需要注意:假如说软引用或者弱引用对象是强可及的(strongly reachable) ,也就是说对象正在使用,那么在这样的软引用或者弱引用将不会被清除。
弱引用(WeakReference)发现即回收
只能生存到下一次垃圾回收之前,当垃圾收集器工作时,无论内存空间是否足够都会把弱引用关联的对象回收。也可以用于缓存。
使用方法:
WeakReference<Object> weakReference = new WeakReference<>(new Object());
System.gc();
log.debug("{}", weakReference.get());
若此时弱引用关联的对象正在使用(强可及),那么不能被回收:
WeakReference<Object> weakReference = new WeakReference<>(new Object());
Object obj = weakReference.get(); // 强可及
System.gc();
log.debug("{}", weakReference.get());
WeakHashMap内部的 Entry 继承了 WeakReference:
private static class Entry<K,V>
extends WeakReference<Object>
implements Map.Entry<K,V> {
/**.......**/
}
http://wiki.c2.com/?CanonicalizedMapping
Weak reference objects, which do not prevent their referents from being
made finalizable, finalized, and then reclaimed. Weak references are
most often used to implement canonicalizing mappings.
虚引用(PhantomReference)
一个对象是否有虚引用的存在,不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收的时候收到一个系统通知。
- 实现对象跟踪
虚引用创建时必须传入一个引用队列作为参数。当垃圾回收器准备回收一个对象时,若发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>(); // 引用队列
PhantomReference<Object> phantomReference = new PhantomReference<>(new Object(), referenceQueue);
log.debug("{}", phantomReference.get()); // null
Note:
更多细节参考:https://www.bilibili.com/video/BV1PJ411n7xZ/?p=167
6.3.7 终结器引用
FinalReference
略
6.4 垃圾回收器
6.4.1 GC 分类与性能指标
GC 分类
按照线程数分:
- 串行(适合单核 CPU)
- 并行
按照工作模式分:
- 并发式
- 独占式
按照碎片处理方式分:
- 压缩式:标记-整理(指针碰撞)
- 非压缩式:标记-清除(空闲链表)
按照工作的内存分:
- 年轻代
- 老年代
性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间=程序的运行时间+内存回收时间)
- 暂停时间:执行垃圾收集时,程序的工作现场被暂停的时间
- 内存占用:Java 堆所占内存大小
一般优秀的收集器通常最多同时满足其中的两项:一般内存占用越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。
简单来说主抓两点:
-
吞吐量 = 运行用户代码时间/总运行时间
-
暂停时间
现在的标准:在最大吞吐量优先的情况下,降低停顿时间。
6.4.2 不同垃圾回收器概述
- 1999,JDK 1.3.1:Serial GC 串行;ParNew,Serial GC 的多线程版本
- 2002,JDK 1.4.2:Parallel GC (JDK 6 之后 HotSpot 默认 GC)和 Concurrent Mark Sweep GC
- 2012,JDK 1.7u4:G1 可用
- 2017,JDK 9:G1 成为默认 GC,代替 CMS
- 2018.3,JDK 10:G1 的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟
- 2018.9,JDK 11:引入 Epsilon GC,又称为 No-Op 无操作 GC;同时引入 ZGC,可伸缩的低延迟 GC
- 2019.3,JDK 13:增强 ZGC,自动返回未用堆内存给 OS
- 2020.3,JDK 14:删除 CMS,扩展 ZGC 在 MacOS 和 Windows 上的应用
经典垃圾回收器:
- 串行:Serial,Serial Old
- 并行:ParNew,Parallel Scavenge(JDK 8 新生代),Parallel Old(JDK 8 老年代)
- 并发:CMS,G1(JDK 9+)
查看默认垃圾回收器
查看命令行参数,包含 GC 的参数:
-XX:+PrintCommandLineFlags
6.4.3 Serial:串行回收
- 新生代采用复制算法,老年代使用标记-压缩算法。
- 只会使用一个 CPU 或者一条线程去完成垃圾回收工作,GC 时必须暂停其他所有的工作线程,直到回收结束(Stop The World)。
- 简单,单线程情况下高效。更适用于单核 CPU Client 下的 JVM;不适用交互较强的应用。
- 使用方法:
-XX:+UseSerialGC
6.4.4 ParNew:并行回收
- Par = Parallel,New = 只针对新生代
- 采用复制算法。Serial New 的并行版本。可以配合 CMS 使用。
- 使用方法:
-XX:+UseParNewGC
-XX:ParallelGCThreads // 限制线程数量,默认值为 CPU 核心数
6.4.5 Parallel:吞吐量优先(JDK 8 默认)
包含 Scavenge 和 Old,
- 分别采用复制算法和标记-压缩算法。Scavenge 针对新生代,Old 针对老年代。
- 高效利用 CPU 时间,尽快完成任务,适合在后台运算而不需要太多交互的任务,比如:批量处理,订单处理,科学计算等
- 使用方法:
-XX:+UseParallelGC
-XX:+UseParallelOldGC
// 以上两个参数,激活其中一个另外一个也会激活
-XX:ParallelGCThreads // 限制年轻代线程数量
-XX:MaxGCPauseMillis // 设置 GC 最大停顿时间(慎用)
-XX:GCTimeRatio // GC 时间占比,默认 99,垃圾回收时间不超过 1%
-XX:+UseAdaptiveSizePolicy // 自适应调节策略,Eden,Survivor 比例,以及老年代对象年龄参数等会自动调整
6.4.6 CMS:低延迟(JDK 14 删除)
Concurrent-Mark-Sweep,第一款并发收集器,第一次实现了垃圾回收线程和用户线程同时工作。
- 采用标记-清除算法。针对老年代
- 低延迟,适合强交互应用
- 可以和 ParNew,Serial Old 配合使用
- CMS 工作中需要预留一块大内存,若不足,则使用后备方案 Serial Old 进行标记-压缩算法回收。
工作原理:
- 初始标记:标记 GC Roots,速度很快(STW)
- 并发标记:从 GC Roots 开始遍历关联对象,耗时较长但是不需要停顿
- 重新标记:修正并发标记,原来标记可回收的对象可能又重新使用了(STW)
- 并发清除:清理删除标记阶段已经死亡的对象,回收内存
缺点:
- 内存碎片
- 吞吐量降低
- 无法处理浮动垃圾,即并发标记阶段产生的新的垃圾无法进行标记
Note:
为什么 CMS 不用标记-整理算法呢?
- 如果使用标记清除算法将导致清除过程STW
使用场景:
6.4.7 G1:区域化分代式(JDK 9+ 默认)
G1 的目标:
- 在延迟可控的情况下获得尽可能高的吞吐量。
介绍
G1 是一个并行回收器,把堆内存分割为很多不相关的 Region(物理上不连续),使用不同的 Region 来表示 Eden、Survivor0,Survivor1,老年代等。
G1 GC 会避免在整个 Java 堆中进行全区域的垃圾回收。G1 会根据每个垃圾堆积的回收价值的性价比维护一个优先队列,优先回收价值最大的 Region。
使用方法:
-XX:+UseG1GC
-XX:G1HeapRegionSize // 设置每个 Region 的大小,值是 2 的幂,范围是 1M ~ 32M,默认是堆内存的 1/2000
-XX:MaxGCPauseMillis // 设置期望达到的最大 GC 停顿时间指标(JVM会尽力实现,不保证一定实现),默认是 200ms
-XX:ParallelGCThreads // 设置 STW GC 线程数的值,最多设置 8
-XX:ConcGCThreads // 设置并发标记的线程数,将 n 设置为并行垃圾回收线程数的 1/4 左右
-XX:InitiatingHeapOccupancyPercent // 设置触发并发 GC 周期的 Java 堆占用率阈值,默认是 45
G1 优势:
- G1 回收期间,可以有多个 GC 线程同时工作,有效利用多核能力。(STW)
- G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,一般不会在整个回收阶段完全阻塞用户程序。
G1 分代收集:
- 堆结构上不要求年轻代或者老年代是连续的,也不再坚持固定大小和固定数量
- 将堆分为若干个 Region,这些区域包含了逻辑上的年轻代和老年代
- 同时兼顾年轻代和老年代
G1 空间整合:
- 内存回收以 Region 为基本单位,Region 之间是复制算法,整体上可以看作标记-压缩算法,不会出现内存碎片问题。有利于程序长时间运行,分配大对象不会因为无法找到连续内存而触发下一次 GC。
G1 可预测的停顿时间(Soft real-time):
- 能让使用者明确指定在一个长度为 M ms的时间片段内,消耗在垃圾回收上的时间不得超过 N ms
- 相对比 CMS,G1 未必能做到 CMS 在最好情况下延时停顿,但是在最差情况下要好很多
G1 缺点:
- G1 维护垃圾回收用到的优先列表,以及额外的负载比 CMS 要高,因此在小内存上 CMS 更有优势,而大内存上 G1 更有优势。
G1 设计原则就是简化 JVM 性能调优,开发人员只需要简单的三步即可完成调优:
- 开启 G1 垃圾收集器
- 设置堆的最大内存
- 设置最大停顿时间
G1 中的三种垃圾回收模式,在不同条件下被触发:
- Young GC
- Mixed GC
- Full GC
G1 回收器的适用场景:
- 面向服务端应用,针对具有大内存、多处理器的机器(在普通大小的堆里表现并不惊喜)
- 低 GC 延迟,并具有大堆内存的应用程序
G1 可以代替 CMS 的场景:
- 超过 50% 的 Java 堆中的对象是存活的
- 对象分配频率或者年代提升频率变化很大
- GC 停顿时间过长(长于 0.5s ~ 1s)
HotSpot 中的 G1 GC 可以使用应用线程承担后台运行的 GC 工作。
★G1 垃圾回收过程
每一个 Rigion 有一个 Remember Set
YoungGC:
- 回收时机:当 Eden 空间耗尽时,会触发一次 Young GC
- 回收范围:只会收集 Eden 和 Survivor
MixedGC:
- 回收时机:并发标记之后
- 回收范围:整个年轻代,部分老年代
FullGC:
- 回收时机:G1 在复制存活对象的时候没有空内存分段(即 Survivor To 区)可用
- 回收范围:整个堆
6.4.8 GC 日志分析
GC Viewer,GCEasy
6.4.9 垃圾回收器的新发展
ZGC:在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在 10ms 以内的低延迟。
基于 Region 内存布局,实现了可并发的标记-压缩算法,以低延迟为首要目标。
除了初始标记是 STW,其他过程中都是可以并发执行的。
使用方法:
-XX:+UseZGC
面试题
- CMS,G1 各自优缺点?
- G1 回收器回收过程?
- GC 是什么?为什么要有 GC?
- 分代回收?
- 垃圾收集的策略和算法?
- G1 应用场景,如何使用垃圾回收器?
- 什么情况会触发垃圾回收?
- 如何选择合适的垃圾收集算法?
- System.gc() 和 runtime.gc() 会做什么事情?
- GC Roots 有哪些?
- CMS 回收停顿几次?为什么要停顿两次?
- CMS 解决什么问题?回收过程?