带你深刻体验不同对象下 G1 回收器的内存变化

带你深刻体验不同对象下 G1 回收器的内存变化

技术博客 admin 509 浏览

👈👈👈 欢迎点赞收藏关注哟

首先分享之前的所有文章 >>>> 😜😜😜
文章合集 : 🎁 juejin.cn/post/694164…
Github : 👉 github.com/black-ant
CASE 备份 : 👉 gitee.com/antblack/ca…

一. 前言

之前学习完了 G1 回收器的一些特性和回收流程 ,这些都是一些偏理论性的东西。这一篇就来通过修改各项配置来看一下 G1 回收器在实际使用中的效果怎么样?

  • 基础参数 : -XX:+UseG1GC -Xms512m -Xmx2048m

二. 代码思路

这里使用的是 grafana + prometheus 进行观测。 同时结合 JVM 的相关组件进行一个观测。

个人 Demo 没有那么大的流量 ,同时为了效果更加清晰,我会灵活使用 强引用/软引用/弱引用达到我们测试的效果

  • 强引用 : 和日常工作一样,日常我们申请的对象都是强引用。为了模拟对象不可达被回收的场景,这里只有一半的强引用对象会被放入数组
  • 软引用 : 当全局内存不足的时候,就会触发软引用的回收,释放内存
  • 弱引用一旦发生垃圾回收,弱引用就会被直接回收

后续我会灵活利用这3种对象,达到我们期望的效果,使得概念的理解更加纯粹。

生产环境可能场景会更复杂,使用的时候要灵活的思考。

