深入理解Java虚拟机之垃圾收集器及策略


垃圾收集器与内存分配策略

程序计数器虚拟机栈本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了

垃圾收集器主要关注的是Java堆方法区这两部分内存

对象已死?

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的

可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的

image-20220109182007556

再谈引用

  • 强引用

    永不回收被强引用的对象

    Object obj = new Object();
  • 软引用

    还有用,但非必须的对象。即内存足够,则保留;内存将要溢出,则回收

  • 弱引用

    非必须的对象。只有GC开始工作,无论内存是否足够都被回收

  • 虚引用

    任何时候都有可能被回收

生存 OR 死亡

可达性分析算法中,至少需要经过两次标记过程,才能真正地宣告一个对象死亡

  • 第一次标记:发现没有与GC ROOTS相连的引用链
  • 第二次标记:筛选该对象是否有必要执行finalize()方法。执行finalize()方法后,再次判断是否可达,不可达则回收,可达则对象复活
    • 筛选条件是:对象是否覆盖finalize()方法,及其是否被虚拟机调用过(若不覆盖或被调用过,则对象被回收)

一次对象自救示例:

/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*/
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAlive() {
    	System.out.println("yes, i am still alive :)");
	}
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
        	SAVE_HOOK.isAlive();
        } else {
        	System.out.println("no, i am dead :(");
        }
    	// 下面这段代码与上面的完全相同,但是这次自救却失败了
     	SAVE_HOOK = null;
    	System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
        	SAVE_HOOK.isAlive();
        } else {
        	System.out.println("no, i am dead :(");
        }
    }
}

回收方法区

方法区垃圾收集主要是两部分内容:

  • 废弃的常量
  • 不再使用的类型

其中判断一个不再使用的类型需满足以下三个条件:

  1. 该类的所有实例都已被回收,即Java堆中不再存在该类及其子类的对象
  2. 加载该类的类加载器已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

垃圾收集算法

  1. 引用计数式垃圾收集(直接)
  2. 追踪式垃圾收集(间接)

分代收集理论

  1. 弱分代假说:大多数对象都是朝生夕灭的
  2. 强分代假说:熬过多次垃圾收集过程都难以消灭的对象

上述两个分代假说奠定了常用垃圾收集器的设计原则:

收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储

  1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的


问题:

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性

解决:

为了避免扫描整个老年代,基于第三个假说,在新生代上建立了一个全局的数据结构:记忆集

该结构将老年代划分若干个内存小块,标识哪小块内存存在跨代引用

此后若进行Minor GC,存在跨代引用的小块内存直接加入GC Roots中进行扫描


名词定义:

  • 部分收集(Partial GC):目标非完整Java堆的垃圾收集
    • 新生代收集(Minor GC/Young GC):目标是新生代垃圾收集
    • 老年代收集(Major GC/Old GC):目标是老年代垃圾收集
    • 混合收集(Mixed GC):目标是整个新生代和部分老年代垃圾收集
  • 整堆收集(Full GC):目标是整个Java对和方法区的垃圾收集

标记清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象

缺点:执行效率不稳定、内存空间碎片化问题

image-20211228110833260

标记复制算法

将可用内存划分成相等的两个半区A、B,每次只使用一个半区。当半区A的内存使用完后,将其还存活的对象复制到另一半区B,然后清理半区A的空间

缺点:存活对象过多会带来内存间复制的开销;每次只能使用一半内存,造成空间浪费

image-20211228114103172


优化半区复制,提出Appel回收

  • 将新生代划分成80%的Eden空间、10%的Survivor(From)空间和10%Survivor(To)空间。

  • 发生垃圾收集时,将Eden空间和From空间中仍存活对象复制到To空间中,然后清理掉Eden和From空间

  • 当To空间不足以存储下剩余存活对象时,多余的通过分配担保机制直接进入老年代

image-20211228141431917

标记整理算法

和标记清除算法类似,先给需要回收的对象做好标记,然后让所有存活的对象都往内存空间的一边移动,最后清除掉边界外的空间

缺点:若有大量的存活对象,那么移动并更新其空间也是一项开销

image-20211228142705017

HotSpot算法细节实现

根节点枚举

所有收集器在根节点枚举这一步骤上都是必须暂停用户线程的。

