JVM内存模型与保险业务系统常见内存问题
对应简历段落
在简历中,和本文对应的经历通常会写成类似下面的表述:
负责核心保险业务系统 JVM 性能调优与线上问题排查,结合 GC 日志、堆 Dump、Arthas、监控告警定位内存泄漏、Full GC 频繁、大 Excel 导出 OOM 等问题;通过参数优化、代码改造、批处理拆分、对象生命周期治理等手段,降低 Full GC 频率,提升系统稳定性。
这段话看起来只有几行,但面试官往往会继续追问:你调过哪些参数?怎么看 GC 日志?Dump 怎么分析?Arthas 具体用过哪些命令?大 Excel 导出为什么会 OOM?你是怎么证明不是数据库慢,而是 JVM 内存问题?Full GC 降频之后有什么指标变化?这些问题不是背几个 JVM 名词就能回答的,需要把业务场景、内存结构、定位链路和项目落地串起来。
保险系统的特点是业务对象复杂、批量数据多、历史数据时间跨度长、报表导出需求重、定时任务集中、峰值访问明显。比如承保系统要处理投保单、保单、险种、责任、客户、缴费计划;理赔系统要处理报案、立案、查勘、定损、赔付、影像材料;保全系统要处理批改、退保、受益人变更;运营后台还会有各种 Excel 导出、对账文件生成、续期提醒、批量短信、批量核保任务。对象层级深、一次查询返回数据多、缓存和集合使用频繁,这些都会放大 JVM 内存问题。
所以这篇文章的目标不是孤立讲 JVM 内存模型,而是回答一个更贴近项目的问题:在保险业务系统里,常见内存问题是怎么产生的,如何定位,如何改造,如何在面试中讲得可信。
业务背景
保险业务系统通常是典型的 Java 后端系统,技术栈可能包括 Spring Boot、Spring Cloud、MyBatis、Redis、消息队列、定时任务调度、分布式文件服务、报表服务等。线上部署一般是多实例集群,单个 JVM 设置固定堆大小,例如 -Xms4g -Xmx4g 或 -Xms8g -Xmx8g,根据服务类型配置 G1、CMS 或其他收集器。
系统日常流量并不一定很高,但内存风险经常来自“低频重操作”。例如:
- 运营人员导出几十万行保单清单,接口把所有数据一次性查到内存,再用 Apache POI 的
XSSFWorkbook生成 Excel,瞬间占满堆。 - 理赔影像或附件元数据查询时,把大字段、Base64 内容、历史轨迹一次性组装到 DTO,导致单次请求对象非常大。
- 批量续保、批量对账、批量佣金计算任务在凌晨集中执行,多个任务同时拉取大批数据,年轻代快速晋升到老年代,引发频繁 Full GC。
- 为了提升查询性能,开发人员使用本地
Map缓存产品、机构、渠道或用户权限数据,但没有过期策略,随着业务运行不断膨胀。 - 线程池、异步任务、ThreadLocal、静态集合、监听器等释放不当,导致对象被长期引用,GC 无法回收。
保险业务还有一个特殊点:数据结构天然“宽而深”。一个保单对象可能关联投保人、被保人、受益人、险种、责任、批单、缴费计划、核保结论、影像材料、销售人员、渠道信息等。如果在代码中无节制地使用全量对象转换、深拷贝、JSON 序列化、日志打印,就会让短时间内创建的对象数量非常惊人。很多问题表面是“接口慢”或“机器 CPU 高”,本质可能是内存分配过快和 GC 压力过大。
JVM 内存结构
讲 JVM 内存结构时,不建议只背“堆、栈、方法区、程序计数器、本地方法栈”。面试中更有价值的表达,是把这些区域和实际问题对应起来。
堆内存
堆是线上内存问题最常发生的区域,Java 对象实例大部分都分配在堆中。业务系统里的 DTO、Entity、List、Map、字符串、Excel 单元格对象、JSON 中间对象,都主要占用堆。
堆通常分为年轻代和老年代。年轻代用于存放新创建、生命周期短的对象,比如一次接口请求中的临时 DTO、查询结果、JSON 解析对象。老年代用于存放存活时间较长的对象,比如缓存、单例持有的集合、长生命周期任务对象,以及多次 Minor GC 后仍然存活的对象。
在保险系统中,大部分查询请求创建的对象应该是“朝生夕死”的,请求结束后就能被回收。如果这些对象被静态集合、本地缓存、ThreadLocal、异步队列或未清理的上下文引用住,就会进入老年代,最终造成老年代持续上涨。
虚拟机栈
每个线程都有自己的 Java 虚拟机栈,方法调用会产生栈帧。栈中保存局部变量、操作数栈、返回地址等。一般业务系统里,栈问题常见于递归调用过深、复杂规则引擎递归解析、对象互相转换时循环引用导致无限递归。典型异常是 StackOverflowError,和堆 OOM 不同。
例如保单产品责任树、机构树、菜单权限树,如果递归遍历时没有终止条件,或者父子节点关系出现环,就可能导致栈溢出。面试时要能区分:java.lang.OutOfMemoryError: Java heap space 是堆不够,StackOverflowError 是调用栈过深。
方法区和元空间
JDK 8 之后,方法区的实现主要是元空间,使用本地内存。它存放类元信息、运行时常量池、方法元数据等。普通业务系统较少直接遇到元空间问题,但如果系统大量动态生成类,例如频繁创建 CGLIB 代理、动态脚本、表达式引擎、报表模板编译、热部署类加载器泄漏,就可能出现 OutOfMemoryError: Metaspace。
保险系统中,如果规则引擎、产品公式、核保脚本或报表模板使用动态编译,就要关注类加载数量是否持续增长。定位时可以通过 JVM 监控、jcmd VM.classloader_stats、Arthas classloader 等方式观察。
直接内存
直接内存不是堆的一部分,常用于 NIO、Netty、文件传输、压缩解压等场景。网关、文件服务、影像服务、报表下载服务更容易遇到直接内存压力。直接内存 OOM 的异常可能是 OutOfMemoryError: Direct buffer memory。
如果系统使用 Netty、文件上传下载、大对象压缩、Excel 流式输出,要同时关注堆内存和堆外内存。只看 -Xmx 不够,还要看容器内存限制、MaxDirectMemorySize、线程栈、本地内存等整体占用。
程序计数器和本地方法栈
这两个区域通常不是业务排查的重点。程序计数器记录线程执行位置,本地方法栈服务于 Native 方法调用。面试中可以简要说明,但不要在这里展开太多。真正和项目经验强相关的,还是堆、老年代、元空间、直接内存和线程栈。
常见内存问题
大 Excel 导出 OOM
这是保险运营后台非常常见的问题。运营人员希望导出保单清单、续期清单、赔案清单、佣金明细或保全记录,数据量可能从几万到几十万行,字段数量也很多。问题代码通常有几个特征:
- 不分页,一次性
selectList查出所有数据。 - 使用普通 POI
XSSFWorkbook,所有行、单元格、样式对象都留在内存。 - 先把 Entity 转成 DTO,再转成 Excel VO,再转成字符串数组,中间产生多份对象。
- 导出完成前,
List、Workbook、字节数组、响应缓存同时存在。 - 接口没有导出上限,也没有异步任务和文件落盘机制。
这类 OOM 的本质不是单个对象特别大,而是对象数量太多,并且生命周期在导出完成前都无法释放。几十万行乘以几十列,再加上单元格对象、字符串对象、样式对象,很容易把 4G 或 8G 堆打满。
改造思路通常是:限制同步导出行数;大数据量导出改为异步任务;数据库分页游标读取;使用 SXSSFWorkbook 或 EasyExcel 流式写出;减少中间 DTO 复制;生成文件后上传文件服务,再通知用户下载;对导出任务做并发控制。
内存泄漏
Java 有 GC,但不代表没有内存泄漏。Java 内存泄漏的核心是:对象已经不再被业务需要,但仍然被 GC Roots 可达引用链持有,所以无法回收。
保险系统里常见泄漏来源包括:
- 静态
Map保存业务数据,没有容量限制和过期策略。 - 本地缓存用
ConcurrentHashMap手写,只有 put 没有 remove。 ThreadLocal保存用户上下文、租户上下文、请求流水号、权限数据,但在线程池场景下没有remove。- 异步队列堆积,生产速度大于消费速度,队列里堆满请求对象。
- 定时任务把每次执行结果追加到成员变量集合中,长期不清理。
- 监听器、回调、观察者注册后没有注销。
- 日志或审计上下文保存大对象,异常时没有释放。
泄漏的表现通常是老年代使用率阶梯式上升,Full GC 后也降不下来。正常的内存波动应该是“上涨、GC 回落、再上涨、再回落”。如果每次 Full GC 后的低水位越来越高,就要怀疑泄漏或长生命周期对象异常增长。
Full GC 频繁
Full GC 频繁并不一定等于内存泄漏,也可能是对象晋升过快、老年代空间不足、元空间压力、System.gc 调用、堆参数不合理、收集器配置不合理导致。
在保险系统中,Full GC 频繁常见于以下场景:
- 批量任务集中执行,短时间创建大量对象。
- 大查询、大导出、大报表导致年轻代放不下,对象直接晋升。
- 缓存占用过多老年代,给临时对象留下的空间太少。
- 接口响应慢导致请求堆积,线程数增加,每个线程都持有请求上下文和查询结果。
- 日志打印大对象,JSON 序列化造成大量临时字符串。
Full GC 的危害是 STW 时间长,业务线程暂停,接口超时,网关重试,重试又带来更多请求,形成雪崩式压力。所以治理 Full GC 不能只调大堆,还要减少无效对象创建、控制批量任务并发、限制大查询、拆分导出链路。
年轻代 GC 过于频繁
如果 Minor GC 非常频繁,但每次停顿短,接口没有明显抖动,可能只是分配速率高。可如果 Minor GC 每几秒一次,且晋升老年代速度很快,就要关注对象是否太大、年轻代是否太小、批量请求是否集中。
例如保单列表接口每次查询 1000 条,每条组装几十个字段,还额外查询客户、险种、渠道、状态字典,最终生成多个中间集合。高峰期并发一上来,年轻代很快被打满,Minor GC 后仍有大量对象存活并晋升,老年代压力随之增加。
元空间 OOM
如果系统频繁动态加载类,例如规则脚本、报表模板、Groovy 脚本、动态代理,没有复用类加载器或释放旧类加载器,就可能导致元空间持续增长。表现是堆看起来还够,但进程内存涨,最终报 Metaspace。
这类问题不能只抓堆 Dump,还要看类加载器和类数量。Arthas 的 classloader、sc,JDK 的 jcmd,以及监控中的 Loaded Class Count 都有帮助。
直接内存 OOM
文件上传下载、影像服务、大 Excel 下载、Netty 网关都可能使用直接内存。直接内存异常时,堆 Dump 未必能直接看到大对象。需要结合 Native Memory Tracking、进程 RSS、容器内存、Netty buffer 指标、文件流关闭情况一起分析。
如何定位
定位 JVM 内存问题,一定要先建立时间线:什么时候开始异常,哪个服务异常,异常期间发生了什么业务操作,监控指标如何变化,日志里有什么错误,GC 是否频繁,是否发生发布、任务调度、活动流量、批量导出。
第一步:看监控
先看 JVM 和系统层指标:
- 堆使用率、老年代使用率、年轻代使用率。
- GC 次数、GC 时间、Full GC 次数和耗时。
- 线程数、活动线程数、线程池队列长度。
- CPU、Load、进程 RSS、容器内存。
- 接口 RT、错误率、超时率。
- 数据库慢 SQL、Redis 延迟、MQ 堆积。
如果老年代持续上涨,Full GC 后不回落,重点怀疑泄漏。如果老年代在大导出期间快速冲高,导出结束后能回落,可能是大对象峰值问题。如果 CPU 高但 GC 时间占比很大,说明 CPU 可能被 GC 消耗了,而不一定是业务计算慢。
第二步:看 GC 日志
GC 日志可以回答几个关键问题:
- GC 发生得频不频繁。
- 每次 GC 前后内存变化如何。
- Full GC 后老年代是否明显下降。
- 停顿时间是否超过业务可接受范围。
- 是否存在晋升失败、并发模式失败、疏散失败等问题。
如果是 G1,可以重点看 Pause Young、Pause Full、Humongous Allocation、Concurrent Mark、Mixed GC 等信息。大对象分配可能触发 Humongous 区域,如果大对象很多,会影响 G1 回收效果。大 Excel、超大 byte 数组、超大字符串、一次性大 List 都可能造成这类压力。
分析 GC 日志时,不要只说“我看了 GC 日志”。推荐说清楚:
我主要看 GC 频率、单次停顿、Full GC 后老年代回收幅度、异常前后的分配速率和晋升情况。那次问题里 Full GC 后老年代从 90% 只能降到 82%,低水位持续抬升,所以判断不是普通流量峰值,而是有长生命周期对象占用或泄漏。
第三步:抓堆 Dump
堆 Dump 适合分析堆内对象分布和引用链。常用方式包括:
jmap -dump:format=b,file=heap.hprof <pid>
jcmd <pid> GC.heap_dump heap.hprof
线上抓 Dump 要谨慎,因为可能造成短暂停顿,也可能生成很大的文件。一般建议在故障实例、下线实例、影子实例或低峰期操作。如果已经 OOM,可以配置:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
分析工具可以用 MAT、VisualVM、JProfiler 等。重点看:
- Dominator Tree,找 retained size 最大的对象。
- Histogram,查看对象数量和浅堆大小。
- GC Roots 引用链,看对象为什么无法回收。
- 大集合内容,例如
ArrayList、HashMap、ConcurrentHashMap。 - 字符串、byte 数组、Excel 相关对象、业务 DTO 是否异常多。
例如大 Excel OOM 时,Dump 里可能看到大量 XSSFCell、XSSFRow、CTCell、String、char[]、业务导出 VO。内存泄漏时,可能看到某个静态 ConcurrentHashMap retained size 特别大,里面保存了机构权限、用户会话、导出任务上下文等。
第四步:用 Arthas 在线观察
Arthas 适合在线诊断,不一定要重启服务。常用命令包括:
dashboard
jvm
memory
thread
thread -n 5
heapdump /tmp/heap.hprof
watch
trace
tt
ognl
jad
sc
sm
classloader
在内存问题里,dashboard 可以快速看线程、内存、GC 情况;memory 看各内存区域;thread 看是否有大量线程阻塞或跑满 CPU;heapdump 可以在线导出堆;watch 和 trace 可以观察某个导出接口或批量任务方法的入参、返回值、耗时和异常。
例如怀疑保单导出接口一次查太多数据,可以用 watch 观察方法返回的 List 大小,或者在服务方法上 trace 看耗时集中在数据库查询、对象转换还是 Excel 写出。Arthas 的价值在于把“猜测”变成“观察到的证据”。
第五步:结合业务日志和链路追踪
JVM 指标只能说明内存异常,不能直接说明哪个业务操作造成异常。保险系统通常要结合业务日志、链路追踪、审计日志和操作记录。
例如某天 10:05 开始老年代快速上涨,GC 日志显示 10:08 出现连续 Full GC。再查业务日志发现 10:04 有用户发起“全量保单导出”,导出条件没有时间范围,预计导出 80 万行。这个关联比单纯说“堆内存不足”更有说服力。
项目落地
把 JVM 排查能力落到项目里,不能停留在“会用工具”。更重要的是建立规范和改造闭环。
导出链路改造
针对大 Excel 导出 OOM,可以落地以下措施:
- 同步导出加上最大行数限制,例如超过 5 万行提示走异步导出。
- 异步导出任务进入任务表,状态包括待执行、执行中、成功、失败。
- 使用分页查询或游标查询,每批处理 1000 到 5000 条。
- 使用流式 Excel 写入,避免 Workbook 持有全部数据。
- 导出文件先写本地临时目录或对象存储,完成后生成下载链接。
- 对导出任务按用户、租户、服务实例做并发限制。
- 对导出字段做精简,避免把详情页对象直接用于列表导出。
改造后,导出接口从同步长请求变成异步任务,JVM 峰值内存下降,接口线程不再长时间占用,用户也可以在任务中心下载结果。
缓存治理
本地缓存必须有边界。优先使用成熟缓存组件,例如 Caffeine,配置最大容量、过期时间、统计指标。对于产品、机构、字典等相对稳定的数据,可以缓存,但要明确刷新机制和容量上限。不要随手写静态 Map,更不要把用户级、请求级、查询条件级数据无限缓存。
如果必须使用 Map,也要明确:
- key 的维度是否会无限增长。
- value 是否包含大对象。
- 什么时候删除。
- 是否需要定时清理。
- 是否有监控缓存大小。
ThreadLocal 清理
在网关、拦截器、审计上下文、租户上下文中使用 ThreadLocal 很常见,但线程池会复用线程,请求结束后必须清理。推荐在过滤器或拦截器的 finally 中 remove:
try {
TenantContext.set(tenantId);
chain.doFilter(request, response);
} finally {
TenantContext.remove();
}
如果 ThreadLocal 保存的是用户对象、权限集合、请求 DTO、大字符串,泄漏风险更高。面试时可以强调:ThreadLocal 问题不是 ThreadLocal 自身导致对象无法回收,而是线程池中的工作线程长期存活,ThreadLocalMap 也跟着长期存在。
批量任务削峰
批量任务要避免在同一时间集中执行。保险系统常见凌晨批处理,包括续保、对账、清分、佣金、报表、短信、同步外部系统。可以通过任务错峰、分片、限流、分页、断点续跑、队列消费等方式降低内存峰值。
如果多个任务都使用大分页、全量 List、复杂对象转换,即使每个任务单独跑没问题,并发执行时也可能把堆打满。治理重点是控制并发和单批数据量。
GC 参数优化
GC 参数优化应建立在日志和压测基础上,不要凭感觉。常见策略包括:
- 设置固定堆大小,避免运行期扩缩容带来的波动,如
-Xms4g -Xmx4g。 - 开启 GC 日志,保留足够历史。
- 根据 JDK 版本选择合适收集器,例如 JDK 8 使用 G1 时关注停顿目标和 Region 情况。
- 对大对象和批处理场景,关注年轻代大小、晋升速度、老年代余量。
- 容器环境下同时考虑堆、直接内存、线程栈、元空间和容器限制。
但要注意:参数优化只能缓解,不能替代代码治理。一次性查 80 万行再生成 Excel,给再大的堆也只是把 OOM 推迟。
示例排查流程
下面用一个“大 Excel 导出导致 OOM 和 Full GC 频繁”的场景串起完整流程。
现象
运营后台在上午高峰期出现大量接口超时,服务监控显示某个保单查询服务 CPU 升高,Full GC 在 10 分钟内发生 6 次,单次停顿 3 到 8 秒。部分请求报错 java.lang.OutOfMemoryError: Java heap space。用户反馈在导出保单清单时页面一直转圈,最后失败。
初步判断
先看监控时间线,发现异常从 10:13 开始,老年代从 55% 快速涨到 95%,连续 Full GC 后只能回落到 88%。接口错误率上升,线程池活动线程数接近上限。业务日志中有一条导出任务记录,查询条件只选择了机构,没有选择日期范围,预计数据量 65 万行。
GC 日志分析
GC 日志显示异常前 Minor GC 频率明显增加,随后出现 Full GC。Full GC 后老年代下降不明显,说明当时仍有大量对象被引用。日志中还出现大对象分配压力,结合导出场景,怀疑 Excel 生成过程持有大量单元格对象和业务 DTO。
Arthas 观察
使用 Arthas 进入实例,先看:
dashboard
memory
thread -n 5
发现 GC 时间占比较高,业务线程中有多个导出相关调用栈。再对导出服务方法进行观察,确认一次查询返回几十万条数据,并且导出方法在内存中构建完整 Workbook。
Dump 分析
对问题实例摘流后抓取堆 Dump,用 MAT 分析 Dominator Tree。结果显示大量 Excel 相关对象和导出 VO,占据主要 retained size。引用链显示这些对象被导出方法中的 Workbook 和 List 持有,在导出完成前无法释放。
结论
问题根因是导出接口没有数据量上限,使用普通内存 Workbook,全量查询加全量生成导致堆内对象暴涨。Full GC 频繁是结果,不是根因。直接调大堆只能降低发生频率,不能根治。
改造
改造方案包括:
- 同步导出限制 5 万行以内。
- 超过限制走异步导出任务。
- 查询按主键或时间分页,每批 2000 条。
- 使用流式 Excel 写入。
- 写入完成后上传文件服务,任务中心提供下载。
- 导出任务线程池单独隔离,限制最大并发。
- 增加导出数据量、耗时、失败原因、内存水位监控。
效果
上线后,同类导出不再触发 OOM。Full GC 次数从高峰期每天多次下降到偶发或基本没有,接口超时率明显降低。导出大文件虽然仍然耗时,但从同步接口压力转移到了可控的异步任务,用户体验和系统稳定性都更好。
常见坑
只会说调大堆
面试中如果一遇到 OOM 就说“把 -Xmx 调大”,会显得经验很浅。调大堆可以作为临时止血,但必须继续分析对象来源、生命周期和业务触发条件。否则堆越大,Full GC 停顿可能越长,故障影响更大。
只看平均值,不看低水位
内存问题不能只看平均堆使用率。要看 Full GC 后的老年代低水位。如果低水位越来越高,说明长期存活对象在增加。很多泄漏都是这种“慢慢涨”的形态。
Dump 抓得太晚
OOM 后进程可能已经重启,现场丢失。最好提前配置 HeapDumpOnOutOfMemoryError 和 GC 日志路径。对于可复现问题,可以在压测或预发环境主动抓 Dump。
把所有问题都归因于 GC
GC 频繁通常是结果,不一定是根因。根因可能是大查询、缓存无界、线程池堆积、外部接口慢导致请求堆积、数据库慢 SQL 让对象在内存中停留更久。定位时要结合链路追踪和业务日志。
忽视容器内存
在容器环境中,-Xmx 不是进程全部内存。还要给元空间、直接内存、线程栈、JIT、Native 库留空间。如果容器限制 4G,堆也设 4G,就很容易被容器 OOM Kill。
抓 Dump 影响线上
线上抓 Dump 可能带来停顿和磁盘压力,尤其是大堆服务。要先评估实例是否能摘流,磁盘空间是否足够,是否可以在低峰操作。不能为了排查把故障扩大。
Excel 流式写入仍然可能 OOM
使用 SXSSFWorkbook 或 EasyExcel 不代表绝对安全。如果查询端仍然一次性加载全部数据,或者样式对象重复创建、共享字符串表过大、图片/大字段写入过多,仍然可能 OOM。流式要覆盖“查、转、写、传”的全链路。
面试追问
你们线上怎么判断是内存泄漏?
可以回答:先看老年代趋势和 Full GC 后低水位。如果业务流量回落后,Full GC 仍无法把老年代降到正常水平,并且低水位持续抬升,就怀疑有长生命周期对象异常持有。然后抓 Dump,用 MAT 看 Dominator Tree 和 GC Roots 引用链,确认是哪类对象、被谁引用。
GC 日志你主要看什么?
可以回答:看 GC 类型、发生频率、停顿时间、GC 前后各区域变化、Full GC 后老年代回收幅度、是否有晋升失败或大对象分配问题。单次 GC 日志价值有限,我更关注一段时间内的趋势。
Arthas 在这类问题里怎么用?
可以回答:我会先用 dashboard 和 memory 看 JVM 状态,用 thread 看是否有线程堆积或 CPU 热点;如果怀疑某个接口,用 trace 看调用耗时,用 watch 看入参、返回值或集合大小;必要时用 heapdump 导出堆,再用 MAT 深入分析。
大 Excel 导出 OOM 怎么解决?
可以回答:先止血,限制导出行数或临时关闭超大导出;再改造为异步导出。技术上用分页或游标读取,流式写 Excel,避免全量 List 和全量 Workbook 同时在内存;任务侧做并发控制、失败重试和文件下载。最后加监控,记录导出行数、耗时、失败原因和内存水位。
Full GC 降频怎么做?
可以回答:先通过 GC 日志确认 Full GC 原因,再结合业务场景治理。我们做过的包括减少大对象和中间对象、限制大查询、导出异步化、缓存加容量和过期、批量任务错峰、线程池隔离,以及在压测验证后调整堆大小和 G1 参数。核心不是单纯调参数,而是降低老年代压力和对象晋升速度。
ThreadLocal 为什么会泄漏?
可以回答:在线程池中,线程执行完请求不会销毁,如果 ThreadLocal 里放了用户上下文或大对象,请求结束后不 remove,就可能一直挂在线程上。即使 key 是弱引用,value 仍可能滞留在 ThreadLocalMap 中,所以要在 finally 里清理。
你怎么证明优化有效?
可以回答:优化前后对比 Full GC 次数、GC 总耗时、接口 RT、错误率、老年代低水位、导出成功率、任务耗时和实例重启次数。比如导出改造后,高峰期 Full GC 从每天多次降到基本没有,导出失败率下降,接口线程池不再被长导出占满。
推荐回答
如果面试官问“你做过 JVM 调优吗”,可以这样回答:
做过,主要是在保险业务系统里处理过 Full GC 频繁、堆内存上涨和大 Excel 导出 OOM。我们系统里保单、理赔、保全这些对象层级比较深,运营后台又经常做几十万行导出,所以内存问题不只是 JVM 参数问题,更多和业务用法有关。
我的排查习惯是先看监控和 GC 日志,确认是年轻代分配过快、老年代回收不掉,还是某次大操作造成峰值。比如有一次保单导出导致 OOM,GC 日志里看到 Full GC 后老年代下降很少,业务日志又能对应到一个 60 多万行的导出请求。后面用 Arthas 看导出调用栈和返回集合大小,摘流后抓 Dump,用 MAT 看到大量 POI 单元格对象和导出 VO 被 Workbook 和 List 持有。
最终我们没有只调大堆,而是把导出改成异步任务,分页查询,流式写 Excel,文件上传后再下载,同时限制导出并发和最大行数。上线后 Full GC 明显下降,导出失败也减少了。除此之外,我们还治理过本地缓存无界、ThreadLocal 未清理、批量任务并发过高等问题。我的理解是,JVM 调优要结合 GC 日志、Dump、Arthas 和业务链路一起看,参数优化只是最后一部分。
这个回答的好处是:有场景、有工具、有证据、有改造、有结果。面试官如果继续追问,也能沿着 GC 日志、Dump、Arthas、导出改造、缓存治理继续展开。
延伸学习路线
第一阶段,先掌握 JVM 内存结构和 GC 基础。重点理解堆、年轻代、老年代、元空间、直接内存、GC Roots、可达性分析、Minor GC、Full GC、Stop The World。不要只背概念,要能解释它们和业务对象生命周期的关系。
第二阶段,学习 GC 日志分析。选择自己项目使用的 JDK 版本和收集器,例如 JDK 8 + G1 或 CMS,理解常见日志字段。可以用压测接口制造大对象分配、频繁 Minor GC、Full GC,再观察日志变化。
第三阶段,熟悉诊断工具。至少掌握 jps、jstat、jmap、jcmd、Arthas、MAT。知道什么时候用在线观察,什么时候抓 Dump,什么时候看线程栈,什么时候看 GC 日志。
第四阶段,结合真实业务场景练习。可以选大 Excel 导出、批量任务、本地缓存、ThreadLocal、接口大查询这几类场景,分别总结问题现象、监控指标、定位步骤、改造方案和复盘指标。
第五阶段,形成工程规范。包括导出上限、异步任务规范、分页查询规范、缓存容量规范、ThreadLocal 清理规范、GC 日志保留规范、OOM 自动 Dump 规范、批量任务错峰规范。真正的线上稳定性来自规范,而不是某次临时排查。
最后,建议把自己的项目经历整理成一套固定叙事:业务背景是什么,问题现象是什么,用什么证据定位,根因是什么,怎么改,效果如何。JVM 面试并不只是考概念,更是在考你有没有把底层知识变成线上问题解决能力。