JDK11中zgc垃圾回收器的探索

背景

垃圾回收器主要做的事情

  1. 自动跟踪和管理程序中创建的对象,确定哪些对象仍在使用,哪些对象已经不再使用。
  2. 回收那些不再使用的对象所占用的内存空间,使得这部分内存可以被重新使用。

1.1 传统垃圾回收器

垃圾回收器 简述 优缺点 应用场景 备注
Serial GC 这是最基本、最古老的GC,它使用单线程进行垃圾回收,不能进行并行处理。因此,当它在进行垃圾回收时,用户线程必须暂停,等待垃圾回收完成。这种方式称为"Stop-The-World"。 优点:简单高效,对于限制了CPU资源的环境,能提供很高的单线程垃圾回收效率。 适用于单核处理器环境,或者小型应用。

Parallel GC

也称为吞吐量收集器,它是Serial GC的多线程版本。它在垃圾回收时也会暂停用户线程,但由于使用了多线程,所以垃圾回收的速度更快。

优点:多线程并行垃圾回收,提高了垃圾回收的效率。

缺点:在垃圾回收过程中,所有CPU资源都会被用于垃圾回收,可能导致应用程序的性能下降

适用于多核处理器环境,以及对吞吐量要求较高的大型应用。
CMS(Concurrent Mark Sweep) GC

这是一种以获取最短回收停顿时间为目标的收集器。它大部分工作都可以和用户线程并发执行,只有在初始标记和重新标记阶段需要"Stop-The-World"。

  • 优点:大部分工作都可以和用户线程并发执行,减少了"Stop-The-World"的时间,提高了应用的响应性。

  • 缺点:由于需要和用户线程并发执行,所以对CPU资源的消耗较大。另外,CMS GC无法处理浮动垃圾,可能会导致内存碎片

适用于对系统响应时间要求较高的应用。

G1 GC:

全称"Garbage-First",是一种面向服务器的垃圾收集器,主要用于多核处理器和大内存环境。G1 GC通过划分多个小块区域的方式,尽可能地减少"Stop-The-World"的时间。

  • 优点:可以预测停顿时间,实现了可控制的垃圾回收。通过划分多个小块区域的方式,尽可能地减少"Stop-The-World"的时间。

  • 缺点:在某些情况下,G1 GC的垃圾回收效率可能不如CMS GC(堆内存较小、对象存活率较高)。另外,G1 GC需要更多的CPU资源来保证高吞吐量。

使用场景:适用于多核处理器和大内存环境,以及对系统停顿时间有严格要求的大型应用。

1.2 传统垃圾回收器痛点

GC停顿

停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。

CMS以及G1的GC停顿

CMS新生代的Young GC、G1Young GC以及混合回收的和ZGC都基于标记-复制算法

Yong GC和G1标记复制算法的实现

标记阶段,即从GC Roots集合开始,标记活跃对象;

转移阶段,即把活跃对象复制到新的内存地址上;

重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。

停顿分析:

CMS以及G1的的yonug GC回收:

  1. 初始标记(Initial Mark):这是一个"Stop-The-World"(STW)阶段,即在这个阶段,所有的应用线程都会被暂停。垃圾回收器会标记出Eden区和一个Survivor区(假设是From)中所有存活的对象。
  2. 对存活对象进行复制:将所有存活的对象复制到另一个Survivor区(To)或者老年代(Old Generation)。同时,这些对象的年龄会加1。如果某个对象的年龄达到了一定的阈值(默认15),就会被晋升到老年代。
  3. 清空Eden区和From区:完成复制后,垃圾回收器会清空Eden区和From区。
  4. Survivor区交换:交换两个Survivor区的角色,即原来的To区变为From区,原来的From区变为To区。上述过程是STW的,触发频繁,耗时短;

标记阶段停顿分析

  • 初始标记阶段:初始标记阶段是指从根节点(GC Roots)出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。

  • 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。

  • 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。

清理阶段停顿分析

  • 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。

复制阶段停顿分析

  • 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。

暂停时间过长

2.ZGC原理

优点总结:

支持TB级别的堆;

不会随着堆的大小增加而增加

2.1 ZGC为什么这么快

ZGC采用标记-复制算法(也有说法说是根据页面的大小选择采用标记复制以及标记整理算法),不过ZGC对该算法做了重大改进:ZGC在标记、复制和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。

回收流程:

  1. 标记阶段:ZGC首先会标记出所有的存活对象。

  2. 复制阶段:ZGC会将所有存活的对象复制到新的内存区域。

  3. 重映射阶段:ZGC会更新所有指向被复制对象的引用。

  4. 清理阶段:ZGC会回收被复制对象所在的内存区域。

三个STW阶段:初始标记,再标记,初始转移。

在标记阶段用于处理并发标记中的漏标记

初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。(G1的转移阶段是完全STW的)

2.2 堆空间的分页模型

ZGC 的 Region可以具有如下图所示的大中下三类容量:

【1】小型 Region(Small Region):容量固定为2MB,用于放置小于 256KB的小对象。

【2】中型 Region(Medium Region):容量固定为 32MB,用于放置大于 256KB但是小于 4MB的对象。