在HotSpot解决方案里是使用一组OopMap数据结构来达到直接获取哪些地方存放着对象引用的目的

image-20211230100930382

安全点

在特定位置记录下栈里和寄存器里哪些位置是引用,这个特定位置就是安全点

安全点的设定就决定了用户程序执行时必须到达此处才能暂停并进行垃圾收集。

安全点的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的

抢先式中断:垃圾收集发生时,系统令所有用户线程产生中断,若发现未到达安全点,则恢复,直至其再次到达安全点后产生中断

主动式中断:设置标志位,各个线程执行时轮询该标志位,若中断标志为真,则在最近的安全点上中断挂起。标志位和安全点重合

image-20211230095120993

安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点

记忆集与卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

卡表是记忆集的一种具体实现。它定义了记忆集的记录精度、与堆内存的映射关系等。HotSpot使用字节数组表示卡表

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。

在垃圾收集的时候,只要筛选出卡表中变脏的元素,就能找到哪些卡页包含跨代指针,加入GC Roots中一起扫描

image-20211228171150119

写屏障

有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。

高并发场景下,卡表可能存在伪共享问题。解决方案是:不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。

并发的可达性分析

可达性分析算法的全过程都需要基于一个能保障一致性的快照中才能够分析,这意味着必须全程冻结用户线程的运行

在OopMap的优化下,GC Roots的查找导致的停顿时间是短暂且相对固定的

但找到GC Roots后还需要往下遍历对象图,做标记,而且随着堆容量的增大,可容纳的对象也越来越多,这就会导致停顿的时间逐渐变长

因此,若能减少这部分的停顿时间,其能带来系统级收益是十分可观的


要解决问题,就必须知道问题发生在哪里,由此引入三色标记工具辅助推导:

  • 白色:表示对象尚未被垃圾收集器访问过。若可达性分析结束后仍然是白色,则表明其不可达,可以回收

  • 黑色:表示对象已经被垃圾收集器访问过,且该对象的所有引用都已经被扫描过,代表是存活的。若有其它引用指向了黑色对象,则无需重新扫描。黑色对象不可能不经过灰色对象直接指向白色对象

  • 灰色:表示对象已经被垃圾收集器访问过,但该对象至少存在一个引用还没被扫描过。当全部访问后会变成黑色

    image-20211229150038165

当只有垃圾回收线程单独工作时,不会出现任何问题。但当其与用户线程同时运行,就可能会产生以下情况了:

  • 将原来要回收的标记为存活。这个问题不大,产生了浮动垃圾,等待下次再回收就好

    image-20211229153313859

  • 将原来存活的标记为需要回收。这就会产生对象消失的大问题了

    image-20211229154123691


当且仅当以下两个条件符合时,就会产生对象消失问题,即黑色被误标成了白色:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

解决方案:

  1. 增量更新:破坏的是第一个条件。新增一条引用时,就将此记录保存,扫描结束之后,再将记录中的黑色引用变为灰色,重新扫描

    image-20211229154938070

  2. 原始快照(SATB):破坏的是第二个条件。将要删除的引用记录保存下来,扫描结束后,将原删除的对象变为灰色对象的根,重新扫描一次

    image-20211229161028839

CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现

经典垃圾回收器

Serial收集器

最基础,同时历史最悠久,在JDK 1.3.1之前是HotSpot虚拟机新生代垃圾收集器的唯一选择

  • 优点:单线程,内存消耗最小
  • 缺点:垃圾收集时必须暂停其它所有的工作线程,直至GC结束

ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,能与CMS收集器配合工作

ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器

Parallel Scavenge收集器

基于标记-复制算法实现的一款能够多线程并行的新生代收集器,其目标是达到一个可控制的吞吐量:
$$
吞吐量 = 运行用户代码时间 / 运行用户代码时间 + 运行垃圾收集时间
$$

垃圾收集的自适应的调节策略(GC Ergonomics) :通过设置参数-XX :+UseAdaptiveSizePolicy,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

若在服务端模式下,它也可能有两种用途:

  • 在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用
  • 作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

Parallel Scavenge/Parallel Old组合工作如图:

image-20220109202801385

CMS收集器

CMS(Concurrent Mark Sweep)收集器是基于标记-清除算法实现的一种以获取最短回收停顿时间为目标的收集器。

