一次“内存持续增长“的排查复盘:从怀疑 HttpClient 连接泄漏到确认堆页预热

用 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()

教训 3PoolingHttpClientConnectionManager 没有公开的 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) + native4.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 进程"内存增长",建议按这个顺序,别一上来就钻某个组件:

  1. 先分层,别先猜组件

    memory / dashboard 先看是 heap / metaspace / direct / native 哪一层在涨,再决定往哪查。

  2. 区分"JVM 内存"和"进程 RSS"

    JVM 各区健康 ≠ 进程内存不涨。RSS 包含堆页预热、native、glibc arena、线程栈等,memory 命令看不全,必须到 OS 层 /proc/<pid>/status 对账。

  3. Xms ≠ Xmx 时,RSS 缓涨是常态,不是泄漏

    这是最容易被误判为"内存泄漏"的场景。生产建议 -Xms=-Xmx,可选加 -XX:+AlwaysPreTouch

  4. 判断真泄漏的黄金标准 :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 导致的堆页预热假象。

排查内存问题,先分层、再对账、最后才下结论------不要被第一直觉牵着走。

相关推荐
深念Y1 天前
SSD 寿命洁癖:编译过程不入盘的原则与实践
缓存·io·内存·编译·ssd·读取·写入
深念Y3 天前
系统内存与Swap优化记录
linux·内存·优化·虚拟内存·压缩·ram·zram
liulilittle10 天前
LLM 推理引擎与内核系统工程原理
c++·ai·llm·内存·memory·core·kernel
葵续浅笑14 天前
K8s Pod内存居高不下假象排查与解决(JDK17 G1+Arthas实战)
java·容器·arthas
AmyLin_200115 天前
PDF 色彩保真工程实践【6】怎么证明“真的无损“?——SHA-256 校验与三个真实踩坑
c++·pdf·内存·sdk·调试·印刷
网络研究院1 个月前
2026年上半年十大网络攻击和数据泄露事件
网络·安全·漏洞·风险·数据·泄露·事件
尽兴-2 个月前
Redis 为什么快?
数据库·redis·内存
cup112 个月前
[开源] Memory Checker:极致轻量的 Windows 托盘内存监测工具,告别内存焦虑
python·内存·工具·任务管理器·托盘
H Journey2 个月前
汇编基础知识:地址总线、数据总线、内存地址空间、物理内存核心知识梳理
内存·cpu·总线