Kevin's blog Kevin's blog
首页
  • AI基础
  • RAG技术
  • 提示词工程
  • Wireshark抓包
  • 常见问题
  • 数据库
  • 代码技巧
  • 浏览器
  • 手册教程
  • 技术应用
  • 流程规范
  • github技巧
  • git笔记
  • vpn笔记
  • 知识概念
  • 学习笔记
  • 环境搭建
  • linux&运维
  • 微服务
  • 经验技巧
  • 实用手册
  • arthas常用
  • spring应用
  • javaAgent技术
  • 网站
友情链接
  • 分类
  • 标签
  • 归档

Kevin

你可以迷茫,但不可以虚度
首页
  • AI基础
  • RAG技术
  • 提示词工程
  • Wireshark抓包
  • 常见问题
  • 数据库
  • 代码技巧
  • 浏览器
  • 手册教程
  • 技术应用
  • 流程规范
  • github技巧
  • git笔记
  • vpn笔记
  • 知识概念
  • 学习笔记
  • 环境搭建
  • linux&运维
  • 微服务
  • 经验技巧
  • 实用手册
  • arthas常用
  • spring应用
  • javaAgent技术
  • 网站
友情链接
  • 分类
  • 标签
  • 归档
  • 常见问题

    • 常见面试题
    • Mybatis一级缓存问题
    • 一次 “NPE 没有堆栈” 的排查记录
      • 现象与初步怀疑
      • 结论先行
      • 复现最小示例
      • 如何确认当前 JVM 是否开启了该优化
      • 为什么“运行一段时间后”才变成无堆栈?
      • 什么叫“代码变热”?
      • 线上排查与定位要点
      • 根因与修复建议(实践清单)
      • 我这次的证据链
      • 附:排查命令速查表
      • 结语
  • 数据库

  • 代码技巧

  • 浏览器

  • spring应用

  • 使用Java Agent字节码技术扩展
  • 什么是AP,什么是CP,什么是CAP
  • RabbitMq相关
  • ELK查询技巧
  • 性能优化手段
  • 经验技巧
  • 常见问题
kevin
2025-10-19
目录

一次 “NPE 没有堆栈” 的排查记录

# 一次 “NPE 没有堆栈” 的排查记录:OmitStackTraceInFastThrow

最近在排查线上告警时,发现一个很诡异的现象:服务启动后不久能看到完整的异常堆栈,但运行一段时间后,同一位置的错误日志只剩下一行:

java.lang.NullPointerException: null
1

没有任何堆栈!截图如下(Kibana、IDE 见文末),而代码里日志 clearly 使用了 log.error("...", e) 传入了异常对象。最终结论:触发了 HotSpot 的 Fast-Throw 优化 —— OmitStackTraceInFastThrow。下面是详细过程与可复制的经验总结。


# 现象与初步怀疑

  • 早期日志:有完整堆栈(定位到具体方法与行号)。
  • 运行一段时间后:同类错误高频出现,但日志只有异常类型和 message,堆栈被省略。
  • 代码层面:catch (Exception e) { log.error("xxx", e); },并非日志框架问题。
  • 平台:Spring Boot + ELK 收集,Kibana 中多条 NPE 聚集在同一个接口。

初步怀疑落到 JVM 行为而不是日志框架:🔧 OmitStackTraceInFastThrow 是什么。


# 结论先行

OmitStackTraceInFastThrow 是 HotSpot 为了提升性能的一项优化: 当**同一字节码位置(同一抛异常点)*反复抛出相同异常(如 NullPointerException、ArrayIndexOutOfBoundsException 等)并达到阈值后,JIT 会走“快速抛异常”路径,**复用一个预分配的异常对象**,该对象*不再填充堆栈,于是你就只看到一行 “NPE: null”。

进一步阅读可见:🧱 HotSpot fast-throw/omit stack trace 原理与讨论。

默认在 Server VM 是开启的(线上通常都是 Server VM),所以出现这个现象并不罕见。


# 复现最小示例

用下面的小程序可以 100% 复现(JDK8/11 实测):