其运作过程主要分四个步骤:

  1. 初始标记(CMS initial mark)

    • 需要暂停用户线程,仅标记GC Roots能够直接关联到的对象
  2. 并发标记(CMS concurrent mark)

    • 遍历对象图,耗时最长但无需暂停用户线程
  3. 重新标记(CMS remark)

    • 需要暂停用户线程,初始化标记<耗时时间<<并发标记。用于修正并发标记中因用户线程运行导致标记变动的部分
  4. 并发清除(CMS concurrent sweep)

    • 清理删除掉标记阶段判断的已经死亡的对象,可与用户线程并发

image-20220109203225468

缺点:

  • 对处理器资源敏感
    • 占据了部分工作线程
  • 无法处理浮动垃圾
    • 因用户线程的同时执行,总会有新的垃圾对象在标记结束后生成,只能等待下一次的垃圾收集
  • 产生内存空间碎片
    • 标记-清除算法的缺点

Garbage First收集器

G1是一款主要面向服务端应用的垃圾收集器。它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。

Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

image-20211229190650203

G1收集器运行步骤及示意图:

  1. 初始标记

    • 仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象

      TAMS:要达到GC与用户线程并发运行,必须要解决回收过程中新对象的分配,所以G1为每一个Region区域设计了两个名为TAMS(Top at Mark Start)的指针,从Region区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围

  2. 并发标记

    • 从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以
      后,还要重新处理SATB记录下的在并发时有引用变动的对象
  3. 最终标记

    • 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
  4. 筛选回收

    • 负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间

    image-20220109203624440

可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡;停顿时间过短可能会出现对象分配速度 > 垃圾回收速度,导致垃圾堆积,因此一般设置在100200300毫秒之间是比较合理的。

与CMS相比,G1在用户程序运行过程中,其垃圾回收的内存占用及程序运行时的额外执行负载都要比CMS的高;其主要原因都是跨代引用的处理,G1维护的卡表(堆中的每个Region都需要维护一份记忆集)比CMS维护的卡表(只需维护一份记忆集)更复杂得多。

低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)吞吐量(Throughput)延迟(Latency),三者共同构成了一个”不可能三角”

Shenandoah收集器

Shenandoah与G1类似,但它摒弃G1中耗费大量内存和计算资源维护的记忆集,转用“连接矩阵”的全局数据结构来记录跨Region的引用关系,降低了伪共享的发生概率。

连接矩阵:可简单理解成一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记

image-20211230160005864

Shenandoah收集器的工作过程大致有九个阶段:

  1. 初始标记
    • 标记与GC Roots直接关联的对象,停顿时间只与GC Roots数量相关
  2. 并发标记
    • 遍历对象图,标记所有可达对象,时间长短取决于堆中对象数量和对象图的结构复杂度
  3. 最终标记
    • 处理剩余的SATB扫描,统计回收价值最高的Region组成回收集(CSet)
  4. 并发清理
    • 清理一个存活对象都没有的Region空间
  5. 并发回收
    • 复制回收集中还存活的对象到空闲的Region空间中。其运行时间取决于回收集的大小
  6. 初始引用更新
    • 上述复制结束后,建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成相应的对象移动任务
  7. 并发引用更新
    • 真正开始引用更新操作,将堆中所有指向旧对象的引用修正到复制后的新地址。时间长短取决于内存中引用数量的多少
  8. 最终引用更新
    • 修正GC Roots中的引用,会产生停顿,时间只与GC Roots数量有关
  9. 并发清理
    • 上述操作过后,回收集中的Region再无存活对象,最后清理Region内存空间

image-20211231092053346


并发整理核心概念——Brooks Pointers

  • 使用转发指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发

  • 在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己

    image-20220103141946552

其工作原理:

当 GC 线程对存活对象进行复制时,旧的对象的转发指针会指向新对象的地址

转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作

image-20220103142041960

ZGC收集器

Z Garbage Collector是在JDK 11中新加入具有实验性质的一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC的Region空间具有动态性——动态创建和销毁,以及动态的区域容量大小。在X64硬件平台下主要分为三种:

  1. 小型Region
    • 固定2M,存放小于256k的小对象
  2. 中型Region
    • 固定32M,存放>=256k但<4M的对象
  3. 大型Region
    • 容量动态变化,为2M的整数倍,存放4M及以上的大对象

