同样的 Java 进程,RT 差一倍?NUMA 内存落错节点
场景:同一台 2-NUMA 节点机器跑两个完全一样的 Java 实例,负载均衡 1:1 分发,A 的 RT P99 25ms,B 却 50ms 抖到 300ms——重启、换端口都没用 路径:numactl -H 看拓扑 → numastat -p A/B 对比内存落点 → /proc/
/numa_maps 逐段确认 → first-touch 原理定位 → 重启 / CPU+内存双绑修复
上篇我们聊了 taskset 绑核把进程"关"进笼子,这篇来看一个更隐蔽的坑——没人绑核,CPU 也不忙,两个代码一模一样的进程,一个快一个慢。
同一个 jar、同一份配置、同一台 2-socket 服务器,A 的 RT P99 稳定 25ms,B 却 50ms 抖到 300ms。CPU 不忙、IO 不忙、没有慢 SQL,也没有人绑过核。
但 numastat -p 一跑,A 和 B 的内存落点南辕北辙——问题不在代码,在内存落在了哪个节点。
【症状】两个一样的实例,RT 差一倍
完全相同的部署,表现截然不同
给支付网关部署了双实例做负载均衡:A 和 B,同一个 jar、同一份配置、同一批启动参数,跑在同一台 2-socket 服务器上。负载均衡按 1:1 把流量分给两个实例。
第一天就发现不对劲:
- 实例 A:RT P99 稳定在 25ms,偶尔 30ms
- 实例 B:RT P99 直接 50ms,高峰期抖到 300ms,时不时有请求超时重试

流量是一半一半分的,代码完全一样,为什么 B 就慢一倍?
重启、换端口、清缓存,全都没用
团队按老办法轮了一遍:
- 重启 B:重启后前几分钟恢复正常,跑起来又慢回去
- 换端口/换负载策略:B 还是慢
- 清页面缓存(
sync && echo 3 > /proc/sys/vm/drop_caches):无效 - 加机器:新加的实例 C 正常,和 A 一样快
最后只剩一招:把 B 整个迁到另一台服务器,立刻恢复正常。
问题跟"这台机器 + 这个进程"绑定,跟代码无关。这时候有人嘀咕了一句:"是不是内存访问慢?"——还真猜对了方向。
术语:NUMA(Non-Uniform Memory Access,非一致性内存访问)——多 socket 服务器上,每个 CPU 访问"自己节点"的内存快,访问别的节点的内存慢。核数越多越可能是 NUMA 架构,用
numactl -H第一行确认。
【诊察】命令组合定位:内存落点对比
第一步:排除 CPU 和 IO
先用 top 看资源分布(us——用户态 CPU,sy——内核态 CPU,wa——等待 IO 的 CPU):
top - 14:02:33 up 21 days, 3 users, load average: 1.20, 1.10, 1.05
%Cpu(s): 18.0 us, 6.0 sy, 0.0 ni, 75.0 id, 0.5 wa, 0.0 hi, 0.5 si, 0.0 st
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1024 root 20 0 8.6g 4.0g 45m S 35.0 3.1 12:03:21 java
2048 root 20 0 8.6g 4.0g 45m S 35.0 3.1 12:03:20 java
两个进程 CPU 占用都在 35% 左右(%CPU 是单核百分比,表示各占约三分之一个核),机器整体才用了 24%(%Cpu(s) 的 us 18% + sy 6% 是全核平均口径),wa 只有 0.5%——CPU 不忙、IO 不忙。两个实例 CPU 占用还几乎一样,排除 CPU/IO 瓶颈。
术语:
wa(wait IO)——CPU 等待磁盘 IO 完成的时间占比。wa > 30% 说明 IO 已明显影响 CPU 利用率,wa > 80% 接近磁盘饱和;本案例 wa 0.5% 说明磁盘不是瓶颈。
第二步:numactl -H 确认 NUMA 拓扑
用 numactl -H(NUMA 拓扑查询命令,-H 是 --hardware 的简写)看这台机器的内存布局:

2 个 NUMA 节点,各带一块本地内存。node distances:同节点访问开销 10,跨节点 21——跨节点内存访问比本地慢一倍。
第三步:numastat -p 对比两个进程的内存落点
关键一步——用 numastat -p(查看进程内存在每个节点的分布)分别看 A 和 B,两张表一对比,真相呼之欲出:

- A 的内存:3986MB 里有 3885MB(97%)落在 node0
- B 的内存:3986MB 里有 3886MB(97%)落在 node1
术语:
numastat -p按 Huge/Heap/Stack/Private 四类列出进程内存在每个节点的分布。哪列数字大,内存就偏向哪个节点。注意 Java 进程的堆(-Xmx 分配的大内存)走 mmap 匿名映射,计入 Private 行——所以判断 Java 内存落点看 Private 和 Total 两行即可,Heap 行(brk 段)通常很小。判断标准:内存在一个节点的占比超过 50%,就说明"内存有归属"了——关键看它和 CPU 所在节点是否一致。
B 的匿名堆(Private 3700MB)几乎全在 node1,A 的堆也几乎全在 node0。调度器把 B 的线程均衡分配到 node0/node1 的核上——跑在 node0 上的那半线程,每次堆访问都要跨节点,延迟翻倍。
第四步:/proc//numa_maps 逐段确认
numastat -p 给了总量,/proc/<pid>/numa_maps(内核暴露的进程内存到物理页的映射)能逐段看每块内存落在哪个节点——Java 的匿名堆对应 anon 段:
$ grep anon /proc/2048/numa_maps | head -5
7f3c00000000 default anon=947200 dirty=947200 N0=22528 N1=924672 kernelpagesize_kB=4
Java 主堆是单一连续匿名段,所以只有这一行。
N1=924672 表示匿名段约 3612MB(924672 页 × 4KB)落在 node1,N0=22528 约 88MB 在 node0——B 的整个堆躺在 node1。anon/dirty 是匿名页与脏页总数(单位是页),kernelpagesize_kB=4 说明页大小 4KB。注意 numa_maps 的 N<n> 编号直接对应节点号:N0 就是 node0、N1 就是 node1,本机只有 2 个节点,所以只会出现 N0/N1。
命令对照表
三条命令的输出互相印证,指向同一个结论:
| 命令 | 发现 | 结论 |
|---|---|---|
top |
us 18%、wa 0.5%、两进程 CPU 相当 | 排除 CPU/IO 瓶颈 |
numactl -H |
2 节点,跨节点距离 21 | 内存与 CPU 存在跨节点访问代价 |
numastat -p A vs B |
A 内存 97% 在 node0,B 内存 97% 在 node1 | B 的内存和 CPU 不在一个节点 |
/proc/2048/numa_maps |
匿名堆段 3612MB 落在 node1 | B 的堆访问全部跨节点 |
四组数据指向同一个真相:B 的堆被分配在了 node1,而调度器会把它一半的线程放到 node0 的核上——跑在 node0 上的那半线程,每次堆访问都要跨节点,延迟翻倍,慢一倍。
【路径】下次遇到直接跑
三命令排查组合
看到"两个一样进程,一个慢一倍 / CPU 内存都不忙"时,按这个顺序排查:
# 1. 确认 NUMA 拓扑(机器有没有节点之分)
numactl -H
# 期望:available: 2 nodes (0-1),node distances 对角线 10、跨节点 21
# 2. 对比多实例的内存落点(快慢各看一个)
numastat -p <快进程PID>
numastat -p <慢进程PID>
# 期望:慢进程的 Heap/Private 大列与快进程相反 → 内存落点不同
# 3. 逐段确认匿名堆的物理落点
grep anon /proc/<慢进程PID>/numa_maps
# 期望:N1=924672 大数字 → 匿名段落在某一个 node(N<n> 对应 node<n>)

