一.故障现场
某客户现场发现Java微服务总内存达到30G。这个微服务xmx分配的16G,一般加上其他Class等最多也就20多G一点,这个现场30G内存肯定有问题。
说明:下面的排障方法对所有的芯片架构都是通用的。
二.初步排障
先用arthas分析一下基础的内存分布
从图中可以看出heap内存+nonheap内存是16.3G+1.7G=18G。
发现一点这个JDK版本是海思HiSilicon下的kunpeng-920芯片AARCH64架构
所以呢安装的是毕昇JDK BiSheng
三.修改JVM启动参数
增加-XX:NativeMemoryTracking=detail
四.监控内存增量
新进程id是39239
#jcmd 39239 VM.native_memory baseline
#jcmd 39239 VM.native_memory summary.diff scale=MB
Native Memory Tracking: end time is 2025-07-14 14:04:16, elapsed time is 7 secs
Total: reserved=29932MB +3MB, committed=28594MB +3MB
- Java Heap (reserved=16384MB, committed=16384MB)
(mmap: reserved=16384MB, committed=16384MB)
- Class (reserved=2108MB, committed=1209MB)
(classes #163211)
(malloc=26MB #349615 +53)
(mmap: reserved=2082MB, committed=1183MB)
- Thread (reserved=9325MB +6MB, committed=9325MB +6MB)
(thread #4653 +3)
(stack: reserved=9304MB +6MB, committed=9304MB +6MB)
(malloc=16MB #27912 +18)
(arena=5MB #9289 +6)
- Code (reserved=782MB, committed=375MB)
(malloc=59MB #107139 +53)
(mmap: reserved=723MB, committed=316MB)
- GC (reserved=730MB, committed=730MB)
(malloc=90MB #189800 +97)
(mmap: reserved=640MB, committed=640MB)
- Compiler (reserved=4MB -5MB, committed=4MB -5MB)
(malloc=4MB #8031)
(arena=0MB -5 #18 -8)
- Internal (reserved=351MB, committed=351MB)
(malloc=351MB #297549 +35)
- Symbol (reserved=170MB, committed=170MB)
(malloc=167MB #1830640)
(arena=3MB #1)
- Native Memory Tracking (reserved=45MB +1MB, committed=45MB +1MB)
(malloc=2MB +1MB #26229 +9919)
(tracking overhead=43MB)
- Arena Chunk (reserved=1MB +1MB, committed=1MB +1MB)
(malloc=1MB +1MB)
- Unknown (reserved=32MB, committed=0MB)
(mmap: reserved=32MB, committed=0MB)
我们重点看这里:
- Thread (reserved=9325MB +6MB, committed=9325MB +6MB)
(thread #4653 +3)
(stack: reserved=9304MB +6MB, committed=9304MB +6MB)
(malloc=16MB #27912 +18)
(arena=5MB #9289 +6)
很明显产生了4653个线程,一共申请9.3G内存。很明显每个线程是2M线程栈大小。
下面对Native Memory Tracking进行详细解释说明:
我们可以逐项分析:
-
总体内存 (
Total
):-
reserved=29932MB +3MB
: JVM 向操作系统申请的虚拟地址空间总量约 29.9GB,比上次报告增加了 3MB。 -
committed=28594MB +3MB
: JVM 实际向操作系统申请并保证可用的物理内存(或交换空间)总量约 28.6GB,比上次报告增加了 3MB。 -
分析: 总保留和提交内存都增加了 3MB。这个增量本身很小,通常不是问题。关键在于这个增长是短暂的波动还是持续增长的趋势。需要持续监控看这个数字是否会稳定下来或继续增长。
-
-
Java 堆 (
Java Heap
):-
reserved=16384MB, committed=16384MB
: 堆最大可保留 16GB,且当前已完全提交(即操作系统已保证这 16GB 物理内存可用)。 -
分析: 这是完全正常且常见的配置。堆大小(
-Xmx
)被设置为 16GB,并且 JVM 在启动时或需要时一次性提交了全部堆空间。没有增长迹象。
-
-
类元数据 (
Class
):-
reserved=2108MB, committed=1209MB
: 为类元数据保留约 2.1GB 地址空间,实际使用约 1.2GB。 -
classes #163211
: 已加载了 163,211 个类。 -
malloc=26MB #349615 +53
: 通过 malloc 分配了 26MB 内存,分配次数增加了 53 次。 -
分析: 加载了非常多的类(16万+),这在大型应用(如应用服务器运行多个应用、使用大量库、使用动态字节码生成技术)中是可能的。1.2GB 的元空间使用量对于加载如此多的类来说不算特别异常,但需要监控其增长趋势(
+53
次分配表明还在加载新类或元数据)。如果应用已完成主要初始化,元空间使用应该趋于稳定。如果持续增长,需检查是否有类加载器泄漏。
-
-
线程 (
Thread
):-
reserved=9325MB +6MB, committed=9325MB +6MB
: 为线程栈保留和提交了约 9.3GB 内存,比上次增加了 6MB。 -
thread #4653 +3
: 当前有 4653 个活跃线程,比上次增加了 3 个。 -
stack: reserved=9304MB +6MB, committed=9304MB +6MB
: 线程栈本身占用了增加的那 6MB。 -
malloc=16MB #27912 +18
: 线程相关结构通过 malloc 分配了 16MB,分配次数增加了 18 次。 -
分析:这是报告中最值得关注的点!
-
4653 个线程是一个非常巨大的数量。虽然现代操作系统和 JVM 能处理很多线程,但这会带来显著的调度开销、内存开销(每个线程栈默认通常是 1MB,这里总栈空间 9.3GB 也印证了平均栈大小在 2MB 左右)和上下文切换成本。
-
线程数还在增加(
+3
)。如果这是持续性的增长(例如,线程池未正确配置导致线程泄漏),这就是一个严重问题,最终会导致资源耗尽或性能急剧下降。 -
即使线程数稳定,4653 个线程对于大多数应用来说也极不寻常,可能表明:
-
应用设计问题(过度依赖线程而非异步/NIO)。
-
线程池配置严重错误(核心/最大线程数设置过高)。
-
存在线程泄漏(线程创建后未正确结束/回收)。
-
-
-
-
代码缓存 (
Code
):-
reserved=782MB, committed=375MB
: 为 JIT 编译的代码保留 782MB,实际使用 375MB。 -
malloc=59MB #107139 +53
: 通过 malloc 分配了 59MB,分配次数增加了 53 次。 -
分析: 375MB 的代码缓存使用量对于大型、长时间运行、执行大量 JIT 编译的应用来说是可能的。
+53
次分配表明 JIT 活动仍在进行(编译新方法或去优化/重新编译)。需要监控其是否最终趋于稳定。如果持续增长,可能表明有大量的动态代码生成或频繁的重新编译。
-
-
垃圾收集 (
GC
):-
reserved=730MB, committed=730MB
: GC 算法和数据结构使用 730MB。 -
malloc=90MB #189800 +97
: 通过 malloc 分配了 90MB,分配次数增加了 97 次。 -
分析: GC 本身需要内存来管理堆和元空间等。730MB 对于管理一个 16GB 堆和 1.2GB 元空间的应用来说在合理范围内。
+97
次分配表明 GC 活动期间有内部结构的变化或调整。只要总量稳定,通常不是问题。
-
-
编译器 (
Compiler
):-
reserved=4MB -5MB, committed=4MB -5MB
: 保留和提交内存都减少了 5MB(现在是 4MB)。 -
malloc=4MB #8031
: 通过 malloc 分配了 4MB。 -
arena=0MB -5 #18 -8
: Arena 分配减少了 5MB(现在是 0MB),分配次数减少了 8 次(现在是 18 次)。 -
分析: 编译器使用的内存减少了。这通常是良性的,可能是编译器临时工作区释放了内存。没有明显问题。
-
-
内部使用 (
Internal
):-
reserved=351MB, committed=351MB
: JVM 内部操作使用 351MB。 -
malloc=351MB #297549 +35
: 全部通过 malloc 分配,分配次数增加了 35 次。 -
分析: 这个类别比较宽泛,包含命令行参数、性能数据、JVMTI 代理等。351MB 的大小不算小,但在大型应用中也是可能的。
+35
次分配表明内部有活动,但总量没变,通常问题不大。
-
-
符号表 (
Symbol
):-
reserved=170MB, committed=170MB
: 用于符号(如字段名、方法名、类名等)的内存。 -
malloc=167MB #1830640
: 大部分通过 malloc 分配。 -
分析: 170MB 与加载的巨量类(163211)是相符的。没有增长迹象。稳定。
-
-
Native Memory Tracking (
NMT
):-
reserved=45MB +1MB, committed=45MB +1MB
: NMT 自身开销约 45MB,增加了 1MB。 -
malloc=2MB +1MB #26229 +9919
: 通过 malloc 分配了 2MB(增加了 1MB),分配次数大幅增加(+9919
次)。 -
tracking overhead=43MB
: 跟踪开销本身占 43MB。 -
分析: NMT 本身有显著开销(43MB + 2MB malloc),这是开启 NMT 的代价。
+1MB
和+9919
次分配表明在此期间 NMT 记录了大量的内存活动。这是开启 NMT 监控的正常现象。
-
-
Arena Chunk:
-
reserved=1MB +1MB, committed=1MB +1MB
: 通过 malloc 分配了 1MB(增加了 1MB)。 -
分析: Arenas 是 JVM 内部使用的一种高效的内存分配方式。1MB 的增量很小,通常不是问题。可能是临时工作区。
-
-
未知 (
Unknown
):-
reserved=32MB, committed=0MB
: 保留了 32MB 地址空间但未提交任何物理内存。 -
分析: 这是 JVM 预留但尚未使用的地址空间。
committed=0MB
意味着它没有消耗实际的物理内存或交换空间。完全正常。可能是为了未来可能的对齐或分配预留的空间。
-
结论与建议:
-
最关键的警示信号是线程数 (4653 个且还在增加 +3): 这是极不寻常且潜在高风险的。你需要立即调查:
-
为什么会有这么多线程?检查应用代码和配置的线程池(核心线程数、最大线程数、队列大小、拒绝策略)。
-
是否存在线程泄漏?使用
jstack <pid>
或可视化工具(VisualVM, YourKit, JMC)分析线程堆栈,查看线程名称和状态,找出大量重复的或僵死的线程。 -
应用的架构是否合理?是否过度依赖阻塞式 I/O 和同步线程?考虑使用异步/NIO(如 Netty, Vert.x)来减少线程需求。
-
高线程数会消耗大量内存(栈)、增加 CPU 调度开销、并可能导致不稳定。这是需要优先处理的问题。
-
-
类加载数量巨大 (163211) 和元空间使用量 (1.2GB): 虽然对于特定的大型应用可能“正常”,但仍需监控其增长趋势(NMT 报告显示
+53
次分配)。如果应用已稳定运行,元空间使用量应趋于平稳。持续增长可能指向类加载器泄漏。 -
代码缓存 (375MB) 和 GC 开销 (730MB): 大小在管理 16GB 堆和大量类的上下文中属于合理范围上限。需要关注其长期稳定性。代码缓存的增长(
+53
次分配)表明 JIT 仍在活动。 -
总内存小幅增长 (+3MB): 单独看这个小幅增长不是问题,但需要结合线程增长和其他区域的增长趋势来看。如果这是持续监控中的一次快照,关键是看长期趋势。
建议行动:
-
立即分析线程情况: 使用
jstack
或 Profiler 工具抓取线程转储,分析线程名称、状态和堆栈,找出线程数爆炸的原因。 -
持续监控 NMT: 定期(例如每分钟)或在关键操作前后生成 NMT 报告 (
jcmd <pid> VM.native_memory [detail|summary|baseline|diff]
)。关注:-
Total reserved/committed
的长期趋势。 -
Thread
的数量和内存变化趋势。 -
Class
的 committed 内存和加载类数的变化趋势。 -
Code
的 committed 内存变化趋势。
-
-
监控应用性能: 关注 CPU 使用率(尤其是系统 CPU
sy
)、GC 暂停时间、吞吐量等指标,高线程数通常会导致这些指标恶化。 -
调整(如果确认是问题):
-
修复线程泄漏或优化线程池配置。
-
如果元空间持续增长,检查并修复类加载器泄漏。
-
根据应用行为,考虑调整
-XX:ReservedCodeCacheSize
(如果代码缓存持续增长并达到上限)。 -
评估是否真的需要 16GB 的固定堆(
-Xms
=-Xmx
)。如果堆使用率长期远低于 16GB,适当降低-Xmx
可以节省大量内存。但如果使用率接近 16GB,则保持是合理的。
-
总结: 这份 NMT 报告显示超高且仍在增长的线程数是最大的异常点和风险点。类加载数量和部分区域的内存使用量偏高,但需要结合应用类型判断是否在预期范围内。必须优先调查并解决线程数量异常的问题,并持续监控内存使用趋势。
五.查看详细的VM.native_memory detail
我们可以使用下面的命令查看详细的内存申请分布数据
#jcmd 39239 VM.native_memory detail scale=MB
我们只取其中线程栈分配相关内容
[0x0000fff953a00000 - 0x0000fff953c00000] reserved and committed 2MB for Thread Stack from
[0x0000ffff94cddb44] java_start(Thread*)+0xfc
[0x0000ffff952882a8]
[0x0000fff953c00000 - 0x0000fff953e00000] reserved and committed 2MB for Thread Stack from
[0x0000ffff94cddb44] java_start(Thread*)+0xfc
[0x0000ffff952882a8]
[0x0000fff953e00000 - 0x0000fff954000000] reserved and committed 2MB for Thread Stack from
[0x0000ffff94cddb44] java_start(Thread*)+0xfc
[0x0000ffff952882a8]
[0x0000fff954000000 - 0x0000fff954200000] reserved 2MB for Class from
[0x0000ffff94ea43d0] ReservedSpace::ReservedSpace(unsigned long, unsigned long, bool, char*, unsigned long)+0x218
[0x0000ffff94c48c24] VirtualSpaceNode::VirtualSpaceNode(bool, unsigned long)+0x1b4
[0x0000ffff94c49a14] VirtualSpaceList::create_new_virtual_space(unsigned long) [clone .part.96]+0x3c
[0x0000ffff94c4a548] VirtualSpaceList::get_new_chunk(unsigned long, unsigned long)+0x578
[0x0000fff954100000 - 0x0000fff954180000] committed 1MB from
[0x0000ffff94ea5d20] VirtualSpace::expand_by(unsigned long, bool)+0x1c0
[0x0000ffff94c4a364] VirtualSpaceList::get_new_chunk(unsigned long, unsigned long)+0x394
[0x0000ffff94c4ab94] SpaceManager::grow_and_allocate(unsigned long)+0x1b4
[0x0000ffff94c4b004] SpaceManager::allocate(unsigned long)+0x16c
[0x0000fff954040000 - 0x0000fff9540c0000] committed 1MB from
[0x0000ffff94ea5d20] VirtualSpace::expand_by(unsigned long, bool)+0x1c0
[0x0000ffff94c4a364] VirtualSpaceList::get_new_chunk(unsigned long, unsigned long)+0x394
[0x0000ffff94c4ab94] SpaceManager::grow_and_allocate(unsigned long)+0x1b4
[0x0000ffff94c4b004] SpaceManager::allocate(unsigned long)+0x16c
[0x0000fff954200000 - 0x0000fff954400000] reserved and committed 2MB for Thread Stack from
[0x0000ffff94cddb44] java_start(Thread*)+0xfc
[0x0000ffff952882a8]
[0x0000fff954400000 - 0x0000fff954600000] reserved and committed 2MB for Thread Stack from
[0x0000ffff94cddb44] java_start(Thread*)+0xfc
[0x0000ffff952882a8]
很明显从提供的 Native Memory Tracking (NMT) 详细输出中可以看出,每个线程栈的大小为 2MB ,reserved and committed 2MB for Thread Stack from
六.查询配置 JVM 线程栈大小的位置
1. 查看当前线程栈大小的命令
# 使用 jinfo 查看进程参数(需替换 <pid> 为实际进程 ID)
jinfo -flags <pid> | grep -E '\-Xss|\-XX:ThreadStackSize'
jcmd <pid>VM.flags | grep ThreadStackSize
-
如果输出中包含
-Xss
或-XX:ThreadStackSize
,则显示当前配置值(如-Xss2m
表示 2MB)。 -
若无输出,表示使用 JVM 默认值(Linux x64 默认通常为 1MB,但我们的场景显示为 2MB)。
我们在服务器上用上述命令没有得到输出。
没有显式配置线程栈大小参数(-Xss
或 -XX:ThreadStackSize
)。这意味着 JVM 使用了默认的线程栈大小。
结论:
-
当前线程栈大小是 JVM 默认值(未通过
-Xss
或-XX:ThreadStackSize
显式配置)。 -
根据 NMT 报告(每个线程栈分配 2MB)
为什么 NMT 显示 2MB?
虽然标准 Linux x64 JDK 默认栈大小是 1MB,但您的环境可能是以下情况:
-
JDK 变体差异:
-
如果使用 OpenJDK 的服务端优化版本(如 Shenandoah JDK),默认栈大小可能为 2MB。
-
-
容器环境:
-
在 Kubernetes/Docker 中,某些基础镜像会修改 JVM 默认参数。
-
-
JVM 版本更新:
-
较新的 JDK 版本(如 JDK 17+)在某些场景下会调整默认值。
-
由于我们使用的是毕昇JDK,所以是OpenJDK的差异导致。
七.显式配置线程栈大小
在 JVM 启动参数中添加:
# 设置为 1MB(常用值)
-Xss1m
# 或明确指定 KB 单位
-XX:ThreadStackSize=1024
注意:当前线程数 4653 严重过高(即使栈设为 1MB,总栈内存仍高达 4.6GB)。
所以我们使用这两步对线程进行控制。