ZGC堆内存布局


Shenandoah使用转发指针和读屏障来实现并发整理,而ZGC使用了染色指针方式来解决并发整理的问题:

除去高18位不能寻址外,剩下的46位,ZGC的染色指针技术将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)

  • Marked0:活跃对象,即不是垃圾对象
  • Remapped:表示是垃圾对象,可以回收

采用Marked0Marked1两个标记是为了区分是前一次标记和当前标记,当发生第二次GC时:

  • Marked0:前一次回收过程中被标记为活跃,但在转移过程中未转移,此次垃圾回收被标记为不活跃
  • Marked1:本次回收过程中标记为活跃
  • Remapped

image-20220103162023297

染色指针带来的三个优势:

  1. 染色指针可以使得一旦某个Region的存活对象被移走之后,该Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理
  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作
  3. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能

Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了

image-20220107160153746


其运行流程大致分为四个阶段:

  1. 并发标记

    • ZGC标记的是指针而不是对象,该阶段会更新染色指针中的Marked 0、Marked 1标志位
  2. 并发预备重分配

    • ZGC每次回收都会扫描所有的Region,查询出本次收集过程中需要清理的Region组成重分配集(Relocation Set)
  3. 并发重分配

    • 把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(ForwardTable),记录从旧对象到新对象的转向关系

      指针的“自愈”能力:ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象

      image-20220107172259997

  4. 并发重映射

    • 修正整个堆中指向重分配集中旧对象的所有引用

image-20220109203725826

选择合适的垃圾收集器

Epsilon收集器

Epsilon,这是一款以不能够进行垃圾收集为“卖点”的垃圾收集器

只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择

收集器的权衡

三个前提因素:

  1. 应用程序的主要关注点是什么。吞吐量?内存占用?停顿时间?
  2. 运行应用的基础设施如何。硬件的规格
  3. 使用JDK的发行商是什么

举个🍩,假设某个直接面向用户提供服务的B/S系统准备选择垃圾收集器,一般来说延迟时间是这类应用的主要关注点:

  • 有充足预算但没调优经验,带有商业技术支持的C4收集器比较适合
  • 无预算,但容易接受新技术,ZGC收集器
  • 若对新技术稳定性有所顾虑,Shenandoah收集器
  • 若是老旧项目,基础设施比较一般,内存空间在4~6G及以下的可以使用CMS收集器,更大内存的可以考虑G1收集器

虚拟机及垃圾收集器日志

阅读分析虚拟机和垃圾收集器的日志是处理Java虚拟机内存问题必备的基础技能。此处仅以JDK 1.8为例,结合idea演示几个常用的例子:

// 示例代码
public class Hello {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    private byte[] bigSize = new byte[2 * _1MB];

    public static void main(String[] args) {
        Hello objA = new Hello();
        Hello objB = new Hello();
        objA.instance = objB;
        objB.instance = objA;
        System.gc();
    }
}
  1. 查看GC基本信息:-XX:+PrintGC

    image-20220105112347715

PS:

可能出现错误:

Error: Could not create the Java Virtual Machine. Error: A fatal exception has occured. Program will exit

解决方案:

