实践更深刻, 来实操一下对象大小和类型对 G1 GC 的实际影响

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

首先分享之前的所有文章 >>>> 😜😜😜
文章合集 : 🎁 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天时间。

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

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

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

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

相关推荐
努力的小郑8 分钟前
Canal 不难,难的是用好:从接入到治理
后端·mysql·性能优化
赫瑞27 分钟前
数据结构中的排列组合 —— Java实现
java·开发语言·数据结构
Victor3561 小时前
MongoDB(87)如何使用GridFS?
后端
Victor3561 小时前
MongoDB(88)如何进行数据迁移?
后端
小红的布丁1 小时前
单线程 Redis 的高性能之道
redis·后端
GetcharZp1 小时前
Go 语言只能写后端?这款 2D 游戏引擎刷新你的认知!
后端
周末也要写八哥2 小时前
多进程和多线程的特点和区别
java·开发语言·jvm
惜茶2 小时前
vue+SpringBoot(前后端交互)
java·vue.js·spring boot
宁瑶琴3 小时前
COBOL语言的云计算
开发语言·后端·golang
杰克尼3 小时前
springCloud_day07(MQ高级)
java·spring·spring cloud