故事背景:
一个 K8S Pod,里面只有一个 Java 进程,K8S request 和 limit memory 都是 2G,Java 进程核心参数包括:-XX:+UseZGC -Xmx1024m -Xms768m
。
服务启动一段时间后,查看 Grafana 监控数据,Pod 内存使用量约 1.5G,JVM 内存使用量约 500M,通过 jvm dump 分析没有任何大对象,运行三五天后出现 K8S Container OOM。
首先区分下 Container OOM 和 Jvm OOM,Container OOM 是 Pod 内进程申请内存大约 K8S Limit 所致。
问题来了:
- Pod 2G 内存,JVM 设置了
Xmx 1G
,已经预留了 1G 内存,为什么还会 Container OOM,这预留的 1G 内存被谁吃了。 - 正常情况下(无 Container OOM),Grafana 看到的监控数据,Pod 内存使用量 1.5G, JVM 内存使用量 500M,差别为什么这么大。
- Pod 内存使用量为什么超过 Xmx 限制。
Grafana 监控图。
统计指标
Pod 内存使用量
统计的指标是 container_memory_working_set_bytes
:
- container_memory_usage_bytes = container_memory_rss + container_memory_cache + kernel memory
- container_memory_working_set_bytes = container_memory_usage_bytes - total_inactive_file(未激活的匿名缓存页)
container_memory_working_set_bytes 是容器真实使用的内存量,也是资源限制 limit 时的 OOM 判断依据。
另外注意 cgroup 版本差异: container_memory_cache
reflects cache (cgroup v1)
or file (cgroup v2)
entry in memory.stat.
JVM 内存使用量
统计的指标是 jvm_memory_bytes_used
: heap、non-heap 以及其他
真实用量总和。下面解释其他。
首先说结论:在 POD 内,通过 top、free 看到的指标都是不准确的,不用看了,如果要看真实的数据以 cgroup 为准。
container_memory_working_set_bytes 指标来自 cadvisor,cadvisor 数据来源 cgroup,可以查看以下文件获取真实的内存情况。
JVM 关于使用量和提交量的解释。
Used Size
:The used space is the amount of memory that is currently occupied by Java objects.
当前实际真的用着的内存,每个 bit 都对应了有值的。
Committed Size
:The committed size is the amount of memory guaranteed to be available for use by the Java virtual machine.
操作系统向 JVM 保证可用的内存大小,或者说 JVM 向操作系统已经要的内存。站在操作系统的角度,就是已经分出去(占用)的内存,保证给 JVM 用了,其他进程不能用了。 由于操作系统的内存管理是惰性的,对于已申请的内存虽然会分配地址空间,但并不会直接占用物理内存,真正使用的时候才会映射到实际的物理内存,所以 committed > res 也是很可能的。
Java 进程内存分析
Pod 的内存使用量 1.5G,都包含哪些。
kernel memory 为 0,Cache 约 1100M,rss 约 650M,inactive_file 约 200M。可以看到 Cache 比较大,因为这个服务比较特殊有很多文件操作。
通过 Java 自带的 Native Memory Tracking 看下内存提交量。
通过 Native Memory Tracking 追踪到的详情大致如下,关注其中每一项 committed
值。
Heap Heap 是 Java 进程中使用量最大的一部分内存,是最常遇到内存问题的部分,Java 也提供了很多相关工具来排查堆内存泄露问题,这里不详细展开。Heap 与 RSS 相关的几个重要 JVM 参数如下: Xms:Java Heap 初始内存大小。(目前我们用的百分比控制,MaxRAMPercentage) Xmx:Java Heap 的最大大小。(InitialRAMPercentage) XX:+UseAdaptiveSizePolicy:是否开启自适应大小策略。开启后,JVM 将动态判断是否调整 Heap size,来降低系统负载。
Metaspace Metaspace 主要包含方法的字节码,Class 对象,常量池。一般来说,记载的类越多,Metaspace 使用的内存越多。与 Metaspace 相关的 JVM 参数有: XX:MaxMetaspaceSize: 最大的 Metaspace 大小限制【默认无限制】 XX:MetaspaceSize=64M: 初始的 Metaspace 大小。如果 Metaspace 空间不足,将会触发 Full GC。 类空间占用评估,给两个数字可供参考:10K 个类约 90M,15K 个类约 100M。 什么时候回收:分配给一个类的空间,是归属于这个类的类加载器的,只有当这个类加载器卸载的时候,这个空间才会被释放。释放 Metaspace 的空间,并不意味着将这部分空间还给系统内存,这部分空间通常会被 JVM 保留下来。 扩展:参考资料中的
Java Metaspace 详解
,这里完美解释 Metaspace、Compressed Class Space 等。Thread NMT 中显示的 Thread 部分内存与线程数与 -Xss 参数成正比,一般来说 committed 内存等于
Xss *线程数
。Code JIT 动态编译产生的 Code 占用的内存。这部分内存主要由-XX:ReservedCodeCacheSize 参数进行控制。
Internal Internal 包含命令行解析器使用的内存、JVMTI、PerfData 以及 Unsafe 分配的内存等等。 需要注意的是,Unsafe_AllocateMemory 分配的内存在 JDK11 之前,在 NMT 中都属于 Internal,但是在 JDK11 之后被 NMT 归属到 Other 中。
Symbol Symbol 为 JVM 中的符号表所使用的内存,HotSpot 中符号表主要有两种:SymbolTable 与 StringTable。 大家都知道 Java 的类在编译之后会生成 Constant pool 常量池,常量池中会有很多的字符串常量,HotSpot 出于节省内存的考虑,往往会将这些字符串常量作为一个 Symbol 对象存入一个 HashTable 的表结构中即 SymbolTable,如果该字符串可以在 SymbolTable 中 lookup(SymbolTable::lookup)到,那么就会重用该字符串,如果找不到才会创建新的 Symbol(SymbolTable::new_symbol)。 当然除了 SymbolTable,还有它的双胞胎兄弟 StringTable(StringTable 结构与 SymbolTable 基本是一致的,都是 HashTable 的结构),即我们常说的字符串常量池。平时做业务开发和 StringTable 打交道会更多一些,HotSpot 也是基于节省内存的考虑为我们提供了 StringTable,我们可以通过 String.intern 的方式将字符串放入 StringTable 中来重用字符串。
Native Memory Tracking Native Memory Tracking 使用的内存就是 JVM 进程开启 NMT 功能后,NMT 功能自身所申请的内存。
观察上面几个区域的分配,没有明显的异常。
NMT 追踪到的 是 Committed,不一定是 Used,NMT 和 cadvisor 没有找到必然的对应的关系。可以参考 RSS,cadvisor 追踪到 RSS 是 650M,JVM Used 是 500M,还有大约 150M 浮动到哪里去了。
因为 NMT 只能 Track JVM 自身的内存分配情况,比如:Heap 内存分配,direct byte buffer 等。无法追踪的情况主要包括:
- 使用 JNI 调用的一些第三方 native code 申请的内存,比如使用 System.Loadlibrary 加载的一些库。
- 标准的 Java Class Library,典型的,如文件流等相关操作(如:Files.list、ZipInputStream 和 DirectoryStream 等)。主要涉及到的调用是 Unsafe.allocateMemory 和 java.util.zip.Inflater.init(Native Method)。
怎么追踪 NMT 追踪不到的其他内存
,目前是安装了 jemalloc 内存分析工具,他能追踪底层内存的分配情况输出报告。
通过 jemalloc 内存分析工具佐证了上面的结论,Unsafe.allocateMemory 和 java.util.zip.Inflater.init 占了 30%,基本吻合。
启动 arthas 查看下类调用栈,在 arthas 里执行以下命令:
通过上面的命令,能看到 MongoDB 和 netty 一直在申请使用内存。注意:早期的 mongodb client 确实有无法释放内存的 bug,但是在我们场景,长期观察会发现内存申请了逐渐释放了,没有持续增长。回到开头的 ContainerOOM 问题,可能一个原因是流量突增,MongoDB 申请了更多的内存导致 OOM,而不是因为内存不释放。
另外,arthas 自带的 profiler 有时候经常追踪失败,可以切换到原始的 async-profiler ,用他来追踪“其他”内存分配比较有效。
总结 Java 进程内存占用:Total=heap + non-heap + 上面说的这个其他。
jemalloc
jemalloc 是一个比 glibc malloc 更高效的内存池技术,在 Facebook 公司被大量使用,在 FreeBSD 和 FireFox 项目中使用了 jemalloc 作为默认的内存管理器。使用 jemalloc 可以使程序的内存管理性能提升,减少内存碎片。
比如 Redis 内存分配默认使用的 jemalloc,早期版本安装 redis 是需要手动安装 jemalloc 的,现在 redis 应该是在编译期内置好了。
原来使用 jemalloc 是为了分析内存占用,通过 jemalloc 输出当前内存分配情况,或者通过 diff 分析前后内存差,大概能看出内存都分给睡了,占了多少,是否有内存无法释放的情况。
后来参考了这个文章,把 glibc 换成 jemalloc 带来性能提升,降低内存使用,决定一试。
how we’ve reduced memory usage without changing any code:https://blog.malt.engineering/java-in-k8s-how-weve-reduced-memory-usage-without-changing-any-code-cbef5d740ad
Decreasing RAM Usage by 40% Using jemalloc with Python & Celery: https://zapier.com/engineering/celery-python-jemalloc/
一个服务,运行一周,观察效果。
注:以上结果未经生产长期检验。
内存交还给操作系统
注意:下面的操作,生产环境不建议这么干。
默认情况下,OpenJDK 不会主动向操作系统退还未用的内存(不严谨)。看第一张监控的图,会发现运行一段时间后,Pod 的内存使用量一直稳定在 80%–90%不再波动。
其实对于 Java 程序,浮动比较大的就是 heap 内存。其他区域 Code、Metaspace 基本稳定
Java 内存不交还,几种情况:
Xms 大于实际需要的内存,比如我们服务设置了 Xms768M,但是实际上只需要 256,高峰期也就 512,到不了 Xms 的值也就无所谓归还。
上面 jmap 的结果,可以看到 Java 默认的配置 MaxHeapFreeRatio=70,这个 70% Free 几乎很难达到。(另外注意 Xmx==Xms 的情况下这两个参数无效,因为他怎么扩缩都不会突破 Xms 和 Xmx 的限制)
对于 ZGC,默认是交还给操作系统的。可通过 -XX:+ZUncommit -XX:ZUncommitDelay=300
这两个参数控制(不再使用的内存最多延迟 300s 归还给 OS,线下环境可以改小点)。
经过调整后的服务,内存提交在 500–800M 之间浮动,不再是一条直线。
内存分析工具速览
使用 Java 自带工具 dump 内存。
使用 jmap 输出内存占用概览。
使用 async-profiler 追踪 native 内存分配,输出火焰图。
使用 vmtouch 查看和清理 Linux 系统文件缓存。
pmap 查看内存内容
使用 pmap 查看当前内存分配,如果找到了可疑的内存块,可以通过 gdb 尝试解析出内存块中的内容。
识别 Linux 节点上的 cgroup 版本
cgroup 版本取决于正在使用的 Linux 发行版和操作系统上配置的默认 cgroup 版本。 要检查你的发行版使用的是哪个 cgroup 版本,请在该节点上运行 stat -fc %T /sys/fs/cgroup/
命令:
对于 cgroup v2,输出为 cgroup2fs。
对于 cgroup v1,输出为 tmpfs。
问题原因分析和调整
回到开头问题,通过上面分析,2G 内存,RSS 其实占用 600M,为什么最终还是 ContainerOOM 了。
- kernel memory 为 0,排除 kernel 泄漏的原因。下面的参考资料里介绍了 kernel 泄露的两种场景。
- Cache 很大,说明文件操作多。搜了一下代码,确实有很多 InputStream 调用没有显式关闭,而且有的 InputSteam Root 引用在 ThreadLocal 里,ThreadLocal 只 init 未 remove。 但是,ThreadLocal 的引用对象是线程池,池不回收,所以这部分可能会无法关闭,但是不会递增,但是 cache 也不能回收。 优化办法:ThreadLocal 中对象是线程安全的,无数据传递,直接干掉 ThreadLocal;显式关闭 InputStream。运行一周发现 cache 大约比优化前低 200–500M。 ThreadLocal 引起内存泄露是 Java 中很经典的一个场景,一定要特别注意。
- 一般场景下,Java 程序都是堆内存占用高,但是这个服务堆内存其实在 200-500M 之间浮动,我们给他分了 768M,从来没有到过这个值,所以调低 Xms。留出更多内存给 JNI 使用。
- 线下环境内存分配切换到 jemalloc,长期观察大部分效果可以,但是对部分应用基本没有效果。
经过上述调整以后,线下环境 Pod 内存使用量由 1G 降到 600M 作用。线上环境内存使用量在 50%–80%之间根据流量大小浮动,原来是 85% 居高不小。
不同 JVM 参数内存占用对比
以下为少量应用实例总结出来的结果,应用的模型不同占用情况会有比较大差异,仅供对比参考。
基础参数 | 中低流量时内存占用(Xmx 6G) | 高流量时内存占用 |
---|---|---|
Java 8 + G1 | 65% | 85% |
Java 17 + G1 | 60% | 75% |
Java 17 + ZGC | 90% | 95% |
Java 21 + G1 | 40% | 60% |
Java 21 + ZGC | 80% | 90% |
Java 21 + ZGC + UseStringDeduplication | 85% | 90% |
Java 21 + ZGC + ZGenerational + UseStringDeduplication | 75% | 80% |
总结:
- G1 比 ZGC 占用内存明显减少。
- Java 21 比 Java 8、17 占用内存明显偏少。
- Java 21 ZGC 分代后确实能降低内存。
- 通过
-XX:+UseStringDeduplication
启用 String 去重后,有的应用能降低 10% 内存,有的几乎无变化。
分享我们所使用的 Java 21 生产环境参数配置,仅供参考请根据自己应用情况选择性使用:
- -XX:InitialRAMPercentage=40.0 -XX:MaxRAMPercentage=70.0:按照百分比设置初始化和最大堆内存。内存充足的情况下建议设置为一样大。
- -XX:+UseZGC -XX:+ZUncommit -XX:ZUncommitDelay=300 -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=30:促进 Java 内存更快交还给操作系统,但同时 CPU 可能偏高。
- -XX:+ZGenerational:启用分代 ZGC,能降低内存占用。
- -XX:+UseStringDeduplication:启用 String 去重,可能降低内存占用。
- -Xss256k:降低线程内存占用,默认 1Mb,线程比较多的情况下这个占用还是很多的。谨慎设置。
- -XX:+ParallelRefProcEnabled:多线程并行处理 Reference,减少 GC 的 Reference 数量,减少 Young GC 时间。
关于 Java 8、17 和 21 不同 GC 更多维度的对比效果可参考: JDK 21: The GCs keep getting better 。
Java 分析工具
- [推荐] 在线 GC 分析工具 GCeasy.io
- [推荐] 在线 Thread 分析工具 FastThread.io
- [推荐] 在线 Heap 分析工具 HeapHero.io
- [推荐] 在线 jstack 分析工具 jstack.review
- [Beta] 可私有化部署 Online GC、Heap Dump、Thread、JFR 分析工具 eclipse/jifa