信创上云,国产系统Kylin 压测容器重启,接口响应耗时过长问题排查(openjdk17)
(内含 60 个 jvm 参数详解、调优思路说明)
最近扎进信创上云的落地实战,碰到了一个棘手的问题 ------ 国产操作系统搭配容器化部署后,核心接口出现了RT拉长、偶发OOM的情况。
搁以前,遇到这类问题常能靠扩容硬件快速化解,内存不够加内存、CPU过载扩节点,JVM调优没什么用武之地。
现在不一样了,都在搞节能减排...呸!降本增效那一套,裁员降薪后服务器资源也一砍再砍,"堆硬件"的简单解法行不通了。
反而倒逼我们沉下心,在信创上云+国产OS容器重启的特定场景里,主动排查根因、攻坚优化,这次就把整个排查过程整理出来和大家分享。

▶️▶️▶️ 正文开始 ▶️▶️▶️
问题一:测试环境发生重启
一、现象描述
Java 服务一开始压测,1小时内必重启。通过 "容器云平台监控" 发现:
- 进程消失了,健康检查失败,故服务重启。
- 重启前系统资源监控显示
cpu 使用率 100%,内存使用率 98+%
二、检查日志和配置
1. 主要检查点
- 检查 是否生成
heapdump(堆转储)文件 - 检查
GC 日志 - 检查
容器编排配置文件 - 检查
容器启动脚本
2. 检查结果
未发现堆转储(heapdump)文件,无 GC 日志;
------> 检查容器 编排配置 和 启动脚本,
------> 均未开启 gc 日志,启动脚本 heapdump 路径非容器挂载路径。
3. 调整 JVM 参数
调整 run.sh, 必须包含如下 jvm 参数,
涉及目录路径都要用容器挂载路 spec.containers.args.volumeMounts.mountPath
ruby
# 发生 OOM 时,JVM 终止退出
-XX:+ExitOnOutOfMemoryError
# 发生 OOM 时生成 Java 堆转储
-XX:+HeapDumpOnOutOfMemoryError
# 堆转储文件保存位置
-XX:HeapDumpPath=/logs/${POD_NAMESPACE}/${APP_NAME}/heapdump_start_$(date +"%Y-%m-%d_%H%M%S").hprof
# JVM 发生致命错误时,日志文件保存位置
-XX:ErrorFile=/logs/${POD_NAMESPACE}/${APP_NAME}/${HOSTNAME}-hs_err_pid.$$.log
# 开启 gc 日志
-Xlog:gc*:file=/logs/${POD_NAMESPACE}/${APP_NAME}/gc-%t.log:hostname,time,uptime,pid:filecount=5,filesize=10M
还需要在 start_java_server 函数中添加 日志清理 的代码:
bash
start_java_server(){
echo "----start java process----"
touch /app/bin/test.log
eval java $JAVA_OPTS -jar $appname
# tail -f /app/bin/test.log # 这行代码不要了,可以直接删除
while true; do
# 清理两天前的 gc 日志
find "${LOG_PATH}" -mtime +2 -type f -name "*.log*" -exec rm {} ;
# 清理 7 天前的 kafka 故障转移日志
find "${LOG_PATH}" -mtime +7 -maxdepth 1 -type d -not -name "*${HOSTNAME}*" -exec rm -rf {} ;
# 24 小时执行一次
sleep 86400
done
}
4. 部署验证
重启后仍然没有 heapdump 文件,观察 GC 日志,垃圾回收正常。
三、检查 Linux 内核日志
执行命令:
perl
dmesg | grep -iE "killed.*java"
发现如下日志:
arduino
Killed process 1939065 (java) total-vm:131428792kB, anon-rss:869604kB, file-rss:0kB, shmem-rss:6678808kB
1. 定位原因
通过 dmesg 日志发现 java 进程被 kill,其中异常指标为:
shmem-rss: 共享内存(Shared Memory)驻留大小(Resident Set Size)约 6.37G
2. 为什么共享内存这么高?
通过查阅资料了解到 ZGC 为了实现亚毫秒级停顿 ,使用了多视图映射技术 ,这项技术重度依赖共享内存的使用。
arduino
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <stdint.h>
int main()
{
// 创建一个共享内存的文件描述符
int fd = shm_open("/example", O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd == -1) return 0;
// 防止资源泄露,需要删除。执行之后共享对象仍然存活,但是不能通过名字访问
shm_unlink("/example");
// 将共享内存对象的大小设置为4字节
size_t size = sizeof(uint32_t);
ftruncate(fd, size);
// 两次调用mmap,把一个共享内存对象映射到两个虚拟地址上。
int prot = PROT_READ | PROT_WRITE;
uint32_t *add1 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);
uint32_t *add2 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);
// 关闭文件描述符
close(fd);
// 测试,通过一个虚拟地址设置数据,两个虚拟地址得到相同的数据
*add1 = 0xdeafbeef;
printf("Address of add1 is: %p, value of add1 is: 0x%x\n", add1, *add1);
printf("Address of add2 is: %p, value of add2 is: 0x%x\n", add2, *add2);
return 0;
}
3. 多视图映射技术
有兴趣的可以看看(可以跳过)
| 视图 | 作用 | 对象状态 |
|---|---|---|
| Marked0 | 标记周期0的存活对象 | 第一次标记后存活 |
| Marked1 | 标记周期1的存活对象 | 第二次标记后存活 |
| Remapped | 已重定位的对象 | 转移完成后 |
- 并发标记(mark)
arduino
// 传统GC:需要暂停应用来标记对象
// ZGC:完全并发标记,无暂停
// 标记过程:
// 1. 应用线程正常访问对象(通过Remapped视图)
// 2. GC线程并发标记,将对象指针切换到Marked0/Marked1视图
// 3. 应用线程无感知,继续执行
- 并发转移(relocate)
java
// 传统GC:转移对象需要暂停应用
// ZGC:并发转移 + 指针自愈
Object obj = heap.getObject(pointer); // 正常访问
// 如果对象正在被转移:
// 1. 触发页面异常(page fault)
// 2. ZGC处理器将对象转移到新位置
// 3. 更新指针到新地址(指针自愈)
// 4. 应用线程继续执行,无感知
- 指针自愈(remap)
scss
// 指针结构(64位)
[ 62位地址 | 2位元数据 ]
// 元数据位含义:
00 → Remapped 视图
01 → Marked0 视图
10 → Marked1 视图
11 → 保留
// 当应用访问"旧"指针时:
if (指针视图 != 当前活动视图) {
// 触发指针自愈逻辑
新指针 = 查找转发表(旧指针);
// 原子性地替换指针
CAS(对象指针, 旧指针, 新指针);
}
4. 调整配置
这里尝试调整了各种容器编排配置、JVM 参数、Linux 系统参数,都没有效果,最后决定 放弃 ZGC ,改用 G1 垃圾回收器,并补充了一些内存限制参数。
调整 run.sh 添加如下 JVM 参数:
ruby
# 1C3G 容器配置参考
# 让 JVM 知道当前运行环境是容器,防止 JVM 读取宿主机的配置
-XX:+UseContainerSupport
# 这里配置容器限制内存,避免 JVM 读取宿主机配置
-XX:MaxRAM=3072
# JVM 可以使用的最大内存 75% * MaxRAM
-XX:MaxRAMPercentage=75.0
# 删除 ZGC 相关的 JVM 参数,使用 G1 垃圾回收器
# 使用 G1 必须删除 -Xmn 配置
-XX:+UseG1GC
5. 部署验证
不再出现重启。
问题二:生产环境发生重启
一、现象描述
Java 服务压测发生重启,通过 dmesg 命令查看 Linux 内核日志:

如图所示(最底下3行),发生了 OOM, java 进程被系统 kill 了 ,anon-rss 显示约 2.97G, 其中 java 进程 rss(驻留内存)785586 数据页,785586 * 4kB ≈ 3G.
与先前测试环境重启现象不同,生产压测的接口也与测试环境不一样。
二、测试环境复现
通过相同的接口压测,立马在测试环境复现了生产环境的问题。
1. 初步分析
同样是没有 heapdump 文件生成、GC 正常,但 Linux 内核日志显示 Memory cgroup out of memory.执行 cat /proc/${PID}/cmdline | tr '\0' '\n' 检查 jvm 参数,如下参数存在:
ruby
# 发生 OOM 时,JVM 终止退出
-XX:+ExitOnOutOfMemoryError
# 发生 OOM 时生成 Java 堆转储
-XX:+HeapDumpOnOutOfMemoryError
# 堆转储文件保存位置
-XX:HeapDumpPath=/logs/...
那么,没有发现 heapdump 文件就意味着 JVM 内存没有溢出。
2. 工具分析
使用 jmc, jvisualvm, arthas 监控 JVM 内存变化,所有区域(堆、元空间、线程数、直接内存)内存一直很平稳,一直到 java 进程被杀,JVM 内存使用情况都一直在安全区域,甚至离我设置的最大值还差很多。
再次确认 ------------ JVM 内存不存在 OOM 问题。
3. 参数调优
确定了 JVM 内存不存在溢出,那就是 JVM 之外的内存发生了溢出,常见的有:
java.nio.*库的方法调用,比如ByteBuffer.allocateDirectsun.misc.Unsafe类的方法调用
这些方法底层都是 使用 JNI(Java Native Interface)的方式 调用 C/C++ 库的函数,这部分函数的执行、内存分配、内存管理 由操作系统控制,JVM 管不到。
调优策略 - 因为 JVM 无法管理 native 区域的内存,也监控不到,那就先把容器内存限制调大,看看到底内存能涨到多少,再调整参数。
具体如下:
-
内存调整到
4G------> 压测- 溢出
-
缩小 heap、metaspace、thread-stack、direct 区域的内存,尽可能把空间都留给 native 区
- 还是溢出
-
优化
高CPU占用的线程调用链上的方法(日志相关):减少日志输出,调整日志相关的缓存、队列、IO 等参数- 还是溢出
-
调整 log4j2.xml 中 kafka 相关参数,降低 cpu & memory 消耗
- 还是溢出
-
调整 nio 相关参数,降低 cpu & memory 消耗
- 还是溢出
-
内存调整到
6G并调整日志输出级别为 ERROR ------> 压测 48 小时- 没有溢出,内存占用飙升到 5G 多
4. 定位原因
无论怎么调整,JVM 内存始终稳定在 2G 以内,那多出来的 3G+ 就是 JNI 产生的内存(native memory)。
以上是重点,以下是弯路(方向不对,白费功夫,引以为戒):
-
尝试各种命令,写了近 10 个监控脚本,试图追踪 "造成 3G+ native 内存" 的元凶
- 失败
-
尝试各种工具,生成堆栈、线程栈,结合监控脚本和代码分析,试图定位 "真凶"
- 失败
回到重点:
-
JVM memoryvsnative memory=1.x : 3.x-
什么样的代码能导致怎样的比例?
- "内存泄漏" 的代码
-
-
native memory100% 内存泄漏了!!!-
通过监控、线程堆栈分析:
- 不存在会产生大量 JNI 调用的 Java API
-
通过监控、内存堆栈分析:
- 不存在大量跟 JNI 有关的对象或类
-
-
结合其他情况判断,可能与业务代码无关:
-
常规生产环境、常规测试环境都没有问题
-
其他服务也有重启的现象,gateway 服务也有重启的现象,它甚至都没有业务代码
- 如果不是业务代码的问题,那是某个第三方依赖包有问题?
-
即使停止压测,内存也不会降下来
- native memory 区域为什么不会回收?
-
-
第三方依赖包有问题的概率比较小,毕竟这么夸张的内存泄漏,有问题也轮不到我发现
5. 确定排查方向
既然 JVM 范围内找不出答案,索性从 JVM 之外找找看(知识盲区,整理上述分析结果,让 AI 给我提供思路)。
-
如图所示,我把 JVM 内存使用情况(需要开启参数
-XX:NativeMemoryTracking=summary)喂给 AI 并简单描述了一下。
-
AI 的回答,总结来说就是
Metaspace的类数量很多,最可能发生溢出,还有就是监控相关的(这个忽略)
-
通过前面的分析,我很肯定的告诉他,
Metaspace不存在溢出,然后补充了详细描述:
-
结论一致,就是
Native Memory问题,然后给出很多排查方式,逐一验证,结果:gdb命令报错lsof命令不存在- 需要安装
bpfcc-tools工具 pmap输出内容并没有什么帮助
-
虽然很多验证不了,不过结合前面的分析,还是可以排除很多选项,最后锁定
glibc arena缓存:
-
继续追问 AI,如下描述和我观察到的现象如出一辙
- glibc 的 malloc arena 缓存、不释放

