Ohhnews

分类导航

$ cd ..
DZone Java原文

揭秘:为何在 M1 Mac 的 Docker 中 Java RSS 内存持续增长

#java#docker#内存管理#rosetta 2#性能优化

问题描述

你在 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,我们发现增长来自于匿名的可执行内存区域:

$ bash
$ cat /proc/1/maps | grep rwxp
efffd1d7c000-efffd9d7c000 rwxp 00000000 00:00 0
efffdb185000-efffe3185000 rwxp 00000000 00:00 0
efffe3d85000-efffebd85000 rwxp 00000000 00:00 0
...

每个区域正好是 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 区域!

根本原因

具体发生了什么:

  1. JIT 编译:Java 的 JIT 编译器为热点方法生成 x86-64 原生代码。
  2. Rosetta 2 拦截:当 x86-64 代码执行时,Rosetta 2 将其翻译为 ARM64 代码。
  3. 翻译缓存:翻译后的 ARM64 代码存储在 128 MB 的 RWXP 内存区域中。
  4. 增长:更多的 JIT 编译方法 = 更多的翻译 = 更多的 RWXP 区域。

证据

观察结果解释
RWXP 区域包含 ARM64 代码Rosetta 2 翻译后的代码
每个区域正好 128 MBRosetta 2 的分配粒度
匿名(无文件映射)运行时翻译缓存
增长与 JIT 活动相关编译方法越多,翻译越多

验证

为了明确证明 JIT 是触发因素,我们使用了 -Xint 参数禁用了 JIT 编译: -Xint # 以仅解释模式运行

结果

指标之前 (启用 JIT)之后 (禁用 JIT)
RWXP 区域5 -> 12 -> 15 (持续增长)1 (稳定,无增长)
RWXP 内存~1.9 GB~128 MB
增长率每小时多个区域0 个区域/小时
编译方法25,606 个 nmethods0 个 nmethods

结论:禁用 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 镜像:

$ bash
# 为 ARM64 构建
docker build --platform linux/arm64 ...

# 或者使用多架构镜像
docker pull --platform linux/arm64 your-image:tag

优点

  • 无需 Rosetta 2 翻译
  • 无 RWXP 内存增长
  • 性能更好(原生执行)
  • 内存占用更低

方案 2:部署到 x86-64 基础设施

如果无法使用 ARM64 镜像,请部署到不需要 Rosetta 2 的 x86-64 服务器或云实例上。

方案 3:接受并监控

如果必须在 M1 Mac 上使用 x86-64 容器:

  • 增加容器内存限制
  • 监控 RWXP 增长
  • 必要时计划定期重启

不推荐

不要在生产环境中禁用 JIT (-Xint)。虽然这会停止 RWXP 增长,但会显著降低性能。仅在测试/调试时使用。

关键要点

  1. Rosetta 2 翻译缓存会导致 M1 Mac 上 x86-64 容器的 RWXP 内存增长。
  2. JIT 编译是主要触发因素;每个编译的方法都需要翻译。
  3. 原生 ARM64 镜像可完全消除该问题。
  4. 这是预期行为,而非 Bug,这是模拟运行的代价。

总结

最初表现为神秘的 RSS 增长,最终被证实是 Rosetta 2 的翻译缓存存储了 Java JIT 编译代码的 ARM64 翻译版本。通过理解其机制并使用禁用 JIT 的方式进行测试,我们证明了根本原因并确定了最佳方案:使用原生 ARM64 镜像。如果你在 M1 Mac 上运行 Java 应用时遇到类似的 RSS 增长,请检查进程内存映射中的 RWXP 区域。如果看到了它们,那么很可能就是 Rosetta 2 翻译导致的。

如何检查

$ bash
# 检查 RWXP 区域
cat /proc/PID/maps | grep rwxp

# 统计 RWXP 区域数量
cat /proc/PID/maps | grep rwxp | wc -l

# 检查 Rosetta 2 是否处于活动状态
cat /proc/PID/maps | grep rosetta