JVM源码级别分析G1发生FullGC元凶的是什么

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 线上系统遭遇频繁Old GC问题,监控显示出现多次“to-space exhausted”日志,这表明垃圾回收过程中因年轻代 Survivor 区或老年代空间不足导致对象晋升失败。通过 JVM 源码分析,此问题源于对象转移至老年代失败时,JVM 无法找到足够的空间存放存活对象。进一步排查发现大对象分配占用了预留空间,加剧了空间不足的情况。使用 JFR 分析工具定位到定期报表序列化导致大量大对象生成,通过改用堆外内存进行序列化输出,最终解决了频繁 Old GC 问题。

问题描述

线上发生GC问题, 有不少OldGC产生, 监控截图如下

收到告警后, 立即上机器排查了, 具体排查过程如下, 还是收获颇丰的.

问题排查

我首先分析了当时的GC日志, 发现在日志中多次出现"to-space exhausted", 并且出现该日志的GC通常耗时非常高, 相关日志如下:

js

代码解读

复制代码

2023-11-16T19:04:02.992+0800: 459907.332: [SoftReference, 8 refs, 0.0000699 secs]2023-11-16T19:04:02.992+0800: 459907.332: [WeakReference, 3 refs, 0.0000076 secs]2023-11-16T19:04:02.992+0800: 459907.332: [FinalReference, 7371 refs, 0.0139270 secs]2023-11-16T19:04:03.006+0800: 459907.346: [PhantomReference, 0 refs, 6 refs, 0.0000206 secs]2023-11-16T19:04:03.006+0800: 459907.346: [JNI Weak Reference, 0.0000448 secs] 459908.553: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 20669530112 bytes, allocation request: 0 bytes, threshold: 7730941110 bytes (30.00 %), source: end of GC]
 459908.553: [G1Ergonomics (Mixed GCs) start mixed GCs, reason: candidate old regions available, candidate old regions: 111 regions, reclaimable: 2193274488 bytes (8.51 %), threshold: 5.00 %]
 (to-space exhausted), 2.9358876 secs]
   [Parallel Time: 1714.2 ms, GC Workers: 13]
      [GC Worker Start (ms): Min: 459905617.7, Avg: 459905617.9, Max: 459905618.1, Diff: 0.4]
      [Ext Root Scanning (ms): Min: 2.2, Avg: 2.8, Max: 8.3, Diff: 6.1, Sum: 36.0]
      [Update RS (ms): Min: 162.9, Avg: 168.4, Max: 169.1, Diff: 6.2, Sum: 2188.8]
         [Processed Buffers: Min: 413, Avg: 439.8, Max: 463, Diff: 50, Sum: 5717]
      [Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 1.5]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      [Object Copy (ms): Min: 1542.1, Avg: 1542.2, Max: 1542.4, Diff: 0.3, Sum: 20048.5]
      [Termination (ms): Min: 0.0, Avg: 0.2, Max: 0.3, Diff: 0.3, Sum: 2.4]
         [Termination Attempts: Min: 1, Avg: 3.3, Max: 19, Diff: 18, Sum: 43]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.3]
      [GC Worker Total (ms): Min: 1713.5, Avg: 1713.7, Max: 1713.8, Diff: 0.4, Sum: 22277.6]
      [GC Worker End (ms): Min: 459907331.5, Avg: 459907331.5, Max: 459907331.6, Diff: 0.1]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]

所谓to-space exhausted是什么? 为什么它导致FGC呢?

查阅网上资料, 发现都讲得特别浅, 不如和我一起从JVM源码看起, 一探究竟.

to-space 耗尽问题排查

上面讲到to-space exhausted, 我们来看下JVM在什么情况下会打出上述日志.

在JVM代码中查找, 发现在G1CollectedHeap::do_collection_pause_at_safepoint中有如下代码:

c++

代码解读

复制代码

// Print the remainder of the GC log output.
if (evacuation_failed()) {
  log_info(gc)("To-space exhausted");
}

可以看到当evacuation_failed()返回true时会打印"To-space exhausted", 那么evacuation_failed()在什么时候返回true呢?

