问题现象
将生产环境的JDK从8升级到JDK21后,系统出现多次内存溢出(OOM)告警,并伴有Pod重启的现象。
概念梳理
在深入分析之前,我们先明确几个关键概念,熟悉相关内容的读者可以直接跳转到【OOM分析】部分。
组件、Pod与容器的关系
在Kubernetes中,一个Deployment(组件)管理多个Pod实例,而一个Pod内可以运行多个容器。Pod可类比为房子,容器则是其中的房间。一个典型的包含Sidecar容器的Deployment配置如下:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-with-sidecar-deployment
spec:
replicas: 3
selector:
matchLabels:
app: web-app
template:
metadata:
labels:
app: web-app
spec:
containers:
- name: main-nginx
image: nginx:alpine
ports:
- containerPort: 80
- name: sidecar-monitor
image: busybox:1.35
JVM堆内存、NMT与WorkingSet的关系
- 容器内存工作集(Workingset, WSS):操作系统维度,统计容器当前运行所需的所有活跃和近期被访问的内存页。
- NMT(Native Memory Tracking):JVM维度,追踪JVM自身(包括堆和堆外)向操作系统申请的内存。它分为
reserved(预留地址空间)和committed(实际提交使用)两部分。
- JVM堆内内存:JVM管理的堆区域,是NMT统计的一部分,也直接影响WSS。
通常的关系为:Pod总内存 > Workingset > NMT > JVM堆内内存。但需注意,JVM的NMT不会统计Glibc内存分配器(Arena)的元数据开销。
K8S的OOM Killer决策机制
Kubernetes主要依据容器的WorkingSet是否接近其内存限制(memory limit)来决定是否终止容器。即使JVM自身内存(NMT)占用不高,只要WSS超标,就会触发OOMKilled。
ZGC垃圾回收器的内存特性
ZGC为实现低延迟,会在启动时预分配大量堆外内存维护其数据结构。JDK21默认开启了-XX:+AlwaysPreTouch参数,导致堆内存物理页在启动时即被完全提交,增大了启动时的物理内存占用。若设置-Xms等于-Xmx,ZGC将隐式禁用内存归还功能,不利于内存回收。
Arena概念
Arena是Glibc内存分配器为缓解多线程分配竞争而设计的独立内存池。每个操作系统线程(pthread)可能拥有自己的Arena(默认最大数量可达CPU核心数的8倍),用于缓存malloc分配的内存。即使线程闲置,其Arena占用的内存也可能被内核记为inactive_anon(非活跃匿名页),而不会被立即释放,导致“幽灵内存”累积。
常见触发Arena分配的场景包括:Tomcat请求处理线程、JDBC驱动操作、Redis客户端缓冲区分配以及日志框架的堆外操作等。
OOM分析
现象与配置
服务Pod因OOMKilled重启。容器内存限制为4GB(4096M)。应用的JVM参数为 -Xms3g -Xmx3g。
初步排查
通过监控和jcmd查看NMT,发现JVM堆内及自身管理的堆外内存(committed)稳定在约3.6GB,使用率不足30%,排除JVM内部内存泄漏的可能。
锁定问题:WSS异常增长
监控显示,容器的WorkingSet持续增长并逼近4GB限制。通过分析容器的memory.stat文件,发现增长主要来自inactive_anon(非活跃匿名内存)。
cat /sys/fs/cgroup/memory/memory.stat | awk '/^inactive_anon/ {tc=$2} /^active_anon/ {tr=$2} /^active_file/ {tif=$2} END {printf \"WSS Total: %.2f M\\n\", (tc + tr + tif)/(1024*1024)}'
对比不同时间点的数据,RSS与inactive_anon同步大幅增长,而shmem(共享内存)未变,表明增长的是进程私有的匿名内存页。这与Glibc Arena的内存行为高度吻合:Arena分配的内存即使不再使用,也常作为inactive_anon滞留,不被迅速归还给操作系统。
使用pmap与NMT对比工具(如开源脚本memleak.sh),也证实了存在大量NMT未追踪的内存差异,进一步指向了JVM之外的系统堆外内存分配。
分析与解决方案
综合现象,根本原因是JDK21环境下,应用(可能是框架或驱动通过JNI调用)触发了Glibc创建过多的Arena,导致堆外内存(inactive_anon)在容器中不断累积,最终使WorkingSet超出K8s内存限制。
解决方案是限制每个进程可创建的Arena数量。通过设置环境变量MALLOC_ARENA_MAX,可以有效控制这部分内存开销。对于常规的Java及SpringBoot应用,设置为4是一个合理的起始值。
优化与结果
优化后的K8s容器配置
env:
- name: MALLOC_ARENA_MAX
value: “4”
- name: JVM_OPTS
value: -XX:+UseContainerSupport -XX:InitialRAMPercentage=50.0 -XX:MinRAMPercentage=50.0 -XX:MaxRAMPercentage=65.0
优化效果
- WSS稳定:设置后,容器
WorkingSet稳定在3900M左右,不再持续增长。
- 内存差异减少:
pmap与NMT的对比显示未追踪的内存差异显著减少。
- 问题解决:应用未再发生
OOMKilled重启。
其他JVM优化建议
- 避免固定堆大小:
-Xms与-Xmx不要设置相同值,以便ZGC能向操作系统归还空闲内存。
- 使用容器感知参数:推荐使用
-XX:+UseContainerSupport配合-XX:MaxRAMPercentage等参数,让JVM根据容器配额动态调整堆大小,这比写死的-Xmx参数更适应Kubernetes环境。
- 调整ZGC内存归还延迟:若内存非常紧张,可考虑调低
-XX:ZUncommitDelay(默认300秒),但需注意这可能会增加GC频率,影响性能。
总结
当Kubernetes中的容器发生OOM时,首先应通过NMT等工具排查JVM自身内存。若JVM内存稳定,则需转向分析容器WorkingSet,特别是memory.stat中的inactive_anon项。若其持续增长,很可能是Glibc Arena导致的操作系统堆外内存泄漏。通过合理设置MALLOC_ARENA_MAX环境变量,可以有效遏制此类内存增长,解决因升级JDK21(特别是使用ZGC)后出现的堆外OOM问题。