【八股消消乐】你在项目中如何优化垃圾回收机制?
😊你好,我是小航,一个正在变秃、变强的文艺倾年。
🔔本专栏《八股消消乐》旨在记录个人所背的八股文,包括Java/Go开发、Vue开发、系统架构、大模型开发、机器学习、深度学习、力扣算法
等相关知识点,期待与你一同探索、学习、进步,一起卷起来叭!
目录
- 题目
- 答案
- 垃圾回收机制
- GC算法
- GC
- GC 性能衡量指标
- 排查分析
- GC调优
题目
💬技术栈:JVM
🔍简历内容:熟悉垃圾回收机制,GC及GC算法,能够独立排查分析GC性能并调优。
🚩面试问:你在项目中如何进行优化垃圾回收机制?
💡建议暂停思考10s,你有答案了嘛?如果你有不同题解,欢迎评论区留言、打卡。
答案
垃圾回收机制
(1)回收发生在哪里?
JVM 的内存区域中,程序计数器、虚拟机栈和本地方法栈
这 3 个区域是线程私有
的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知
的,因此这三个区域的内存分配和回收都具有确定性
。
所以,垃圾回收的重点就是关注堆和方法区中的内存
了,堆中的回收主要是对象的回收
,方法区的回收主要是废弃常量和无用的类
的回收。
(2)对象在什么时候可以被回收?
一般一个对象不再被引用
,就代表该对象可以被回收。
判断该对象是否可以被回收的算法:
-
引用计数算法:这种算法是
通过一个对象的引用计数器
来判断该对象是否被引用了。每当对象被引用,引用计数器就会加 1;每当引用失效,计数器就会减 1。当对象的引用计数器的值为 0 时,就说明该对象不再被引用,可以被回收了。这里强调一点,虽然引用计数算法的实现简单,判断效率也很高,但它存在着对象之间相互循环引用
的问题。 -
可达性分析算法:GC Roots 是该算法的基础,
GC Roots 是所有对象的根对象
,在 JVM 加载时,会创建一些普通对象引用正常对象。这些对象作为正常对象的起始点,在垃圾回收时,会从这些 GC Roots 开始向下搜索
,当一个对象到 GC Roots没有任何引用链相连
时,就证明此对象是不可用的。目前 HotSpot 虚拟机采用的就是这种算法。
JDK 1.2 之后:Java 对引用的概念进行了扩充,将引用分为了以下四种:
(3)如何回收这些对象?
-
自动性:Java 提供了
一个系统级的线程来跟踪每一块分配出去的内存空间
,当 JVM 处于空闲循环
时,垃圾收集器线程会自动检查
每一块分配出去的内存空间,然后自动回收每一块空闲的内存块。 -
不可预期性:一旦一个对象没有被引用了,该对象是否立刻被回收呢?答案是不可预期的。我们很难确定一个没有被引用的对象是不是会被立刻回收掉,因为
有可能当程序结束后,这个对象仍在内存中
。
垃圾回收线程在 JVM 中是自动执行的,Java 程序无法强制执行
。我们唯一能做的就是通过调用 System.gc 方法来"建议"执行垃圾收集器
,但是否可执行,什么时候执行?仍然不可预期。
GC算法
GC
JDK1.7 update14 之后 Hotspot 虚拟机所有的回收器整理如下:
我们可以通过 JVM 工具查询当前 JVM 使用的垃圾收集器类型,首先通过 ps 命令查询出经常 ID
,再通过 jmap -heap ID 查询出 JVM 的配置信息
,其中就包括垃圾收集器的设置类型。
GC 性能衡量指标
-
吞吐量:
应用程序所花费的时间和系统总运行时间的比值
。我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 =应用程序耗时 +GC 耗时
。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于 95%
。 -
停顿时间:垃圾收集器正在运行时,
应用程序的暂停时间
。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。 -
垃圾回收频率:
多久发生一次指垃圾回收
呢?通常垃圾回收的频率越低越好
,增大堆内存空间
可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间
。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。
排查分析
(1)通过 JVM 参数预先设置 GC 日志:
-XX:+PrintGC 输出 GC 日志
-XX:+PrintGCDetails 输出 GC 的详细日志
-XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
示例:
-XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gclogs
如果是很长的日志文件,可以通过GCView工具打开日志文件,图形化界面查看整体的 GC 性能,如下图所示:
https://sourceforge.net/projects/gcviewer/
GCeasy是一款非常直观的 GC 日志分析工具,我们可以将日志文件压缩之后,上传到 GCeasy 官网即可看到非常清楚的 GC 日志分析结果:
https://www.gceasy.io/index.jsp
GC调优
(1)降低 Minor GC 频率
动机:通常情况下,由于新生代空间较小,Eden 区很快被填满
,就会导致频繁 Minor GC
,因此我们可以通过增大新生代空间
来降低 Minor GC 的频率。
扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?如果单次 Minor GC 的时间增加,那也很难达到我们期待的优化效果呀。
单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)
。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2。
当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms
,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉
,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1
。
可见,扩容后,Minor GC 时增加了 T1,但省去了 T2
的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本
。
如果在堆内存中存在较多的长期存活的对象
,此时增加年轻代空间
,反而会增加 Minor GC 的时间
。如果堆中的短期对象很多
,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。
(2)降低 Full GC 的频率
动机:通常情况下,由于堆内存空间不足或老年代对象太多
,会触发 Full GC,频繁的 Full GC
会带来上下文切换,增加系统的性能开销。
-
减少创建大对象:在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示。例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种
大对象如果超过年轻代最大对象阈值,会被直接创建在老年代
;即使被创建在了年轻代,由于年轻代的内存空间有限
,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多的 Full GC
。我们可以将这种大对象拆解出来
,首次只查询一些比较重要的字段
,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段
。 -
增大堆内存空间:在
堆内存不足的情况下,增大堆内存空间
,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。
(3)选择合适的 GC 回收器
动机:当遇到要求每次操作的响应时间必须在 500ms 以内
,我们一般会选择响应速度较快的 GC 回收器,CMS(Concurrent Mark Sweep)回收器和 G1 回收器
都是不错的选择。
而当我们的需求对系统吞吐量
有要求时,就可以选择 Parallel Scavenge 回收器
来提高系统的吞吐量。
🔨复盘:
(1)垃圾收集器的种类很多,我们可以将其分成两种类型,一种是响应速度快,一种是吞吐量
高。通常情况下,CMS 和 G1 回收器的响应速度快
,Parallel Scavenge 回收器的吞吐量高
。
(2)在 JDK1.8 环境下,默认使用的是 Parallel Scavenge(年轻代)+Serial Old(老年代)垃圾收集器
。
(3)通常情况,JVM 是默认垃圾回收优化的,在没有性能衡量标准的前提下,尽量避免修改 GC 的一些性能配置参数
。如果一定要改,那就必须基于大量的测试结果或线上的具体性能来进行调整。
📌 [ 笔者 ] 文艺倾年
📃 [ 更新 ] 2025.5.9
❌ [ 勘误 ] /* 暂无 */
📜 [ 声明 ] 由于作者水平有限,本文有错误和不准确之处在所难免,本人也很想知道这些错误,恳望读者批评指正!