文章

lxcfs 重新挂载后 JVM 单核 CPU 100%:一次 EOF 处理缺失导致的死循环

lxcfs 重新挂载后 JVM 单核 CPU 100%:一次 EOF 处理缺失导致的死循环

lxcfs 重新挂载后 JVM 单核 CPU 100%:一次 EOF 处理缺失导致的死循环

摘要

在使用 lxcfs 向容器提供 /proc 视图的环境中,lxcfs 服务重启或文件系统重新挂载后,JVM 已打开的 /proc 文件描述符可能无法继续获得有效内容。某些旧版本 JDK 的 Linux 系统监控实现逐字符读取文件时,只判断换行符而未判断 EOF,因此可能在 native 代码中持续空转,表现为某个 JVM 监控线程长期占满一个 CPU 核。

该问题已由 OpenJDK 社区修复。处理时应优先升级到包含 JDK-8268361 修复的 JDK 版本,或在自行维护的旧版本分支中回移相应补丁。

1. 现象

典型现象包括:

  • Java 进程突然出现约一个 CPU 核的持续占用;
  • 高 CPU 线程通常与 JVM/操作系统指标采集有关;
  • 问题发生前,宿主机上的 lxcfs 服务可能经历过重启或重新挂载;
  • Java 业务线程未必存在明显热点,常规 Java 栈分析可能无法直接定位;
  • 线程可能长期停留在 JVM 的 native 系统指标读取路径中。

这里的“单核 100%”应结合 CPU 配额理解:在多核机器或容器中,进程总 CPU 百分比的展示口径可能不同,但本质特征是一个线程持续占用一个逻辑 CPU。

2. 触发链路

问题可概括为以下链路:

  1. JVM 或监控组件打开 /proc/stat 等 procfs 文件,并周期性读取系统指标;
  2. 容器中的 /proc 视图由 lxcfs 提供;
  3. lxcfs 重启或重新挂载后,先前打开的文件描述符所对应的后端状态发生变化;
  4. 后续读取提前返回 EOF,无法再读到预期的换行符;
  5. 旧版 JDK 的逐行读取循环只检查 \n,没有检查 EOF;
  6. fgetc() 持续返回 EOF,循环无法退出,线程在用户态持续空转。

需要注意:重新挂载后的具体表现与 lxcfs 版本、挂载方式和运行环境有关,不能笼统地认为所有已打开文件描述符都会以完全相同的方式“失效”。本问题成立的关键条件是:读取端持续得到 EOF,而调用方未把它作为循环终止条件。

3. 根因分析

问题代码的逻辑可抽象为:

1
2
3
4
5
static void next_line(FILE *f) {
    while (fgetc(f) != '\n') {
        // keep reading
    }
}

fgetc() 的返回值类型是 int,这样既可以表示所有 unsigned char 值,也可以表示特殊值 EOF。当文件到达末尾或读取失败时,fgetc() 返回 EOF。

如果输入流在读到换行符前就进入 EOF 状态,后续调用通常仍会返回 EOF。由于 EOF != '\n',上述循环会无限执行:

1
2
3
4
5
fgetc() -> EOF
EOF != '\n' -> true
fgetc() -> EOF
EOF != '\n' -> true
...

因此,高 CPU 的直接原因不是 lxcfs 本身持续进行昂贵计算,而是 JVM native 代码对异常输入流处理不完整。lxcfs 的重新挂载只是暴露该缺陷的触发条件。

4. 修复方式

正确实现需要同时把换行符和 EOF 作为终止条件。例如:

1
2
3
4
5
6
static void next_line(FILE *f) {
    int c;
    while ((c = fgetc(f)) != '\n' && c != EOF) {
        // keep reading
    }
}

OpenJDK 社区已在 JDK-8268361 中修复该问题,并将修复合入相关维护版本。

建议按以下优先级处理:

  1. 升级 JDK:使用已经包含该修复的受支持版本;
  2. 回移补丁:如果受兼容性约束必须继续使用旧版 JDK,可在自维护分支中回移社区补丁并完成回归测试;
  3. 减少触发概率:规范 lxcfs 的重启和重新挂载流程,但这只能规避触发条件,不能代替 JDK 修复;
  4. 升级后复核:若仍然出现单核 CPU 持续占满,应重新采集线程栈、native 栈或 CPU profile,避免把其他问题误判为同一根因。

升级 JDK 前应进行应用兼容性验证,尤其是从较老的 Oracle JDK 或早期 OpenJDK 版本迁移时。兼容性风险与本缺陷本身无关,不应为了规避迁移风险而长期保留已知死循环问题。

5. 排查方法

5.1 确认高 CPU 线程

先定位进程内持续占用 CPU 的线程,并把操作系统线程 ID 与 Java 线程栈对应起来。若 Java 栈信息有限或线程处于 native 状态,应继续获取 native 栈。

重点关注以下特征:

  • 线程职责与系统指标、CPU 使用率或 /proc 采集有关;
  • 调用路径进入 JDK 的 Linux 操作系统指标实现;
  • native 栈停留在逐字符读取或跳过当前行的逻辑附近;
  • 问题时间点与 lxcfs 重启、升级或重新挂载一致。

5.2 验证环境事件

检查宿主机或容器平台事件:

  • lxcfs 服务是否发生重启;
  • /proc 相关挂载是否被重新建立;
  • 问题是否只出现在事件发生前已启动的 JVM;
  • 新启动 JVM 是否不再复现。

这些证据可以建立触发链路,但不能单独证明 JVM 死循环。最终仍应以线程栈、profile 和 JDK 版本为准。

5.3 核对 JDK 修复状态

确认实际运行的 JDK 构建,而不只查看镜像标签或部署配置:

1
java -version

对于自定义 JDK 发行版,还应核对构建变更记录或源码,确认是否包含 JDK-8268361,而不是仅根据大版本号推断。

6. 如何避免误诊

“监控线程占满一个核”并不是该问题独有的现象。以下情况也可能产生相似表象:

  • 指标组件高频调用 ThreadMXBean.getThreadInfo(),反复抓取全部线程信息和堆栈;
  • 网络规则或内核网络路径异常,导致系统调用成本显著上升;
  • JVM 启动阶段发生密集 JIT 编译;
  • 其他 native 代码存在自旋、重试或错误处理缺陷。

因此,不能仅凭“CPU 约为 100%”或线程名称判断根因。建议至少形成如下证据闭环:

1
2
3
4
lxcfs 重启/重新挂载
    + 高 CPU 线程位于 JDK 系统指标读取路径
    + 运行版本不包含 JDK-8268361
    + 升级或回移补丁后问题消失

7. 经验总结

这次问题体现了几个通用的系统编程原则:

  1. 字符读取循环必须处理 EOF:任何“读取直到分隔符”的代码都要考虑输入提前结束;
  2. 跨层故障需要区分触发条件与根因:lxcfs 重新挂载触发异常输入,JDK 的错误处理缺陷才导致 CPU 死循环;
  3. Java 栈不是排查 JVM 问题的全部:线程进入 native 路径后,需要结合 native 栈、profile、系统事件和源码分析;
  4. 版本判断应落实到具体补丁:大版本一致不代表补丁集合一致,自定义发行版尤其如此;
  5. 修复后仍需重新取证:相似症状可能有完全不同的调用路径,不应把所有单核高 CPU 都归因于同一问题。

参考资料

本文由作者按照 CC BY 4.0 进行授权