做Java开发这些年,天天跟异常打交道。NullPointerException、ConcurrentModificationException、ClassCastException……光是这几个高频错误就已经劝退了不少刚入行的朋友。异常处理这件事,看起来只是try-catch-finally几个关键字,但真正到了线上排查、性能优化和面试扯淡的时候,才发现里面的门道比想象中多得多。这篇内容不打算讲教科书里那套分类,我直接把自己踩过的高频错误坑、排查套路和优化技巧整理出来,适合正在写业务代码的初中级Java工程师,也适合准备Java面试的人拿去做复习提纲。
异常处理其实反映了一个人对代码边界条件的敏感度。很多项目前期跑得欢,一上线就崩,问题往往不是出在业务逻辑复杂度上,而是出在异常被吞掉、资源没释放、边界没控制这些“不起眼”的地方。接下来的内容,全部来自我真实踩坑后的复盘,不绕弯子,每条都能直接落到代码里。
1. 高频异常类型深度解析与排查套路
1.1 NullPointerException:空指针其实最值得深挖
空指针在Java高频错误里排名第一,几乎每个Java开发者每天都要见几次。但很多人对空指针的理解停留在“某个对象是null”,却没有真正建立一套系统的排查思路。
最常见的触发场景有这么几类:方法返回值为null后直接调方法、从Map或JSON里取出的值没有判空、ORM框架查询单条数据没查到返回null、外部接口响应体为null但没有做防护。这些场景有一个共同特征:调用链上某个环节“静默”地返回了null,而代码里没有提前拦截。
排查空指针时,我常用的套路是三步走。第一步,看异常日志最底部“Caused by”信息,确认到底是哪一行代码报的错,别光看异常类型就慌了,行号才是定位的关键。第二步,沿调用链往上反向追踪,看这个对象是从哪来的,是参数传入、方法返回还是容器取出,重点排查数据源头。第三步,如果日志行号不明确(经常发生在lambda表达式或动态代理场景里),就在可疑位置临时加日志,把关键变量的值打出来,不要凭感觉乱猜。
这里有一个很大的优化空间:Java 14开始提供了Helpful NullPointerException,启动时加上-XX:+ShowCodeDetailsInExceptionMessages参数,JVM会直接告诉你哪个引用是null,比如Cannot invoke "String.length()" because "name" is null。这个参数强烈建议在开发环境开启,能省掉一大半猜空指针的时间。
防御层面,我个人的实践原则是:外部数据边界一律判空,内部逻辑尽量fail-fast。对外部接口返回值、JSON解析结果、数据库查询结果,用Objects.requireNonNull或者显式判空提前拦截;对内部方法调用,如果有参数不合法,尽早抛出带上下文的异常,而不是让NPE在几十层调用之后爆出来。很多团队用Optional满天飞,我反而建议谨慎:Optional适合作为方法返回值提示调用方“可能为空”,但不适合做字段类型、不适合做方法参数,滥用Optional只会让代码更绕,排查起来更痛苦。
注意:千万别在catch了NullPointerException之后写一句
return null或者return new Object()糊弄过去。空指针的本质是代码有漏洞,吞掉它只会让问题下沉到更深的调用链,变成某个诡异的数据错乱,到时候你连怎么死的都不知道。
1.2 ClassCastException与数组越界异常:类型和边界的双重考验
ClassCastException(类型转换异常)在高频错误里排名也很靠前。它最常出现的地方是:从List或Map里取出Object后强转成具体类型、JSON反序列化时泛型丢失导致的类型不匹配、反射调用时Class与预期类型不一致。
Java的泛型是擦除式的,List<String>在运行时等价于List<Object>,所以从集合里取元素再强转,编译器不会报错,但运行时就可能炸。排查这类异常,核心思路是确认“实际类型”和“期望类型”是否一致。如果数据来源是第三方接口或者数据库字段,先用反射或者toString看看真实类型,别急着改代码,很多时候是上游给你塞了意想不到的数据结构。
预防ClassCastException,我在代码规范里定了两条硬性要求。第一,从集合取元素后如果需要强转,一定要先instanceof校验,特殊业务场景可以写一个类型安全的转换工具类统一处理。第二,涉及JSON序列化和反序列化的地方,优先使用带TypeReference的API,比如Jackson的TypeReference<Map<String, User>>,避免泛型信息丢失。
ArrayIndexOutOfBoundsException和StringIndexOutOfBoundsException本质上是边界问题。数组越界大多发生在循环里i+1、i-1这类索引运算中,或者分页计算起始位置时出现负数。字符串越界则常见于substring(beginIndex, endIndex)的参数没做范围校验。这类问题没什么玄学,就是写循环和截取操作前算清楚边界条件,尤其是循环的终止条件。
我踩过一个印象很深的坑:处理Excel导入时按行读取数据,某行数据缺失导致数组长度为0,但我直接取了row[2],结果ArrayIndexOutOfBoundsException。后来总结的教训是,从外部数据源获取数组或列表后,不能只判断是否为null,还要判断长度是否符合预期。判断长度这件事,写一个requireLength之类的工具方法,比在业务代码里手工写一大堆if (arr.length < 3)要清爽得多。
1.3 ConcurrentModificationException:遍历时修改集合的经典陷阱
ConcurrentModificationException是集合遍历过程中最常见的并发相关问题,但它不只是多线程才会触发,单线程里同样会踩。只要在遍历ArrayList的同时执行add或remove操作,就会触发这个异常。原因是ArrayList内部的modCount和expectedModCount不一致时,迭代器会快速失败(fail-fast)。
很多人一看到“Concurrent”就以为是并发问题,实际上单线程的for-each里删除元素是最典型的触发场景。比如这段代码就会炸:
for (String item : list) { if (item.startsWith("test")) { list.remove(item); // 直接改集合,迭代器检测到modCount变化 } }正确的处理方式有三种。如果你用的是普通ArrayList,想遍历时删元素,必须使用迭代器的remove方法:
Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if (item.startsWith("test")) { iterator.remove(); // 迭代器自己删除,不会抛异常 } }如果数据量小、读多写少,直接换成CopyOnWriteArrayList也可以。它的迭代器基于snapshot,遍历时随便改原集合都不会抛异常。但注意CopyOnWriteArrayList每次写操作都会复制底层数组,写频繁的场景性能开销很大,不能盲目替换。如果是先收集再统一删除,那就用一个临时List记录要删的元素,遍历结束后一次性removeAll。
还有一个容易被忽略的细节:多线程环境下如果遍历的同时其他线程修改集合,也会抛ConcurrentModificationException。这种情况下光改遍历逻辑没用,要加锁或者换并发容器。我的经验是,业务代码里如果出现这个异常,先别急着换容器,先看是“遍历时修改”还是“真正的并发写”,两者的解决方案完全不一样。
2. 异常处理的反模式与正确姿势
2.1 吞异常与日志规范:别让错误变成噪音
异常处理最大的反模式,就是空catch块。catch (Exception e) { e.printStackTrace(); }这种代码,除了把堆栈打到控制台之外什么都没做,在高并发场景下还可能把日志系统刷爆。更可怕的是catch (Exception e) { },一个字都不写,出了错误你连看都看不到,完全是在给线上埋雷。
我理解很多人的心理:catch了异常,总得做点什么,但又不知道该做什么,于是就打出来或者写一句空日志。这其实是设计问题,不是编码问题。异常处理只有三个正确方向:
第一,可以处理的异常,处理完及时恢复。比如重试一次远程调用、使用降级数据、把当前任务丢进死信队列。第二,不需要处理的异常,往上抛让上层统一处理。比如业务规则校验失败,抛一个带错误码的业务异常。第三,无法恢复的异常,记录下来并标记失败状态,在合适的出口(如Controller的@ExceptionHandler)统一返回,而不是在catch点打一枪就跑。
日志规范方面,我有几个实操建议。一是永远不要用System.out.println打日志,线上环境没人会盯着控制台看,必须走SLF4J+Logback这套标准组合。二是日志要带上业务上下文,比如订单号、用户ID、请求链路ID。排查线上问题时,“订单号为null”和“订单号12345在处理xx步骤时失败”是完全不同的两个日志,后者能帮你直接定位问题。三是异常日志要保留堆栈。很多同事图省事只打e.getMessage(),结果线上报错只说了一句“null”或者“timeout”,堆栈在哪、哪一行报的、调用链是什么样,全部丢失,这种日志等于没有。
注意:
catch (Exception e) { log.error(e.getMessage()); }是典型的错误用法,应该写成log.error("业务描述, 关键参数={}", 参数, e);,最后一个参数e会把完整堆栈打出来。这是最基础也最容易被忽视的日志规范。
2.2 finally、return与资源释放的坑
finally块里写return,是异常处理里非常经典的一个坑。如果方法在try块里已经决定要return一个结果,而finally块里又有一个return语句,那finally里的return会覆盖try里的返回值。同理,如果try块里抛出了异常,finally里有return的话,异常会被直接吞掉,调用方什么都感知不到。
这段代码就是反面教材:
public int getResult() { try { return compute(); // 可能抛异常 } finally { return 0; // 如果compute抛异常,返回值变成0,异常被吞掉了 } }为什么Java允许这种写法?这是语言层面遗留的坑。finally的语义是“无论是否发生异常都必须执行”,但如果在finally里写了return,它就会覆盖try里的所有结果,包括异常。业界普遍的观点是:永远不要在finally里写return。如果非要返回默认值,放在catch块里处理,至少异常还有机会被记录。
资源释放是另一个高频问题。流、连接、锁这类资源,如果不在finally里释放,就会出现连接泄漏、锁无法释放、文件句柄耗尽这些问题。Java 7之后有了try-with-resources,这个语法应该是处理AutoCloseable资源的默认首选:
try (FileInputStream fis = new FileInputStream(file); BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) { // 读写逻辑 } catch (IOException e) { log.error("读取文件失败", e); }try-with-resources会自动按逆序关闭所有实现了AutoCloseable的资源,不需要手动写finally。这里有个小细节:try-with-resources的close方法本身也可能抛异常,如果close异常和业务逻辑异常同时发生,业务异常会被优先抛出,close异常会被添加到suppressed列表里。排查异常时可以看getSuppressed()方法,不要因为少了某个异常信息就一脸蒙。
锁的释放同样要放在finally。JVM的synchronized会自动释放锁,但Lock.lock()这种显式锁不会,必须在finally里unlock。我见过生产事故就是因为Lock使用不当,线程直接卡死,锁永远不释放,最后只能重启应用。规则很简单:lock之后立刻跟try,finally里unlock,中间不要混入多余逻辑。
2.3 自定义异常与异常粒度:让错误码说话
很多Java项目的异常体系是混乱的:要么所有方法都抛Exception,要么干脆全局捕获。真正成熟的工程里,会有一套清晰的自定义异常体系。
我的实践方案是把异常分成两类:业务异常和系统异常。业务异常指的是可以预见的规则性错误,比如“库存不足”“金额超过限制”“用户已存在”,这类异常应该有明确的错误码和错误消息,抛出后由全局统一拦截返回给前端。系统异常指的是数据库连接失败、Redis不可用、网络超时这类底层错误,这类异常通常不需要对外暴露细节,但必须完整记录堆栈,方便运维排查。
自定义业务异常的基础结构通常是:
public class BizException extends RuntimeException { private final String code; public BizException(String code, String message) { super(message); this.code = code; } // getter... }为什么继承RuntimeException而不是Exception?核心原因是非检查异常不需要在方法签名里强制声明,业务代码里可以任意抛出,不会被编译器的throws约束绑架。我见过很多老项目把自定义异常设计成检查异常,结果每个方法都要加throws声明,改一处异常影响一大片调用方,维护成本极高。当然,如果你们团队的项目规范就是要求显式处理异常,那另说。但从工程实践角度看,业务异常用RuntimeException是更普遍、更省心的方案。
异常粒度的设计也值得讲究。我见过有人为每一个业务场景定义一个异常类,比如OrderNotExistException、UserBlackListException、GoodsSoldOutException,类数量爆炸,维护起来像灾难。更合理的做法是定义一个BizException配合枚举错误码,一个异常类覆盖所有业务错误。错误码的枚举设计要分模块、分区间,比如用户模块10001-20000、订单模块20001-30000,通过错误码就能快速定位是哪个模块的哪个业务分支出了错。
3. 异常性能优化与JVM参数调整技巧
3.1 异常创建的隐藏成本:为什么循环里抛异常这么慢
Java里创建异常的代价比想象中高得多,核心开销在fillInStackTrace()。每创建一个Throwable对象,JVM都要去抓取当前线程的方法调用栈,然后填充到异常对象里。这个操作涉及栈帧遍历和内存分配,在高频调用场景下非常昂贵。
我自己做过一个粗糙的性能对比:在百万次循环里分别用“抛异常标记错误”和“返回值判断错误”两种方式处理,前者的耗时大概是后者的几十倍。这不是危言耸听,异常创建和抛出确实会拖慢系统。
这个问题的本质是:异常应该用于“异常情况”,而不是常规流程控制。比如检查参数格式是否正确,不应该靠抛异常来反馈结果;数据不存在时返回null或Optional.empty()也比抛异常更合适。很多从其他语言转Java的同学容易犯这个错:用异常做业务分支判断,结果系统TPS上不去,一压测就超时。
3.2 OmitStackTraceInFastThrow与日志“缺堆栈”之谜
这里有个很有意思的坑,我在排查线上问题时遇到过:某个NullPointerException在测试环境能打出完整堆栈,到了线上日志里却只剩一行“java.lang.NullPointerException”,没有at xxx堆栈信息。很多人以为是日志框架配置问题,实际上这是JIT编译器做的优化。
HotSpot虚拟机有一个优化开关叫OmitStackTraceInFastThrow,默认是开启的。当一个异常类型在同一个位置反复抛出(比如循环里的NPE),JIT会把异常对象重新创建成一个“轻量级异常”,不再填充堆栈信息。这是为了节省性能开销,但结果就是日志里看不到堆栈。
要解决这个问题,线上JVM启动参数可以加上-XX:-OmitStackTraceInFastThrow,强制关闭这个优化。代价是每次抛异常都会填充完整堆栈,性能上有一点损耗。但对比排查问题的难度,这点损耗完全可以接受。我的建议是线上环境统一加上这个参数,反正解决了大量“日志没堆栈”的困惑。
另外还有一个小技巧:如果某个异常确实高频发生、又不需要完整堆栈,可以覆写fillInStackTrace()方法让它什么都不做,这样创建异常的开销会大幅下降:
public class FastException extends RuntimeException { @Override public synchronized Throwable fillInStackTrace() { return this; // 不填充堆栈,降低创建耗时 } }但注意,用这种方式创建的异常没有堆栈信息,出了问题难以追踪,只适合把它当流程标记用,不适合常规异常。常规异常还是老老实实保留堆栈。
3.3 try-with-resources与事务、锁的边界控制
异常处理和资源管理经常纠缠在一起,除了前文说的流和连接,事务和锁的边界控制也容易出问题。
Spring的声明式事务(@Transactional)和try-catch组合有一个经典陷阱。事务方法内部如果catch了异常但没有重新抛出,Spring的默认回滚策略就失效了,异常被吞掉,事务照样提交,数据一致性直接崩掉。我见过一个真实事故:转账服务里catch了余额不足的异常,打了日志就return了,结果事务没有回滚,钱还是扣了,用户投诉一片。
排查思路是:在@Transactional方法里,如果必须要catch异常,确保业务失败时重新抛出RuntimeException,让AOP感知到并触发回滚。或者使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记事务回滚,但这种方式不推荐,代码侵入性太强,还是抛异常最自然。
锁的问题也类似。ReentrantLock等显式锁必须保证try-finally释放,但有一种更深层的坑是锁内调用远程服务或者数据库操作,万一远程超时,锁的持有时间异常拉长,阻塞其他线程。异常处理方案是给锁操作加tryLock(timeout),超时后直接走降级分支,不要无限等待。
3.4 异常链路追踪:从堆栈到业务链路
传统堆栈只能告诉你“在哪一行出错了”,但回答不了“这个请求从哪来、参数是什么、往哪走”。在微服务和分布式系统里,只靠堆栈远远不够。所以异常排查一定要结合TraceId、链路追踪和业务日志。
一种低成本的做法是:在入口网关生成一个TraceId塞到MDC(Mapped Diagnostic Context)里,日志配置里统一打印TraceId,这样同一个请求的所有日志(包括异常堆栈)都带同一个ID,日志系统里一搜就全出来了。如果用了SkyWalking这类全链路监控工具,异常还要关联spanId和serviceName,排查跨服务问题时能快速在地图上看到链路断点在哪个节点。
具体的日志格式建议是:[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%X{traceId}] [%-5level] [%logger{36}] - %msg%n。这个格式里TraceId打出来,配合日志采集系统按TraceId搜索,效率提升非常明显。
4. 高频框架异常排查实录
4.1 Spring容器与事务类异常
Spring应用里最常见的容器异常是NoSuchBeanDefinitionException,含义就是找不到对应的Bean。这个异常本身很好懂,但排查起来有几个隐蔽的点:Bean有没有加@Component等注解、包路径有没有被扫描到、Bean的构造是不是抛了异常导致创建失败、是否存在多个同类型的Bean导致类型注入失败。
我记得有个项目启动时报NoSuchBeanDefinitionException,查了半天发现新写的Service类忘记加@Service注解,包扫描路径也不包含这个子包。这类问题就是检查组件扫描范围。另外,如果配置类里定义了模糊的@Bean方法,返回的Bean名称和类名不一致,也会导致按名称注入失败。
事务相关的Transaction rolled back because it has been marked as rollback-only也很经典。这个异常往往发生在两个@Transactional方法嵌套调用时,内层方法把事务标记为rollback-only,外层方法在try-catch里捕获了异常但没重新抛出,提交事务时发现事务已经被标记回滚,就抛这个异常。
出现这个问题的根源是:Spring事务默认采用代理机制,同一个线程内嵌套事务,如果内层标记了回滚,外层没有办法改变这个决定。解决方案有三种:内层方法事务传播改为REQUIRES_NEW,让内层事务独立;外层方法不要catch内层的异常,直接让它往上抛;或者内层方法不要用事务,把业务逻辑调整成无事务模式。具体用哪种,要看业务语义,不能无脑套一个方案。
4.2 MyBatis与数据访问异常
TooManyResultsException是MyBatis里的高频异常,语义是“期望查询一条记录,结果却返回了多条”。这类异常常见于使用selectOne方法时,SQL条件没有命中索引、联表查询产生重复数据、或者数据库里存在并发写入的脏数据。
排查这类异常,第一件事是把SQL日志打开看一下,MyBatis打印的SQL日志里有完整的SQL语句和参数,直接复制到数据库里执行一遍,就能看到到底是查出了几条记录。然后分析导致重复的条件,是逻辑问题还是数据脏,再决定是修SQL还是修数据。
BindingException: Invalid bound statement (not found)也是让很多新手崩溃的异常。出现这个错误,要么是Mapper接口的方法在XML里没有对应的Statement,要么是XML的namespace写错,要么是mapper-locations配置没扫描到XML路径。排查顺序:先看接口名和XML的namespace完全一致,再看方法ID和方法名一致,最后看target/classes目录里XML有没有被打包进去。Spring Boot项目里XML放到了resources目录,但构建插件没有把它复制到最终jar包里,会出现开发环境正常、打包后全部报BindingException的情况。
还有一类数据访问异常是权限相关的问题,比如行级权限的控制条件没生效时,用户能查到越权数据,这种问题不会报异常,但比异常更难排查。我的建议是行级权限的SQL条件统一注入,在MyBatis拦截器里动态拼接,不要分散在每个SQL里手写,否则权限条件漏一步,线上就是数据安全事件。
4.3 并发与分布式场景异常
Java并发编程里的异常排查,最让人头疼的是OutOfMemoryError: Java heap space和OutOfMemoryError: unable to create new native thread。前者是堆内存不够,常出现在高并发查询、批量处理过多数据、缓存无上限增长的场景中;后者是操作系统的线程数达到上限,多出现在线程池配置不合理、每请求创建一个线程的项目里。
排查OOM的方法,最关键的是拿到堆转储(heap dump)。启动参数加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump,OOM发生时自动生成dump文件,然后用MAT或JProfiler分析。为什么OOM只靠堆栈不够?因为堆栈只能告诉你哪一行分配了大对象,但真正的问题是整个堆里对象占用情况,需要看dump才能分析出是缓存存了太多数据、还是某个集合无限增长、还是大数组一次性加载了过多数据。
线程数耗尽的问题更隐蔽。unable to create new native thread不是Java堆空间的问题,而是JVM向操作系统请求创建线程失败。排查要看操作系统的ulimit -u线程数限制、进程本身的线程数、线程池大小配置。很多项目的线程池用Executors.newCachedThreadPool(),在高并发下线程数可以无限膨胀,直到系统资源耗尽。解决方案是把线程池改成有界队列、有最大线程数的ThreadPoolExecutor,并配合合理的拒绝策略。
数据一致性相关的异常,常见于分布式事务场景。比如分布式锁释放时抛异常、MQ消息重复消费导致的幂等冲突。排查分布式锁问题,核心是确认锁的加锁、业务操作、解锁三个环节的原子性和异常路径。如果业务执行过程中抛了异常导致锁没释放,后续请求会全部阻塞;如果锁的过期时间设置太短,业务还没执行完锁就自动释放了,其他线程就会拿到锁,造成并发冲突。解决方案是使用Redisson这类支持看门狗自动续期的分布式锁,过期时间设长一点,解锁放在finally里。
4.4 高频异常速查表
| 异常类型 | 典型场景 | 核心排查思路 | 推荐解决方案 |
|---|---|---|---|
| NullPointerException | 调用为null的对象方法 | 看堆栈行号,反查数据来源 | 外部数据判空、fail-fast、开启Helpful NPE |
| ClassCastException | 强制类型转换失败 | 确认实际类型与期望类型 | instanceof校验、TypeReference反序列化 |
| ArrayIndexOutOfBoundsException | 循环索引越界 | 检查循环边界和数组长度 | 长度校验、边界工具方法 |
| ConcurrentModificationException | 遍历时修改集合 | 确认是单线程还是并发修改 | iterator.remove或CopyOnWriteArrayList |
| NoSuchBeanDefinitionException | 找不到Spring Bean | 检查组件扫描和Bean定义 | 修正包路径、检查注解 |
| TooManyResultsException | MyBatis查询返回多条 | 打开SQL日志复现 | 修正SQL条件、处理脏数据 |
| BindingException | Mapper绑定关系缺失 | 检查namespace/ID/XML打包 | 统一配置mapper-locations |
| Transaction rolled back | 事务回滚标记冲突 | 分析嵌套事务传播行为 | 调整Propagation或异常处理逻辑 |
| OutOfMemoryError | 堆内存/线程数不足 | 获取heap dump分析对象占用 | 优化缓存、线程池参数、启动参数 |
5. 面试中的异常处理高频考点与回答思路
既然Java面试是很多人搜索这个话题的初衷,我这里把异常处理相关的面试核心考点也梳理一遍。
关于“checked exception和unchecked exception的区别”,回答要点是:检查异常继承Exception但不继承RuntimeException,必须在编译期显式处理(try-catch或throws);非检查异常继承RuntimeException,编译期不强制处理。实际工程中,大部分框架(Spring、MyBatis等)抛出的都是非检查异常,业务自定义异常也通常设计成RuntimeException。
关于“catch块中异常的处理顺序”,面试官主要想考察你是否理解多态和异常匹配。多个catch块的顺序必须是子类异常在前、父类异常在后,否则子类异常永远无法被匹配到。比如先写catch(Exception e)再写catch(IOException e),编译器会直接报错。
关于“finally块是否一定会执行”,这个经典问题的答案是:一般情况下finally块一定执行,但如果JVM在try块中执行了System.exit()、或者所在线程被强制终止,finally可能不会执行。前面提到的finally里写return会吞掉异常,也是面试官常挖的坑,务必记得这个点。
关于“try-with-resources的底层原理”,要能回答出它本质上是语法糖,编译器会生成调用close()的代码,并处理主异常和close异常的suppressed关系。能提到查看Throwable.getSuppressed()方法是一个加分项。
关于“如何设计一个合理的全局异常处理”,建议回答成:定义统一异常基类和错误码枚举,业务异常继承RuntimeException携带code和message,Controller层用@RestControllerAdvice做全局拦截,配合@ExceptionHandler统一返回响应体。同时细分业务异常、参数校验异常、系统异常的处理分支,外层还要保证完整堆栈日志。这个回答基本能覆盖大多数面试官的考核点。
关于“异常对性能的影响”,能说出异常创建时的fillInStackTrace开销、JIT的OmitStackTraceInFastThrow优化、以及不要用异常做流程控制这个实践原则,面试官会觉得你是真正做过性能优化的人,而不只是背概念。
面试中还有一个容易被追问的场景题:“线上某个接口偶发报错,如何一步步排查?”我推荐的回答思路:先看异常类型和堆栈行号,定位是哪个模块的哪一行代码;再查该接口最近是否有代码变更和配置变更;然后用日志系统按TraceId拉取这个请求的完整链路日志,找出异常发生前的业务上下文;如果堆栈信息不足,再判断是否触发了JIT的堆栈裁剪;最后如果是偶发性问题,检查并发量、连接池状态、外部依赖的稳定性。能按照这个顺序回答,基本可以证明你具备真实的线上问题排查能力。
我个人在实际操作中最深的体会是,异常处理的水平不是看你会不会写try-catch,而是看你有没有一套“异常发生之后怎么办”的完整预案。空指针、类型转换、资源泄漏、事务回滚,每一个高频错误背后都对应一个可以被预防的设计缺陷。把这些坑在代码评审阶段就堵住,远比线上出了故障再排查来得轻松。如果你所在的项目还在靠一个个catch块去堵漏洞,我建议你从今天开始,把异常设计当成和业务设计同等重要的事来做。最后再分享一个小技巧:新写的方法,先在注释里写清楚“什么情况下会抛异常、调用方该怎么处理”,再写实现代码;坚持一年,你会发现代码的坑会明显变少。