配置环境变量,变量:_JAVA_OPTIONS 值:-Xmx512M

  1. 查看GC详细信息:-XX:+PrintGCDetails

    [Full GC (System.gc()) [PSYoungGen: 5208K->0K(75776K)] [ParOldGen: 8K->5009K(173568K)] 5216K->5009K(249344K), [Metaspace: 3191K->3191K(1056768K)], 0.0047931 secs] [Times: user=0.16 sys=0.00, real=0.00 secs] 
    Heap
     PSYoungGen      total 75776K, used 1953K [0x000000076b600000, 0x0000000770a80000, 0x00000007c0000000)
      eden space 65024K, 3% used [0x000000076b600000,0x000000076b7e8460,0x000000076f580000)
      from space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
      to   space 10752K, 0% used [0x0000000770000000,0x0000000770000000,0x0000000770a80000)
     ParOldGen       total 173568K, used 5009K [0x00000006c2200000, 0x00000006ccb80000, 0x000000076b600000)
      object space 173568K, 2% used [0x00000006c2200000,0x00000006c26e4410,0x00000006ccb80000)
     Metaspace       used 3199K, capacity 4496K, committed 4864K, reserved 1056768K
      class space    used 350K, capacity 388K, committed 512K, reserved 1048576K
  2. 查看GC前后的堆、方法区可用容量变化:-XX:+PrintHeapAtGC

    {Heap before GC invocations=1 (full 0):
     PSYoungGen      total 75776K, used 11816K [0x000000076b600000, 0x0000000770a80000, 0x00000007c0000000)
      eden space 65024K, 18% used [0x000000076b600000,0x000000076c18a1e0,0x000000076f580000)
      from space 10752K, 0% used [0x0000000770000000,0x0000000770000000,0x0000000770a80000)
      to   space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
     ParOldGen       total 173568K, used 0K [0x00000006c2200000, 0x00000006ccb80000, 0x000000076b600000)
      object space 173568K, 0% used [0x00000006c2200000,0x00000006c2200000,0x00000006ccb80000)
     Metaspace       used 3191K, capacity 4496K, committed 4864K, reserved 1056768K
      class space    used 349K, capacity 388K, committed 512K, reserved 1048576K
    Heap after GC invocations=1 (full 0):
     PSYoungGen      total 75776K, used 5224K [0x000000076b600000, 0x0000000770a80000, 0x00000007c0000000)
      eden space 65024K, 0% used [0x000000076b600000,0x000000076b600000,0x000000076f580000)
      from space 10752K, 48% used [0x000000076f580000,0x000000076fa9a040,0x0000000770000000)
      to   space 10752K, 0% used [0x0000000770000000,0x0000000770000000,0x0000000770a80000)
     ParOldGen       total 173568K, used 8K [0x00000006c2200000, 0x00000006ccb80000, 0x000000076b600000)
      object space 173568K, 0% used [0x00000006c2200000,0x00000006c2202000,0x00000006ccb80000)
     Metaspace       used 3191K, capacity 4496K, committed 4864K, reserved 1056768K
      class space    used 349K, capacity 388K, committed 512K, reserved 1048576K
    }
    ...
  3. 查看GC过程中用户线程并发时间以及停顿的时间:-XX:+PrintGCApplicationConcurrentTime

    Application time: 0.0034427 seconds
    Application time: 0.0005117 seconds
  4. 查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息:-XX:+PrintAdaptiveSizePolicy

    AdaptiveSizePolicy::update_averages:  survived: 5349440  promoted: 8192  overflow: false
    AdaptiveSizeStart: 0.166 collection: 1 
    AdaptiveSizeStop: collection: 1 
    AdaptiveSizeStart: 0.170 collection: 2 
    AdaptiveSizeStop: collection: 2 
  5. 查看熬过收集后剩余对象的年龄分布信息:-XX:+PrintTenuringDistribution

    Desired survivor size 11010048 bytes, new threshold 7 (max 15)

垃圾收集器参数总结

参数过多,此节具体内容可自行阅读《深入理解Java虚拟机》

实战:内存分配与回收策略

Java技术体系中的自动内存管理主要为了解决两个问题:对象内存分配以及回收。前面一堆都是理论,实践才是检验真理唯一的标准,因此通过实战来验证收集器的内存分配规则,巩固知识体系。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(新生代垃圾回收)

-Xms:最小值

-Xmx:最大值

-Xmn:新生代大小

Allocation Failure:表示年轻代中无足够空间存储新数据

image-20220105141459088


因为限制堆内存为20M,而新生代和老年代各占10M,前面3个对象分配占去Eden区6M,剩下Eden区与一个Survivor区(即From)已经不足以分配allocation4的4M内存,所以分配allocation4的时候会发生MinorGC。垃圾回收期间原有的6M对象无法全部放入Survivor区(即To)中,所以通过分配担保机制进入老年代中。最终,4M大小的allocation4成功进入Eden区中(日志中PSYoungGen used 5457K),而另外三个2M的对象进入老年代中(日志中ParOldGen used 6224K),Survivor区空闲

image-20220107142620959

大对象直接进入老年代