通过对代码的追踪, 发现了这条调用链:

G1CollectedHeap::preserve_mark_during_evac_failure函数中会设置_evacuation_failedtrue:

c++

代码解读

复制代码

void G1CollectedHeap::preserve_mark_during_evac_failure(uint worker_id, oop obj, markOop m) {
  if (!_evacuation_failed) {
    _evacuation_failed = true;
  }

  _evacuation_failed_info_array[worker_id].register_copy_failure(obj->size());
  _preserved_marks_set.get(worker_id)->push_if_necessary(obj, m);
}

接着找,在G1ParScanThreadState::handle_evacuation_failure_par中会调用preserve_mark_during_evac_failure:

c++

代码解读

复制代码

oop G1ParScanThreadState::handle_evacuation_failure_par(oop old, markOop m) {
  assert(_g1h->is_in_cset(old), "Object " PTR_FORMAT " should be in the CSet", p2i(old));
  // 指向自己
  oop forward_ptr = old->forward_to_atomic(old, m, memory_order_relaxed);
  if (forward_ptr == NULL) {
    // Forward-to-self succeeded. We are the "owner" of the object.
    HeapRegion* r = _g1h->heap_region_containing(old);

    if (!r->evacuation_failed()) {
      r->set_evacuation_failed(true);
     _g1h->hr_printer()->evac_failure(r);
    }
    // 保存旧的markword
    _g1h->preserve_mark_during_evac_failure(_worker_id, old, m);

    G1ScanInYoungSetter x(&_scanner, r->is_young());
    // 扫描转移失败对象的field,将它们push到pss的队列
    old->oop_iterate_backwards(&_scanner);

    return old;
  } else {
    return forward_ptr;
  }
}

handle_evacuation_failure_par函数会在G1的转移阶段,将存活对象转移到Old区失败时调用:

c++

代码解读

复制代码

