Java异常处理是被讨论得最多、又最容易流于表面的知识点。我见过不少能把继承结构倒背如流的人,真到线上排查时,却连Caused by那一行都不看,直接把整个堆栈甩到群里,然后问“这啥意思”。下面要聊的内容,我不想讲八股,而是把那些真正坑过我的典型问题、排查链路、沉淀下来的最佳实践,以及设计模式在异常处理里的融合用法一次性说透。内容偏向后端开发真实场景,适合准备Java面试的中级工程师、正在为线上异常挠头的开发,也想给想建异常处理规范的团队做参考。
1. 线上高频异常现场:NoClassDefFoundError 与 Redis 自增,我都被坑过
1.1 空指针看起来简单,定位起来要花半天
NPE可能是Java里出现频率最高的异常,但很多人对它的定位思路还停留在Java 8时代:看到NullPointerException,然后去代码里猜是哪个变量为null。JDK 14之后JVM默认会在NPE的异常消息里带上具体信息,比如Cannot invoke "String.length()" because "name" is null,这大大缩短了定位时间。问题在于,很多老项目还在JDK 8上跑,线上打出来的堆栈依然是冷冰冰的NullPointerException,不带任何message。
这时候我的做法是:先从堆栈行号反推代码,再看那一行有几次方法调用,判断哪个对象最可能是null。如果方法调用链特别长,可以在对应行前面临时把每个中间对象打印出来,或者用条件断点。更彻底的办法是升级到JDK 14+,让NPE消息自带答案,但在老版本上就只能靠代码层面排查。
另外有一种NPE很容易被忽略,就是包装类型自动拆箱。比如从缓存里拿到一个Long类型的count,count为null时直接执行count + 1,JVM在自动拆箱的瞬间抛出NPE。统计类场景里这种问题最常见,因为缓存未命中时返回null是常态。排查时不要只看“哪一行”,还要看那一行有没有隐藏的拆箱动作。
1.2 NoClassDefFoundError:堆栈上一闪而过的类加载事故
NoClassDefFoundError我在一个老项目里真实遇过:应用启动阶段一切正常,某个接口第一次被调用时直接抛异常,堆栈最前面是uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet。看到java.applet.Applet这个类名,心里基本就有数了——这是JDK 9模块化之后被移除的老类,项目里某个库还在引用它。
这类问题的迷惑性在于,应用能正常启动,只有运行到某个方法时才炸。原因是JVM的类加载是懒加载的,启动阶段只会加载启动路径上必需的类,等到运行期new某个对象或访问某个静态字段,才会去加载对应类,而此时发现这个类引用了不存在的类型,就会抛NoClassDefFoundError。要是你对类加载的时机不敏感,很容易误判成“代码写错了”或者“环境有问题”,然后在错误的方向上浪费一晚上。
1.3 RedisTemplate.increment 报错的真实原因
还有一个我见过很多次的运行时异常,场景是在Spring Boot项目里用RedisTemplate做自增:
Long count = redisTemplate.opsForValue().increment("order:count:20250101", 1L);结果运行时抛异常,报错信息类似not integer or out of range,或者直接ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long。很多人第一反应是Redis里存了奇怪的值,其实用命令行查一下key,value很可能是正常的数字。真正的问题往往出在序列化器配置上。
默认的RedisTemplate使用JdkSerializationRedisSerializer,key和value在Redis里都是二进制形式。increment操作要求key对应的value在Redis服务端可以被解析成整数,而jdk序列化后的value是一段二进制,服务端根本没法把它当整数解析,于是抛错。这个问题在“只跑通了Spring Boot demo但没细看RedisTemplate默认配置”的项目里特别容易出,因为它不报编译错误,运行期才炸。
2. 把JVM异常机制讲透,才知道该不该catch
2.1 Throwable体系:Error、检查型异常与运行时异常的边界
Java的异常体系以Throwable为根,往下分Error和Exception。Error表示JVM层面的严重问题,比如OutOfMemoryError、StackOverflowError、NoClassDefFoundError,这类异常通常不期望应用去捕获;Exception下面又分检查型异常和RuntimeException(非检查型),前者编译器强制你处理,后者可以完全不管。
不过,“Error不能catch”不是绝对的。我见过一个缓存模块会捕获OutOfMemoryError,在catch块里先清理一级缓存和软引用对象,再尝试重新分配,如果内存能释放,服务就继续跑。这里的关键不是“能不能catch”,而是“catch之后有没有能力让程序恢复”。如果catch完只是打个日志然后继续往深层走,那跟没catch没区别,只是把崩溃延后了几秒钟。
用一个生活类比来记:Error像小区停电,Exception像家里某个电器出故障。停电时正确做法是关掉大功率设备等待恢复,而不是假装没停电继续开空调;但也不是说停电就一定不能处理,比如你能切到备用电源,那这个“catch”就是有意义的。
2.2 为什么现在的框架都在抛弃checked exception
检查型异常是Java设计里争议最大的一环。早期JDBC、IO API大量使用检查型异常,比如IOException、SQLException,强制调用方try-catch或throws。听起来很安全,但实际上带来了成片的模板代码,而且很多检查型异常在调用方根本没有恢复能力。比如文件不存在,你让业务代码catch之后做什么?大部分时候只能往上抛。
Spring给了一个反向示范:把数据访问层的检查型异常统一包装成RuntimeException的子类DataAccessException,让上层按需处理,而不是被迫处理。Java 8之后,检查型异常和函数式接口的兼容性也暴露了问题:在Lambda里调用一个声明throws检查型异常的方法时,函数式接口的抽象方法不允许抛出检查型异常,所以你只能在Lambda内部try-catch,或者包一层RuntimeException再抛。这导致现在的新项目普遍在领域层只抛自定义运行时异常,再由全局异常处理器统一兜底。
2.3 try-with-resources与suppressed exception的设计亮点
try-with-resources是Java 7引入的,底层原理是编译器自动在finally里按逆序调用close(),并且用addSuppressed方法保存被压制(suppressed)的异常。为什么需要suppressed?因为在传统finally里关闭资源如果又抛一个异常,最外层catch到的是finally里的新异常,真正的业务异常反而丢了。try-with-resources不会这样,主异常会被保留,关闭资源的异常以suppressed异常的形式附加在主异常上,日志一打出来,整条链路清清楚楚。
try (FileInputStream in = new FileInputStream("a.txt"); FileOutputStream out = new FileOutputStream("b.txt")) { byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }如果你用等价的老写法,就得在finally里再嵌套try-catch去关资源,然后手动addSuppressed,代码又长又容易漏。所以现在写文件、网络连接、数据库连接时,我基本只用try-with-resources,只有资源类实在没实现AutoCloseable时,才会退回finally手动关闭。
3. 一次异常排查的完整链路:从堆栈到根因再到修复
3.1 案例一:NoClassDefFoundError的定位五步
回到那个NoClassDefFoundError,我把完整的排查过程拆成五步,以后你遇到类似问题可以直接照着走。
第一步,区分异常类别。如果堆栈是java.lang.ClassNotFoundException: java.applet.Applet,通常是Class.forName或自定义类加载器主动去查类路径,找不到才抛;如果是java.lang.NoClassDefFoundError: java/applet/Applet,则是代码在运行期通过new或访问静态字段触发了某个类的加载,而那个类引用的类型找不到。虽然结果都是类没加载到,但排查方向完全不同。
第二步,看堆栈里第一个业务代码类。NoClassDefFoundError的堆栈往往会指向真正调用出问题的代码位置,去那个位置看方法依赖了哪些类。Applet这个例子,通常是老库在某个工具类里引用了Applet,或者是反射代码里出现了java.applet.Applet字符串。
第三步,查依赖树。用mvn dependency:tree或者IDEA的依赖分析,找到引用java.applet.Applet的库。如果是直接依赖,还好替换;如果是传递依赖,需要一层一层往上找。
第四步,用javap验证类签名。如果源码里找不到引用,可以反编译或者用javap -c查看字节码,确认类引用。这一步能避免“凭感觉猜依赖”。
第五步,给修复方案。按优先级来:升级或替换掉依赖该类的库;如果只是反射字符串,直接去掉这段逻辑;如果SDK按Java 9以上编译,就同步升级运行JDK。改完重新打包,用下面的命令验证启动阶段不再加载这个类:
java -verbose:class YourMainClass 2>&1 | grep -i applet没有输出,说明那个类已经完全退出加载路径。整个过程里最花时间的往往不是修复,而是确认“谁在引用它”,所以不要跳过第四步。
3.2 案例二:RedisTemplate自增失败的定位步骤
Redis自增的问题,我也按步骤拆一遍。第一步,先确认Redis里的实际value类型。用命令行GET key,如果是正常数字,说明数据本身没问题。第二步,看堆栈。如果是ClassCastException,那重点基本可以锁定到序列化器。第三步,打印当前RedisTemplate的valueSerializer配置,没配置时默认是JdkSerializationRedisSerializer,value以二进制形式存在Redis里,increment操作在服务端自然无法把它当整数解析。
修复思路分两种情况。如果业务上只存字符串和数字,最简单的是直接注入StringRedisTemplate,它内部已经配好StringRedisSerializer,opsForValue().increment()返回Long,不会踩类型坑:
Long count = stringRedisTemplate.opsForValue().increment("order:count:20250101", 1L);如果业务上确实需要RedisTemplate<String, Object>,那就在@Bean里明确指定序列化器:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }这样key保持字符串可读,value用JSON序列化,既不影响increment,也能存对象。改完之后,建议先删除掉之前被jdk序列化过的旧key,再重新set一个数字,用increment验证一次。这个案例的本质是泛型擦除:RedisTemplate<String, Object>在编译期看着没问题,运行期RedisConnection返回的实际类型由序列化器决定,类型约定一旦和代码不一致,ClassCastException就来了。
3.3 排查异常时容易被忽略的隐藏信息
排查异常时,有几个信息经常被忽略,但往往藏着根因。第一个是Caused by链。日志里第一行只是入口,真正的原因可能在下面好几层,一定要把完整堆栈拉出来再看。第二个是suppressed信息。try-with-resources里关闭资源时抛出的异常会出现在Suppressed: 后面,如果我没注意这个,遇到“业务异常”和“关闭异常”叠加的情况就会非常拧巴。第三个是线程名。同一段代码在请求线程和异步线程里抛异常,处理策略完全不一样,异步线程的异常往往不会经过全局异常处理器。
另外,在日志平台里排查线上异常时,不要只用message去搜,因为message里的业务参数可能每次都不同。更好的办法是用“堆栈第一行的类名+方法名+异常类型”做聚合,先看这类异常的整体频次,再挑一条具体堆栈展开分析。这个习惯能帮你快速判断是偶发问题还是系统性故障,而不是一上来就被一屏日志淹没。
4. 异常处理的最佳实践怎么落地才不空谈
4.1 先定规矩:什么时候catch,什么时候抛,什么时候包装
团队里如果没有统一的异常处理约定,代码很快就变成各种try-catch大杂烩。我一般定三条规矩:第一,能往上抛就让统一出口处理,业务代码不要到处catch;第二,只有在当前层可以补偿时才catch,比如回滚、重试、清理资源,否则继续抛;第三,包装异常时务必把原始异常作为cause传进去,少了cause,排查链路就断了。
反例很常见:
try { orderService.create(order); } catch (Exception e) { throw new BizException("创建订单失败"); }这样抛出的BizException里没有cause,线上看到报错只能知道“创建订单失败”,至于底层是数据库超时还是字段校验问题,完全不知道。正确写法是:
try { orderService.create(order); } catch (Exception e) { throw new BizException("创建订单失败", e); }一行之差,排查难度是两种量级。
4.2 业务异常与系统异常的分层设计
更完整的做法是把异常分成两层:业务异常和系统异常。业务异常用自定义BizException,继承RuntimeException,内部带错误码和提示信息;系统异常包括NPE、数据库异常、IO异常等,不应该直接暴露给用户,而是由全局异常处理器统一转换成通用错误码。
我可以给一个经典的骨架。定义错误码枚举:
public enum ErrorCode { SUCCESS(0, "ok"), PARAM_ERROR(400, "参数错误"), BIZ_ERROR(500, "业务处理失败"), SYSTEM_ERROR(500, "系统繁忙,请稍后再试"); private final int code; private final String message; // 构造器和getter省略 }定义业务异常:
public class BizException extends RuntimeException { private final int code; public BizException(ErrorCode errorCode) { super(errorCode.getMessage()); this.code = errorCode.getCode(); } public BizException(ErrorCode errorCode, Throwable cause) { super(errorCode.getMessage(), cause); this.code = errorCode.getCode(); } }再配一个@RestControllerAdvice统一出口,Controller里就不用到处try-catch了。这里有一个容易踩的细节:业务异常最好不要做成检查型异常,哪怕编译期也不会强制你处理,否则Service接口签名上全是throws,既难看,又难以和Java 8的Stream/Lambda配合。
4.3 日志规范:别让异常信息变成失忆现场
日志里打异常,最常见的坏习惯是只打e.getMessage()。message往往只是一句描述,没有堆栈,也没有上下文参数,出了事什么都看不出来。正确姿势是把整个异常对象作为最后一个参数传进去,让日志框架打印完整堆栈:
log.error("处理订单失败,orderId={}", orderId, e);同时要避免同一个异常打两遍:有的代码在catch里先log.error,然后往外throw,全局异常处理器又log一次,一条异常在日志里出现两条相似堆栈,告警平台还会重复通知。我的约定是:异常只在“真正兜底的地方”打一次,中间层包装时可以不打,最多在异常对象里带上上下文信息。至于printStackTrace(),线上环境基本不会输出到日志文件,看到这种代码直接改掉。
4.4 资源关闭、Optional与防御式编程
资源关闭优先用try-with-resources,这条前面已经说透了。Optional则更适合在“返回值可能不存在”的场景里用,比如根据ID查用户,查不到就返回Optional.empty(),调用方显式处理空值。但我不建议把Optional用在方法参数上,那会让每个调用方都要包一层Optional.ofNullable,很别扭。关键入参可以用Objects.requireNonNull做快速失败,在入口就把null拦截住。
防御式编程并不意味着到处判空。我见过有人每个方法开头写三行if (obj == null) return,结果真的出问题时完全不知道是哪个环节漏了。更好的做法是把判空放在边界位置:外部API入口、DB查询结果、RPC响应转换处,内部流转的数据一旦进入对象包里,就默认它是非null的。这样异常不会在中间某个角落悄悄出现,而是会在一个可预期的位置暴露出来,排起来也快。
5. 设计模式融合:把异常处理做成一套可扩展的机制
5.1 模板方法:统一处理流程的骨架
模板方法非常适合用来固化异常处理流程。异常处理的典型步骤是固定的:判断是否该处理、收集上下文、记录日志、决定是否告警、给用户返回结果。这些步骤的先后顺序不会变,变的只是每一步的具体实现。把骨架写进抽象类,把变化点留给子类,就是模板方法模式。
public abstract class AbstractExceptionHandler<T extends Throwable> { public final void handle(T ex) { if (!shouldHandle(ex)) return; ExceptionContext context = buildContext(ex); recordLog(context); doHandle(context); if (context.needNotify()) { notify(context); } } protected abstract boolean shouldHandle(T ex); protected abstract ExceptionContext buildContext(T ex); protected abstract void doHandle(ExceptionContext context); protected void recordLog(ExceptionContext context) { log.error("异常处理,errorCode={}, message={}", context.getErrorCode(), context.getMessage(), context.getCause()); } protected void notify(ExceptionContext context) { // 默认不通知,子类按需覆盖 } }子类只需要实现shouldHandle、buildContext、doHandle这三个方法,流程不会乱。比如BizExceptionHandler继承这个抽象类,doHandle里构造统一的错误响应,其他异常走SystemExceptionHandler。模板方法的好处是把“每新加一种异常处理都要重新写一遍日志和告警”这件事彻底终结。
5.2 策略模式:按异常类型选择降级方案
策略模式适合处理“同一种异常有多种处理方式”的场景。比如数据库连接异常可能需要重试,上游服务超时可能需要返回兜底数据,业务参数异常只需要直接提示。每种策略定义一个实现,由选择器决定当前异常走哪个策略。
public interface ExceptionStrategy { boolean supports(Throwable throwable); void handle(Throwable throwable); }public class RetryStrategy implements ExceptionStrategy { @Override public boolean supports(Throwable throwable) { return throwable instanceof DataAccessException; } @Override public void handle(Throwable throwable) { // 记录重试次数,按指数退避重试 } }选择器可以是一个Map,把异常类型映射到对应策略,也可以遍历全部策略,用supports判断。策略模式的核心价值是:以后要加一种新的降级方案,不需要改已有的if-else链,新增一个实现类注册进去就行。这和模板方法正好互补——模板方法管“流程骨架”,策略模式管“某个步骤的可替换实现”。
5.3 责任链模式:异常处理器的链式路由
当多个处理器都可能处理某个异常时,用责任链更顺手。每个处理器先判断自己能不能处理,能处理就处理并结束,否则把异常传给下一个。Spring MVC的HandlerExceptionResolver本质上也有这个味道,多个解析器按优先级依次尝试。
public interface ExceptionHandler { boolean canHandle(Throwable throwable); void handle(Throwable throwable); }public class ExceptionHandlerChain { private final List<ExceptionHandler> handlers = new ArrayList<>(); public void addHandler(ExceptionHandler handler) { handlers.add(handler); } public void handle(Throwable throwable) { for (ExceptionHandler handler : handlers) { if (handler.canHandle(throwable)) { handler.handle(throwable); return; } } throw new GlobalException(ErrorCode.SYSTEM_ERROR, throwable); } }这个模式的威力在于扩展性。今天链上是BizHandler、ValidateHandler、SystemHandler,明天要加一个RateLimitHandler,只需要实现ExceptionHandler并加到链里,其它代码一行都不用动。相比之下,大型if-else链每加一种异常都要小心翼翼,可能影响其它异常的分支,责任链天然规避了这个问题。
5.4 观察者模式:异常事件与告警联动
异常发生之后的副操作,比如发邮件、钉钉通知、记录监控指标,这些不应该写死在异常处理主流程里。这时候观察者模式最好用。在Spring环境里,可以直接用ApplicationEventPublisher发布一个异常事件,由不同的监听器处理各自的逻辑。
public class ExceptionOccurredEvent extends ApplicationEvent { private final Throwable cause; private final String requestId; public ExceptionOccurredEvent(Object source, Throwable cause, String requestId) { super(source); this.cause = cause; this.requestId = requestId; } // getter省略 }发布点放在全局异常处理器里,监听器可以写多个:
@Component public class AlarmListener { @EventListener public void onException(ExceptionOccurredEvent event) { // 发送告警,注意不要因为网络问题影响主流程 } }观察者模式的好处是主流程只做“发布事件”这一件事,后面的告警和分析由监听器异步处理,主流程不会被通知的延迟或失败拖住。这里要留意:如果监听器是同步的,告警网络超时还是会影响主流程,所以生产环境建议用@Async或者消息队列解耦。这正好也是“设计模式融合”的实际体验——模式不是靠背的,是在一次次线上事故里长出来的。
6. 异常处理里的反模式:这些坑我劝你别踩
6.1 空catch与无脑return null
空catch是异常处理里最危险的反模式。一段代码如果catch住异常却不记录任何信息,线上出了问题就像拔掉了烟雾报警器,看似安静,实际上故障在悄悄蔓延。return null更致命,它把异常变成了另一个NPE,而且原来的根因已经丢了。如果实在觉得某个异常可以忽略,至少写清楚为什么:
try { cache.put(key, value); } catch (Exception ignore) { // 缓存失效场景可以容忍,降级为下一次全量加载 }注释里的“为什么可以忽略”比代码本身更有价值,它告诉后来的人这个决定是有意识的,而不是随手一写。
6.2 用异常控制业务流程
用异常做流程控制是我最反对的做法。异常对象的创建要填充堆栈,开销远高于一个if判断,在高频循环里可以直接把性能拖垮。更麻烦的是,它会误导阅读代码的人,让人以为这里真的出了什么错。用户输入校验、状态判断、循环终止这类场景,应该用if、Optional、自定义结果码,异常只留给真正的异常情况。
6.3 在循环里抓异常与重复打日志
在循环里逐条try-catch并打印日志,是我见过线上日志量暴涨的头号原因。一个10万次循环,即使异常率是1%,也会瞬间产生1000条堆栈日志。正确处理是在循环外层catch,或者把出错项的ID收集起来,循环结束后一并上报。还有一个容易被忽略的问题:try块范围过大。有些方法从头到尾包在一个try里,一旦异常出现,定位范围是整段方法。try块尽量缩小到最容易出错的语句附近,别让异常排查变成大海捞针。
6.4 全局异常处理器兜不住的异步场景
全局异常处理器不是万能的,它只能兜住Controller抛上来的异常。异步线程、MQ消费线程里发生的异常不会自动走进@RestControllerAdvice,这些地方需要单独加兜底逻辑和监听器,否则异常会直接打到控制台,线上日志平台根本收集不到。
比如CompletableFuture异步任务里抛了异常,如果不显式处理,异常会静默丢失。常用的做法是加exceptionally:
CompletableFuture<Order> future = CompletableFuture.supplyAsync(() -> orderService.get(orderId)); future.exceptionally(ex -> { log.error("异步查询订单失败, orderId={}", orderId, ex); return defaultOrder(); });@Async方法里同理,要么在方法内部try-catch,要么配置一个自定义AsyncUncaughtExceptionHandler去收集异常。MQ消费线程更要小心:如果异常没被捕获,消息可能一直重试直到阻塞队列;如果异常被无脑吞掉,消息又可能被确认消费但业务没执行。消费逻辑里要明确区分“可重试异常”和“业务不可恢复异常”,前者抛出触发重试,后者记录日志并走死信。
这些反模式往往不是一开始就存在,而是项目迭代到中后期,大家为了“快速解决问题”随手加try-catch时慢慢长出来的。等到线上告警变成噪音,再想回头清理,成本就高了。所以从一开始就要把这些规矩定下来,比我事后苦口婆心劝代码要有效得多。
最后分享一个我自己养成的排查习惯:线上看到异常,先别急着打开编辑器,第一件事是把Caused by链路、suppressed信息、当时的入参日志截图存下来。很多时候问题不是代码写错,而是数据、依赖、环境三者的组合,堆栈和数据一起看,才能避免在错误的方向上折腾。我现在写业务代码时,异常处理的优先级永远是:保留完整上下文 > 优雅降级 > 及时告警。代码里宁可少几个try-catch,也要把异常出口设计得统一。这个习惯帮我省下了大量深夜排查的时间,希望也能帮到你。