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 或其他收集器。

系统日常流量并不一定很高,但内存风险经常来自“低频重操作”。例如:

  1. 运营人员导出几十万行保单清单,接口把所有数据一次性查到内存,再用 Apache POI 的 XSSFWorkbook 生成 Excel,瞬间占满堆。
  2. 理赔影像或附件元数据查询时,把大字段、Base64 内容、历史轨迹一次性组装到 DTO,导致单次请求对象非常大。
  3. 批量续保、批量对账、批量佣金计算任务在凌晨集中执行,多个任务同时拉取大批数据,年轻代快速晋升到老年代,引发频繁 Full GC。
  4. 为了提升查询性能,开发人员使用本地 Map 缓存产品、机构、渠道或用户权限数据,但没有过期策略,随着业务运行不断膨胀。
  5. 线程池、异步任务、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

这是保险运营后台非常常见的问题。运营人员希望导出保单清单、续期清单、赔案清单、佣金明细或保全记录,数据量可能从几万到几十万行,字段数量也很多。问题代码通常有几个特征:

  1. 不分页,一次性 selectList 查出所有数据。
  2. 使用普通 POI XSSFWorkbook,所有行、单元格、样式对象都留在内存。
  3. 先把 Entity 转成 DTO,再转成 Excel VO,再转成字符串数组,中间产生多份对象。
  4. 导出完成前,List、Workbook、字节数组、响应缓存同时存在。
  5. 接口没有导出上限,也没有异步任务和文件落盘机制。

这类 OOM 的本质不是单个对象特别大,而是对象数量太多,并且生命周期在导出完成前都无法释放。几十万行乘以几十列,再加上单元格对象、字符串对象、样式对象,很容易把 4G 或 8G 堆打满。

改造思路通常是:限制同步导出行数;大数据量导出改为异步任务;数据库分页游标读取;使用 SXSSFWorkbook 或 EasyExcel 流式写出;减少中间 DTO 复制;生成文件后上传文件服务,再通知用户下载;对导出任务做并发控制。

内存泄漏

Java 有 GC,但不代表没有内存泄漏。Java 内存泄漏的核心是:对象已经不再被业务需要,但仍然被 GC Roots 可达引用链持有,所以无法回收。

保险系统里常见泄漏来源包括:

  1. 静态 Map 保存业务数据,没有容量限制和过期策略。
  2. 本地缓存用 ConcurrentHashMap 手写,只有 put 没有 remove。
  3. ThreadLocal 保存用户上下文、租户上下文、请求流水号、权限数据,但在线程池场景下没有 remove
  4. 异步队列堆积,生产速度大于消费速度,队列里堆满请求对象。
  5. 定时任务把每次执行结果追加到成员变量集合中,长期不清理。
  6. 监听器、回调、观察者注册后没有注销。
  7. 日志或审计上下文保存大对象,异常时没有释放。

泄漏的表现通常是老年代使用率阶梯式上升,Full GC 后也降不下来。正常的内存波动应该是“上涨、GC 回落、再上涨、再回落”。如果每次 Full GC 后的低水位越来越高,就要怀疑泄漏或长生命周期对象异常增长。

Full GC 频繁

Full GC 频繁并不一定等于内存泄漏,也可能是对象晋升过快、老年代空间不足、元空间压力、System.gc 调用、堆参数不合理、收集器配置不合理导致。

在保险系统中,Full GC 频繁常见于以下场景:

  1. 批量任务集中执行,短时间创建大量对象。
  2. 大查询、大导出、大报表导致年轻代放不下,对象直接晋升。
  3. 缓存占用过多老年代,给临时对象留下的空间太少。
  4. 接口响应慢导致请求堆积,线程数增加,每个线程都持有请求上下文和查询结果。
  5. 日志打印大对象,JSON 序列化造成大量临时字符串。

Full GC 的危害是 STW 时间长,业务线程暂停,接口超时,网关重试,重试又带来更多请求,形成雪崩式压力。所以治理 Full GC 不能只调大堆,还要减少无效对象创建、控制批量任务并发、限制大查询、拆分导出链路。

年轻代 GC 过于频繁

如果 Minor GC 非常频繁,但每次停顿短,接口没有明显抖动,可能只是分配速率高。可如果 Minor GC 每几秒一次,且晋升老年代速度很快,就要关注对象是否太大、年轻代是否太小、批量请求是否集中。