public class FastThrowNpeDemo {
    public static void main(String[] args) {
        for (long i = 0; i < 5_000_000L; i++) {
            try {
                String s = null;       // 固定字节码位置的 NPE
                s.length();            // 反复触发同一位置的 NPE
            } catch (NullPointerException e) {
                if (i % 200_000 == 0) { // 打点查看某些时刻的堆栈情况
                    e.printStackTrace();
                }
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

运行初期你会看到完整堆栈;跑一会儿后,输出会变成只有第一行 NullPointerException,没有任何堆栈信息——说明触发了 Fast-Throw。


# 如何确认当前 JVM 是否开启了该优化

  1. 查看进程 JVM 参数

    # 方式一:直接查看进程命令行
    cat /proc/<pid>/cmdline | tr '\0' '\n'
    
    # 方式二:jcmd
    jcmd <pid> VM.command_line
    jcmd <pid> VM.flags | grep OmitStack
    
    # 方式三:离线打印默认值
    java -XX:+PrintFlagsFinal -version | grep OmitStack
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

    看到 bool OmitStackTraceInFastThrow = true 基本就确定了。 参考:🔎 jcmd VM.flags 使用方式。

  2. 运行时临时关闭(很多版本可行)

    # jcmd(优先)
    jcmd <pid> VM.set_flag OmitStackTraceInFastThrow false
    
    # 或 jinfo(部分版本)
    jinfo -flag -OmitStackTraceInFastThrow <pid>
    
    1
    2
    3
    4
    5

    关闭后再触发同一异常,日志会恢复带完整堆栈。 参考:🛠️ jinfo -flag 的说明与案例。

小贴士:JDK14+ 的“更友好的 NPE 文案”(JEP 358)与是否打印堆栈是两回事。即使有更清晰的 NPE 文案,Fast-Throw 依然会让堆栈被省略。了解更多:🧪 JEP 358 Helpful NullPointerExceptions。


# 为什么“运行一段时间后”才变成无堆栈?

到这里,自然会有一个问题:既然是同一处 NPE,为什么启动初期有堆栈,过一会儿又没了?

关键在于 代码是否已被 JIT 编译(也就是“是否变热”)。

  • 启动初期:方法还在解释执行或低层编译,异常会创建新对象并完整填充堆栈;
  • 运行一段时间后:该方法因持续被调用/循环很热而被编译(通常升级到 C2),同一 BCI 的隐式异常再次出现 → 触发 Fast-Throw → 省略堆栈。

注意:这里没有对外可配的固定“次数阈值”。触发是“状态条件”而非某个常量数字。 🔥 🧠 JIT 何时触发(概念总览)


# 什么叫“代码变热”?

为了让上面的因果链自洽,只需掌握下面两点:

  • 热度来自计数器:HotSpot 会为方法与循环记调用计数与回边计数,当“足够频繁且持续”时,就会把它放进**分层编译(TieredCompilation)**队列。 🧮 Invocation Counter | 🔁 Backedge/OSR
  • 被编译 ≈ 进入“已编译代码路径”:方法先被 C1 编译,继续升温与画像稳定后可能到 C2。Fast-Throw 针对“已编译代码里的隐式异常”生效,因此就出现了“先有堆栈、后无堆栈”的时间差。 🧱 TieredCompilation 层级

到此我们就能理解主线:不是异常“抛了 N 次”触发,而是“同一抛点在已编译代码里再次抛出”触发。


# 线上排查与定位要点

  1. 在日志平台(如 ELK/Kibana)按“异常类型 + 接口/traceId/部署单元”聚合,对比服务“启动早期 vs 运行一段时间后”的差异。 相关检索技巧:📈 Kibana 多行 Java 堆栈采集/展示。
  2. 对比相同接口的 QPS 与异常次数,如果某个接口的 NPE 呈现“高频且稳定”,高度可疑。
  3. 暂时在侧路关闭 OmitStackTraceInFastThrow(如灰度、压测环境或短时线上观测窗口)以拿到完整堆栈,迅速定位代码行。
  4. 结合代码审阅:在抛异常点附近搜索统一的空指针来源(例如某个字段未判空、缓存 miss 返回 null 后继续链式调用等)。

# 根因与修复建议(实践清单)

  • ✅ 优先修复业务根因:
    • 关键路径严禁将异常作为控制流;
    • 对外部依赖(缓存/下游/配置)的返回值做显式校验;
    • 为“可能为 null 的字段”提供默认值或引入 Fail-Fast 校验(Objects.requireNonNull 带 message);
    • 在入口层统一判空,减少在深层逻辑处抛出 NPE。
  • 🧯 临时需要堆栈时:
    • 短时关闭 -XX:-OmitStackTraceInFastThrow,或在运行中通过 jcmd VM.set_flag 关闭;
    • 加上采样/限频(避免海量堆栈刷盘),例如每秒只打印首若干条。
  • 🧪 本地/预发回归:
    • 加入自动化用例覆盖 NPE 场景;
    • 对关键接口增加异常率监控与熔断;
    • 若使用 JDK14+,开启“更友好 NPE 文案”辅助开发调试:🧪 Helpful NPE 搜索。
  • ⚖️ 是否长期关闭该优化?
    • 不推荐。Fast-Throw 能显著降低高频异常的开销;
    • 正确做法是减少/消除高频异常,而不是让 JVM 每次都构建完整堆栈。

# 我这次的证据链

  • Kibana:服务启动早期能看到完整堆栈;随时间推移,同一接口仅剩 “NPE: null”。
  • 代码:统一的 catch + log.error(..., e),不存在日志框架吞堆栈。
  • JVM:检查到 OmitStackTraceInFastThrow = true;在观测窗口 临时关闭该选项后,堆栈恢复。
  • 结论:触发了 Fast-Throw;根因是该接口在某些输入下高频空指针。

相关截图(示意):

  • Kibana:高亮的 NullPointerException: null(服务启动不久能看到堆栈,随后省略)。
  • IDE:catch (Exception e) { log.error("...", e); } 的代码位置。

# 附:排查命令速查表

# 1) 查看 JVM 是否开启 OmitStackTraceInFastThrow
java -XX:+PrintFlagsFinal -version | grep OmitStack

# 2) 查看正在运行的进程参数
jcmd <pid> VM.command_line
jcmd <pid> VM.flags | grep OmitStack
cat /proc/<pid>/cmdline | tr '\0' '\n'

# 3) 运行时临时关闭(采样阶段使用,注意窗口期)
jcmd <pid> VM.set_flag OmitStackTraceInFastThrow false

# 4) 再次观察日志是否出现完整堆栈
# (如接入 ELK,建议在采样窗口内做日志限频/采样)
1
2
3
4
5
6
7
8
9
10
11
12
13

# 结语

看到“只有异常类型、没有堆栈”的日志,并不一定是日志框架的问题。当错误在同一位置高频发生时,JVM 可能已为你“优化”到了省略堆栈的快速抛异常。 解决之道依然是那个朴素的工程结论:把异常当异常,别当控制流;减少高频异常,系统自然更健康。

上次更新: 2025/10/16, 16:24:22
Mybatis一级缓存问题
Oracle技巧

← Mybatis一级缓存问题 Oracle技巧→

最近更新
01
提示词工程实践指南
10-19
02
chatGpt提示原则
10-19
03
AI是如何学习的
10-19
更多文章>
| Copyright © 2022-2025 Kevin | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式