有一次帮同事排查线上问题翻了一上午日志终于找到那一行NullPointerException。再往下翻没了。没有堆栈、没有调用方、没有请求ID。异常被某个 catch 接住之后只打了一行e.getMessage()而 getMessage 的结果又是 null。也就是说这条日志除了告诉我们确实出过事之外什么也没留下。这种经历我遇到太多次了。很多人写代码时会写 try/catch但对 Java 异常机制的理解只停留在语法会用的层面。于是有的异常被悄悄吞掉有的异常被转成一条没有上下文的信息有的异常链在层层包装中丢了根因。Java 异常从入门到使用差的往往不是那几条 catch 怎么写而是对异常本质、传播机制、设计取舍的整套理解。文章想把我这几年的实践和思考梳理一遍从 JVM 里的传播机制讲到语法细节再到项目里的分层设计、日志策略和排查心法希望能帮你少踩几个我踩过的坑。1. 两个真实排查场景异常处理不当的代价1.1 场景一日志里有一行异常但没有堆栈先说说开头那个空指针。实际情况是服务端接了一个第三方回调对方传了一个 JSON 字符串我们的系统解析以后取某个字段去查库。那段时间接口时好时坏一小时报几次错又不会导致服务重启就一直没人重视。直到业务方投诉说数据经常对不上才被迫开始查。日志里确实有异常但只记录了e.getMessage()。问题在于NullPointerException的 message 本来就是 null打出来以后你只能知道有空指针这件事却完全不知道发生在哪个类、哪个方法、哪一行。整个服务几十个地方可能抛空指针靠猜根本不可能定位。最后只能临时加日志、改代码、重新发布线上跑一晚上才从新日志里抓到了完整的堆栈。1.2 场景二有堆栈却不知道是哪条数据另一个更隐蔽的场景。程序抛了一个NumberFormatException堆栈完整说的是把字符串abc转成数字失败了。异常确实有堆栈但只看堆栈你仍然不知道用户传入的到底是哪条记录、哪个字段、哪次请求。因为解析代码是一个公共方法所有入口都走它堆栈只能告诉我们解析失败不能告诉我们为什么这次解析失败。后来我在 catch 里把上下文信息补进去了比如用户ID、数据主键、原始字符串再抛出异常时把 message 写成用户 123 的文件解析失败字段 age 内容为 abc。这样排查效率一下子提高了不止一倍。两个场景放在一起看核心问题都是同一个异常对象本身只是信号要让信号发挥作用必须带上足够的现场信息。1.3 异常处理是开发的分水岭我后来跟同事总结过一句话看一个人对异常的处理方式基本能判断他写代码的成熟度。入门阶段的人把异常当报错来看觉得 catch 住就行熟练阶段的人把异常当流程控制来用知道什么场景抛什么异常资深阶段的人把异常当产品来设计会考虑别人拿到这个异常以后能不能快速解决问题。下面所有内容本质都在把这个产品打磨好。2. 先把异常看透它在 JVM 里是怎么流动的2.1 用驿站退件理解异常传播机制你可以把一次方法调用想象成寄快递调用方把请求发出去快递沿着调用链送到各个方法手里。正常情况下每个环节都顺利签收返回结果像回执一样一层层退回去。但某个环节出问题了——比如业务逻辑不合法、外部服务超时、文件打不开——这时系统会创建一个异常对象相当于打了一张退件单然后沿着调用链一层层往上传递。每一层方法都有机会做两件事要么自己处理这张退件单catch要么在方法签名上声明我处理不了继续退throws。如果一直传到最顶层都没人处理JVM 就会亲自接管把异常和堆栈打印到标准错误流然后终止当前线程。这个过程就叫异常传播。理解这一点特别重要因为很多人以为方法里 try 一下就能拦住其实 try 能不能拦到取决于异常从哪个方法抛出、沿着哪条链走。2.2 Throwable 体系Error、Exception、RuntimeException 的边界Java 的异常继承体系并不复杂所有可抛出的东西最终都继承自Throwable下面分两大分支Error和Exception。Error描述的是 JVM 或系统层面的严重问题比如OutOfMemoryError、StackOverflowError。这类问题通常不是你写几行 catch 就能解决的代码里一般也不应该去捕获它。比如递归没有出口导致StackOverflowError你 catch 住之后线程状态已经很不健康了正确做法是修代码而不是接住它。Exception是真正需要在代码层面处理的部分。它又分两类RuntimeException及其子类叫非受检异常其余像IOException、SQLException这类叫受检异常。受检异常的特点是编译器会强制你处理——要么 catch要么在方法签名上 throws。所以你可以把受检异常理解为loud failure编译器在提醒你这个方法可能会失败你得想清楚怎么办。// 受检异常编译期强制处理 public void readFile(String path) throws IOException { Files.readAllLines(Paths.get(path)); } // 非受检异常编译期不检查运行期才可能炸 public void setAge(String age) { int value Integer.parseInt(age); // 可能抛 NumberFormatException }2.3 不用背也能看懂的判定方法经常有同学问我到底哪些异常需要 throws哪些不需要其实不用死记。受检异常通常出现在外部资源不可控的场景比如文件不存在、网络断连、数据库连不上。这些不是你写代码能保证的所以编译器强制你考虑失败的情况。非受检异常通常出现在程序逻辑本身有缺陷的场景比如空引用、数组越界、参数不合法。理论上好代码不该触发这些异常所以编译器不强制你处理但问题真出来的时候多半是测试没覆盖到。一个实用的判断标准调用方捕获后能做什么有意义的处理如果能重试、能降级、能提示用户换一种输入就适合用受检异常如果捕获后只能打印日志没法改变结果那用非受检异常更合适。比如调用一个读取配置文件的接口文件不存在时调用方可以走默认配置这种就声明throws IOException而某个方法要求参数不能为 null如果有人传了 null那是调用方写错了直接抛IllegalArgumentException运行时干净利落。3. 捕获异常这些语法细节值得抠3.1 try 块的范围决定了你能处理得多精准我见过有人把整个方法体几百行代码全部塞进一个 try 里理由是不想每个地方都处理异常干脆统一接住。这种写法的直接后果是catch 住以后你只知道这段逻辑里出了异常却不知道是在哪一步出的。就好比你一整年把每天花的钱记在一个总账本里年底发现钱少了却完全想不起来哪一笔有问题。正确的做法是让 try 块尽量小只包裹真正会抛异常的语句。比如读取文件、解析字符串、远程调用这些操作单独包一层catch 逻辑紧跟在旁边。这样每次出现异常问题范围被压缩到几行代码里定位成本立刻下降。try 本身对性能几乎没有影响影响的是你排查可维护性的上限。什么情况下适合整个方法都 try当你的方法本身就是一个不可分割的业务动作时比如对账任务里的单个批次处理任何一步失败都不影响整体结构catch 以后统一上报失败原因。这时候大范围 try 是有意为之不是偷懒。3.2 catch 顺序和内建 final 变量异常匹配不是最精确优先而是自上而下按顺序匹配第一个符合条件的 catch。所以多个 catch 块必须从子类写到父类否则后面的 catch 就成了永远执行不到的代码编译期直接报错。try { someMethod(); } catch (NullPointerException e) { // 先匹配具体异常 } catch (RuntimeException e) { // 再匹配父类 } catch (Exception e) { // 最兜底 }Java 7 以后支持多异常合并用竖线分隔适合处理方式完全一致的情况try { readAndParse(); } catch (IOException | NumberFormatException e) { logger.error(read or parse failed, e); }这里有个容易忽略的细节合并捕获的异常变量e默认是 final 的你不能重新赋值。因为设计者希望你把它理解为两种异常共用的处理逻辑而不是把原异常丢掉。3.3 finally 里的三个经典坑finally 块的设计初衷是无论是否抛异常都要执行清理动作。但这个块也是最容易埋雷的地方我挑三个最常见的第一个坑是 finally 里 return。下面这段代码方法返回 2而不是 1public int test() { try { return 1; } finally { return 2; } }因为 finally 块在 return 语句计算完结果之后、真正返回之前执行一旦 finally 里有 return它会直接覆盖 try 里的返回值。这种隐藏在代码深处的覆盖逻辑是调试噩梦我强烈建议不要在 finally 里写 return。第二个坑是 finally 里抛异常。如果 try 块里的资源释放代码抛出了异常而这个异常发生在原有异常被抛出之前那么原有异常会被覆盖调用方只能看到 finally 里的新异常根因直接丢失。第三个坑是把业务逻辑写进 finally。finally 的正确职责只有一个释放非内存资源文件、连接、锁。不要在 finally 里做耗时校验、不发通知、也不做复杂的业务处理。3.4 try-with-resources用语法糖解放资源释放手工管理资源的历史写法又啰嗦又容易错因为你要记住 finally 里关闭资源还得处理关闭本身抛出的异常。Java 7 之后提供了try-with-resources语法糖声明在 try 括号里的资源会自动关闭。try (FileInputStream fis new FileInputStream(path); BufferedInputStream bis new BufferedInputStream(fis)) { // 读取文件 } catch (IOException e) { // 处理 IO 异常 }这段代码等价于编译器为你生成的 try/finally正常执行完会关闭抛异常时也会关闭而且多个资源按声明的逆序关闭。它的前提是资源类实现了AutoCloseable接口你的自定义资源如果想用它实现这个接口就行。我目前写 IO 和数据库连接相关代码时基本只用这种写法手工 finally 释放的场景已经非常少了。4. 抛出异常从 throw 到 throws契约要写清楚4.1 throws 是接口的一部分不是甩锅很多新人把 throws 当成编译器逼我写的诅咒能用 Exception 就把所有情况一起抛出于是方法签名长这样public void process() throws Exception { // ... }这种写法的问题在于信息量为零。调用方看到throws Exception只知道这个方法可能会失败但不知道失败的具体类型也就无法针对性地做处理。好的 throws 声明应当像相亲简历一样坦诚这个方法会因为文件不存在而失败IOException会因为参数非法而失败IllegalArgumentException但一般不会因为你没权限就失败。更关键的是throws 是方法对外部世界承诺的一部分。你改了抛出的异常类型调用方的 catch 逻辑可能全部失效这属于接口变更要谨慎再谨慎。4.2 什么时候抛受检、什么时候抛运行时前面从调用方的角度判断过一次这里从设计方的角度再补充一个经验不要因为方便就一律抛运行时异常也不要因为过度谨慎就什么都抛受检异常。我的习惯是外部故障用受检内部缺陷用非受检。文件系统、网络、数据库这些不可控因素导致的失败用受检因为调用方必须面对现实参数校验、逻辑状态非法、并发竞争这些属于程序质量的问题用运行时异常让问题在测试期就暴露出来。Java 标准库其实也是这么设计的访问文件用throws IOException解析数字失败抛NumberFormatException。4.3 自定义异常应该怎么写自定义异常的核心目的不是显得专业而是让错误表达更精确、更好被调用方识别。比如一个系统里各种失败都抛RuntimeExceptioncatch 的时候就很难区分到底是一类问题还是另一类问题日志也不好检索。我通常会让自定义业务异常继承RuntimeException并带上错误码。原因很简单非受检异常不会强制每个中间层都 throws项目演进更友好。public class BizException extends RuntimeException { private final int code; public BizException(int code, String message) { super(message); this.code code; } public BizException(int code, String message, Throwable cause) { super(message, cause); this.code code; } public int getCode() { return code; } }错误码的意义在于对接方可以直接根据 code 做程序化处理message 则留给人看。但我也见过反模式有人把几百个错误码塞进一个常量类最后谁也不知道该用哪个反而降低了可读性。错误码要在真的会被调用方差异化处理时才设计否则写清楚 message 就够了。4.4 异常链根因必须被保留最让我无奈的一种写法是 catch 住异常以后抛一个新异常但完全不传原异常catch (IOException e) { throw new BizException(1001, 文件解析失败); }这样做的后果是调用方看到的是文件解析失败但到底是文件不存在、权限不足、还是磁盘坏了全部丢失。排查时还得去看原始日志去对时间点运气好能对上运气不好就要回滚代码加日志。正确的做法是把原异常作为 cause 传入新异常catch (IOException e) { throw new BizException(1001, 文件解析失败, e); }这样新异常打印堆栈时会附上一长串Caused by: java.io.IOException根因完整保留。我自己的规则是任何 catch 后重新抛出的新异常必须带上 cause。不带 cause 的包装异常等于亲手销毁犯罪现场的指纹。4.5 补充一下堆栈跟踪和性能的成本还有一种声音说抛异常性能差所以尽量别用异常。这个说法有前提。创建异常对象确实会记录堆栈堆栈填充有一定成本但现代 JVM 对这块做了不少优化正常业务体量下每秒钟几百个异常并不会让服务崩溃。性能问题通常出在用异常控制非常高频的正常流程上比如用异常来判断循环结束、用异常做参数校验。判断一个场景该不该用异常关键是看它到底是异常情况还是预期分支。预期分支用返回值或状态码异常情况用异常这个边界清晰以后性能不是首要顾虑。5. 项目里真正的异常策略分层、日志与并发5.1 三层架构的异常转换路径实际项目里大部分是 Controller、Service、Dao 三层结构。异常在每一层的处理方式不同我通常会定几条团队规范Dao 层不吞异常也不在 DAO 里打印堆栈直接抛给 ServiceService 层是异常转换的核心把底层异常翻译成业务异常Controller 层的职责只有一个把异常转成统一的响应结构绝对不要泄露技术细节给客户端。比如一个 Controller 接口依赖 Redis 超时底层抛的是RedisConnectionFailureException到了 Controller 如果原样返回给前端客户端看到的是一堆连接池参数和 IP 端口这对业务毫无意义还会暴露内部架构。正确的做法是 Service 捕获以后转换为带业务错误码的异常Controller 统一处理成服务暂时不可用请稍后重试这类响应。异常在整个链路中的每一步转换都应该让错误信息离业务越来越近而不是越来越像一段内部报错。5.2 吞异常是最要命的反模式说到日志策略先给一个反面清单。我见过的最坑写法大概是下面这类try { riskyMethod(); } catch (Exception e) { // 什么都不做 } try { riskyMethod(); } catch (Exception e) { e.printStackTrace(); } try { riskyMethod(); } catch (Exception e) { log.info(riskyMethod error: e.getMessage()); }第一种不用说了问题爆发以后连发生过异常都不知道第二种在生产环境基本无效因为printStackTrace()输出到 stdout通常不会被你的日志框架收走第三种比第一种好一点但丢了堆栈只留 message定位困难。我在前面场景一里就吃过这种亏。好的异常日志标准很简单一条错误日志至少要能回答三个问题——发生了什么、在哪发生的、影响范围是什么。这对应的就是异常类型、堆栈、上下文。所以我要求团队里的 catch 最少写成这样catch (Exception e) { log.error(parse order failed, orderId{}, content{}, orderId, rawContent, e); }orderId、rawContent就是回答影响范围最后一个参数e会带上完整堆栈。注意日志参数里不要拼接字符串用占位符避免无意义地创建新字符串对象。一个统一的日志模板比任何强制工具都有效。5.3 异常、错误码与业务分支的边界有一种争论经常出现接口到底应该返回错误码还是抛异常我的立场是分场景。外部 API 协议层需要稳定的错误码因为调用方要写分支逻辑内部模块之间的调用优先用异常传递错误因为内部调用方更关心失败原因而非状态码。还有一种常见情况参数校验失败比如用户输入了非法邮箱。严格来说这是业务预期分支不是你系统坏了。用异常当然也能做但如果你在超高并发且高频的入口上用异常做纯校验确实会造成无谓的堆栈创建。我的做法是靠用户输入驱动的普通校验在入口处用校验框架返回字段错误信息不抛异常真正进入 Service 之后的业务约束不满足才抛业务异常。前者给用户友好的提示后者给开发清晰的问题信号。5.4 并发线程里的异常处理方式不一样主线程里 try/catch 的经验直接搬到线程池会踩坑。比如ThreadPoolExecutor.execute()提交任务任务内部抛异常时线程池会捕获并交给未捕获异常处理器默认行为是把堆栈打到控制台但你的主流程根本感知不到任务失败了——你甚至不知道自己丢了几个任务。解决方式是在 Runnable 内部 catch 所有异常记录日志并做好统计。如果用submit()Future.get()异常会被包装成ExecutionException。很多人拿到以后只看到ExecutionException忘了拆getCause()去看真正的业务异常所以我在团队里要求用 Future 拿结果时必须对ExecutionException的 cause 做拆解再决定是重试还是返回默认值。异步场景还容易碰到一个问题异常发生在别的线程你 try 的是主线程根本接不住。比如CompletableFuture里某个 stage 异常了主线程直接get()会拿到CompletionException而如果用exceptionally()提前兜底就能把链路恢复到正常流程。这些细微差别稍不注意就会表现为服务偶发异常但日志里什么都没留下的灵异事件。6. 快速排查异常的方法论6.1 抓大放小先读异常类型和 message定位异常是有方法论的。我不主张一上来就一头扎进堆栈里逐行看而是先读三样东西异常类型、message、第一行堆栈。异常类型告诉你属于什么大类是空指针、数组越界还是外部服务超时message 告诉你具体的失败原因比如Connection refused几乎是直说对端没开端口第一行堆栈则指出异常在代码里的精确位置。这三样读完百分之六十的问题基本已经定位了。比如看到一个ClassCastExceptionmessage 写明class java.lang.String cannot be cast to class java.lang.Long你马上知道是哪个地方把类型搞错了完全不需要继续翻堆栈。6.2 别被第一行骗了根因往往藏在 Caused By 链里最需要经验的地方在于异常堆栈从下到上是调用链但真正的根因往往在最后一串Caused by里。举个例子一条异常堆栈可能是BizException: 订单处理失败它对应的Caused by是IllegalStateException: 库存不足再往下Caused by还可能是一个空指针。如果你只盯着最外层的业务异常只会看到订单处理失败这种废话无法知道库存那个环节的具体细节。所以我排查多级异常时有个习惯直接滑到堆栈最底部从最内层的Caused by开始往外看。最内层往往最接近物理上的故障原因比如文件句柄不够、数据库连接数打满、磁盘写入失败。修好最内层的问题外层所有异常会一并消失。6.3 没有堆栈的三类特殊情况有一种情况比较棘手异常有 message但没有完整堆栈。常见于 OOM、栈溢出、以及异常被序列化/反序列化传输后堆栈为空。OutOfMemoryError本身不一定会出现在日志里而是表现为服务卡死、频繁 Full GC 或者线程全部阻塞这时候不能靠日志堆栈得抓内存快照分析对象占用。StackOverflowError有深堆栈你会看到同一个类名反复出现几百次一眼看出是递归没有出口。异常被跨进程传输是另一个隐蔽点比如经过消息队列或 RPC 传过来的异常对象堆栈往往只剩服务端的类名没有客户端调用链。遇到这种我会让接口协议层统一返回错误码和简短描述详细堆栈留在服务端本地日志用 traceId 关联而不是指望跨系统丢完整堆栈。6.4 用好的日志习惯把排查时间砍半最后聊一个很多人忽视的点好的异常处理习惯是在写代码的时候为几个月后的你省时间。我总结了自己的几条固定动作第一所有 catch 分支里日志至少带一个业务主键没有主键就加请求 ID 或会话 ID第二重要服务在统一入口生成 traceId通过日志框架的 MDC 自动把它附加到每条日志里这样一次请求的完整链路可以一次性拉出来第三确定一个团队统一异常日志格式别一个组打一种风格回头你排查别人代码时还得先学他的日志方言。我尤其推荐给每个服务设置一个错误清单意识每周挑出出现最多的前五类异常不急着修代码先去补全这些异常发生处的上下文日志。把上下文补全以后很多偶发问题会迅速变成一眼可定位的问题。这种方式比对着监控面板猜来猜去高效得多。说白了Java 异常这套机制并没有多高深但它像一个放大镜把你写代码时的每一个草率决策放大成线上事故。我自己最深的一点体会是写 catch 的时候把看日志的人能不能凭着这段记录复原现场当成默认标准很多坏习惯自然就改了。下次再遇到调不通的接口、诡异的偶发失败不妨先检查一下异常是在哪一层被拦下的、有没有完整保留根因、日志里有没有带业务上下文。守护好自己项目里的每一张退件单从入门到实战的路其实就是这么一点点走出来的。