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

问题描述

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

1714057261729.jpg

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

问题排查

我首先分析了当时的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打开:

1714058012254.jpg

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

1714058030855.jpg

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

优化后:

1714058047220.jpg

可以看到OldGC全部消失.

总结

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值