JVM 垃圾回收机制
# 一、概念
垃圾(Garbage)是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾,回收垃圾的过程被称为 Garbage Collection,简称 GC。如果不对垃圾对象进行回收,那么这些垃圾占用的内存空间会一直保留到应用程序结束,这些内存无法被其它对象使用,甚至会导致内存溢出。
GC 线程在执行时会暂停用户线程,俗称 Stop The World,简称 STW。被 GC 线程中断的用户线程会在 GC 结束之后恢复,频繁的 STW 会导致应用程序执行效率降低。
垃圾收集的概念并不是 Java 独有,它的历史比 Java 久远。虽然 Java 实现了自动内存管理,开发人员不需要主动申请和释放内存,但是为了能够排查各种内存溢出、内存泄露等问题,了解 JVM 的自动内存分配和回收原理也是必要的。
内存泄露 & 内存溢出
- 内存泄露就是有对象已经不会被程序使用了,但是垃圾收集器又不能回收它们。比如数据库连接或网络连接等需要手动 close 但是没有 close 的情况。内存泄露不会直接导致应用程序奔溃,但是会一点一点蚕食 JVM 内存,并最终导致 OOM。
- 内存溢出就是 JVM 中的空闲内存无法满足接下来的内存分配需求,而且垃圾收集器也无法提供更多内存,JVM 就会抛出 OOM。造成内存溢出的原因主要有:
- JVM 的内存设置本身就不够用。比如本身就是一个很庞大的系统,需要加载很多大对象,而堆内存 本身设置就很小不够用。
- 代码中创建了大量大对象,而且因为这些大对象存在被引用,所以长时间无法被垃圾收集器收集。
- 存在内存泄露。
理解 JVM 垃圾回收需要思考三个问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
# 二、哪些内存需要回收
从 JVM 运行时数据区的角度来看,除了程序计数器,其它区域都需要回收。
# 三、什么时候回收
总体来说,至少被垃圾判断算法(在 Java 中实际上就是可达性分析算法)标记两次才会回收,而不是马上回收。
# 四、如何判定为垃圾
回收总体上来讲可以分为两个阶段:
- 需要通过一系列算法标准来判断一个对象是否为垃圾;
- 通过垃圾收集器执行回收。
通过引用计数法和可达性分析算法来标记垃圾。
# 4.1 引用计数法
# 4.1.1 概念
引用计数法的原理是在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。若计数器为 0 就将该对象标记为垃圾。
# 4.1.2 特点
优点:
原理简单,判定效率高。
缺点:
需要单独的字段存储计数器,会增加存储空间的开销。每次赋值都需要更新计数器,增加了时间开销。无法处理循环引用的问题。
在实际主流的 Java 虚拟机并不会使用此算法,原因是使用此算法有很多额外情况需要考虑,且要配合大量额外的处理才能保证其正确工作,比如很难解决对象之间循环引用的问题。
# 4.2 可达性分析算法
# 4.2.1 概念
它的原理是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
# 4.2.2 哪些对象可以作为 GC Root
TODO:补充
# 4.3 对象的 finalization 机制
# 4.3.1 概念
Java 提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,在回收此对象之前,总会先调用这个对象的finalize()
方法。finalize()
方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常会在该方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
需要注意的是,开发人员不能主动调用finalize()
方法,应该交给垃圾回收机制调用。理由如下:
- 在调用
finalize()
时可能会导致对象复活。 finalize()
方法的执行时间是没有保障的,它完全由 GC 线程决定,在极端情况下,若不发生 GC,则不会执行该方法。- 一个糟糕的
finalize()
实现会严重影响 GC 的性能。
# 4.3.2 三种状态
如果从所有的根结点都无法访问到某个对象,说名该对象已经不再使用了。一般来说,此对象是需要被回收的。但事实上,在某些情况下也并非是非死不可的,因为一个无法触及的对象有可能在某个条件下会复活自己,如果在复活之前对其进行回收,那这个回收就是不合理的。为此,虚拟机中的对象可以被分为三种状态:
- 可触及的。从根节点开始,可以到达这个对象。
- 可复活的。对象的所有引用都被释放,但是对象有可能在
finalize()
中复活。 - 不可触及的。对象的
finalize()
方法被调用,并且该对象没有被复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()
只会被调用一次。
只有不可触及状态的对象才可以被回收。
# 4.3.3 垃圾标记过程
判定一个对象 objA 是否可回收,至少要经历两次标记过程:
如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
进行筛选,判断此对象是否有必要执行
finalize()
方法。a. 如果对象 objA 没有重写
finalize()
方法,或者finalize()
方法已经被虛拟机调用过,则虛拟机视为“没有必要执行”,objA 被判定为不可触及的。b. 如果对象 objA 重写了
finalize()
方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其finalize()
方法执行。c.
finalize()
方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queaes 列中的对象进行第二次标记。如果 objA 在finalize()
方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出 “即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()
方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()
方法只会被调用一次。
# 五、如何回收垃圾
# 5.1 分代收集理论
很多版本的 JVM 都是基于分代收集理论设计的。分代收集理论的核心概念是收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
分代收集是建立在三个分代假说上的:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。跨代引用假说主要是用来解决老年代对象可能依赖新生代对象,或新生代对象可能依赖老年代对象的问题。
# 5.2 垃圾收集原则
垃圾收集整体上遵循以下原则:
- 对象优先在伊甸区分配。大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Young GC。
- 大对象直接进入老年代。大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
- 长期存活的对象将进入老年代。在伊甸区诞生,经历第一次 GC 之后就会进入幸存区,在新生代共计经历 15 次(由
-XX:MaxTenuringThreshold
参数指定,默认 15)GC 就进入老年代。 - 动态对象年龄判定。HotSpot 虚拟机并不是永远要求对象的年龄必须达到
XX:MaxTenuringThreshold
才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold
中要求的年龄。 - 空间分配担保。在发生 Young GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Young GC 可以确保是安全的。如果不成立,则虚拟机会先查看
XX:HandlePromotionFailure
参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Young GC,尽管这次 Young GC 是有风险的;如果小于,或者-XX:HandlePromotionFailure
设置不允许冒险,那这时就要改为进行一次 Full GC。
具体的执行流程:
- 新创建的对象先放伊甸园区,此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Young GC), 将 Eden 区中的不再被其它对象所引用的对象进行销毁,再加载新的对象到伊甸园区。
- 然后将 Eden 中的剩余对象移动到幸存者 0 区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。
- 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。
- 默认经历 15 次垃圾回收就会从幸存者区到老年区。
- 当老年区内存不足时,再次触发垃圾回收(Old GC),针对养老区进行内存清理。(只有 CMS 收集器才有单独收集老年代的行为,如果否则会触发 Full GC。)
- 如果针对老年区进行垃圾收集之后,或 Full GC 之后内存还不够,就会触发 OOM。
# 5.3 回收行为分类
- 部分收集(Partial GC):指并非完整收集整个 Java 堆。
- 新生代收集(Minor GC/Young GC)。
- 老年代收集(Major GC/Old GC):只有 CMS 收集器才有单独收集老年代的行为。
- 混合收集(Mixed GC):收集整个新生代以及部分老年代,只有 G1 收集器才会有。
- 整堆收集(Full GC):指收集整个 Java 堆和方法区。
回收算法的选择
- 年轻代的特点是:空间相对老年代较小,对象生命周期短、存活率低,回收频繁。复制算法执行速度快,而且执行速度只和存活的对象有关,加上年轻代存活率低的特点,所以复制算法很适合年轻代。复制算法内存利用率不高的问题,通过两个 Survivor 区的设计得到了很好的解决。
- 老年代的特点是:空间大,对象生命周期长、存活率高,回收没有年轻代频繁。由于对象存活率高,所以复制算法不合适。通常是标记清除和标记整理混合实现。以 HotSpot 虚拟机的 CMS 收集器为例,CMS 是基于标记清除算法实现的,回收率很高。针对内存碎片问题,CMS 收集器基于标记整理算法的 Serial Old 回收器作为补偿措施,当内存回收不佳时,将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。
哪些场景下会触发 Full GC?
- 调用
System.gc()
方法可能会触发。 - 老年代不足。需要大量连续存储空间的对象会直接分配到老年代、长期存活的对象晋升到老年代,老年代空间不足以存放这些对象的时候会触发 Full GC。
- 永久代或元空间不足。在 JDK8 之前的版本中,永久代是 HotSpot 虚拟机中方法区的一种实现,它用于存放类的信息、常量、静态变量等数据,当系统中要加载的类、反射的类等较多时,永久代出现空间不足,在未配置为采用 CMS GC 的情况下会触发 Full GC。
- Young GC 晋升到老年代的平均大小大于老年代的剩余空间。
- 在发生 Young GC 前,会检查老年代是否有足够的连续空间,如果当前老年代最大可用连续空间小于平均历次晋升到老年代大小,则触发 Full GC。
- 在执行 CMS GC 的过程中,如果此时有线程将对象放入老年代,并且老年代空间不足,或者在做 Young GC 的时候,新生代 Survivor 空间不足,需要放入老年代,而老年代空间也不足,则触发 Full GC。
# 5.4 回收算法
主流的回收算法包括:
- 标记-清除算法
- 标记-复制算法
- 标记-整理算法
# 5.4.1 标记-清除算法
标记-清除算法(Mark-Sweep)分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
它的特点是:
- 执行效率不稳定。如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
# 5.4.2 标记-复制算法
标记-复制算法(Mark-Copy)是将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
它的特点是:
- 实现简单,运行高效。
- 将可用内存缩小为原来的一半,空间浪费严重。不适合老年代。
- 大多商用虚拟机采用才算法收集新生代。
# 5.4.3 标记-整理算法
标记-整理算法(Mark-Compact)在对可回收对象进行标记清除之后,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
# 5.5 垃圾收集器
# 5.5.1 Serial 收集器
Serial 收集器使用一个处理器和一个收集线程去完成垃圾收集工作,在收集垃圾时,必须暂停其它工作线程,Stop The World。
它的特点是:
- 单线程收集。
- HotSpot 虚拟机运行在客户端模式下的默认新生代收集器。
# 5.5.2 ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,同时使用多个线程进行垃圾收集,垃圾收集时同样会暂停其它工作线程。
它的特点是:
- 多线程收集。
- HotSpot 虚拟机运行在服务端模式下将其作为新生代收集器。
# 5.5.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器的特点是它的关注点与其它收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)。
它的特点是:
- 基于标记-复制算法实现。
- 多线程收集。
# 5.5.4 Serial Old 收集器
Serial Old 收集器是 Serial 收集器的老年代版本。
它的特点是:
- 基于标记-整理算法。
- 单线程收集。
# 5.5.5 Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本。
它的特点是:
- 基于标记-整理算法。
- 多线程收集。
# 5.5.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。收集过程分为四步:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
它的特点是:
- 基于标记-清除算法。
- 并发收集、低停顿。
- 适合在乎响应速度的服务,如 B/S 系统。
# 5.5.7 Garbage First 收集器
Garbage First 收集器就是常说的 G1 收集器,开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。
G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
G1 的收集过程可以分为四个步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
它的特点是:面向全堆内存任何部分进行回收,不关心是哪个分代,只关心哪块内存垃圾最多,就像它的名字一样,Garbage First,垃圾至上。
# 5.5.8 Shenandoah 收集器
Shenandoah 收集器可以认为是 G1 收集器的下一代继承者,但是和 G1 有三个不同:
- G1 支持多线程并行收集,但是不能与用户线程并行,但是 Shenandoah 却可以;
- 没有实现分代;
- 摒弃了在 G1 中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨 Region 的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降 低了伪共享问题的发生概率。
# 5.5.9 ZGC 收集器
全名 Z Garbage Collector,是在 JDK11 中新加入的实验性收集器。
参考美团的新一代垃圾回收器 ZGC 的探索与实践 (opens new window)。
# 六、安全点 & 安全区
# 6.1 安全点
程序执行时并非能在所有地方进行 GC,只有在特定的位置才能停顿下来进行 GC,这些位置被称为安全点(Safe Point)。安全点的选择很重要,如果太少可能导致 GC 等待时间太长,如果太多可能导致 GC 太频繁。由于大部分指令的执行时间都非常短暂,所以通常会根据“是否具有让程长时间执行的特征”为标准选择。比如选择一些执行时间比较长的指令作为安全点(方法调用、循环跳转和异常跳转等)。
那如何判断在 GC 即将发生时,是否所有线程斗殴执行到安全点了呢?
- 抢占式中断:首先中断所有线程,如果还有线程不在安全点,就恢复那部分线程,让其执行到安全点。目前没有虚拟机采用这种方式。
- 主动式中断:设置一个中断标志,各个线程运行到安全点的时候主动判断此处的中断标志是否为真,如果为真则将自己进行中断。
# 6.2 安全区
安全点机制保证了程序执行时,在不太长的时间内就可以遇到并可以进入 GC。但是对处于类似 Sleep 或 Blocked 状态的非执行状态的线程,其无法响应 JVM 的中断请求,JVM 也不可能等待线程被唤醒。对于这种情况就需要安全区(Safe Region)来解决。
安全区是指在一段代码片段种,对象的引用关系不会发生变化,在这个区域中的任何位置执行 GC 都是安全的。
# 七、对象引用
无论是引用计数法还是可达性分析算法,都是和引用脱不了关系的。从一个 Java 开发者的视角,引用只有两种状态,引用与未被应用。但从垃圾收集的角度,引用分为强软弱虚 4 种类型。
# 7.1 强引用
强引用(Strongly Reference)指在程序代码之中普遍存在的引用赋值,即类似Object obj = new Object()
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
# 7.2 软引用
软引用(Soft Reference)是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
# 7.3 弱引用
弱引用(Weak Reference)也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
# 7.4 虚引用
虚引用(Phantom Reference)也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
提示
除了强引用,其它三个是自 JDK1.2 扩充的概念。