揭秘:为何在 M1 Mac 的 Docker 中 Java RSS 内存持续增长
问题描述
你在 M1 Mac 上的 Docker 容器中运行 Java 应用程序,一切运行正常,但你注意到一个奇怪的现象:尽管堆内存(Heap)使用量保持稳定,但进程的驻留集大小(RSS)却在不断增加。经过数小时的调查,你在进程内存映射中发现了神秘的 rwxp 内存区域,每个区域正好 128 MB,且在不断累积。
这是什么原因导致的?是内存泄漏?JVM 错误?还是其他原因?
调查过程
我们的调查始于对部署在基于 Docker 的 Minikube 环境中 Java 17 应用程序的 RSS 增长监控。尽管堆内存使用稳定且没有明显的内存泄漏,但 RSS 仍在数小时内增长了数百兆字节。
初步观察
- RSS 增长:11 小时内增长约 500-700 MB
- 堆内存使用:稳定且在限制范围内
- 线程数:稳定
- 原生内存跟踪:无明显泄漏
深入内存映射
通过分析 /proc/PID/maps 和 /proc/PID/smaps,我们发现增长来自于匿名的可执行内存区域:
每个区域正好是 128 MB,位于 0xefff* 地址范围,并具有读-写-执行(RWXP)权限。那么里面到底是什么呢?
发现真相
读取内存内容后,我们发现了意想不到的情况:ARM64 机器码指令。但请注意,Java 二进制文件是 x86-64 架构,进程也报告为 x86_64 架构。为什么那里会有 ARM64 代码?
“顿悟”时刻
答案是:Rosetta 2 翻译缓存。当通过 Docker Desktop 在 ARM64 M1 Mac 上运行 x86-64 容器时,Rosetta 2 会将 x86-64 指令翻译为 ARM64 指令。翻译后的代码被缓存到可执行内存区域中——这就是我们看到的那些神秘的 RWXP 区域!
根本原因
具体发生了什么:
- JIT 编译:Java 的 JIT 编译器为热点方法生成 x86-64 原生代码。
- Rosetta 2 拦截:当 x86-64 代码执行时,Rosetta 2 将其翻译为 ARM64 代码。
- 翻译缓存:翻译后的 ARM64 代码存储在 128 MB 的 RWXP 内存区域中。
- 增长:更多的 JIT 编译方法 = 更多的翻译 = 更多的 RWXP 区域。
证据
验证
为了明确证明 JIT 是触发因素,我们使用了 -Xint 参数禁用了 JIT 编译:
-Xint # 以仅解释模式运行
结果
结论:禁用 JIT 后,RWXP 的增长完全停止。经过 1 小时以上的监控,确认增长为零。
为什么会发生这种情况
完美风暴
- ARM64 主机:M1 Mac (Apple Silicon)
- x86-64 容器:为 AMD64 构建的 Docker 镜像
- 启用 Rosetta 2:Docker Desktop 使用 Rosetta 2 进行模拟
- 动态代码生成:Java JIT 编译器
当满足以上四个条件时,Rosetta 2 必须将每个 JIT 编译的方法从 x86-64 翻译为 ARM64,并将翻译结果存储在计入进程 RSS 的可执行内存区域中。
解决方案
方案 1:使用原生 ARM64 镜像(推荐)
最佳方案是使用原生 ARM64 架构的 Docker 镜像:
优点:
- 无需 Rosetta 2 翻译
- 无 RWXP 内存增长
- 性能更好(原生执行)
- 内存占用更低
方案 2:部署到 x86-64 基础设施
如果无法使用 ARM64 镜像,请部署到不需要 Rosetta 2 的 x86-64 服务器或云实例上。
方案 3:接受并监控
如果必须在 M1 Mac 上使用 x86-64 容器:
- 增加容器内存限制
- 监控 RWXP 增长
- 必要时计划定期重启
不推荐
不要在生产环境中禁用 JIT (-Xint)。虽然这会停止 RWXP 增长,但会显著降低性能。仅在测试/调试时使用。
关键要点
- Rosetta 2 翻译缓存会导致 M1 Mac 上 x86-64 容器的 RWXP 内存增长。
- JIT 编译是主要触发因素;每个编译的方法都需要翻译。
- 原生 ARM64 镜像可完全消除该问题。
- 这是预期行为,而非 Bug,这是模拟运行的代价。
总结
最初表现为神秘的 RSS 增长,最终被证实是 Rosetta 2 的翻译缓存存储了 Java JIT 编译代码的 ARM64 翻译版本。通过理解其机制并使用禁用 JIT 的方式进行测试,我们证明了根本原因并确定了最佳方案:使用原生 ARM64 镜像。如果你在 M1 Mac 上运行 Java 应用时遇到类似的 RSS 增长,请检查进程内存映射中的 RWXP 区域。如果看到了它们,那么很可能就是 Rosetta 2 翻译导致的。