oop G1ParScanThreadState::copy_to_survivor_space(InCSetState const state,
                                                 oop const old,
                                                 markOop const old_mark) {
  const size_t word_sz = old->size();
  HeapRegion* const from_region = _g1h->heap_region_containing(old);
  // +1 to make the -1 indexes valid...
  const int young_index = from_region->young_index_in_cset()+1;

  uint age = 0;
  InCSetState dest_state = next_state(state, old_mark, age);
  if (_old_gen_is_full && dest_state.is_old()) {
    // 第一种情况
    return handle_evacuation_failure_par(old, old_mark);
  }
  HeapWord* obj_ptr = _plab_allocator->plab_allocate(dest_state, word_sz);

  if (obj_ptr == NULL) {
    bool plab_refill_failed = false;
    obj_ptr = _plab_allocator->allocate_direct_or_new_plab(dest_state, word_sz, &plab_refill_failed);
    if (obj_ptr == NULL) {
      obj_ptr = allocate_in_next_plab(state, &dest_state, word_sz, plab_refill_failed);
      if (obj_ptr == NULL) {
        // 第二种情况
        return handle_evacuation_failure_par(old, old_mark);
      }
    }
  }

这是G1在转移阶段通过遍历每个对象将其转移到Survivor区的代码,可以看到如果:

  1. 对象本身在Survivor区且年龄超过晋升阈值,但是Old区满了。
  2. 或者Survivor区和Old区都没有空闲空间。

那么这个对象转移失败,在handle_evacuation_failure_par中会调用preserve_mark_during_evac_failure设置_evacuation_failed标志位为true。所以如果GC日志中出现to-space exhausted字样,我们就需要注意了,这时我们的堆空间没有足够的地方存放这些存活对象导致晋升失败(promotion failure)!

那么该怎么解决呢? 通过查阅网上资源,大部分的建议都是调整G1ReservePercent这个参数,这个参数的含义在官方文档的解释是:

Sets the percentage of reserve memory to keep free so as to reduce the risk of to-space overflows. The default is 10 percent. When you increase or decrease the percentage, make sure to adjust the total Java heap by the same amount.

大意是在JVM中预留的一部分空间用于降低to-space溢出的风险。

JVM代码里的解释是:

It determines the minimum reserve we should have in the heap to minimize the probability of promotion failure.

大意是通过设置最小预留空间来降低晋升失败的概率。

但是你可能还是一头雾水,这个所谓最小预留空间到底是如何保证晋升成功的? 这里我们还是需要代码来说话。接下来我们来分析JVM源码搞清楚G1ReservePercent的原理是什么,增加它会给JVM带来什么影响,以及它能解决我们上面的问题吗?

G1ReservePercent参数的作用及原理

G1Policy::record_new_heap_size中通过G1ReservePercent(即_reserve_factor)计算出需要保留的region数量:_reserve_regions

c++

代码解读

复制代码

void G1Policy::record_new_heap_size(uint new_number_of_regions) {
  // re-calculate the necessary reserve
  double reserve_regions_d = (double) new_number_of_regions * _reserve_factor;
  // We use ceiling so that if reserve_regions_d is > 0.0 (but
  // smaller than 1.0) we'll get 1.
  _reserve_regions = (uint) ceil(reserve_regions_d);

G1Policy::young_list_target_lengths函数中,使用_reserve_regions计算Young区最大regions数量,这里使用Young区全部空闲的region数量减去保留的region数量,得到一个Young区的最大值:absolute_max_length

c++

代码解读

复制代码

if (_free_regions_at_end_of_collection > _reserve_regions) {
  absolute_max_length = _free_regions_at_end_of_collection - _reserve_regions;
}

那么absolute_max_length这个值在什么地方起作用呢?

答案是在分配新Region的时候:

c++

代码解读

复制代码

HeapRegion* G1CollectedHeap::new_mutator_alloc_region(size_t word_size,
                                                      bool force) {
  assert_heap_locked_or_at_safepoint(true /* should_be_vm_thread */);
  bool should_allocate = g1_policy()->should_allocate_mutator_region(); // 当前young区region数量是否小于_young_list_target_length
  if (force || should_allocate) {
    HeapRegion* new_alloc_region = new_region(word_size,
                                              HeapRegionType::Eden,
                                              false /* do_expand */);
    if (new_alloc_region != NULL) {
      set_region_short_lived_locked(new_alloc_region);
      _hr_printer.alloc(new_alloc_region, !should_allocate);
      _verifier->check_bitmaps("Mutator Region Allocation", new_alloc_region);
      _g1_policy->remset_tracker()->update_at_allocate(new_alloc_region);
      return new_alloc_region;
    }
  }
  return NULL;
}

在决定是否可以分配的时候调用了g1_policy()->should_allocate_mutator_region()函数,接下来看这个函数是如何实现的:

c++

代码解读

复制代码

bool G1Policy::should_allocate_mutator_region() const {
  uint young_list_length = _g1h->young_regions_count();
  uint young_list_target_length = _young_list_target_length;
  return young_list_length < young_list_target_length;
}

可以看到这里使用当前young区region数量大小是否小于_young_list_target_length,而_young_list_target_length这个变量就是在:_free_regions_at_end_of_collection - _reserve_regions;这一步计算出来的。

由此可见,JVM通过G1ReservePercent参数确实预留出了一部分空间用于晋升,但增加这个配置意味着可用于对象分配的区域变少了, 所以往往在增加这个配置的同时也要增加堆大小.

但这个配置默认就是10%,我们线上Heap大小为24G,总共预留出了2.4G,但为什么还会出现to-space exhausted问题吗?难道是对象超过2.4G?

其实G1ReservePercent这个参数并不是在所有情况下都有效, 比如其在分配大对象时是不起作用的。(大对象是指超过region大小一半的对象),下面来看怎么回事

大对象分配

首先看大对象分配的代码,调用链:

G1CollectedHeap::attempt_allocation_humongous -> G1CollectedHeap::humongous_obj_allocate -> G1CollectedHeap::new_region, 在这个调用链上是不存在类似should_allocate_mutator_region的函数判断的,也就是说大对象分配的空间会占用reserve region的空间!

相关代码:

c++

代码解读

复制代码

HeapWord* G1CollectedHeap::attempt_allocation_humongous(size_t word_size) {
  ResourceMark rm; // For retrieving the thread names in log messages.
  if (g1_policy()->need_to_start_conc_mark("concurrent humongous allocation",
                                         word_size)) {
  collect(GCCause::_g1_humongous_allocation);
}

result = humongous_obj_allocate(word_size);


HeapWord* G1CollectedHeap::humongous_obj_allocate(size_t word_size) {
  assert_heap_locked_or_at_safepoint(true /* should_be_vm_thread */);

  _verifier->verify_region_sets_optional();

  uint first = G1_NO_HRM_INDEX;
  uint obj_regions = (uint) humongous_obj_size_in_regions(word_size);

  if (obj_regions == 1) {
    // Only one region to allocate, try to use a fast path by directly allocating
    // from the free lists. Do not try to expand here, we will potentially do that
    // later.
    HeapRegion* hr = new_region(word_size, HeapRegionType::Humongous, false /* do_expand */);
    
HeapRegion* G1CollectedHeap::new_region(size_t word_size, HeapRegionType type, bool do_expand) {
  assert(!is_humongous(word_size) || word_size <= HeapRegion::GrainWords,
         "the only time we use this to allocate a humongous region is "
         "when we are allocating a single humongous region");

  HeapRegion* res = _hrm->allocate_free_region(type);

而在我们的GC日志里也发现了大对象的踪影:

js

代码解读

复制代码

2023-11-16T19:03:47.107+0800: 459891.447: Application time: 5.0003976 seconds
 459891.490: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 7683964928 bytes, allocation request: 47852800 bytes, threshold: 7660476810 bytes (30.00 %), source: concurrent humongous allocation]
 459891.499: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 7751073792 bytes, allocation request: 71779192 bytes, threshold: 7660476810 bytes (30.00 %), source: concurrent humongous allocation]
 459891.518: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 7851737088 bytes, allocation request: 23926408 bytes, threshold: 7660476810 bytes (30.00 %), source: concurrent humongous allocation]
 459892.274: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 7885291520 bytes, allocation request: 56455480 bytes, threshold: 7660476810 bytes (30.00 %), source: concurrent humongous allocation]
 459892.327: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 7952400384 bytes, allocation request: 362587112 bytes, threshold: 7660476810 bytes (30.00 %), source: concurrent humongous allocation]
 459893.207: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 8321499136 bytes, allocation request: 24613616 bytes, threshold: 7660476810 bytes (30.00 %), source: concurrent humongous allocation]
