用 Arthas 排查线上服务"内存不断增加",最初笃定是 OkHttp/HttpClient 连接没释放,结果一路被数据打脸,最终发现根本不是泄漏------而是 JVM 堆内存页的正常"预热"。这篇复盘完整记录排查路径、踩过的坑,以及每一步该用什么命令、怎么读数据。
一、问题背景
线上一个 Spring Boot 服务(ebrms),运维反馈进程内存持续增长,怀疑是代码里 HTTP 连接没有正确释放,导致连接积压、内存上涨。
第一反应:这是典型的连接池泄漏,用 Arthas 挂上去看连接池状态就能定位。于是排查从"HttpClient 连接泄漏"这个假设开始。
剧透:这个假设从头到尾就是错的,但排查的价值恰恰在于------用数据一步步把错误假设证伪,最终逼近真相。
二、排查全过程
阶段 0:环境信息
- HttpClient 版本:
httpclient-4.5.12.jar - 运行环境:Spring Boot fat-jar,
LaunchedURLClassLoader@306a30c7 - Arthas 版本:4.3.4
- 堆配置:
-Xmx约 4GB,-Xms明显小于-Xmx(关键伏笔)
阶段 1:以为是 OkHttp ------ 类都不存在
一开始按 OkHttp 排查:
bash
ognl '#pool=@okhttp3.ConnectionPool@default, #pool'
# ✗ ClassNotFoundException: Unable to resolve class: okhttp3.ConnectionPool
教训 1 :报 ClassNotFoundException 先别急着怀疑 shade/ClassLoader,先确认项目到底用没用这个库。这里项目根本没用 OkHttp,用的是 Apache HttpClient。
阶段 2:转向 Apache HttpClient ------ ognl 语法与方法坑
确认用的是 CloseableHttpClient 后,想读连接池统计:
bash
# ✗ 多行 Map 字面量,OGNL 解析失败
ognl -c 306a30c7 '
#manager=...,
{"leased": #stats.getLeased(), ...}
'
# ognl.ParseException: Encountered ":" ...
教训 2 :Arthas 的 OGNL 不要写多行、不要用带引号 key 的 Map 字面量 ,容易解析失败。改成单行、或直接 .toString()。
bash
# ✗ 又踩一坑:这个方法根本不存在
ognl -c 306a30c7 '...getConnectionPool()...'
# java.lang.NoSuchMethodException: getConnectionPool()
教训 3 :PoolingHttpClientConnectionManager 没有公开的 getConnectionPool(),它直接提供 getTotalStats()。不要凭想象拼方法名。
阶段 3:vmtool 抓不到实例 ------ ClassLoader 是关键
bash
vmtool --action getInstances --className org.apache.http.impl.conn.PoolingHttpClientConnectionManager --limit 1 --express 'instances[0].getTotalStats().toString()'
# ✗ ArrayIndexOutOfBoundsException(instances[0] 越界,说明没抓到实例)
vmtool --action getInstances --className org.apache.http.impl.conn.PoolingHttpClientConnectionManager --limit 10
# @PoolingHttpClientConnectionManager[][isEmpty=true;size=0]
教训 4(重要) :Spring Boot fat-jar 的类由 LaunchedURLClassLoader 加载,而 vmtool 默认用 SystemClassLoader ,两者不匹配就抓不到实例。必须用 -c <classLoaderHash> 指定:
bash
# 先用 sc -d 拿到 classLoaderHash
sc -d *CloseableHttpClient
# → classLoaderHash: 306a30c7 (Spring Boot LaunchedURLClassLoader)
# 抓实例时带上 -c
vmtool --action getInstances -c 306a30c7 --className ...
阶段 4:抓到了,但类型出乎意料 ------ DefaultHttpClient
带 -c 后,从抽象基类 CloseableHttpClient 一网打尽所有子类实例:
bash
vmtool --action getInstances -c 306a30c7 --className org.apache.http.impl.client.CloseableHttpClient --limit 20
@CloseableHttpClient[][
@DefaultHttpClient[...@2c9e7ffa],
@DefaultHttpClient[...@4e344f42],
@DefaultHttpClient[...@2169be6b],
@DefaultHttpClient[...@409f4952],
]
发现 :实例类型是 已废弃的 DefaultHttpClient (4.3+ 已 @Deprecated),用的是老的 PoolingClientConnectionManager(注意不是新的 PoolingHttpClientConnectionManager)。这解释了为什么前面抓新版类全是空------类体系根本对不上。
此时"每次 new 一个 client 不 close"的泄漏假设看起来很有说服力。
阶段 5:连接池却是空的 ------ 假设开始崩塌
bash
vmtool --action getInstances -c 306a30c7 --className org.apache.http.impl.client.DefaultHttpClient --limit 1 --express 'instances[0].getConnectionManager().getTotalStats().toString()'
# @String[[leased: 0; pending: 0; available: 0; max: 100]]
leased: 0 ------ 没有任何连接被租出去。连接泄漏假设被直接推翻。
退一步猜:也许是 client 实例本身在泄漏?继续验证实例数量是否增长:
bash
vmtool --action getInstances -c 306a30c7 --className org.apache.http.impl.client.DefaultHttpClient --limit 5000 --express 'instances.length'
# @Integer[4] (多次执行都是 4,不增长)
实例稳定在 4,不涨。client 泄漏假设也被推翻。这 4 个大概是几个业务模块各自持有的单例。
阶段 6:回到源头 ------ 内存到底涨在哪?
既然 HttpClient 这条线全清白,必须回到最根本的问题:内存增长在哪一层? 用 memory 看全局:
| 区域 | used / committed | 占用 | 判断 |
|---|---|---|---|
| heap | 670M / 4060M | 16.5% | ✅ 堆根本没满 |
| ps_old_gen | 243M / 2731M | 8.9% | ✅ 老年代极低,无对象堆积 |
| direct(堆外) | 400K | --- | ✅ 无 NIO/Socket buffer 泄漏 |
| metaspace | 114M | 95.6%(commit) | 需留意但 max 无限 |
堆、堆外、metaspace 全部健康。 old gen 才 8.9%,说明根本没有 Java 对象泄漏。
继续排除线程泄漏:
bash
vmtool --action getInstances --className java.lang.Thread --limit 100000 --express 'instances.length'
# 211 → 210 → 210 → 211 (稳定,不增长)
线程也稳定。JVM 内部所有指标健康。
阶段 7:定案 ------ OS 层 RSS 对账
既然 JVM 内部干净,就到操作系统层看进程实际物理内存 RSS:
bash
cat /proc/14831/status | grep -iE "VmRSS|VmHWM|VmData"
三次采样:
VmRSS: 2162544 kB → 2164788 kB → 2164788 kB (≈ 2.11 GB,基本平稳)
VmHWM: 2162608 kB → 2164852 kB → 2164852 kB (历史峰值=当前)
VmData: 10202524 kB (≈ 9.7 GB,虚拟地址预留,正常)
关键对账:
| 项 | 数值 |
|---|---|
| 进程 RSS(实际物理内存) | ~2.11 GB |
| JVM 堆 committed | 4060 MB |
| JVM 堆 used | 670 MB |
堆已经向 OS commit 了 4GB,但 RSS 才 2.1GB。 差值就是尚未被"触碰"(page fault 进来)的堆内存页。
三、真相:不是泄漏,是堆内存页的正常"预热"
Linux 的内存机制:JVM 向 OS commit 4GB 堆,只是预留了地址空间;物理内存页只有在被实际写入时才真正计入 RSS。
随着服务运行、GC 不断在整个堆范围内移动对象、eden/old 区被逐渐写满触碰,RSS 会持续爬升,慢慢逼近 committed 的容量。这就是运维看到的"内存持续增长"的真相------
不是连接泄漏,不是对象泄漏,而是堆内存页在正常 fault-in(预热)。
它最终会涨到约 堆committed(4G) + metaspace(114M) + code_cache(52M) + 线程栈(210×1M) + native ≈ 4.5~5GB 封顶,然后稳定。
根本诱因:-Xms 远小于 -Xmx,堆随运行缓慢扩张、RSS 逐步爬升,视觉上极像内存泄漏。
四、解决方案
问题本质不是 bug,无需改任何业务代码。只需消除"缓慢增长"的观感:
方案 1:让 -Xms = -Xmx(推荐)
bash
-Xms4g -Xmx4g
堆一启动就 commit 满,RSS 快速到位并稳定,不再有"缓涨"错觉。
方案 2:加 AlwaysPreTouch(可选)
bash
-XX:+AlwaysPreTouch
启动时把所有堆页触碰一遍,RSS 启动即满值、运行期完全平稳(代价是启动慢几秒)。
最终确认方法
让服务正常跑,隔 30 分钟~1 小时记录一次 RSS:
bash
cat /proc/<pid>/status | grep VmRSS
- RSS 涨到 4.55GB 后封顶稳定 → 确认堆预热,无泄漏;
- RSS 突破合理上限还无限涨 → 才需继续查 native(glibc arena / JNI)。
五、排查路径复盘表
| 阶段 | 假设 | 验证命令 | 数据 | 结论 |
|---|---|---|---|---|
| 1 | OkHttp 连接泄漏 | ognl @okhttp3.ConnectionPool |
类不存在 | ✗ 否 |
| 2 | HttpClient 连接泄漏 | getTotalStats() |
池 leased=0 | ✗ 否 |
| 3 | HttpClient 实例泄漏 | instances.length |
稳定=4 | ✗ 否 |
| 4 | 堆对象泄漏 | memory |
old gen 8.9% | ✗ 否 |
| 5 | 堆外/线程泄漏 | direct / Thread 计数 |
0.4M / 210 | ✗ 否 |
| 6 | 堆页预热 + Xms<Xmx | /proc/pid/status RSS |
RSS 2.1G < committed 4G | ✅ 就是它 |
六、经验总结(Checklist)
排查 JVM 进程"内存增长",建议按这个顺序,别一上来就钻某个组件:
-
先分层,别先猜组件
memory/dashboard先看是 heap / metaspace / direct / native 哪一层在涨,再决定往哪查。 -
区分"JVM 内存"和"进程 RSS"
JVM 各区健康 ≠ 进程内存不涨。RSS 包含堆页预热、native、glibc arena、线程栈等,
memory命令看不全,必须到 OS 层/proc/<pid>/status对账。 -
Xms ≠ Xmx 时,RSS 缓涨是常态,不是泄漏
这是最容易被误判为"内存泄漏"的场景。生产建议
-Xms=-Xmx,可选加-XX:+AlwaysPreTouch。 -
判断真泄漏的黄金标准 :Full GC 后 old gen 是否回落。回落=干净,持续高位不降=真泄漏。
Arthas 使用避坑
- Spring Boot fat-jar 必须用
-c <classLoaderHash>:类由LaunchedURLClassLoader加载,vmtool 默认 ClassLoader 抓不到实例。先sc -d <类>拿 hash。 - OGNL 写单行、避免带引号 key 的 Map 字面量 ,优先用
.toString()输出。 - 不确定方法名先别拼 :
getConnectionPool()不存在,PoolingHttpClientConnectionManager直接用getTotalStats()。 - 抓不确定类型的实例,从抽象基类/接口入手 :
getInstances对抽象类会返回所有子类实例(如从CloseableHttpClient抓到了DefaultHttpClient)。 instances[0]越界 = 没抓到实例 ,先去掉--express用--limit N确认数量。
附带发现(可优化但非本次问题根因)
代码里用了已废弃的 DefaultHttpClient + 老版 PoolingClientConnectionManager(4.2 时代写法)。虽然本次不是它导致内存问题,但建议后续技术债治理时升级为:
java
// 全局单例 CloseableHttpClient + PoolingHttpClientConnectionManager
private static final PoolingHttpClientConnectionManager CM = new PoolingHttpClientConnectionManager();
static { CM.setMaxTotal(200); CM.setDefaultMaxPerRoute(50); }
private static final CloseableHttpClient CLIENT = HttpClients.custom()
.setConnectionManager(CM).build();
// 每次请求 try-with-resources 确保归还连接
try (CloseableHttpResponse resp = CLIENT.execute(httpGet)) {
return EntityUtils.toString(resp.getEntity());
}
结语
这次排查最大的价值不在于"找到了多复杂的 bug",而在于用数据把一个看似合理的假设一步步证伪 。很多线上"内存泄漏"告警,最后查下来是 Xms<Xmx 导致的堆页预热假象。
排查内存问题,先分层、再对账、最后才下结论------不要被第一直觉牵着走。