【3】大型 Region(Large Region):容量不固定,可以动态变化,但必须为 2MB的整数倍,用于放置 4MB或以上的大对象。每个大型 Region中会存放一个大对象,这也预示着虽然名字叫“大型 Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB.大型 Region在ZGC的实现中是不会被重分配的。

2.3 转移过程中访问对象问题的解决

2.3.1 色指针和读屏障技术;

大致工作原理:应用线程访问到对象时会触发读屏障,如果发现对象被移动了,读屏障”会把读出来的指针更新到对象的新地址上;而判断对象是否被移动过:利用对象引用的地址,即着色指针。

  1. 着色指针:在ZGC中,每个对象指针都被“着色”。每个指针都包含了关于该对象的一些信息(并发标记,转移,重定位等)。这样,ZGC就可以在并发的过程中,通过检查指针的颜色来知道该对象的状态,从而决定是否需要进行某些操作,如标记或复制该对象。

    ZGC中低42位(第0 ~ 41位)用于描述真正的虚拟地址(这就是上面提到的应用程序可以使用的堆空间),接着的4位(第42 ~ 45位)用于描述元数据,其实就是大家所说的Color Pointers,还有1位(第46位)目前暂时没有使用,最高17位(第47~63位)固定为0(限于java11中 4TB的内存空间)

  2. 读屏障:读屏障是一种在读取对象指针时插入的检查操作。当应用线程试图读取一个对象指针时,读屏障会先检查该指针的颜色。如果指针指示该对象已经被移动,那么读屏障就会先将应用线程重定向到对象的新地址,然后再返回该指针。这样,就可以确保应用线程总是能看到正确的对象状态。

2.3.2 转移过程详解

  1. 准备阶段,所有对象都在小页面A中,指针为蓝色

  2. 初始标记,所有对象都在A中,GCroots可达的对象指针变为绿色

  3. 并发标记,业务线程和回收现场并发,标记其他对象,将其他使用对象的指针变为绿色

  4. 再标记;

    标记在并发标记中漏标的对象

  5. 并发转移准备

    选择清理哪些页面

  6. 初始转移,转移A对象(GC可达的),修改地址,改变指针颜色

  7. 并发转移

    转移BC,并在转发表中记录转移地址,

    此时业务指针从堆中取对象时,读屏障发现指针颜色为绿色,读屏障则会去读取转发表,返回新地址;

    并在业务线程中更新指针颜色以及实际地址;

  8. 此时第一次垃圾回收结束,这个时候GC root直达对象指针颜色为蓝色,其余所有存活对象指针颜色为绿色;

  9. 此时开始第二次垃圾回收

  10. 初始标记阶段

    将GCroots 可达对象标记为红色

  11. 并发标记阶段

    如果扫描到指针颜色为绿色的对象,则根据转发表找到新地址,将该对象变为红色,并在业务线程中的对象实地址,清除转发表数据,蓝色的对象同第一次回收阶段

总结:利用空间(虚拟空间)去换取时间

3. JDK 11中的ZGC的坑

Loading...

https://segmentfault.com/a/1190000023192220

简单梳理下就是:每次调用StackWalker遍历栈帧的时候,每个栈帧都会生成一个ResolvedMethodName对象放到jvm中的ResolvedMethodTable中,但jdk11的zgc不能有效清理其中不用的对象。因为ResolvedMethodTable是个定容的hashtable,随着其中的数据越来越多,每个bucket的单链表越来越长,查询效率会越来越慢。 所以最终导致CPU的使用率越来越高。

总结:由于没有讲logger写为static,所以logger频繁初始化调用StackWalker方法

jdk11+zgc+log4j+编码不规范

注:在jdk13中已修复

  1. 在zgc增加调用SymbolTable::unlink()方法

  2. ResolvedMethodTable的实现,支持了动态扩缩容,可以避免单链表过长的问题

    1. do_concurrent_work(JavaThread* jt)函数:这是处理并发工作的主要函数。首先,计算负载因子(load factor),这是一个衡量表的填充程度的指标。然后,根据负载因子的大小,决定是扩展方法表还是清理无用的条目。如果负载因子大于预设值(这里是2),并且方法表还没有达到最大大小,那么就调用grow(jt)函数来扩展方法表。否则,就调用clean_dead_entries(jt)函数来清理无用的条目。

    2. grow(JavaThread* jt)函数:这是用于扩展方法表的函数。首先,创建了一个GrowTask对象,这是一个用于扩展表的任务。然后,在一个循环中执行这个任务,直到任务完成。在执行任务的过程中,如果需要,它会暂停任务并将线程切换到VM状态,然后再继续执行任务。任务完成后,它会更新当前的表大小,并打印出新的大小。

void ResolvedMethodTable::do_concurrent_work(JavaThread* jt) {
  _has_work = false;
  double load_factor = get_load_factor();
  log_debug(membername, table)("Concurrent work, live factor: %g", load_factor);
  // 人工load_factor大于2,并且没有达到最大限制,就执行bucket扩容,并且移除无用的entry
  if (load_factor > PREF_AVG_LIST_LEN && !_local_table->is_max_size_reached()) {
    grow(jt);
  } else {
    clean_dead_entries(jt);
  }
}