java
复制代码
public void get001() { applicationContext.getBean(SessionController.class); // 创建一个存储软引用的列表 List<SoftReference<byte[]>> softReferenceList = new ArrayList<>(); List<WeakReference<byte[]>> weakReferenceList = new ArrayList<>(); ArrayList<byte[]> retainedObjects = new ArrayList<>(); Random random = new Random(); // 分配内存并使用软引用引用这些内存块 while (true) { int i = atomicInteger.addAndGet(1); log.info("内存分配完成:{}", i); if (i % 10 < 3) { byte[] largeArray = new byte[(i % 10) * 1024 * 64]; // 64K - 0.64M WeakReference<byte[]> weakReference = new WeakReference<>(largeArray); weakReferenceList.add(weakReference); try { Thread.sleep(500); } catch (InterruptedException e) { throw new RuntimeException(e); } } else if (i % 10 < 5) { // 以一定概率保留对象引用,防止它们被回收 byte[] largeArray = new byte[(i % 10) * 1024 * 64]; if (random.nextInt(10) < 5) { retainedObjects.add(largeArray); } } else { byte[] largeArray = new byte[(i % 10) * 1024 * 64]; SoftReference<byte[]> softReference = new SoftReference<>(largeArray); softReferenceList.add(softReference); try { Thread.sleep(500); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } // 当内存满了 ,自动进行垃圾回收。
  • G1 里面 Region 是 1M ,内存超过 50% 的 region 会直接纳入回收范围 ,让对象不要过大
    • 但是不同的场景我也会调整对象的大小,每个环节前面会特别标注

三. G1 性能指标

3.1 效果图如下

以上就是我们需要关注的核心指标 ,同时还顺带看一下 JMC 的核心数据。

3.2 我们应该关注哪些指标?

内存部分 :

  • G1 Eden Space : Eden 区分配的内存大小
  • G1 Survivor Space :Survivor 幸存者空间大小
  • G1 Old Gen : 老年代分配的空间大小

垃圾回收次数部分 :

  • end of major GC (Allocation Failure) - FullGC
    • 当 JVM 需要分配内存但没有足够的空间
  • end of minor GC (G1 Evacuation Pause) - YoungGC
    • 将存活对象从 Eden 区域移动到 Survivor 区域或老年代
  • end of minor GC (G1 Humongous Allocation) - YoungGC
    • 大对象分配触发的 GC (大对象分配频繁 / 内存碎片化)
  • end of minor GC (Metadata GC Threshold) - YoungGC
    • 元数据(如类和方法的元数据)达到一定阈值时触发的垃圾回收
    • 主要原因 : 类加载过多 / 元数据泄露 / Metaspace 分配过少

`这些变量的参数为 ops/s , 表示每秒执行的次数

垃圾回收时间部分 :

  • avg end of major GC (Allocation Failure)
    • 在因为内存分配失败而触发的全堆垃圾回收(Full GC)事件中,GC 暂停的平均时间
  • avg end of minor GC (G1 Evacuation Pause)
    • 在 G1 垃圾回收器的年轻代 GC(Evacuation Pause)事件中,GC 暂停的平均时间
  • avg end of minor GC (G1 Humongous Allocation)
    • 在由于大对象(Humongous Object)分配触发的年轻代 GC 事件中,GC 暂停的平均时间
  • avg end of minor GC (Metadata GC Threshold)
    • 在元数据(如类和方法)达到一定阈值时触发的年轻代 GC 事件中,GC 暂停的平均时间
  • max end of major GC (Allocation Failure) : 下面四个和上面同理,区别在于最大时间
  • max end of minor GC (G1 Evacuation Pause)
  • max end of minor GC (G1 Humongous Allocation)
  • max end of minor GC (Metadata GC Threshold)

四. 常见问题先分析

4.1 大对象 (10M)(只有软引用

  • 开局先来个纯粹的 ,大对象是直接到老年代,这个符合预期
  • Edenused 数据 不太正常
    • 其主要原因也是由于大对象的问题
    • 按照以往的分配规则 ,应该占三分之一才对,这里 明显是偏小了
  • Edencommitted 数据 不太正常
    • G1 回收器没有严格按照常规垃圾回收器的比例划分 ,而是基于当前状况做了动态调整
    • 年轻代的 Region 最小 5% , 最大不超过 60% , 这里应该没到 60% ,因为停顿时间要超
  • Survivor 区域就 相当不正常
    • 只有 1M 不到,基本上不经过处理直接就去了老年代
  • 老年代相对大了一点 , FullGC 和 YoungGC 差不多频繁 , 这个符合预期 ,没年轻代嘛

阶段总结 :

  • 这里出现这些问题一方面是软引用和大对象的特性导致,内存不足才会回收,导致大部分对象会升级到老年代
  • 其次是由于 G1 回收器没有任何配置,G1 偏向于智能处理 ,很容易出现奇怪的问题 ,所以线上还是要调优好哦

4.2 概念认知 :申请的内存和使用的内存

  • 前置知识点一 : 虽然我们为应用分配了2G的内存,但是一开始是不会直接给这么多内存的
    • 就如上图 , 黄色为申请的内存 , 绿色为实际使用的内存
    • init : JVM启动时从操作系统申请的初始内存 ,也就是 -Xms
    • used : 实际使用的内存,包括未被垃圾回收期回收的不可达对象占用的内存
    • committed : 系统为 JVM 保留的内存 ,它可以等于/大于 used , 可以小于 init
      • 如果在分配对象的时候 ,used 内存可能操作 committed 时,就会香 JVM 申请这个值
      • 但是如果该值无法再提高时,且后续出现无法分配 used 内存的时候 ,就会触发内存溢出
    • max : JVM能从操作系统申请的最大内存

4.3 形态变化 : 申请 committed 内存的时机

  • 可以明显看到 , 一开始内存 committed 是没有增长的 ,而在 老年代 used = committed 时 ,触发了 committed 的申请
  • 后续 committed 内存会逐步变多 ,直到 committed 内存无法再申请,此时只有 used 内存会增长

4.4 内存溢出 : 我们是怎么把内存干爆的

  • 现象一 :当最后达到某个临界点后 ,老年代 used 区域已经没有增长了 ,此时观测应用可以发现内存溢出
  • 现象二 :每次 FullGC 后 ,有一批 Region 又被划归到 Eden 区域 ,虽然这批空间并没有被使用
    • 新生代 / 老年代空间非线性变化
  • 现象三 :虽然 FullGC 后区域被划给 Eden ,但是随着老年代可用空间减少 ,Eden 区的最终空间越来越少
  • 现象四 :committed 并没有被完全使用 ,老年代不够了不会完全挤压新生代(最小5%) ,当达到某个阈值的时候,系统就自然崩了

❓ 这里我其实一直不理解 ,为什么 used 还没有把 Committed 干满,为啥就溢出了 ?

其实想一想大概能推测出来 :

  • G1 里面Eden 最小 5% , 拿走了100M , 这些没办法再压缩了
  • 老年代自己用了 1.5G ,加上各种类,元数据 ,拿走了一部分 , 总共剩余 300M
  • 再扣除碎片 ,大对象占用的一些废空间 ,实际剩余可用的空间可能就1-200M
  • 重点 : 垃圾回收是要预留一部分空间去做移动和复制的 ,剩余的空间可能根本不够这些操作。 加上没有连续的内存区域 ,就可能直接触发内存溢出
  • 👉👉👉 仅推测 ,有其他的看法欢迎交流,这里我到现在都不太肯定

五. 实操效果 - 来了来了 ,他们来了

4.1 第一次尝试 : 修改引用类型

  • 新的案例中,我将一半的对象转换成 弱引用弱引用的对象在垃圾回收时就会被处理

  • 当我修改了弱引用后 ,由于弱引用在垃圾回收时就会立即被处理 ,所以明显走到老年代的对象平缓了
  • 还是一样的原因,由于弱引用直接被收集了 ,所以能到 Survivor 区的对象也几乎没有
  • FullGC 的频率更多了 ,相对的效果就是 FullGC 的时间明显变少
  • G1 Humongous Allocation 大对象回收变得更加频繁了 (原因未知❗❗)
  • 总结 :大对象对于GC的影响是很大的,由于几乎不受 YGC 的影响 ,使得GC变得不可控

4.2 第二次尝试 : 修改对象大小

  • 上一轮中由于对象被设置得太大(10M) ,所以对象直接走了大对象回收而不是正常回收,所以继续优化 ,让每个对象大小不一样 👉

这里就可以着重关注下回收频率了,看是否和我们预期一样,大对象回收变少

java
复制代码
byte[] largeArray = new byte[(i % 10) * 1024 * 512];

  • 老年代成长的曲线更流畅了 ,回收频率也变得更加正常
    • 因为之前基本上都是大对象,直接入了老年代
    • 现在不会突然有大对象干满老年代内存,小对象停顿时间更加可控,回收自然更加流畅
  • 年轻代回收的频率明显变高了 (因为小对象变多了,可以在年轻代被回收)
  • 但是由于还有弱引用,且对象大小大部分还是超过了1M ,所以效果还是不明显
  • 出于引用类型的特性 ,Survivor 区还是个空~~

4.3 第三次尝试 : 让对象更加微小

在这个环节,我们会少量加入强引用对象 ,同时移除弱引用的影响 ,保留软引用(避免直接内存溢出).

同时调整对象大小 ,由64K 到 0.5M 不等,且强引用都是小对象 ,来 ,继续 :

java
复制代码
byte[] largeArray = new byte[(i % 10) * 1024 * 64];

这里信息简直太多了,我们一个个来分析

S1 : 年轻代充实起来了

  • 可以看到 ,相比之前的尝试 ,Eden 区 和 Survivor 区明显更加充实了 ,比例也是健康的 8:1:1
  • 由于对象更加可控,所以老年代 committed 和 used 更加接近,内存利用率最大化

S2 : 总内存在阶梯式申请 ,形态开始发生变化

  • 每到 committed 内存不足时,就会阶梯式的申请一段内存
  • 随着老年代内存的增长 ,新生代的内存空间也在动态变化
  • 同时基于目标暂停时间, 相关的参数也会变化(下一期讨论) ,所以这里的回收时间每次变化的时候都很长

但是这个环节对象大小太小了,而且强引用仅占十分之一,所以一直每触发 FullGC.

各种类型和大小的对象对 GC 的影响都分析过了,下面来看一版贴近生产的

4.4 最终版本 ,贴近生产

  • 这一版停顿时间为 200ms ,强引用的数量会较多 ,同样加入了弱引用模拟用完的对象

  • 由于调低了停顿时间, 所以年轻代回收的频率也有所变高
  • 其他的和预期的线上环节区别不大 ,老年代挤压新生代空间
  • 新生代虽然每次 FullGC 后都有大量的空间,但是时间的要求 ,每次还没变大就被回收了

4.5 最终会演变成什么 ?

可能有人会问 ,为什么一开始没有使用这种方式 ,其实原因很简单 :内存会泄露, 因为老年代会越来越多,而且无法清除。当所有的软引用都被清除后 ,剩下的就是无法清除的对象了

我们把上文代码里面的取模调大点,让大部分对象都是强引用,就会触发如下效果 :

4.6 阶段总结

在不优化配置的情况下 ,对象的类型和大小直接影响了垃圾回收的效果。

所以一般情况下 ,不要在系统里面使用大对象,对垃圾回收的负荷是很大的。

另外要格外的注意 ,为了让这个过程变得更加可控,上文我使用的只有软引用和弱引用。他们俩一个碰到回收就被收走了,一个是空间没了才会被收走。这种其实不符合生产的业务场景,所以对代码做一些改进 :

总结

本来想一起做的配置影响这一篇是上不了了, 虽然看起来内容不是很多 ,但是实打实的花了2天时间。

一个是跑数据不能太快,不然不好去分析,再一个遇到问题查资料也很花时间。

注意 : 这些数据都是没有进行调优时得到的结果 , 目的是为了现象更纯粹,所以有些点不能按照生产的结果直接往上面套 ,注意灵活思考

还有一些问题不确定, 后续会继续深入,如果结论有意思,还会单独发出来。

大家有看法也可以提出宝贵的意见,想不通,真的想不通。

源文:带你深刻体验不同对象下 G1 回收器的内存变化

如有侵权请联系站点删除!

技术合作服务热线,欢迎来电咨询!