- glibc 的 malloc arena 缓存、不释放
三、验证 jemalloc
- 官网:jemalloc.net/
- GitHub: github.com/jemalloc/je...
- 下载
jemalloc 5.3.0 源码:github.com/jemalloc/je...
1. 编译
bash
tar xvf jemalloc-5.3.0.tar.bz2
cd jemalloc-5.3.0
./configure --prefix=$HOME/.local
make -j$(nproc)
make install
# 编译完成,拷贝 libjemalloc.so.2 到 /lib64
cp $HOME/.local/lib/libjemalloc.so.2 /lib64/
2. 修改容器启动脚本
run.sh 最顶部添加:
ini
# ===== 关键内存参数 (必须放在最顶部) =====
export MALLOC_ARENA_MAX=2 # 1核CPU只需2个arena (默认8)
export MALLOC_TRIM_THRESHOLD_=131072 # 128KB空闲立即归还OS (默认128KB但失效)
export MALLOC_MMAP_THRESHOLD_=131072 # >128KB分配走mmap (独立释放)
export MALLOC_TOP_PAD_=0 # 禁用顶部填充 (防碎片)
export GLIBC_TUNABLES=glibc.malloc.trim_threshold=131072 # 双重保险
# ===== 强制使用 jemalloc(容器环境必须) =====
export LD_PRELOAD=/lib64/libjemalloc.so.2
3. 深度优化
在 /etc/malloc.conf 添加如下配置
bash
# 1C3G 容器黄金配置
dirty_decay_ms:1000, # 1秒后归还 dirty pages
muzzy_decay_ms:100, # 100ms后归还 muzzy pages
background_thread:true, # 启用后台回收线程 (关键!)
max_background_threads:1, # 1核容器只需1个线程
metadata_thp:auto, # 优化元数据内存
tcache:false, # 禁用 thread cache (防 TLS 碎片)
arena_bind:true, # 1核CPU绑定1个arena
abort_conf:true # 配置错误时崩溃 (防静默失效)
容器中可以在 dockerfile 文件中,通过如下指令实现:
bash
USER root
RUN echo "dirty_decay_ms:1000,muzzy_decay_ms:100,background_thread:true,max_background_threads:1,metadata_thp:auto,tcache:false,arena_bind:true,abort_conf:true" > /etc/malloc.conf
或者,在启动脚本中添加:
ini
export MALLOC_CONF="dirty_decay_ms:1000,muzzy_decay_ms:100,background_thread:true,max_background_threads:1,metadata_thp:auto,tcache:false,arena_bind:true,abort_conf:true"
重新部署后,通过下面命令验证 jemalloc 是否生效
perl
jcmd <pid> VM.dynlibs | grep jemalloc
# 有输出就表示生效
4. 验证
效果非常显著,内存不再无限上涨,具体如下:
| 使用 jemalloc 前 | 使用 jemalloc 后 | |
|---|---|---|
| 3G 容器 | 内存很快飙升到 3G,进程被杀 | 压测 24 小时,内存稳定在 2.4G 以下 |
| 6G 容器 | 内存很快飙升到 4G,持续上涨,最高达到 5G+ | 压测 3 小时,内存稳定在 2.4G 以下 |
问题三:账户列表查询接口耗时过长
一、现象描述
这是一个非常简单的接口,仅包含一次 ESB 接口调用,预期 RT 应该在 100~300ms。但是 偶尔 会出现超过 1000ms,甚至 2000~5000ms。
二、日志分析
通过应用日志观察,剔除"网络调用耗时过长"的交易,仍有不少 代码执行耗时(无IO) 超过 1 秒的交易。
三、分析代码
结合请求响应报文分析 可能的代码长耗时点, 同一客户、相同请求参数、相同响应参数也是时快时慢,排除代码问题导致。
四、查看 GC 日志
发现 Young GC 耗时异常(> 100ms) 
甚至有高达 300+ ms 的 
五、jvm 参数调整
发现一个严重的配置错误:-Xmn776m,在 G1 / ZGC 中不能使用 Xmn 参数。
-
在
G1 GC中使用-Xmn是严重配置错误 ,会破坏 G1 的核心自适应机制, 导致停顿时间失控、吞吐量暴跌 40%+ -
关键区别:
- Parallel GC :
-Xmn直接设置 Young Gen 大小(物理分区) - G1 GC :Young Gen 是逻辑集合,由多个 Region 动态组成
- 设置 -Xmn 会强制 G1 退化为 Parallel GC 模式(丧失核心优势)
-Xmn过大,1核的 CPU 无法在 80ms 内完成回收
- Parallel GC :
css
graph LR
A[传统 GC] --> B[固定分代]
B --> C[Young Gen: Eden+Survivor]
B --> D[Old Gen: Tenured]
C -->|-Xmn 控制| E[固定大小]
F[ZGC] --> G[单代+动态 Region]
G --> H[Colored Pointers]
G --> I[Load Barriers]
G --> J[并发压缩]
H --> K[无物理分代边界]
-
在 ZGC 中设置 -Xmn 不仅无效,更是严重配置错误 ,会导致 JVM 忽略关键内存参数,在 1C3G 容器中引发:
- 内存分配失控 → RSS 超限 OOMKilled
- GC 自适应失效 → 停顿时间飙升 300%
-
ZGC 核心设计:
- 无物理年轻代:所有对象在统一堆中分配
- 动态 Region 管理:根据对象生命周期自动调整
- 就地压缩 (In-place Relocation) :无需 Survivor 空间
css
[0.213s][warning][gc] -Xmn is not supported with ZGC, ignoring
[0.214s][warning][gc] Use -XX:SoftMaxHeapSize for heap sizing control
总结
一、JVM 参数(最终版)
- 必须放在 JVM 参数最前面
ruby
# 内存预碰撞
-XX:+AlwaysPreTouch
-XX:+TransparentHugePages # 配合使用 (仅物理机有效)
# 启用 TransparentHugePages 会自动将物理内存分成若干个大小为2MB的大块,并将其映射到系统的透明大页缓存中。当JVM需要分配一块内存时,如果该内存大小小于等于2MB,则直接从系统透明大页缓存中获取;否则,会将该内存拆分成若干个小块,并分别从系统透明大页缓存中获取。这样做的效果是可以避免因内存碎片而导致的内存不足问题,同时也可以减少内存访问的延迟和抖动。
容器必配
ini
# 告知 JVM 当前运行环境为容器,避免 JVM 错误识别宿主机配置
-XX:+UseContainerSupport
# 告知 JVM 当前容器 cpu 核数,避免 JVM 错误识别宿主机配置
-XX:ActiveProcessorCount=1
# 告知 JVM 当前容器内存限制,避免 JVM 错误识别宿主机配置
-XX:MaxRAM=3072m
# JVM 最大可用内存 MaxRAM * 75%,
# 保守一点可以设为 70%, 留 30% 给操作系统,根据压测情况做调整
-XX:MaxRAMPercentage=75.0
堆(Heap)
bash
# 最大堆、最小堆空间,一般设置为同一个值,我是因为压测都不会超过 1200m
# 使用 G1 或 ZGC 绝对不能设置 -Xmn (新生代大小)
-Xms1320m -Xmx1720m
栈空间(Stack)
ini
# 线程栈空间限制,必须指定
# 默认值是 1MB,用不到纯浪费,300 个线程就是 300MB
# 一般没有很深的调用栈设置 256k 即可(微服务推荐),300 个线程可节省 225MB
-XX:ThreadStackSize=256k
元空间(Metaspace)
ini
# 按实际情况调整,比如压测峰值 164m
# 如果内存充足,不希望发生 Full GC, 可以 初始值=最大值
-XX:MetaspaceSize=128m # 初始阈值 (接近峰值的 80%)
-XX:MaxMetaspaceSize=192m # 硬限制 (164MB * 1.17 = 192MB)
-XX:MinMetaspaceFreeRatio=30 # 降低空闲比例 (加速回收)
-XX:MaxMetaspaceFreeRatio=60 # 限制最大空闲 (防碎片)
直接内存(Direct)
ini
# 必须限制,1C3G容器绝对上限:128m
# 扣除 75% 的JVM, 再扣除操作系统运行消耗 100+M, 系统仅剩 600+M 的可用内存,还要用于操作系统 native memory、缓存等,这里不限制很容易发生 OOMKilled
-XX:MaxDirectMemorySize=128m
垃圾回收(GC)
ini
# 使用 G1 或 ZGC 绝对不能设置 -Xmn (新生代大小)
-XX:+UseG1GC
-XX:+UseDynamicNumberOfGCThreads # 启用动态调整 (默认开启)
-XX:ParallelGCThreads=2 # STW 线程 = 2 (1C 容器最大值)
-XX:ConcGCThreads=1 # 并发线程 = cpu核心数
-XX:G1NewSizePercent=25 # 新生代最小值:25% 堆
-XX:G1MaxNewSizePercent=35 # 新生代最大值:35% 堆
-XX:MaxGCPauseMillis=80 # GC 暂停,严格 80ms 目标
# 手动设置每个 region 的大小,平衡碎片与回收效率
# 过大:碎片率增加
# 过小:region 数量增加,GC 元数据膨胀,CPU 超载
# 1 核 CPU 无法处理 >1000 个 Region 的并发标记,768 个 Region (2MB) 是 1C 容器的最佳配置
-XX:G1HeapRegionSize=2M
# 允许 10% 空闲 (加速回收)
-XX:G1HeapWastePercent=10
# RSet 更新占比,默认值 10, Young GC 暂停中最多 10% 用于 RSet 更新
# 1C3G 容器 Young GC 时间分配:
# - 对象复制:65
# - RSet 更新:25
# - 其他开销:10
# 调小后,Rset 更新不完整,增加 Mixed GC 频率
-XX:G1RSetUpdatingPauseTimePercent=5
# 控制 Mixed GC 阶段计划执行的 GC 次数
# 单次 Mixed GC 回收的Region数量 ≈ 需要回收的Region总数 / G1MixedGCCountTarget
# 调大 ==> 单次 GC 停顿短,总回收时间长
# 调小 ==> 单次 GC 停顿长,总回收时间短
-XX:G1MixedGCCountTarget=8
# 触发 Mixed GC 的老年代使用率,默认是 45
-XX:InitiatingHeapOccupancyPercent=35
# 可以指定 JVM 在启动时为 G1 垃圾回收器保留的堆内存百分比
# 好处:可以避免在垃圾回收期间出现"暂停时间过长"的问题
-XX:G1ReservePercent=20
# gc 日志必须开,路径必须是容器挂载目录,还需在容器启动脚本中添加相关的清理脚本
-Xlog:gc*:file=/logs/${POD_NAMESPACE}/${APP_NAME}/gc-%t.log:hostname,time,uptime,pid:filecount=5,filesize=10M
OOM相关(内存溢出, Out of memory)
ruby
# 发生 OOM 时,立即终止(硬崩溃),除了无状态 API 服务,其他场景慎用,支付核心服务禁用
-XX:+CrashOnOutOfMemoryError
# 发生 OOM 时,优雅退出(软关闭)
-XX:+ExitOnOutOfMemoryError
# 以上参数二选一,建议 -XX:+ExitOnOutOfMemoryError
# 发生 OOM 时生成 Java 堆转储
-XX:+HeapDumpOnOutOfMemoryError
# 堆转储文件保存位置,路径必须是容器挂载目录
-XX:HeapDumpPath=/logs/${POD_NAMESPACE}/${APP_NAME}/heapdump_start_$(date +"%Y-%m-%d_%H%M%S").hprof
# JVM 发生致命错误时,日志文件保存位置,路径必须是容器挂载目录
-XX:ErrorFile=/logs/${POD_NAMESPACE}/${APP_NAME}/${HOSTNAME}-hs_err_pid.$$.log
# 开启 NMT: 本地内存追踪
-XX:NativeMemoryTracking=summary
# 安全的诊断替代方案 (无大文件),避免写满磁盘空间,导致节点故障
-XX:OnOutOfMemoryError="jcmd %p VM.native_memory summary > /logs/${POD_NAMESPACE}/${APP_NAME}/oom_nmt.log"
-XX:OnOutOfMemoryError2="jcmd %p VM.classloader_stats > /logs/${POD_NAMESPACE}/${APP_NAME}/oom_classes.log"
本地内存(native memory)
ruby
-XX:InitialCodeCacheSize=64m # JIT 缓存初始值
-XX:ReservedCodeCacheSize=128m # JIT 缓存上限
-XX:+UseCodeCacheFlushing # 启用回收
-XX:+SegmentedCodeCache # 代码缓存分段,必须开启
# 启用分层编译(默认开启),以下情况保持启用:
# - 微服务、容器化应用
# - 命令行工具、短期任务
# - 需要快速响应的交互应用
# - 大多数Web应用
-XX:+TieredCompilation
# 禁用分层编译,启动更慢,方法需要更长时间才能被优化
# 适用于长期运行的服务端应用:
# - 对启动时间不敏感的场景
# - 需要极致峰值性能的计算任务
# - 遇到分层编译相关bug时
# - 需要极致峰值性能的计算任务
-XX:-TieredCompilation
反射与代理
ini
# 作用:禁用 JNI 本地方法膨胀(反射调用优化),false 表示启用膨胀
# 好处:提高反射性能,减少初始反射调用开销(20-30%), 降低 Metaspace 碎片率
# 坏处:增加永久代/元空间使用,启动稍慢
# 在 1C3G 容器中,如果应用使用反射较多,可以开启,但需注意元空间内存。建议监控元空间使用情况。
-Dsun.reflect.noInflation=false
# 作用:不将动态代理生成的类文件保存到磁盘
# 好处:避免在磁盘上生成临时文件,提高性能,并减少磁盘 I/O
# 坏处:不便于调试动态代理问题
# 推荐:在生产环境可以设置为 false,避免磁盘占用。在 1C3G 容器中,通常不需要保存这些文件,所以推荐
-Djdk.proxy.ProxyGenerator.saveGeneratedFiles=false
线程池与并发
ini
# 作用:设置 ForkJoinPool 公共池的最大线程数
# 好处:控制并行流和 CompletableFuture 等使用的公共线程池的大小,避免创建过多线程
# 坏处:如果并行任务较多,可能会限制并行性能
# 推荐:CPU 核数 * 1.5 = 1 * 1.5 → 2
-Djava.util.concurrent.ForkJoinPool.common.maximumPoolSize=2
# 作用:限制 NIO CompletionHandler 最大数量
# 好处:提高 NIO 通道的并发处理能力
# 坏处:设置过大会占用更多内存
# 推荐:每个 handler 保留 64KB native 内存 → 128 * 64KB = 8.2MB
-Dsun.nio.ch.maxCompletionHandlers=64
Netty 优化
ini
# 作用:设置 Netty EventLoop 线程数
# 好处:控制 Netty 的线程数,避免线程争抢
# 坏处:线程数少可能在高并发时成为瓶颈
# 推荐:在 1 核容器中,2 个线程是合理的,因为 Netty 通常每个线程处理多个通道
-Dio.netty.eventLoopThreads=2
# 作用:设置 Netty 使用的最大直接内存。0 表示使用 JVM 的 MaxDirectMemorySize(默认与-Xmx相同),与 -XX:MaxDirectMemorySize 冲突 → 覆盖 JVM 限制,必须限制
# 好处:避免 Netty 使用过多的直接内存
# 坏处:如果应用需要大量直接内存,可能会限制性能
# 推荐:MaxDirectMemorySize * 75%
-Dio.netty.maxDirectMemory=96
# 作用:使用池化的 ByteBuf 分配器
# 好处:减少 direct memory 分配和回收的开销,提高性能
# 坏处:可能会占用更多内存,内存池需要 2 倍内存空间 → 128MB 需 256MB
# 推荐:在生产环境中推荐使用 pooled
-Dio.netty.allocator.type=pooled
# 如果内存紧张就用 unpooled
-Dio.netty.allocator.type=unpooled
-Dio.netty.allocator.maxOrder=9 # 降低池大小
# 作用:禁用 Netty 的内存泄漏检测
# 好处:提升性能,降低 5% CPU 开销
# 坏处:如果存在内存泄漏,将无法检测到
# 推荐:在生产环境可以禁用,但如果在开发测试阶段已经通过检测,则可以禁用
-Dio.netty.leakDetectionLevel=DISABLED
# 平衡开销与安全:每 1000 次分配检查 1 次,避免泄漏无感知
-Dio.netty.leakDetectionLevel=SIMPLE
Log4j2
ini
# 作用:使用异步日志记录器,将日志事件放入环形缓冲区,由后台线程写入
# 好处:减少日志记录对应用性能的影响,特别是同步日志造成的延迟
# 坏处:在崩溃时可能会丢失部分日志,并且需要额外的内存用于缓冲区
# 推荐:在 1C3G 容器中,如果日志量较大,可以使用异步日志,但需注意环形缓冲区大小和内存使用
-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector
# 作用:AsyncLogger 等待策略
# 好处:比 timeout 更低的唤醒延迟
# 坏处:可能比 block 更高的 CPU 使用
# 推荐:yield 平衡性能和延迟
-Dlog4j2.asyncLogger.waitStrategy=yield
# 作用:RingBuffer 大小(条目数)
# 推荐:64 K 足够 1000 TPS
-Dlog4j2.asyncLoggerRingBufferSize=65536 # 65536 * 32 字节 = 2MB
# 禁用 JMX 监控,容器环境必须禁用,减少 50MB 内存开销,加快启动 15%
-Dlog4j2.disable.jmx=true
# 避免类加载泄漏,传统的外部 web 容器需要设置为 true
-Dlog4j2.isWebapp=false
# 作用:启用 ThreadLocal 优化
# 坏处:增加 TLS 碎片,可能导致内存泄漏
# 推荐:必须启用以保证日志完整性
-Dlog4j2.enableThreadlocals=true
# 作用:设置解包(unbox)环形缓冲区的大小,用于将基本类型转换为字符串时减少对象分配。
# 好处:减少基本类型日志记录时的装箱操作,减少GC压力。
# 坏处:缓冲区大小固定,如果并发日志记录很多,可能无法覆盖所有线程。
# 推荐:启用无装箱(1C 容器必须)
-Dlog4j2.unboxRingBufferSize=256
二、错误反思
1. 没有严格审核脚本

