1. 概述
在 Java 虚拟机内存管理中,程序计数器、虚拟机栈、本地方法栈三个区域,随着线程的而生,也随着线程而灭。所以这三个区域的内存随着线程结束时,内存也跟着被回收。而堆和方法区中,有两个不确定性:一个接口的多个实现类需要的内存不一样,一个方法所执行的条件分支不同,所需的内存也可能不一样。只有在运行时,我们才知道究竟会创建哪些对象,会创建多少个对象。所以这部分的内存才是垃圾收集器关注的部分。
2. 对象死亡判断
垃圾收集器在对对象进行回收前,首先需要判断对象是不是已经死亡(没有任何用途了)。
2.1 引用计数
引用计数法判断对象是否死亡的方式:
- 给每个对象添加一个计数器。
- 当对象被引用增加一个,计数器就加一,引用减少一个,计数器就减一,当计数器为零,表示对象已经死亡。
引用计数器优缺点:
- 原理简单,判定效率高。
- 没法解决循序引用问题。
2.2 可达性分析
通过一系列名叫 GC Root 的根对象作为起点,从起点向下搜索,如果某个对象到 GC Root 没有任何引用链,那么这个对象到 GC Root 就是不可达,证明该对象不可能再被使用了。
GC Root 包含以下几种:
- 虚拟机栈中栈帧的局部变量表中的引用的对象。
- 方法区中,静态属性引用的对象。
- 方法区中,常量引用的对象。
- 本地方法栈中 JNI(Native 方法)引用的对象。
- JVM 内部的引用,比如基本数据类型的 Class 对象,一些常驻的异常对象(NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被 synchronized 持有的对象。
- 反映 JVM 内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
- 对于分区或者分代回收,需要将与关联区域的对象加入到 GC Root 中。
2.3 引用
- 强引用:指代码中普遍存在的引用赋值,只要强引用存在,垃圾收集器永远都不会回收掉被引用对象。
- 软引用:用来描述一些还有用,但是非必须得对象。被软引用关联的对象,在系统将要发生内存溢出前,会把这些对象列进被回收对象进行回收。如果这次回收后,内存还不够,就会发生 OOM。
- 弱引用:比软引用强度更弱,只能生存到下一次垃圾回收。当垃圾回收器开始工作,弱引用关联的对象会被回收。
- 虚引用:最弱的引用关系,虚引用不会影响它关联的对象的存活时间。也无法通过它来获取对象。它存在的作用就是在对象被回收时,能收到一个系统通知。
2.4 对象死亡判断过程
对象被判定为需要回收至少要经历两次标记。
- 可达性分析判定为不可达对象,被第一次标记。
- 标记后,会进行一次筛选,看次对象是否有必要执行 finalize() 方法。如果对象没有重新 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,则会被视为没有必须执行 finalize() 方法了。
- 被判定为需要执行 finalize() 方法方法的对象,会被放到一个叫 F-Queue 的队列,由一个低优先级的 finalizer 线程去执行 finalize() 方法。
- JVM 并不承诺一定要等到 finalize() 方法被执行完成才被回收。这是为了避免执行缓慢或者出现死循环导致 finalize() 方法无法执行完成。
- 对象可以在 finalize() 方法中"自救",即把自己与引用链上的对象关联起来,从而让自己被从即将回收对象集合中移除。但是这种机会只有一次,因为 finalize() 方法只会被执行一次。
2.5 方法区回收
方法区主要回收两部分内容:废弃的常量和不再使用的类。
- 废弃的常量判定:系统中没有任何对象引用指向常量池中对应的常量。比如:系统中没有任何字符串对象引用指向字符串常量 "abc" ,此时 "abc" 就是一个废弃常量。
- 不再使用的类的判定:
-
- 该类的所有实例都已经被回收,包括该类的派生类实例也都被回收。
-
- 加载该类的类加载器已经被回收。
-
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
-
3. 垃圾收集算法
3.1 分代收集理论
分代收集理论基于三个假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说:跨代引用相对于同代引用来说,只占极少数。
前两个假说奠定了大多数垃圾收集器的一致设计原则:
- 将 Java 堆划分出不同的区域。
- 根据对象的年龄,将他们分配到不同的区域存储。
- 将朝生夕灭的对象放到同一个区域,每次只标记那少部分存活的对象,从而以较小的代价回收较多的空间。
- 将难以消亡的对象放在同一区域,垃圾回收器以较小的频率进行回收,从而做到兼顾时间和空间开销来完成垃圾回收。
对于第三个假说:可以在新生代记录那些老年代存在跨代引用,从而在垃圾回收时,将这部分老年代纳入到 GC Root 中,从而避免扫描整个老年代。
3.2 标记-清除算法
标记-清除算法是最基础的算法,由标记、清除两部分组成。标记由前面介绍的标记算法进行标记,标记完成后,对未存活对象进行清除。
标记-清除算法的缺点:
-
- 执行效率不稳定:如果 Java 堆中包含大量对象,且其中大部分是需要回收的,这是需要大量的标记和清除动作,导致标记和清除的过程随着对象的数量增加而降低。
-
- 内存空间碎片化:标记-清除会导致产生大量不连续的内存碎片,如果碎片太多,会导致大对象无法完成内存分配,从而导致提前触发新的垃圾收集动作。
3.3 标记-复制算法
标记-复制算法将可用内存区域划分为大小相等的两块,每次只使用其中的一块,当这一块使用完了,就将还存活的对象复制到另一块上面,然后再将使用过的内存空间一次性清理掉。
它解决了标记-清除算法面对大量对象执行效率低的问题。
但是如果内存中大量对象都是存活的,这种算法会产生大量复制开销。但是对于大多数对象是可回收的情况,复制的对象少,且只需要按顺序分配内存进行复制,这样实现简单,运行效率高。
不过缺点也很明显:会标记-复制算法会使可用内存缩小为原来的一半。
IBM 公司研究发现,新生代中对象98%熬不过第一轮回收,因此对划分比例进行了优化,其中具体的做法是:
- 把新生代划分为一块较大的 Eden 区和 两块较小的 Survivor 区。 Eden 和 Survivor 的大小比例是:8:1 ,即 Eden:Survivor:Survivor = 8:1:1。
- 每次只使用 Eden 和一块 Survivor。则每次使用的空间达到了 90%, 只有 10% 的空间被浪费。
- 当回收时,将 Eden 和 Survivor 中的存活对象,复制到另一个 Survivor 中。 然后清理掉 Eden 和使用过的 Survivor 空间。
由于 98% 会被回收是普通情景下,对于特殊情景,存活对象大于 10% 时,老年代会进行分配担保,即对象会直接进入老年代。
3.4 标记-整理算法
标记-复制算法在对象存活率高的时候,需要进行较多的复制,效率就会降低。如果不想浪费 50% 内存,则需要额外的空间进行分配担保,以应对 100% 存活的极端情况,所以老年代一般不能直接用这种算法。
老年代一般使用标记-整理算法:标记-整理算法是对对象进行标记后,让存活对象像内存空间一端移动,然后清理掉边界以外的内存。
标记-清除算法和标记-整理算法的本质区别是:是否移动存活对象。是否移动存活对象是一个优缺点并存的风险决策。
如果移动:在老年代中,每次都有大量对象存活,移动的开销很大,而且移动的过程中,用户进程需要暂停。
如果不移动:考虑到空间碎片,需要额外的机制来分配内存,比如:分区空闲分配链表。但是内存访问时用户进程最频繁的操作,所以如果在内存分配上增加额外的操作,势必会影响吞吐量。
所以综合下来,移动对象会更划算。
还有一种方式,可以在不采用额外的方式进行内存分配:平时采用标记-清除算法,在空间碎片化到无法分配对象时,采用标记-整理算法。
4. HotSpot 的算法实现细节
4.1 根节点枚举
4.2 安全点
4.3 安全区域
4.4 记忆卡与卡表
4.5 写屏障
4.6 并发的可达性分析
5. 经典垃圾收集器
概述
今典收集器的搭配使用关系,互相连线表示能搭配使用:
5.1 Serial 收集器
Serial 收集器是最基础、历史最悠久的收集器,它是一个单线程收集器,同时在它进行垃圾收集的时候,必须暂停其他的工作线程,直到它工作完成,即:Stop The World。
5.2 ParNew 收集器
ParNew 是 Serial 收集器的多线程版本,除了使用多线程进行垃圾回收外,与 Serial 收集器完全一致。比如:控制参数、收集算法、Stop The World、对象分配规则、回收策略等都要一致。
在 JDK 7 之前,ParNew 是不少运行在服务端得 HotSpot 虚拟机上的新生代收集器的首选,其中一个很重要的原因是 除了 Serial 收集器之外,目前只有它能与 CMS 收集器配合工作。
CMS 是第一款真正意义上的支持并发的垃圾收集器,它首次实现了垃圾收集线程与用户线程同时工作。
CMS 无法与新生代 Parallel Scavenge 配合工作。它只能与 Serial 或者 ParNew 配合工作。
G1 是一个全堆收集器。
在单核处理器的情况下,ParNew 的收集效果没有 Serial 收集器效果好。
5.3 Parallel Scavenge 收集器
Parallel Scavenge 是一款新生代收集器,它基于标记-复制算法实现。 Parallel Scavenge 收集器与其他收集器的不同点是:它关注的是能达到一个可控制的吞吐量。而 CMS 等收集器关注点在尽可能缩短垃圾收集时用户线程的停顿时间。吞吐量是处理器用于运行用户代码的时间与处理器总耗时的比值:
由于与吞吐量密切相关:它也经常被叫 吞吐量优先收集器。
停顿时间短:适合需要与用户交互的程序,能提升用户体验。
吞吐量高:可以最高效率的利用处理器资源,尽快完成运算任务,适合不需要太多交互的分析任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量:
-XX:MaxGCPauseMillis
:控制最大垃圾收集停顿时间。参数值是一个大于 0 的毫秒数,它会牺牲吞吐量和新生代空间大小来减少停顿时间,比如把新生代空间调小,此时停顿时间变小,但是停顿的频率会变高,从而到导致总的停顿时间变多,导致吞吐量下降。-XX: GCTimeRatio
:设置吞吐量的大小。参数是一个 0 到 100 之间的数字(不包含0 和100)。比如设置为 19 则:垃圾收集时间占比为 5% = 1/(1 + 19),当设置为 99 时,垃圾收集时间占比 1% = 1/(1 + 99).
5.4 Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它也是一个单线程收集器,使用标记-整理算法。主要提供给客户端下的 HotSpot 虚拟机使用。它有两种用途:JDK 5 及之前与 Parallel Scavenge 收集器搭配使用。另一种是作为 CMS 失败时的后备预案,当 CMS 发生 Concurrent Mode Failure 时使用。
5.5 Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本。支持多线程并发收集,基于标记-整理算法实现。
5.6 CMS 收集器
CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器。它适合于关注响应速度的应用,比如互联网网站、B/S系统的服务端等。
CMS 的四个步骤:
- 初始标记:标记与 GC Roots 直接关联到的对象,速度很快。
- 并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长,与工作线程并发运行。
- 重新标记:修正并发标记期间因用户线程运行而导致标记产生变动的那一部分对象,需要暂停用户线程,耗时比初始标记长,但是远比并发标记短。
- 并发清除:并发清除死亡对象。
初始标记和重新标记两个步骤仍然需要 Stop The World。
CMS 的缺点:
- CMS 收集器对处理器资源非常敏感。它不导致用户线程暂停,但是会占用一部分线程,从而导致应用程序执行变慢,降低总吞吐量 CMS 默认启动的线程数是 (处理器核心数 + 3)/4,如果处理器核心数不足 4 个,且应用程序导致处理器负载很高时,会导致用户程序执行速度大幅降低。如果处理器核心数大于等于 4 时,垃圾收集线程占总数的比例不会超过 25%,且随着核心数增加而下降。
- CMS 收集器无法处理 "浮动垃圾",有可能出现 Concurrent Model Failure 而导致一次完全的 Stop The World 的 Full GC 产生。浮动垃圾是指在并发标记和并发清除阶段,用户线程继续运行产生的垃圾,这些垃圾无法在当次垃圾回收时被回收,必须要等到下次回收才能被回收。由于浮动垃圾的存在就不能等到老年代内存几乎耗尽才进行回收,必须要预留内存给并发收集时程序运行使用。如果预留空间过大,会导致垃圾收集次数变多,如果预留空间过少内存无法满足新对象分配,会出现 Concurrent Model Failure 失败。此时用户线程冻结,启用 Serial Old 重新进行回收,导致停顿时间很长。
- CMS 收集器采用的标记-清除算法,会产生大量空间碎片,容易导致大对象无法分配空间,从而提前触发 Full GC。
5.7 Garbage First 收集器
Garbage First 收集器简称 G1 收集器。它开创了收集器面向局部的设计思路和基于 Region 的内存布局形式。
G1 是一款主要面向服务端应用的垃圾收集器。JDK 9 发布时,G1 取代了 Parallel Scavenge 加 Parallel Old 组合成为服务端模型下的默认垃圾收集器,而 CMS 被声明为不推荐使用。
G1 希望建立起 "停顿时间模型",即在指定的时间片长度 M 毫秒内,消耗在垃圾收集器上的时间大概率的超过 N 毫秒,这几乎是一个软实时的垃圾收集器了。
为了实现 "停顿时间模型" ,就不能像之前的收集器,将内存分为老年代回收(Major GC)、新生代回收(Minor GC)、或者整堆回收(Full GC)。而是衡量堆中某一块区域的回收收益最大,这就是 G1 的 Mixed GC 模式。
G1 将堆内存划分为很多大小相等的独立区域,每个区域根据需要都可以扮演新生代的 Eden 区、 Surivor 区、或者老年代空间。独立区域中有一类特殊的大型区域,专门用来存储大对象。当一个对象的大小超过单个独立区域的容量的一半,即可判定为大对象。独立区域的大小可以通过参数 -XX:G1HeapRegionSize
控制,取值在 1MB-32MB。
G1 的最小回收单元是单个独立区域,每次回收整数个独立区域。为了实现 "停顿时间模型",G1 会评估每个独立区域的回收价值大小,比如设置最大停顿时间 200 毫秒,则 G1 会优先回收价值最大的区域。
G1 解决的一些关键细节问题:
- 1.跨独立区域的对象引用解决?给每个独立区域设置一个记忆集,用来记录当前对象跨域引用了谁,以及被谁跨区域引用,从而避免了全堆做 GC Roots 扫描。
-
- 并发标记阶段如何保证收集线程与用户线程互不干扰?参考(并发的可达性分析)。另外还需要解决垃圾收集时,独立区域中新对象的产生,回收时新分配的对象时有一部分区域分配给新对象的。同时如果内存回收速度跟不上内存分配速度,G1 也会产生 Concurrent Mode Failure 错误导致 Full GC 发生。
-
- 如何建立可靠的停顿预处模型? G1 收集器的停顿预测模型是以衰减均值为理论基础来实现的,衰减均值比普通的平均值更容易受到新数据的影响,即衰减平均值更准确的代表最近的平均状态,更接近最新的回收价值。
G1 收集器运行大致也可以划分为 4 个阶段:
- 初始标记:标记 GC Roots 能直接关联的对象,设置 TAMS 指针,让下一阶段的用户线程将新对象分配到 TAMS 指针之上,从而正常运行。这个阶段需要暂停,但是耗时很短。
- 并发标记:从 GC Roots 开始对堆总对象进行可达性分析,找出要回收的对象,与用户线程同时进行。还要重新处理原始快照中并发时引用发生变动的对象。
- 最终标记:用户线程短暂暂停,处理并发阶段结束后,少量遗留的原始快照中的变动记录。
- 筛选回收:更新独立区域的统计数据,对每个区域的回收价值和成本进行排序,根据用户的期望暂停时间制定回收计划,然后将回收部分的独立区域中的存活对象复制到空的独立区域,再清理掉回收区域。需要移动存活对象,所以需要暂停用户线程,由多条收集器线程并行完成。
G1 设置期望暂停时间需要符合实际,它默认是 200 毫秒,一般回收阶段耗时在几十到接近两百毫秒都是正常的,如果设置太低,比如设置为 20 毫秒,可能导致目标时间太短,导致每次筛选出来的回收内存太少,从而导致回收速度跟不上分配速度,导致运行时间一长,引发堆占满,进而引发 Full GC 降低性能。一般停顿时间设置为: 100 - 300 毫秒比较合理。
CMS、G1 都关注暂停时间控制,它们的优缺点如下:
- G1 算法理论更有发展潜力。CMS 采用标记-清除算法,会产生空间碎片,G1 从整体看是基于标记-整理算法,从独立区域看是标记-复制算法,不会产生空间碎片。
- G1 内存占用比以及运行时的额外负载都比 CMS 高。
- G1 的记忆集比 CMS 复杂,且占用内存空间更多。
6. 低延迟垃圾收集器
垃圾收集器的三项最重要的指标:内存占用、吞吐量、延迟。垃圾收集器不可能同时在这三个指标上都很优秀。
由于硬件的发展,内存占用和吞吐量会随着硬件性能增长而提高。但是硬件的发展反而对延迟带来负面效果,比如:回收 1TB 的内存耗时必定会比回收 1GB 内存耗时高。因此延迟逐渐成了最重要的指标。
Shenandoah 和 ZGC 两款收集器,只在初始标记、最终标记两个阶段有短暂的停顿,这部分的停顿时间基本是固定的,不与堆中对象的数量成正比关系。它们可以在任意堆容量下,实现不超过 10 毫秒的暂停。它们被官方称为:低延迟垃圾收集器。
6.1 Shenandoah 收集器
Shenandoah 与 G1 有相似的内存布局,在初始标记、并发标记等阶段处理思路一致。 Shenandoah 相比 G1 的改进:Shenandoah 支持与用户线程并行的整理算法。不使用分代收集。放弃 G1 上的记忆集,采用连接矩阵全局数据结构来记录跨独立区域的引用关系。
Shenandoah 的工作过程大致划分为九个阶段:
- 初始标记: 与 G1 一样,标记与 GC Roots 直接关联的对象,需要 Stop the World,暂停时间与堆大小无关,只与 GC Roots 的数量有关。
- 并发标记: 与 G1 一样,遍历对象图,标记出全部可达对象,与用户线程并发进行。
- 最终标记: 与 G1 一样,处理剩余的 原始快照中变化的对象引用。统计回收价值最高的独立区域,,需要暂停一小段时间。
- 并发清理: 用于清理那些一个存活对象都没有的独立区域。
- 并发回收: 使用读屏障和转发指针来实现并发的将回收集里的存活对象复制到其他没有被使用的独立区域里。
- 初始引用更新: 初始引用更新阶段没有实际上的处理,只是对象复制结束的一个标识,此时会产生一个非常短暂的停顿。
- 并发引用更新: 真正开始进行引用更新,与用户线程并发,他只需要按照物理内存地址的顺序,把旧值改为新值。
- 最终引用更新: 引用更新后,修正 GC Roots 中的引用,需要一个很小的停顿。
- 并发清理: 此时整个回收集中,以及没有存活的对象了,需要清理这些空间。
读屏障和转发指针:转发指针是指在对象布局上,增加一个引用字段,正常情况下,引用字段指向自己,当复制对象后,将旧的引用字段指向新的副本。
由于 GC 线程和用户线程在同时工作,如果在旧对象的引用指向新对象之前,发生了用户线程对旧对象的修改,就会导致新对象上没有最新的修改。所以必须要对转发指针采取同步保护措施。在转发指针时,用户线程和 GC 线程只能有其中一个能访问成功。从而保证引用成功指向新对象,或者修改根据旧对象的引用指向新对象,对新对象进行修改。
6.2 ZGC
ZGC 是一款基于独立区域(Region)内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术实现的可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC 的内存也是基于独立区域的布局,但是它具有动态性,可以动态创建和销毁,以及动态的区域容量大小。在 x64 硬件平台下,ZGC 的独立区域可以分为三类:
- 小型区域:容量固定为 2MB,用于放置小于 256KB 的小对象。
- 中型区域:容量固定为 32MB,用于放置大于等于 256KB 小于 4MB 的对象。
- 大型区域:容量不固定,可以动态变化,但必须是 2MB 的整数倍,放置大于 4MB 的对象。每个大型区域只会放置一个对象,所以大型区域的容量可能小于中型区域,大型区域的在对象不会被复制,因为复制大对象的代价非常高。
ZGC 的并发整理算法实现:ZGC 采用了染色指针技术(Colored/Tag/Version Pointer),通常我们会选择在对象头中增加额外的存储字段,如哈希码、分代年龄、锁记录等,记录在对象头中,对正常的读写都没有额外的影响。但是如果涉及到对象移动、或者希望在不访问对象的情况下获取对象的某些信息就很麻烦。ZGC 的染色指针直接把标记信息记在引用对象的指针上,此时我们进行对象的可达性分析更像是在遍历引用图,而不是遍历对象图。
在 64 位系统中,理论上可以访问额内存高达 16EB(字节),但是基于性能和成本等考虑,硬件和操作系统一般都会进行限制,比如 Linux 支持 47 位(128TB)的虚拟地址空间,和 46 位(64TB)的物理空间地址。在 Linux 下 64 位指针的高 18 位不能用来寻址。剩下的 46 位仍然能支持 64TB 内存。ZGC 的染色指针技术则继续讲剩下的 46 位指针的高 4 位用来存储四个标志信息。 这样导致 ZGC 管理的内存不能超过 4TB()。
ZGC 的染色指针的三大优势:
- 染色指针可以使得一旦某个 Region 的存活对象被移走之后,这个 Region 就能立即被释放和重用,而不用等到整个堆上指向该 Region 的引用都被修正后才能清理。使得理论上只要有还有一个空闲的 Region,ZGC 就能完成收集。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障通常是为了记录对象引用的变动情况,当这些信息记录在指针上时,可以省掉一些专门的记录操作。这样可以提高应用程序的吞吐量。
- 染色指针可以作为一种可扩展的存储结构,用来记录更多的对象标记、重定位过程相关的数据,以便日后进一步提高性能。
ZGC 的工作阶段:
- 并发标记: 与 G1、Shenandoah 一样,并发标记是遍历对象图做可达性分析的阶段,也要经历初始标记、最终标记的短暂停顿,但是 ZGC 是标记在指针上而不是对象上,标记阶段或更新染色指针的 Marked 0、Marked 1 标志位。
- 并发预备重分配: 根据特定查询条件,统计出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集。ZGC 的重分配集只是决定了里面的存活对象会被重新赋值到其他 Region 中,里面的 Region 会被释放掉。而 G1 的 Region 是为了做收益优先的增量回收。
- 并发重分配: 并发重分配阶段是 ZGC 执行过程中的核心阶段,这个阶段要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表,记录从旧对象到新对象的转向关系。由于有染色指针, ZGC 可以从引用上就确定一个对象是否在重分配集中,如果线程并发访问了重新分配集,此次访问就会被内存屏障截获,然后立即根据 Region 上的转发表,将访问转发新对象上,同时修正更新该引用值,让它直接指向新对象。 ZGC 这种行为被称为"指针自愈"。这样做的好处是,只有第一次访问旧对象时,才会陷入转发,而 Shenandoah 每次都要通过对象头转发一次。由于有染色指针和转发表,一旦重分配集中某个 Region 的存活对象复制完毕后,就可以立即释放该 Region 的内存,旧的指针可以通过转发表自愈。
- 并发重映射: 并发重映射就是修正整个堆中指向重分配集中旧对象的所有引用,由于有指针自愈,所以这个阶段并不迫切需要完成。因此 ZGC 把这个阶段的工作合并到了下一个回收阶段的并发标记里去完成,因为它们都要遍历所有对象,这样可以节省一次遍历开销,一旦所有指针都被修正,原来的转发表就可以释放掉了。
由于 ZGC 没有分代处理,它不能承受对象分配速度过高的应用。比如:当对象分配很快,但是 ZGC 回收时间较长,此时大量本该属于新生代的应该快速被回收的对象,会被当做存活对象(浮动垃圾),等待 ZGC 来进行回收。此时会让可用内存越来越少,从而触发 Full GC。所以如果要从根本上提升 ZGC 处理对象快速分配问题,还需要引入分代收集。
7. 选择合适的垃圾收集器
1. Epsilon 收集器
Epsilon 被形容成一个无操作的收集器。事实上只要 Java 虚拟机能工作,垃圾收集器就不可能真正无操作。因为垃圾收集器并不能形容它的全部职责。更贴切的是"自动内存管理子系统"。垃圾收集器除了垃圾收集之外,还要负责堆的管理布局、对象的分配、与解释器协作、与编译器协作、与监控子系统协作等。至少要有堆管理和对象分配这部分工作,才能使 Java 虚拟机正常运作。为了隔离垃圾收集器与 Java 虚拟机解释、编译、监控等子系统的关系, RedHat 在 JDK10 提出了垃圾收集的统一接口。Epsilon 是这个接口的有效性验证和参考实现,同时也用于需要剥离垃圾收集影响的性能测试和压力测试。
2. 收集器的权衡
选择合适的收集器主要会受到以下三个因素影响:
- 应用程序的主要关注点是什么?如果是数据分析、科学计算类,主要目标是尽快出结果,那应该关注吞吐量。如果是 SLA 应用,停顿时间直接影响服务体验,主要关注点是延迟。如果是客户端应用或者嵌入式,那垃圾收集的内存占用是不可忽视的。
- 运行的应用的基础设施如何?比如硬件规格、系统架构是 x86-32/64 或者是 ARM/Aarch64、处理器的数量,内存大小、操作系统是 Linux、Solaris 还是 Windows。
- 使用的 JDK 的发行商是什么?版本号是多少?是 ZingJDK/Zulu、OracleJDK、OpenJDK、OpenJ9 获取其他公司的发行版本?该版本对应的 Java 虚拟机规范版本?
举例: 如果是一个 B/S 系统,一般来说关注的是延迟:如果预算充足但是没有调优经验,可以选择商用解决方案。如果预算不够,但是能掌握硬件型号,可以使用 ZGC。 如果是运行在 Windows 上,可以试试 Shenandoah。 如果是遗留系统, JDK 版本较老,可以考虑 CMS,也可以考虑 G1。
3. 虚拟机及垃圾收集器日志
4. 垃圾收集器参数总结
8. 内存分配与回收策略
对象的内存分配,从概念上讲,应该都是在堆上分配(实际上也有经过即时编译后被拆为标量类型在栈上分配)。在经典的分代设计下,新对象会分配在新生代中,少数情况下(例如对象大小超过一定阈值)也可能直接分配在老年代。对象分配规则并不固定,Java 虚拟机规范没有规定新对象的创建和存储细节,这取决于虚拟机使用的哪一种垃圾收集器,以及虚拟机中与内存相关的参数设定。
对象优先在 Eden 分配,大多数情况下,对象在新生代 Eden 区中分配,当 Eden 区没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。
大对象直接进入老年代,大对象是指需要大量连续内存空间的 Java 对象,比如很长的字符串,元素数量庞大的数组。大对象容易导致明明内存充足,却提前触发了垃圾回收,当复制大对象时,意味着高额的内存复制开销。 HotSpot 虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该值的对象直接在老年代分配,这样避免了在 Eden 区和两个 Survivor 区来回复制,产生大量的内存复制操作。
长期存活的对象将进入老年代,HotSpot 虚拟机中的垃圾收集器大多采用了分代收集来管理堆内存,因此内存回收时就必须能决策哪些存活对象放在新生代,哪些放在老年代。为此,虚拟机给每个对象定义了一个对象年龄计数器。对象出生在 Eden 区,如果经过一次 Minor GC 后仍然能存活,且被 Survivor 容纳后,该对象被移动到 Survivor 区,并且对象的年龄设置为 1,对象在每熬过一次 Minor GC,年龄增加 1 岁,当年的达到一定程度,就会晋级到老年代中。默认的晋升年龄是 15 岁,可以通过参数 -XX: MaxTenuringThreshold 设置。
动态对象年龄判定,HotSpot 虚拟机并不是永远要求对象年龄必须达到设置的年龄才能晋升到老年代,如果 Survivor 空间中相同年龄所有对象的大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
空间分配担保,在进行 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总大小,如是大于,则本次 Minor GC 是安全的。否则虚拟机会先查看 -XX: HandlePromotionFailure 是否允许担保失败。如果允许,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将会尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的。如果小于,或者 -XX: HandlePromotionFailure 设置的不允许,这是就会进行一次 Full GC。
所谓空间分配担保就是,当新生代进行 Minor GC 时,老年代要保证在所有新生代都需要进入老年代的极端的情况下,老年代担保有足够的内存容纳新生代的对象。