void ResolvedMethodTable::grow(JavaThread* jt) {
  ResolvedMethodTableHash::GrowTask gt(_local_table);
  if (!gt.prepare(jt)) {
    return;
  }
  log_trace(membername, table)("Started to grow");
  {
    TraceTime timer("Grow", TRACETIME_LOG(Debug, membername, table, perf));
    while (gt.do_task(jt)) {
      gt.pause(jt);
      {
        ThreadBlockInVM tbivm(jt);
      }
      gt.cont(jt);
    }
  }
  gt.done(jt);
  _current_size = table_size();
  log_info(membername, table)("Grown to size:" SIZE_FORMAT, _current_size);
}

4 ZGC在之后JDK版本迭代中的改进

4.0 ZGC在JDK11下的缺陷

  1. ZGC时Java进程占用三倍内存问题:由于ZGC着色指针把内存空间映射了3个虚拟地址,使得TOP/PS等命令查看占用内存时看到Java进程占用内存过大。此问题不影响操作系统,但是会影响到监控运维工具。

    Tencent Kona JDK11无暂停内存管理ZGC生产实践

  2. 吞吐量低于G1 GC。一般来说,可能会下降5%-15%。对于堆越小,这个效应越明显,堆非常大的时候,比如100G,其他GC可能一次Major或Full GC要几十秒以上,但是对于ZGC不需要那么大暂停。这种细粒度的优化带来的副作用就是,把很多环节其他GC里的STW整体处理,拆碎了,放到了更大时间范围内里去跟业务线程并发执行,甚至会直接让业务线程帮忙做一些GC的操作,从而降低了业务线程的处理能力。

  3. 对象分配卡顿,除了ZGC的暂停阶段之外,还受到下面的一些因素的影响:Page Cache Flush问题影响分配速度:ZGC把堆分为不同大小的page(对应G1的Region)——small/medium/large page(不同大小的object分配到不同类型的page中),如果各种大小对象分配速度不稳定(比如medium大小的object突然变多,那么就需要把large/small page转换成medium page,比较耗时),JDK15 production-ready之后有所缓解;

  4. 由于ZGC采用colored pointer技术,因此不支持压缩指针,一定程度上影响小堆(32GB以下)的性能(JDK15后可以支持UseCompressedOops关闭时依然开启UseCompressedClassPointers)

4.1 JDK17中LTS版本的改进

  1. 并发类卸载:在JDK 15中,ZGC增加了并发类卸载的功能,这使得ZGC可以在垃圾收集过程中并发地卸载不再使用的类。

  2. 并发预处理:在JDK 14中,ZGC增加了并发预处理的功能,这可以减少垃圾收集的暂停时间。

  3. JFR事件:在JDK 14中,ZGC增加了对Java Flight Recorder (JFR)事件的支持,这使得开发者可以更好地监控和诊断ZGC的行为。

  4. 精简的锁:在JDK 15中,ZGC的内部锁机制被精简,这可以提高ZGC的性能。

  5. 支持更多的平台:在JDK 11中,ZGC只支持Linux/x64平台。在后续的版本中,ZGC增加了对更多平台的支持,包括macOS、Windows和Linux/Aarch64。

4.2 JDK21分代ZGC的不同

它在ZGC的基础上引入了分代概念。在分代ZGC中,堆被划分为多个代,通常包括新生代和老年代。新创建的对象首先放在新生代,当它们存活足够长的时间后,会被移动到老年代。

分代ZGC的主要优点是可以更有效地处理短生命周期的对象。由于大多数对象的生命周期都很短,所以通过在新生代中更频繁地进行垃圾收集,可以更快地回收这些对象,从而提高垃圾收集的效率。同时,由于老年代中的对象相对稳定,所以可以减少在老年代中的垃圾收集频率,从而减少了垃圾收集对应用的影响。

总的来说,分代ZGC和ZGC的主要区别在于是否使用了分代概念,以及如何处理不同生命周期的对象。

相关推荐

  1. Go语言垃圾回收

    2024-07-11 04:38:04       56 阅读
  2. JVM各种垃圾回收(GC)

    2024-07-11 04:38:04       41 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-11 04:38:04       70 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-11 04:38:04       74 阅读
  3. 在Django里面运行非项目文件

    2024-07-11 04:38:04       62 阅读
  4. Python语言-面向对象

    2024-07-11 04:38:04       72 阅读

热门阅读

  1. KKT条件

    2024-07-11 04:38:04       23 阅读
  2. vscode离线方式远程到没有网络的服务器上

    2024-07-11 04:38:04       19 阅读
  3. 第一节 SHELL脚本中的常用命令(1)

    2024-07-11 04:38:04       17 阅读
  4. 开发指南042-产生待办

    2024-07-11 04:38:04       23 阅读
  5. 理解c程序的翻译过程

    2024-07-11 04:38:04       22 阅读
  6. 目标检测之非极大值抑制——NMS

    2024-07-11 04:38:04       27 阅读