判断规则:
| 看到 | 说明 | 下一步 |
|---|---|---|
numactl -H 只有 1 个 node |
非 NUMA,此路不通 | 换方向查 |
node distances 10/21 |
NUMA 架构,跨节点有 2 倍延迟代价 | 对比多实例内存落点 |
| 慢进程内存在某 node 占比 >90% | 内存"住"在了别处 | 查线程跑在哪个 node |
numa_maps 匿名段 N1 大数字 |
堆跨节点,确认根因 | 修复(见处方) |
经验之谈:三条命令跑完不到 1 分钟。核心心法不是看单个命令,而是快慢各看一次,对比落点——单看 B 看不出问题,A 和 B 一对比就现形。
【定位】first-touch:内存落点是谁决定的?
内存不是"共享"的,是"先到先得"的
NUMA 机器上,内核分配物理内存时遵循 first-touch 策略——每个内存页落在第一个访问它的 CPU 所在节点。
新进程启动、JVM 分配堆时,最先触达这些页的线程跑在哪个 node 的核上,堆页就落在哪个 node。这解释了全部现象:
- A 启动时:初始化线程恰好跑在 node0 的核上 → 堆按 first-touch 落在 node0
- B 启动时:初始化线程恰好跑在 node1 的核上 → 堆按 first-touch 落在 node1
- 之后:负载均衡把流量均分,B 的线程被内核调度到 node0/node1 各一半的核上 → 跑在 node0 上的线程访问堆(在 node1)→ 每次跨节点
术语:first-touch(首次触达分配)——物理页在第一次被访问时,分配在访问它的 CPU 所在节点的本地内存上。这是内核的默认分配策略,不是"均衡分配"。同一个进程,第一次碰它的人决定了它的"出生地"。
这意味着 first-touch 是个"看运气"的机制——谁先碰页,内存就住谁家。你的进程会不会中招,取决于启动那一刻初始化线程恰好跑在哪个核上:负载均衡、前一个进程残留的 CPU 亲和、甚至部署脚本的执行顺序,都会影响这个"恰好"。所以别赌运气,直接看本文处方的双绑。
回看案例,B 的"重启后短暂正常又慢回去"正是这个随机性的最好证据:重启时 first-touch 让堆短暂落在 node0,但负载均衡压上来后,调度器把一半线程调度到 node1 的核,配合内存回收和新的堆分配,落点又漂回 node1。
跨节点访问为什么慢一倍?
node distances 里的 21 vs 10 不是抽象数字,对应真实的硬件路径:
- 本地访问(distance 10):CPU → 自己 socket 上的内存控制器(IMC)→ 本地 DRAM,一条直路
- 跨节点访问(distance 21):CPU → 自己 socket 的 IMC → 跨 socket 互联总线(QPI/UPI) → 对面 socket 的 IMC → 对面 DRAM,多走一段物理链路

跨节点访问慢在三个地方:总线往返时延(QPI/UPI 链路本身有延迟)、带宽争抢(两个 socket 同时访问对方内存时互相挤占)、缓存不共享(每个 CPU 的 LLC 末级缓存只缓存本地内存,跨节点数据每次都要去对面读,缓存命中率骤降)。
对 Java 这种堆访问极其频繁的程序,堆在对面节点等于每次 new、每次引用读写都付过路费——P99 翻倍一点都不奇怪。
金句:"NUMA 的世界里,内存不是公共图书馆,是每个 CPU 自己的书房——跨节点访问,就是大半夜穿过走廊去邻居家借书,借一次,慢一次。"
内核为什么没自动修好?
现代内核(3.13+)默认开启自动 NUMA balancing(numa_balancing),会周期性扫描、把高频访问的页迁移回访问它的 CPU 所在节点。它没修好 B,有两个常见原因:
- 内核版本过旧:自动 NUMA balancing 在 3.8 引入(默认关闭的实验版)、3.13 起默认开启。低版本没有或默认关闭,
cat /proc/sys/kernel/numa_balancing输出 0 - 迁移成本高/不触发:自动迁移不是无条件的——迁移需要留出内存和 CPU 开销,且只迁移"值得"的热页;如果节点内存紧张或页访问模式分散,内核会认为迁移不划算
术语:
numa_balancing(自动内存均衡)——内核后台任务定期扫描页的访问频率,把高访问页从远端迁移到访问它的 CPU 所在节点。默认开启(3.13+),可通过/proc/sys/kernel/numa_balancing查看(1 开启 / 0 关闭)。
这是系统层(内存分配策略)的问题,不是应用层的问题——代码、配置、JVM 参数全都一样,差的是内存页的物理落点。
金句:"同样的代码,不同的命运——进程快慢不只看代码,还看内存落在了哪个节点。"
【处方】让内存跟 CPU 住到同一个节点
方案一:重启,重新 first-touch(不保证,但最便宜)
重启进程让 first-touch 重新决定落点。但落点取决于重启时初始化线程跑在哪个核——不保证一定落到 CPU 当前节点:
# 先确认当前负载低的 node 作为目标,再把进程拉起
numactl --cpunodebind=0 java -jar app.jar & # 见方案二,用双绑最稳
注意:上面这个命令只是示例——单绑 CPU 不解决内存落点问题(first-touch 仍可能把堆落在别处)。要真正修复,直接跳到方案二用
--cpunodebind+--membind双绑。
单纯 kill 后重启可能又落在原节点,所以实践中重启要配合方案二的双绑才有把握。
方案二:CPU 和内存一起绑(推荐)
用 numactl 同时指定 CPU 节点和内存节点,让线程和堆从一开始就住在同一个 node:
# 启动时绑定:CPU 和内存都绑到 node0
numactl --cpunodebind=0 --membind=0 java -jar app.jar
# 验证:进程级确认绑定生效(numactl --show 只能看当前 shell,验证目标进程要用下面两种)
taskset -pc <新PID> # 期望:0-7(node0 的全部核)
cat /proc/<新PID>/status | grep -i mems_allowed_list # 期望:mems_allowed_list: 0
--cpunodebind=0 绑 CPU 节点,--membind=0 绑内存节点——CPU 在哪、内存跟到哪。这是对付 NUMA 跨节点最干净的办法。注意它绑的是整个 node(CPU 0-7),不是单个核。
方案三:开启自动均衡(让内核自己搬)
如果不想绑核、想让内核自动迁移,确认 numa_balancing 已开启:
# 查看(1 开启 / 0 关闭)
$ cat /proc/sys/kernel/numa_balancing
1
# 临时开启
echo 1 > /proc/sys/kernel/numa_balancing
# 持久化(重启后仍生效)
echo 'kernel.numa_balancing=1' >> /etc/sysctl.conf && sysctl -p
输出 1 表示自动内存均衡已开启——本案例内核 3.13+ 默认就是开启的,B 仍然慢,说明迁移要么未触发、要么迁移成本高。而且它生效慢(数分钟到数十分钟)、只迁移"热页"、迁移本身还要消耗 CPU 和内存——适合不能重启进程的场景应急,生产上别指望它兜底,直接用方案二更可靠。
修复验证
修复后(方案二:--cpunodebind=0 --membind=0 重启 B,PID 以重启后的实际值为准,此处沿用 2048 作示例),再跑一遍命令组合——node1 占比从 97% 降到 3%,堆几乎全部回到 node0;实测 RT 从 50ms 回到 24ms,和实例 A 的 25ms 基本对齐:

生产环境注意事项
| 场景 | 正确做法 |
|---|---|
| 多实例部署在同一台 NUMA 机器 | 各实例 --cpunodebind=X --membind=X 绑定不同 node,天然分散 |
| 单实例 + 不想绑核 | 开启 numa_balancing(3.13+ 默认开),让内核自动均衡 |
| 低版本内核(<3.13) | 没有自动均衡,必须手动双绑或考虑升级内核 |
| 容器环境 | 检查容器 cgroup 内的 cpuset.cpus(v1: /sys/fs/cgroup/cpuset/<容器>/cpuset.cpus,v2: /sys/fs/cgroup/<容器>/cpuset.cpus);容器内 numactl --show 反映的是 cpuset 限制下的有效 node,而非宿主机全部 node |
| 判断是否为 NUMA | lscpu | grep NUMA——有输出即 NUMA 架构 |
修复后 B 的 RT P99 回到 24ms,与 A 的 25ms 基本对齐,负载均衡恢复正常分流。
这次排查留给你的,是三句话:
- NUMA 机器上,内存不是"共享"的,是有"归属"的——first-touch 决定每块页住哪个节点
- 进程快慢不只看代码,还看内存落点——同样的代码,落点不同,命运不同
- 唯一确定性的解法是双绑——
--cpunodebind=X --membind=X,让 CPU 和内存从一开始就住在一起,别赌 first-touch 的运气
整个排查 30 分钟,确认根因 5 分钟——快慢各看一次 numastat -p,对比落点,问题 30 秒现形。
内核版本说明:
numactl依赖 numactl 包(CentOS:yum install numactl,Ubuntu:apt install numactl)。自动 NUMA balancing 在 3.8 引入(默认关闭的实验版)、3.13 起默认开启;kernel.numa_balancingsysctl 自 3.8 存在,3.13 后默认值为 1,通过/proc/sys/kernel/numa_balancing查看(1 开 / 0 关)。/proc/<pid>/numa_maps自 Linux 2.6.14 存在,所有现代内核一致。容器内 cgroup 的 cpuset.cpus 路径:v1 为/sys/fs/cgroup/cpuset/<容器>/cpuset.cpus,v2 为/sys/fs/cgroup/<容器>/cpuset.cpus。
🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操
下篇我们聊 CPU 节流(throttling)导致容器内 Java 性能不稳定——CPU 配额 2 核,实际只用到 1.5 核,接口却频繁超时。