大对象指的是需要连续内存空间的Java对象,Hotspot虚拟机提供了-XX:PretenureSizeThreshold参数来设定界限,当超过该值时直接进入老年代分配,减少Eden区与Survivor区之间复制带来的开销

注:该参数只支持Serial和ParNew两款新生代收集器

image-20220105150929288

长期存活对象进入老年代

对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor(From)容纳的话,该对象会被移动到Survivor(From)空间中,并且将其对象年龄设为1岁。对象在Survivor(From)区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置

public class Hello {
    private static final int _1MB = 1024 * 1024;
    /**
     * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        // 触发一次MinorGC
        allocation3 = new byte[4 * _1MB];
        // 无直接引用
        allocation3 = null;
        // 重新分配内存,再次触发MinorGC
        allocation3 = new byte[4 * _1MB];
    }
}

设置参数-XX:MaxTenuringThreshold=1

image-20220105165306862

分析日志内容,第一次触发MinorGc:

  • 本次GC新生代减少容量(包含存活对象转移和被回收的对象):4859K-404K=4455K
  • 堆区回收后减少容量(被回收的对象):4859K-4500K=359K
  • 新生代转移到老年代对象:4455K-359K=4096K

上述代码执行到allocation3 = new byte[4 * _1MB];时,因为Eden空间不足,由此触发MinorGC,然后由于allocation1,allocation2都是强引用不会被回收,只能进入Survivor空间中,但其空间只能够存下allocation1和一些系统对象,allocation2就通过分配担保机制进入老年代,至此,Eden空间足够分配给allocation3

image-20220105194111630

第二次触发MinorGC:

  • 本次GC新生代减少容量(包含存活对象转移和被回收的对象):4500K-0=4500K
  • 堆区回收后减少容量(被回收的对象):8596K-4500K=4096K
  • 新生代转移到老年代对象:4500K-4096K=404K

首先,allocation3 = null;无直接引用,等待下一次GC回收,但其依然会占据4M空间。接着,allocation3 = new byte[4 * _1MB];重新分配内存,4M+4M=8M(因为还有一些其它对象会占据空间),再次触发MinorGC,而因为参数-XX:MaxTenuringThreshold=1,所以原来失去直接引用的allocation3所占用的空间会被回收,而allocation1因达到年龄,晋升老年代

image-20220105194911079


设置参数-XX:MaxTenuringThreshold=15

整个流程和上述的差不多,主要是第二次MinorGC,Survivor(From)区中的对象(404K)会因为未达到年龄,而不会晋升到老年代,因此新生代依旧占用404K的空间

image-20220105202454677

动态对象年龄判断

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄

public class Hello {
    private static final int _1MB = 1024 * 1024;
    /**
     * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        // allocation1 + allocation2 + 其它对象 > survivor空间(1M)
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        // 第一次MinorGC
        allocation4 = new byte[4 * _1MB];
        // 无直接引用
        allocation4 = null;
        // 第二次MinorGC
        allocation4 = new byte[4 * _1MB];
    }
}

设置参数-XX:MaxTenuringThreshold=15,图解分析一下:

  • 第一次MinorGC:

    • 本次GC新生代减少容量(包含存活对象转移和被回收的对象):5115K-660K=4855K
    • 堆区回收后减少容量(被回收的对象):5115K-4756K=359K
    • 新生代转移到老年代对象:4855K-359K=4496K

    image-20220107104959421

  • 第二次MinorGC:

    • 本次GC新生代减少容量(包含存活对象转移和被回收的对象):4756K-0K=4756K
    • 堆区回收后减少容量(被回收的对象):8852K-4756K=4096K
    • 新生代转移到老年代对象:4756K-4096K=660K

    image-20220107104907315

最终运行结果:

image-20220105202549474

空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC

注:所谓冒险,指的是赌概率,赌的是与老年代历史平均值之间的大小比较,若担保失败,依然无法避免Full GC

注:JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC

image-20220107140517420

public class Hello {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation1 = null;
        // 第一次触发MinorGC
        allocation4 = new byte[2 * _1MB];
        allocation5 = new byte[2 * _1MB];
        allocation6 = new byte[2 * _1MB];
        allocation4 = null;
        allocation5 = null;
        allocation6 = null;
        allocation7 = new byte[2 * _1MB];
    }
}

image-20220107141551583


评论
  目录