深度解析 Linux Direct Reclaim 导致 Java 应用 JVM GC 停顿与假死的底层机制
在日常的高并发 Java 服务维护中,你可能遇到过一种诡异的“假死”现象:系统监控显示 Java 进程的 CPU 使用率极低,但业务请求全部超时;查看 GC 日志,发现一次普通的 Young GC(甚至是 Mixed GC)停顿时间(STW)竟然达到了数秒甚至数十秒,但实际回收的内存并不多。
通过线程堆栈(jstack)排查,会发现大量 GC 线程或工作线程处于 Runnable 或内核态阻塞状态。这种现象,十有八九是触发了 Linux 内核的**直接内存回收(Direct Reclaim)**机制。
本文将从 Linux 内核内存分配算法与 JVM 内存管理两个维度,深度剖析 Direct Reclaim 导致 JVM GC 假死的底层本质,并给出生产环境的排查与优化方案。
一、 内核视角:什么是 Direct Reclaim?
要理解 Direct Reclaim,必须先了解 Linux 内核物理内存的分区(Zone)与水位线(Watermark)机制。
1. 内存物理分区与三级水位线
Linux 内核将物理内存划分为不同的区域(如 ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM)。针对每个 Zone,内核都维护了三条关键的内存水位线:
WMARK_HIGH(高水位):内存非常充足,分配器可以自由分配。WMARK_LOW(低水位):内存开始紧张。当剩余物理内存低于该水位时,内核会唤醒异步内存回收守护进程kswapd。kswapd会在后台静默地回收 Page Cache 等内存,直到剩余内存重新回到WMARK_HIGH以上。此时,应用分配内存的请求不会被阻塞。WMARK_MIN(最小水位):这是系统运行的生死底线。
+-------------------------------------------+ 物理内存顶部
| |
| 充分空闲内存 (Free) |
| |
+-------------------------------------------+ <--- WMARK_HIGH
| kswapd 异步回收区 (Background) | (物理内存降低到此,kswapd 激活)
+-------------------------------------------+ <--- WMARK_LOW
| Direct Reclaim 极度危险区 | (内存分配线程被迫同步回收内存)
+-------------------------------------------+ <--- WMARK_MIN (系统极度饥饿)
| 内核保留区 (Reserved) |
+-------------------------------------------+ 0
2. 同步回收:Direct Reclaim 的介入
如果应用申请物理内存的速度极快,或者突发大 I/O 导致 Page Cache 瞬间占满内存,导致剩余物理内存越过了 WMARK_LOW,甚至直接跌破了 WMARK_MIN,情况就会急剧恶化。
由于跌破了 WMARK_MIN,内核认为随时可能发生 OOM(Out Of Memory)。此时,内核不再依赖异步的 kswapd,而是让当前申请内存的线程“自理”——这就是 Direct Reclaim(直接内存回收)。
当触发 Direct Reclaim 时:
- 当前发起内存申请(Page Fault)的线程被强制挂起。
- 该线程必须在内核态同步执行内存回收逻辑:
- 将脏页(Dirty Pages)同步刷盘(触发磁盘 I/O 阻塞)。
- 释放不再使用的 File-backed Pages。
- 进行内存碎片整理(Memory Compaction),移动物理页框。
- 只有当该线程同步回收了足够的内存,使其高于安全水位后,它才能继续执行原本的内存分配并返回用户态。
二、 联动效应:Direct Reclaim 为什么会让 JVM 假死?
一个普通的 Linux 线程因为 Direct Reclaim 被阻塞几百毫秒,顶多是该请求变慢。但如果这个线程是 JVM 的 GC 线程,或者是正在处于 Safepoint 状态下的 Java 线程,就会引发灾难性的级联反应。
1. GC 过程中的物理内存申请
Java 程序员常有一种误解:JVM 启动时已经通过 -Xmx 申请了整块堆内存,后续 GC 只是在 JVM 内部进行对象移动,不应该再向操作系统申请物理内存。
其实不然。以下场景会导致 JVM 频繁向内核申请物理内存:
- 物理内存延迟分配(Lazy Allocation / Page Fault):JVM 启动时,即便指定了
-Xmx8g,操作系统通常也只是分配了虚拟地址空间,并没有真正分配物理内存。只有当 JVM 实际往这些内存地址写入数据(如 GC 过程中的对象晋升、复制)时,才会触发 Page Fault(缺页异常),由内核动态分配物理物理页。 - 堆外内存申请:DirectByteBuffer、G1 GC 的 Remembered Set、Metaspace(元空间)、线程栈(Thread Stack)等,都是在运行期动态向内核申请物理页。
- GC 线程复制存活对象:在 Young GC 时,存活的对象需要从 Eden 区复制到 Survivor 区,或者晋升到 Old 区。如果目标物理页尚未映射(未被 Mutator 触碰过,或之前被 Swapped),GC 线程就会在内核态触发 Page Fault 申请物理页。
2. GC 线程遭遇 Direct Reclaim 导致 STW 无限延长
当 JVM 触发 Young GC 时,JVM 会通过 Safepoint 机制将所有业务工作线程(Mutator)挂起,进入 STW(Stop-The-World) 状态。此时,所有的 GC 工作线程开始并发或并行地回收、复制对象。
如果此时宿主机物理内存告急,某个 GC 线程在复制对象、触发 Page Fault 申请物理内存时,恰好跌破了 WMARK_MIN 水位:
- 该 GC 线程在内核态直接进入 Direct Reclaim。
- 为了腾出空间,该 GC 线程被迫在内核态做 I/O(写回磁盘脏页)。如果此时系统的磁盘 I/O 负载极高(例如正在进行大日志写入或数据库备份),该 GC 线程将被长久挂起(处于
D状态,不可中断睡眠)。 - 因为这是一个 STW 的 GC 过程,所有的 Java 业务线程此时都在 Safepoint 挂起,苦苦等待 GC 线程完成工作。
- 只要这一个内核态的 GC 线程因 Direct Reclaim 被阻塞 10 秒,整个 JVM 就会表现为假死 10 秒。在此期间,任何外部的 TCP 握手、HTTP 请求、KeepAlive 心跳均无法得到响应。
三、 生产环境排查实战
当怀疑 JVM 假死与 Direct Reclaim 相关时,可以通过以下步骤进行定量分析与取证。
1. 观察 GC 日志中的 Sys 时间
查看 JVM GC 日志,重点关注 user、sys 和 real 时间。
例如,一次异常的 GC 日志:
[GC (Allocation Failure) [ParNew: 1843200K->1251K(1843200K), 12.1034200 secs] [Times: user=0.45 sys=3.12 real=12.10 secs]
user:进程在用户态消耗的 CPU 时间。sys:进程在内核态消耗的 CPU 时间。如果sys异常偏高(相比user),说明内核态系统调用、缺页中断或内存管理占用了大量时间。real:实际流逝的物理时间。- 特征:如果
real远大于user + sys(如上例中real=12.10s,而user+sys=3.57s),说明 GC 线程大部分时间都在等待 I/O 或被内核挂起(处于睡眠/阻塞状态)。这极其符合 Direct Reclaim 的特征。
2. 使用 /proc/vmstat 查看 allocstall 计数
allocstall 是 Linux 内核中用于记录因内存分配而被迫进入 Direct Reclaim(直接回收)并导致调用者线程被阻塞(Stall)的次数。
运行以下命令观察:
watch -d -n 1 "grep -E 'allocstall|pgscand' /proc/vmstat"
或者监控其增长速度:
cat /proc/vmstat | grep allocstall
allocstall计数器如果一直在快速递增,说明系统当前存在严重的内存不足,且大量的物理内存申请正在经历 Direct Reclaim 阻塞。pgscand_direct_*计数器表示通过 Direct Reclaim 扫描的页面数量,如果该值大幅上升,也是直接回收高发的铁证。
3. 使用 sar -B 查看内存页回收活动
使用 sysstat 工具包中的 sar 命令,实时观察页回收行为:
sar -B 1 10
输出指标关注:
pgscand/s:每秒由kswapd异步扫描的页面数。pgscand/s对应的pgscand/s(如果拆分了,通常是pgscand_direct):每秒由 Direct Reclaim 扫描的页面数。若该值非 0,说明异步回收已经跟不上,系统已进入同步直接回收状态。vmeff %:页面回收效率。如果接近 100%,说明扫出来的页都能很快释放;如果极低(如低于 30%),说明内存极度僵死,回收困难,此时 Direct Reclaim 阻塞时间会极长。
四、 深度调优与预防方案
要彻底根治 Direct Reclaim 导致的 JVM 假死,核心策略是:推迟 Direct Reclaim 的触发时机,给后台异步回收(kswapd)争取缓冲空间,并防止内存过度零碎化。
1. 调整内核水位线参数:vm.extra_free_kbytes
默认情况下,Linux WMARK_LOW 和 WMARK_MIN 之间的间距(即 kswapd 异步回收的反应缓冲区)非常小。在高并发、大内存申请的场景下,还没等 kswapd 反应过来,物理内存就已经突破 WMARK_MIN 了。
通过提高 vm.extra_free_kbytes,可以人为增大 WMARK_LOW 与 WMARK_MIN 之间的距离,给 kswapd 留出更充足的提前量去异步回收:
# 临时生效(例如设定 1GB 的额外空闲保留区,根据物理内存大小灵活调整,一般设为物理内存的 1%~3%)
sysctl -w vm.extra_free_kbytes=1048576
# 永久生效:在 /etc/sysctl.conf 中追加
vm.extra_free_kbytes = 1048576
注:该值过大可能会导致物理内存浪费,过小则无效果。对于 64G 内存的服务器,设置为 1G~2G 是合理的。
2. 关闭或合理配置透明大页(Transparent Huge Pages, THP)
透明大页(THP)在 Linux 中默认是 always 启用的。当 JVM 申请内存时,内核会尝试分配 2MB 的大页。
然而,大页的分配极易触发内存碎片整理(Memory Compaction)。如果此时没有连续的 2MB 物理内存,内核线程和 JVM 申请线程会直接进入 Direct Compaction 和 Direct Reclaim,导致严重的分配延迟。
生产环境强烈建议将 THP 设置为 madvise 或 never:
# 查看当前设置
cat /sys/kernel/mm/transparent_hugepage/enabled
# 临时禁用
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 永久禁用:在 /etc/rc.local 中加入上述两条 echo 命令,或在 Grub 启动参数中加入 transparent_hugepage=never
3. 调整脏页写回策略,避免 Direct Reclaim 时的磁盘 I/O 阻塞
当 Direct Reclaim 发生时,如果需要回收的 Page Cache 包含了大量的“脏页”(未落盘的缓存数据),内核必须同步将这些脏页写入磁盘。此时,由于磁盘 I/O 速度瓶颈,GC 线程会彻底卡死。
可以通过降低系统脏页比例阈值,让内核提早、更频繁地在后台异步把脏页刷盘:
# /etc/sysctl.conf
# 当脏页占系统可用内存的比例达到 5% 时,便开始在后台异步唤醒 pdflush/flush 线程进行写回
vm.dirty_background_ratio = 5
# 当脏页占系统可用内存的比例达到 10% 时,强制阻塞后续的写操作并同步刷盘(防止脏页无限累积)
vm.dirty_ratio = 10
4. 优化 vm.swappiness
对于 Java 应用,过度的 Swap(交换分区)操作同样是 JVM GC 停顿的死敌。
- 如果
vm.swappiness设得太高(如 60),系统会倾向于把 JVM 的匿名页(Anonymous Pages)交换到磁盘,一旦 GC 扫到被 Swap 的页,就会产生磁盘 I/O 导致 STW 极长。 - 但在现代 Linux 内核中,如果设为
0,在某些极端情况下反而会更容易触发 OOM Killer 或 Direct Reclaim。 - 最佳实践:推荐将
vm.swappiness设为1或10,既保留了一点点 Swap 空间用作安全缓冲,又最大限度地避免了 JVM 堆内存被置换。
sysctl -w vm.swappiness=10
5. JVM 层面:预分配物理内存(Pre-touch)
为了避免在运行期(尤其是 GC 阶段)动态触发 Page Fault 去申请物理页,可以在 JVM 启动参数中加上 -XX:+AlwaysPreTouch。
-XX:+AlwaysPreTouch:JVM 在启动初始化 Heap 时,会强行向所有分配的内存页写入0,这会强制操作系统在启动阶段就为 JVM 分配真正的物理内存页,而不是等到运行期。- 代价:这会导致 JVM 启动过程变慢(特别是大堆,如 32G 堆可能需要多花数十秒启动),但在运行期能极大减少 Page Fault,绝不会因为 GC 过程中的物理页申请而触发 Direct Reclaim。
五、 总结
Linux 的 Direct Reclaim 是一种自我保护性质的“伤敌一千,自损八百”的同步重度回收手段。当它与 JVM 的 Safepoint / STW 机制意外相遇时,便会因“GC 线程被卡在内核态 I/O”而衍生出应用假死的现象。
在排查高延时 GC 难题时,不要仅仅把目光局限在 JVM 内部参数调优上。跳出 JVM,利用 sysstat、procfs 审视宿主机的物理内存水位线、磁盘 I/O 负载以及内核回收活动,往往才能真正直击问题的本质。