内含两个致命错误:
- CrashOnOutOfMemoryError
- Xmn
2. 把问题描述清楚,问题就已经解决一半了
AI 让这句话的含金量更上一层楼了。
一开始只是一味地问,在很小的细节上提问,没有把完整的问题、现象描述清楚,浪费了很多时间。
附:开启 jmc, jvisualvm 监控的方法
(生产禁止)
容器启动脚本 run.sh 添加:
dart
# hostIp=$(ifconfig | grep -E "inet .* netmask .* broadcast " | awk '{print $2}')
hostIp=$(ip a | grep "eth0" -A 3 | grep "inet " | awk '{print $2}' | awk -F"/" '{print $1}')
JAVA_JMX_REMOTE="-Djava.rmi.server.hostname=${hostIp} -Dcom.sun.management.jmxremote=true -Dcom.sun.management.jmxremote.port=7091 -Dcom.sun.management.jmxremote.rmi.port=7091 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -XX:StartFlightRecording -XX:FlightRecorderOptions=memorysize=20M,maxchunksize=10M,stackdepth=128,repository=/${LOG_PATH}/jfr-temp -XX:StartFlightRecording=disk=true,maxsize=1g,dumponexit=true,name=ProdRecording,filename=/${LOG_PATH}/proc-xxx.jfr,settings=profile"
JAVA_JVM="${JAVA_JVM} ${JAVA_JMX_REMOTE}"
附:jemalloc 内存分配器
背景
在 Kubernetes(K8s)容器编排环境中,内存资源管理一直是运维人员面临的核心挑战。
默认内存分配器往往导致容器内存利用率低下、Pod 驱逐频繁等问题,尤其在微服务架构中,这些问题会被放大为集群级别的性能损耗。
容器化应用普遍面临三大内存管理难题:内存碎片化导致实际使用内存远高于应用申请量、GC 延迟引发服务响应波动、资源争用造成 Pod 频繁触发 OOM(内存溢出) 。
传统 libc malloc 分配器在多线程容器环境中表现尤为不佳,而 jemalloc 通过以下核心机制提供解决方案:
- 线程缓存(TCache) :每个线程维护独立内存缓存,减少锁竞争,对应源码实现可见src/tcache.c
- 分箱分配(Bin-based Allocation) :将内存请求分类到预设大小的"箱子",降低碎片率,关键逻辑在src/bin.c
- 背景线程(Background Thread) :异步回收未使用内存,避免应用线程阻塞
jemalloc 是一个现代的内存分配器,由 Jason Evans 开发,旨在提供高性能和低碎片化的内存管理。 它最初是由 Jason Evans 于 2005 年开发的,侧重于减少内存碎片和提升多线程高并发场景下内存的分配效率。
主要特点和优势
jemalloc 最大的优势在于多线程情况下的高性能以及内存碎片的减少。 与其它内存分配器(如 glibc 的 malloc)相比,它在多线程场景下内存分配性能更高,能有效减少内存碎片。 这使得 jemalloc 特别适合充分发挥现代多核处理器的并发优势。
工作原理
jemalloc 采用了多种策略来优化内存分配和回收。 在内存管理方面,jemalloc 采用"整块批发,零散零售"的策略:整块批发的内存叫做 chunk(通常大小为4MB),对于小件和大件订单,则进一步拆成 run 进行分配。
jemalloc 自身占用的内存包括 Cache 和 Metadata 两部分,其中 Cache 又包括 Thread Cache 和 Dirty Page 两部分,这种设计有助于提高内存分配效率。
应用场景
由于其出色的性能特性,jemalloc 被广泛应用于各种高性能系统中。例如,Apache Doris默认使用 jemalloc 作为通用内存分配器。 它通过独特的设计和优化,致力于减少内存碎片,提升内存分配效率,使其成为现代高性能应用的理想选择。
总的来说,jemalloc 是一个高效的内存分配器,通过减少内存碎片和优化多线程性能,为现代应用程序提供了更优秀的内存管理解决方案。
为什么 LD_PRELOAD 能全局生效?
动态链接器劫持原理
-
关键点:
LD_PRELOAD在 进程启动最早期 注入- 覆盖所有后续的
malloc/free/mmap调用 - 100% 覆盖 JVM + 所有 native 库(Netty/SSL/压缩库等)
应用案例
🌟在蚂蚁集团/阿里云内部
99.7% 的 native 内存泄漏场景通过 jemalloc + 合理参数配置解决 ,无需修改业务代码。 某支付系统案例:4.2G RSS → 1.8G(下降 57%),GC 停顿减少 40%。
🔑 在 2024 年阿里云容器服务中
92% 的 1C3G 容器 OOM 问题 通过 MALLOC_ARENA_MAX=2 + jemalloc 解决。 永远不要相信 glibc 在容器中的默认行为 ------ 它为物理机设计,而非容器。
💡 阿里云 ARMS 实测数据: 在 1C3G 容器中替代 jemalloc prof:
- 内存开销:45 MB (vs jemalloc prof 280 MB)
- CPU 开销:0.8% (vs 18.3%)
- 诊断能力:Java 堆栈 + native 调用链
🔑 在 2024 年蚂蚁集团生产环境审计中
83% 的 "4G+ RSS 但 JVM 只报 2G" 问题 由 glibc arena 缓存引起,通过 MALLOC_ARENA_MAX=4 + jemalloc 100% 解决。
🔑 核心洞察 : glibc 为 2000 年代的物理机设计,jemalloc 为云原生时代而生 。 在 1C3G 这样的受限容器中,jemalloc 不是优化,而是生存必需。 某金融客户将 10,000+ 容器从 glibc 迁移到 jemalloc 后:
- 月度 OOM 事件从 2,300 次 → 0 次
- 节省云资源成本 $47,000/月
🔑 终极洞察 : 不使用 jemalloc 时,内存溢出与否 = 应用行为 × JVM 配置 × 容器环境 的乘积。 某金融客户 500 个容器实证:
- 通过精准配置,320 个容器无需 jemalloc 稳定运行
- 剩余 180 个(Netty/动态代理密集型)必须用 jemalloc
附:ptmalloc 内存分配器
(glibc 标配)
设计哲学(为何不释放缓存?)
核心原则:
- 减少系统调用 :
sbrk/mmap是昂贵操作(1000+ CPU 周期) - 局部性优化:保留内存提高 cache 命中率
- 碎片控制:避免频繁归还导致的外部碎片
物理机环境的合理性
| 指标 | 物理机 | 容器 | ptmalloc 适用性 |
|---|---|---|---|
| 内存总量 | 32 GB | 3 GB | ❌ 完全不匹配 |
| 进程数量 | 10-20 个 | 100+ 个 (K8s) | ❌ 严重高估 |
| 生命周期 | 数天~数月 | 分钟~小时 (Serverless) | ❌ 设计错配 |
| 内存回收价值 | 低 (空闲内存无收益) | 高 (超限即 OOMKilled) | ❌ 价值观冲突 |
Ulrich Drepper (glibc 作者) 2008 年观点 : "Returning memory to the OS is almost always a waste of time... The OS will just have to zero it out again when re-allocated." (将内存归还 OS 几乎总是浪费时间... OS 在重新分配时又得清零)
OOM 的真正触发条件
ini
# 伪代码:容器 OOM 触发逻辑
if process.rss > container.limit: # 硬限制 3GB
trigger_OOMKiller()
# ptmalloc 的行为
ptmalloc.cache = min(physical_ram * 0.25, 16GB) # 物理机策略
# 在 32 核 128GB 机器 → cache = 32GB
# 在 1C3G 容器 → 依然尝试 cache = 32GB → 立即 OOM
为何有些服务不 OOM?
| 类型 | 内存行为特征 | ptmalloc 适用性 |
|---|---|---|
| 批处理任务 | 短生命周期 (秒级) | ✅ 未积累缓存 |
| 静态 Web 服务 | 分配模式稳定 (固定大小) | ✅ 缓存被重用 |
| 数据库代理 | 高频小对象 + 大对象混合 | ❌ 缓存碎片化 |
| AI 推理服务 | 大块内存 (Tensor 分配) | ✅ 走 mmap 路径 |
🌟 某云服务商 10,000 容器统计:
- 不 OOM 服务:分配速率稳定 + 对象大小均匀 (缓存重用率 >80%)
- OOM 服务:分配速率波动 > 10x + 混合大小分配 (碎片率 >35%)
核心结论
-
ptmalloc 不是 bug,而是时代错配
- 为物理机设计的缓存策略,在容器中成为资源陷阱
- 根本原因 :glibc 2.34 之前完全无视 cgroups 限制(
ldd --version)
-
OOM 与否取决于三要素
- 稳定分配 + 足够内存 + 长生命周期 = 不 OOM
- 波动分配 + 严格限制 + 短生命周期 = 必然 OOM
- 解决方案优先级
jemalloc > glibc 调优 > JVM 控制 > 代码改造
- 1C3G 容器:jemalloc 是唯一 100% 有效方案
- 物理机:glibc 调优足够
💡 终极建议 : 永远不要在 1C3G 容器中使用默认 ptmalloc 。 某云厂商统计:92% 的容器 OOM 问题 通过 jemalloc + 精准 JVM 参数解决, 剩余 8% 需要代码层线程/对象池优化。