例如保单列表接口每次查询 1000 条,每条组装几十个字段,还额外查询客户、险种、渠道、状态字典,最终生成多个中间集合。高峰期并发一上来,年轻代很快被打满,Minor GC 后仍有大量对象存活并晋升,老年代压力随之增加。

元空间 OOM

如果系统频繁动态加载类,例如规则脚本、报表模板、Groovy 脚本、动态代理,没有复用类加载器或释放旧类加载器,就可能导致元空间持续增长。表现是堆看起来还够,但进程内存涨,最终报 Metaspace

这类问题不能只抓堆 Dump,还要看类加载器和类数量。Arthas 的 classloadersc,JDK 的 jcmd,以及监控中的 Loaded Class Count 都有帮助。

直接内存 OOM

文件上传下载、影像服务、大 Excel 下载、Netty 网关都可能使用直接内存。直接内存异常时,堆 Dump 未必能直接看到大对象。需要结合 Native Memory Tracking、进程 RSS、容器内存、Netty buffer 指标、文件流关闭情况一起分析。

如何定位

定位 JVM 内存问题,一定要先建立时间线:什么时候开始异常,哪个服务异常,异常期间发生了什么业务操作,监控指标如何变化,日志里有什么错误,GC 是否频繁,是否发生发布、任务调度、活动流量、批量导出。

第一步:看监控

先看 JVM 和系统层指标:

  1. 堆使用率、老年代使用率、年轻代使用率。
  2. GC 次数、GC 时间、Full GC 次数和耗时。
  3. 线程数、活动线程数、线程池队列长度。
  4. CPU、Load、进程 RSS、容器内存。
  5. 接口 RT、错误率、超时率。
  6. 数据库慢 SQL、Redis 延迟、MQ 堆积。

如果老年代持续上涨,Full GC 后不回落,重点怀疑泄漏。如果老年代在大导出期间快速冲高,导出结束后能回落,可能是大对象峰值问题。如果 CPU 高但 GC 时间占比很大,说明 CPU 可能被 GC 消耗了,而不一定是业务计算慢。

第二步:看 GC 日志

GC 日志可以回答几个关键问题:

  1. GC 发生得频不频繁。
  2. 每次 GC 前后内存变化如何。
  3. Full GC 后老年代是否明显下降。
  4. 停顿时间是否超过业务可接受范围。
  5. 是否存在晋升失败、并发模式失败、疏散失败等问题。

如果是 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 等。重点看:

  1. Dominator Tree,找 retained size 最大的对象。
  2. Histogram,查看对象数量和浅堆大小。
  3. GC Roots 引用链,看对象为什么无法回收。
  4. 大集合内容,例如 ArrayListHashMapConcurrentHashMap
  5. 字符串、byte 数组、Excel 相关对象、业务 DTO 是否异常多。

例如大 Excel OOM 时,Dump 里可能看到大量 XSSFCellXSSFRowCTCellStringchar[]、业务导出 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 可以在线导出堆;watchtrace 可以观察某个导出接口或批量任务方法的入参、返回值、耗时和异常。

例如怀疑保单导出接口一次查太多数据,可以用 watch 观察方法返回的 List 大小,或者在服务方法上 trace 看耗时集中在数据库查询、对象转换还是 Excel 写出。Arthas 的价值在于把“猜测”变成“观察到的证据”。

第五步:结合业务日志和链路追踪

JVM 指标只能说明内存异常,不能直接说明哪个业务操作造成异常。保险系统通常要结合业务日志、链路追踪、审计日志和操作记录。

例如某天 10:05 开始老年代快速上涨,GC 日志显示 10:08 出现连续 Full GC。再查业务日志发现 10:04 有用户发起“全量保单导出”,导出条件没有时间范围,预计导出 80 万行。这个关联比单纯说“堆内存不足”更有说服力。

项目落地

把 JVM 排查能力落到项目里,不能停留在“会用工具”。更重要的是建立规范和改造闭环。

导出链路改造

针对大 Excel 导出 OOM,可以落地以下措施:

  1. 同步导出加上最大行数限制,例如超过 5 万行提示走异步导出。
  2. 异步导出任务进入任务表,状态包括待执行、执行中、成功、失败。
  3. 使用分页查询或游标查询,每批处理 1000 到 5000 条。
  4. 使用流式 Excel 写入,避免 Workbook 持有全部数据。
  5. 导出文件先写本地临时目录或对象存储,完成后生成下载链接。
  6. 对导出任务按用户、租户、服务实例做并发限制。
  7. 对导出字段做精简,避免把详情页对象直接用于列表导出。