2023-11-16T19:03:50.110+0800: 459894.451: Application time: 3.0002494 seconds
2023-11-16T19:03:50.207+0800: 459894.547: Application time: 0.0937297 seconds
2023-11-16T19:03:50.225+0800: 459894.566: Application time: 0.0153778 seconds
2023-11-16T19:03:50.231+0800: 459894.572: Application time: 0.0031969 seconds
 459894.583: [G1Ergonomics (Concurrent Cycles) do not request concurrent cycle initiation, reason: still doing mixed collections, occupancy: 8355053568 bytes, allocation request: 16778256 bytes, threshold: 7660476810 bytes (30.00 %), source: concurrent humongous allocation]

合理猜想:由于大对象分配导致reserve regions的空间被占用,对象晋升到Old区但没有足够空间导致to-space exhausted。

验证猜想 & 问题解决

既然知道大对象是可能的原因,我们就来看如何解决。首先我们通过JFR查看大对象分配事件:

修改JFR配置文件:

xml

代码解读

复制代码

  ...
  <event name="jdk.ObjectAllocationOutsideTLAB">
    <setting name="enabled">true</setting>
    <setting name="stackTrace">true</setting>
  </event>
  ...

开启采集TLAB外分配对象的事件(大对象分配时不会在线程的TLAB里分配,而是会在TLAB外分配),然后通过:

shell

代码解读

复制代码