改造后,导出接口从同步长请求变成异步任务,JVM 峰值内存下降,接口线程不再长时间占用,用户也可以在任务中心下载结果。

缓存治理

本地缓存必须有边界。优先使用成熟缓存组件,例如 Caffeine,配置最大容量、过期时间、统计指标。对于产品、机构、字典等相对稳定的数据,可以缓存,但要明确刷新机制和容量上限。不要随手写静态 Map,更不要把用户级、请求级、查询条件级数据无限缓存。

如果必须使用 Map,也要明确:

  1. key 的维度是否会无限增长。
  2. value 是否包含大对象。
  3. 什么时候删除。
  4. 是否需要定时清理。
  5. 是否有监控缓存大小。

ThreadLocal 清理

在网关、拦截器、审计上下文、租户上下文中使用 ThreadLocal 很常见,但线程池会复用线程,请求结束后必须清理。推荐在过滤器或拦截器的 finally 中 remove:

try {
    TenantContext.set(tenantId);
    chain.doFilter(request, response);
} finally {
    TenantContext.remove();
}

如果 ThreadLocal 保存的是用户对象、权限集合、请求 DTO、大字符串,泄漏风险更高。面试时可以强调:ThreadLocal 问题不是 ThreadLocal 自身导致对象无法回收,而是线程池中的工作线程长期存活,ThreadLocalMap 也跟着长期存在。

批量任务削峰

批量任务要避免在同一时间集中执行。保险系统常见凌晨批处理,包括续保、对账、清分、佣金、报表、短信、同步外部系统。可以通过任务错峰、分片、限流、分页、断点续跑、队列消费等方式降低内存峰值。

如果多个任务都使用大分页、全量 List、复杂对象转换,即使每个任务单独跑没问题,并发执行时也可能把堆打满。治理重点是控制并发和单批数据量。

GC 参数优化

GC 参数优化应建立在日志和压测基础上,不要凭感觉。常见策略包括:

  1. 设置固定堆大小,避免运行期扩缩容带来的波动,如 -Xms4g -Xmx4g
  2. 开启 GC 日志,保留足够历史。
  3. 根据 JDK 版本选择合适收集器,例如 JDK 8 使用 G1 时关注停顿目标和 Region 情况。
  4. 对大对象和批处理场景,关注年轻代大小、晋升速度、老年代余量。
  5. 容器环境下同时考虑堆、直接内存、线程栈、元空间和容器限制。

但要注意:参数优化只能缓解,不能替代代码治理。一次性查 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 频繁是结果,不是根因。直接调大堆只能降低发生频率,不能根治。

改造

改造方案包括:

  1. 同步导出限制 5 万行以内。
  2. 超过限制走异步导出任务。
  3. 查询按主键或时间分页,每批 2000 条。
  4. 使用流式 Excel 写入。
  5. 写入完成后上传文件服务,任务中心提供下载。
  6. 导出任务线程池单独隔离,限制最大并发。
  7. 增加导出数据量、耗时、失败原因、内存水位监控。

效果

上线后,同类导出不再触发 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 在这类问题里怎么用?

可以回答:我会先用 dashboardmemory 看 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,再观察日志变化。

第三阶段,熟悉诊断工具。至少掌握 jpsjstatjmapjcmd、Arthas、MAT。知道什么时候用在线观察,什么时候抓 Dump,什么时候看线程栈,什么时候看 GC 日志。

第四阶段,结合真实业务场景练习。可以选大 Excel 导出、批量任务、本地缓存、ThreadLocal、接口大查询这几类场景,分别总结问题现象、监控指标、定位步骤、改造方案和复盘指标。

第五阶段,形成工程规范。包括导出上限、异步任务规范、分页查询规范、缓存容量规范、ThreadLocal 清理规范、GC 日志保留规范、OOM 自动 Dump 规范、批量任务错峰规范。真正的线上稳定性来自规范,而不是某次临时排查。

最后,建议把自己的项目经历整理成一套固定叙事:业务背景是什么,问题现象是什么,用什么证据定位,根因是什么,怎么改,效果如何。JVM 面试并不只是考概念,更是在考你有没有把底层知识变成线上问题解决能力。