jcmd $(pgrep java) JFR.start duration=600s filename=/data/applogs/cat/logs/flight.jfr settings=/usr/java/default/lib/jfr/all_tlab_17.jfc

filename是最终输出的jfr文件路径,开启JFR(注意JVM参数需要加上:-XX:+UnlockCommercialFeatures),最终得到文件,可以通过Java Mission Control打开:

通过这张图可以看到在问题发生的时刻确实发生了大量的TLAB分配,其中第一个线程TLAB外分配量最高,我们接着分析下面的火焰图,找到对应的方法继续分析:

通过分析代码发现是由于我们定期生成的报表序列化在JVM内存中导致的,这里我们通过使用堆外内存作为序列化的输出减少了JVM大对象的分配,最终解决了这个问题:

优化后:

可以看到OldGC全部消失.

总结

本篇文章从一个线上GC故障开始,从JVM源码级别分析了to-space问题的产生原因,和G1ReservePercent参数的作用,最后我们通过JFR分析出了问题的根因并通过堆外内存最终解决了这个问题。


本文转载自:https://juejin.cn/post/7361684907809554442

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
3月前
|
存储 算法 Oracle
极致八股文之JVM垃圾回收器G1&ZGC详解
本文作者分享了一些垃圾回收器的执行过程,希望给大家参考。
|
1月前
|
小程序 Oracle Java
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
这篇文章是关于JVM基础知识的介绍,包括JVM的跨平台和跨语言特性、Class文件格式的详细解析,以及如何使用javap和jclasslib工具来分析Class文件。
41 0
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
|
5月前
|
缓存 Java
《JVM由浅入深学习九】 2024-01-15》JVM由简入深学习提升分(生产项目内存飙升分析)
《JVM由浅入深学习九】 2024-01-15》JVM由简入深学习提升分(生产项目内存飙升分析)
50 0
|
30天前
|
Java
JVM进阶调优系列(5)CMS回收器通俗演义一文讲透FullGC
本文介绍了JVM中CMS垃圾回收器对Full GC的优化,包括Stop the world的影响、Full GC触发条件、GC过程的四个阶段(初始标记、并发标记、重新标记、并发清理)及并发清理期间的Concurrent mode failure处理,并简述了GC roots的概念及其在GC中的作用。
|
1月前
|
存储 Java PHP
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
【JVM】垃圾回收机制(GC)之引用计数和可达性分析
56 0
|
4月前
|
存储 算法 安全
(八)JVM成神路之GC分区篇:G1、ZGC、ShenandoahGC高性能收集器深入剖析
在《GC分代篇》中,我们曾对JVM中的分代GC收集器进行了全面阐述,而在本章中重点则是对JDK后续新版本中研发推出的高性能收集器进行深入剖析。
157 12
|
4月前
|
算法 安全 Java
(七)JVM成神路之GC分代篇:分代GC器、CMS收集器及YoungGC、FullGC日志剖析
在《GC基础篇》中曾谈到过分代以及分区回收的概念,但基础篇更多的是建立在GC的一些算法理论上进行高谈阔论,而本篇则重点会对于分代收集器的实现进行全面详解,其中会涵盖串行收集器、并行收集器、三色标记、SATB算法、GC执行过程、并发标记、CMS收集器等知识,本篇则偏重于分析GC机制的落地实现,也就是垃圾收集器(Garbage Collector)。
110 8
|
4月前
|
Java
jmap 查看jvm内存大小并进行dump文件内存分析
jmap 查看jvm内存大小并进行dump文件内存分析
92 3
|
4月前
|
运维 监控 Java
(十)JVM成神路之线上故障排查、性能监控工具分析及各线上问题排错实战
经过前述九章的JVM知识学习后,咱们对于JVM的整体知识体系已经有了全面的认知。但前面的章节中,更多的是停留在理论上进行阐述,而本章节中则更多的会分析JVM的实战操作。
107 1
|
4月前
|
Arthas 监控 Java
JVM内存问题之使用gperftools分析JNI Memory泄漏的具体步骤是什么
JVM内存问题之使用gperftools分析JNI Memory泄漏的具体步骤是什么
104 2