
做Java开发这些年要说每天见得最多的东西异常绝对排得上号。打开日志文件满屏的堆栈信息红字报错刚开始工作时看见就头大后来见得多了光看异常类型和第一行堆栈就能猜个八九不离十。面试别人的时候我也喜欢从异常入手因为这东西最能看出一个人是背过八股还是真写过代码——没踩过坑的人答不出那些细节。这篇内容不想写成教科书就结合我实际开发中遇到过的情况把Java里最常见的异常类型、产生原因、排查思路一次说透。既有给新手看的代码示例也有给老手参考的排查清单话不多说直接进正题。1. Java异常体系到底怎么设计的1.1 Error和Exception是两类完全不同的东西很多初学者分不清Error和Exception觉得反正都是Throwable的子类catch一下就完事了。这个认知在面试和实战中都会出问题。Error代表的是JVM本身或者底层资源出了严重问题比如OutOfMemoryError、StackOverflowError、NoClassDefFoundError。这类错误不是应用程序能处理的你没内存了catch住有什么用JVM马上要挂了你再怎么catch也救不回来而且强行catch很可能连异常处理代码都执行不了。实际工作中遇到Error第一反应不应该是怎么catch它而是为什么会发生怎么从根源上避免。Exception才是应用程序层面的问题又分成两类这个后面细说。打个比方Error就像是楼体承重结构出了问题你住在楼里能做的只有疏散Exception就像是家里水管漏水了你可以关阀门、修水管、找物业主动权在你手里。1.2 受检异常和非受检异常的区别讲透受检异常Checked Exception和非受检异常Unchecked Exception的最大区别体现在编译期。受检异常在编译期就被强制要求处理你不catch或者不往上抛编译器直接报错这就是热词里说的编译期异常的意思。典型的受检异常有IOException、SQLException、ClassNotFoundException。非受检异常就是运行时异常RuntimeException的子类编译期不会强制你处理代码能正常编译只有运行到出错的那一行才抛出来。NullPointerException、ArrayIndexOutOfBoundsException、IllegalArgumentException都属于这一类。对比项受检异常非受检异常编译期检查强制处理不强制父类Exception非RuntimeException子类RuntimeException典型例子IOException、SQLExceptionNullPointerException、ClassCastException处理策略必须catch或throws通常靠防御式编程规避代表场景文件读写、网络请求、数据库操作参数校验失败、类型转换错误为什么这个设计被后来的很多新语言抛弃像C#、Go这些语言都不区分受检和非受检了。Java的受检异常用了这么多年实际开发中确实存在被滥用的情况——很多人不管什么异常一律throws Exception结果方法签名上挂着一长串异常调用方层层往上抛最后在入口处统统catch这跟非受检异常也没什么区别了。我的习惯是调用外部资源网络、文件、数据库时必须处理受检异常这个不能省但是自己写的业务代码里的逻辑错误尽量用运行时异常避免过度污染调用方的方法签名。1.3 运行时异常为什么设计成不用强制处理很多人问过我这个本质问题既然受检异常强制处理为什么不把所有的异常都设计成受检的答案是不是所有的异常都可恢复。受检异常隐含的意思是调用方有机会也有义务恢复环境。比如文件不存在用户可以换一个路径重试网络超时可以重连一次。这些是调用方可以处理的。而运行时异常比如数组越界这是程序员的逻辑bug调用方根本没法恢复总不能catch住数组越界之后再猜一个合法下标继续跑吧。就是因为这类异常无法在业务层面恢复所以设计成非受检让代码自然崩溃把问题暴露出来交给日志系统记录而不是让每个调用方都去写一堆无意义的catch逻辑。理解了这一层你写代码的时候心里就有谱了调用别人的方法时如果它是受检异常说明对方在告诉你这里可能会出可恢复的问题你看着办如果是运行时异常默认前提就是你保证传入的参数是对的我不要你来背锅。2. 高频异常的实战剖析——你大概率都遇到过2.1 空指针异常Java里当之无愧的头号杀手NullPointerException简称NPE常年霸占各大公司线上异常榜第一名。Java设计者自己也承认这是设计失误之一十亿美元的错误就是托尼·霍尔爵士对空引用设计的评价。实际开发中NPE高发场景有这几类方法A调用方法B返回的对象直接对返回结果调方法没判空从Map或JSON反序列化获取的值直接使用没考虑key不存在数组或集合操作时集合本身为null框架注入的依赖没初始化成功比如Spring的bean没加载到我自己踩过最深的一个坑是从数据库查出来的数据封装成对象后一个嵌套属性为空直接拿来拼字符串做后续逻辑NPE出来之后由于没有记录业务ID排查了很久不知道是哪条数据出了问题。后来养成一个习惯——在关键业务节点上先做校验用Objects.requireNonNull或者自定义断言把NPE提前暴露在容易定位的位置。Java 14之后的一个改进值得说一下JVM可以通过开启-XX:ShowCodeDetailsInExceptionMessages参数让NPE的堆栈信息直接告诉你哪个变量是null。比如Cannot invoke String.length() because name is null这一下子就把哪一行报错提升到了哪个变量是空的粒度排查效率天差地别。按我的经验防御NPE有几道防线顺序不能乱方法入口校验参数该拒绝就拒绝不要等到用的时候才发现对象内部用Optional或者默认值兜底避免null传导关键链路用统一异常处理器兜底保证日志里有上下文信息编码规范上禁用直接返回null要么返回空集合要么用Optional2.2 数组越界和集合越界循环边界的隐形炸弹ArrayIndexOutOfBoundsException和IndexOutOfBoundsException一个是数组专用一个是List等集合的越界两者本质相同——你访问了一个不存在的下标。常见的产生原因循环条件写了i list.size()而不是i list.size()最后多循环一次索引计算错误比如list.size() - 1写成了list.size()导致最后一个下标越界多线程环境下一个线程正在遍历另一个线程把元素删除了集合size变了有一个非常经典的坑用for循环遍历集合并删除元素。你删除一个元素后后面的元素往前移动索引就乱了。举个例子ArrayList有3个元素你从下标0开始删除删完一个size变2下一个元素已经移到下标0了但你的循环参数还是按原来的次数走轻则漏删元素重则越界。Java 8之后建议用removeIf或者Stream过滤既简洁又安全。数组越界这个问题从根源上说其实是边界值审查不严。我的自查习惯是凡是写循环遍历的代码一律先确认边界条件用for-each或Stream能避免就避免手动索引。还有一个细节——数组的长度在循环里每次都要重新获取吗其实固定长度的数组在循环开始前把arr.length存成局部变量既高效又能避免循环中数组被意外修改导致的异常当然数组长度本身不能变但集合是可以变的。2.3 类型转换异常instanceof不是万能的ClassCastException当你试图把一个对象强制转换成它不兼容的类型时就会抛出。举一个我在生产环境遇到过的真实场景有一个接口Animal实现类有Dog和Cat。一个从数据库反序列化回来的对象本应该是Dog但因为在存储时类型信息丢失或者JSON序列化时使用了多态配置不当拿回来之后被映射成了Cat强制转换时直接ClassCastException。这个问题的本质是类型信息在传递过程中失真了。通常的规避手段是转换前先instanceof判断Java 16之后可以用pattern matching for instanceof一行搞定判断和强转if (obj instanceof String str) { System.out.println(str.length()); }泛型和反射场景下一定要检查Type信息是否完整还有一个隐蔽场景数组协变带来的转换问题。Object[]可以指向String[]实例但你把Object[]中的元素当成String去访问时如果里面装的是Integer一样抛ClassCastException。这类问题在泛型类型擦除后的集合中也会出现——ListString在运行时其实就是List你往里面混入别的类型取出来时强转String就炸了。2.4 非法参数异常防御式编程的第一道防线IllegalArgumentException字面意思就是参数不合法。这个异常很有意思——它是你自己主动制造的一般在方法入口校验参数时主动抛出用来说明调用方传错了参数。比如一个方法要求年龄在0到150之间你收到一个负数或200这时候直接抛IllegalArgumentException比后面算出一堆奇怪结果再排查要高效得多。Spring源码里大量使用这种模式比如Assert.notNull(obj, obj must not be null)内部就是封装IllegalArgumentException的。我自己写公共方法时的规范是对于public方法入口必做参数校验异常消息里带上期待值和实际值。比如public void setAge(int age) { if (age 0 || age 150) { throw new IllegalArgumentException( Age must be between 0 and 150, but got: age); } this.age age; }这个习惯三年下来帮了大忙。以前不校验偶尔线上数据出了脏数据SQL执行报错层层排查最后发现是上游接口传入的日期格式不对现在直接在最外层接口用统一校验拦截一把非法参数直接拒绝数据干净了下游问题少了一大半。2.5 并发修改异常一个非受检异常里最有行为艺术的ConcurrentModificationException简称CME。很多人第一次看到这个名字会想当然地以为是多线程并发修改导致的其实不完全是。举个例子ListString list new ArrayList(); for (String item : list) { if (item.startsWith(A)) { list.remove(item); } }即使在单线程环境下这个代码也会抛ConcurrentModificationException。原因是ArrayList内部维护了一个modCount变量每次结构性修改增删元素都会递增。迭代器在创建时记录了这个值每次next()时对比发现不一致就立刻抛异常——目的是防止你在迭代过程中默默修改数据导致不可预期的行为。实际生产中经常踩这个坑的场景是遍历过程中remove不符合条件的元素用Stream的forEach里直接调用list.remove()异步回调中修改正在被迭代的集合解决办法有三条路一是用Iterator.remove()它会把modCount同步更新二是先收集要删除的元素遍历完之后统一删除三是用CopyOnWriteArrayList这种并发安全的变体。项目里我通常直接用list.removeIf()一个方法搞定语义清晰。另外提一句如果是在Stream的forEach里修改集合那等同于在迭代过程中修改同样会被CME盯上。但Stream配合filter再collect到新列表就是一种干净的做法既没有CME也不会出现迭代中元素索引错乱的问题。3. 异常处理机制的正确打开方式3.1 try-catch-finally的滚动顺序和时机很多人写try-catch最常犯的错误就是catch块里只打印几行日志然后啥也不干。用一句行话来说这叫做吞异常。吞异常的后果比异常本身更可怕——你表面上看着程序没崩但实际上数据状态已经不对了等到问题爆发时根本找不到源头。正确的处理方式有三条路能恢复就恢复比如网络超时重试、读取配置失败降级到默认值不能恢复就向上抛让上层统一处理记录完整日志后再重新抛出给上层补充上下文实际开发中还需要特别注意多异常的catch顺序必须是子类在前、父类在后。如果你先catch了Exception后面的catch (IOException) 根本不会执行编译器也会直接报错。Java 7之后可以用|合并多个异常类型比如catch (IOException | SQLException e)但前提是这些异常类型之间没有继承关系。字符串/整型这类工具类我这里想提醒一个细节很多人写catch (Exception e) { log.info(参数错误{}, e.getMessage()); }然后把所有异常都归成参数错误。这种写法会把真正的IOException、NullPointerException等都掩盖成参数错误日志里看到的全是假象。要想日志可用异常类型必须原样保留不能随意归类。3.2 try-with-resources再也不用记finally里关资源了Java 7引入的try-with-resources是近年Java语法中性价比最高的改进之一。只要资源类实现了AutoCloseable接口InputStream、OutputStream、Connection、Statement、ResultSet都实现了就可以写try (FileInputStream fis new FileInputStream(a.txt); BufferedReader reader new BufferedReader(new InputStreamReader(fis))) { // 业务逻辑 } catch (IOException e) { // 处理异常 }关键是它保证资源一定会关不管正常执行还是异常退出。这一点上老手踩过的坑最典型的就是finally里手动关流的时候顺序写反了——先关了外层BufferedReader再关内层FileInputStream导致内层资源泄漏或者关资源时没判空NPE又把原始异常“覆盖”了。try-with-resources把这些问题直接消灭了。有个踩坑细节必须提一下在try-with-resources的括号里声明的资源作用域只在try块内想在catch或finally里访问是不行的。如果你确实需要可以在外部先声明变量在括号里赋值例如Connection conn null; try (Connection c getConnection()) { conn c; // 业务 }但注意要读取资源关闭时的异常或状态用addSuppressed机制。当主体代码和关闭资源时同时抛异常关闭异常会被附加为主体异常的Suppressed异常日志里能看到“Suppressed”标记别漏掉这类信息。3.3 catch到异常之后的处理策略升级我之前维护过一个内部后台系统因为异常处理不规范线上出了很多奇怪问题。后来我在团队里推行了一个异常处理分级规范场景处理方式说明参数校验失败抛出带错误码的业务异常提示前端明确原因外部服务调用失败记录错误日志 重试/熔断降级尽量避免直接让用户看到错误数据访问异常完整记录参数和SQL并向上抛便于DBA和开发定位问题第三方SDK异常包装成自定义业务异常 原始cause保留保留堆栈才能追踪根因定时任务失败捕获后记录详情继续跑下一个任务不要因为单条数据故障中断整个任务这套分级的核心思想是异常处理不是把异常解决掉而是把异常信息准确地传递到能处理它的地方。很多人守着避免崩溃的目标去写catch却忘了异常处理的目标其实是保证系统状态正确且可观测。4. 自定义异常什么时候不该省、怎么设计才不坑人4.1 项目里到底该不该疯狂自定义异常经常有同事问我是不是业务里每个错误情况都要定义一种异常我的答案是千万别。Java自带的标准异常已经覆盖了大部分通用场景参数问题用IllegalArgumentException状态非法用IllegalStateException数值越界用IndexOutOfBoundsException。自己造新异常的成本不只是多写一个类还要让团队所有人理解这个异常什么时候抛、怎么处理、错误码体系怎么对齐长期维护成本很高。那么什么情况下值得自定义异常我的经验是两个字分类。当你需要一个机制把业务可预见的错误和系统不可预见的错误区分开时自定义异常才有真正的价值。比如支付失败、库存不足、订单状态不允许这些是业务规则的拒绝你应该用BizException携带错误码和提示信息返回给前端而连接数据库失败、JSON解析失败、反射调用异常属于系统异常应当走另一条处理通道记录完整堆栈并告警。4.2 自定义异常的黄金结构设计自定义异常时我建议遵循以下规范public class BizException extends RuntimeException { private static final long serialVersionUID 1L; private final int errorCode; public BizException(int errorCode, String message) { super(message); this.errorCode errorCode; } public BizException(int errorCode, String message, Throwable cause) { super(message, cause); this.errorCode errorCode; } public int getErrorCode() { return errorCode; } }几个关键决策点继承RuntimeException而不是Exception这样业务方法不必声明throws不会污染上层接口签名。业务异常是给调用方看的结果不是需要恢复的故障。保留errorCode而不仅仅是message前端可以根据错误码做本地化提示、跳转、重试等差异化处理而不是靠解析中文字符串。支持传入cause很多业务异常底层往往有一些根因异常保留cause才能在日志里追到原始堆栈这个太重要了。有一点容易被忽略自定义异常一定要带上serialVersionUID。类实现了Serializable接口Throwable体系都实现了如果不显式声明serialVersionUID序列化时会动态生成如果字段一改版本号变了反序列化时直接InvalidClassException。虽然异常序列化在项目里用得少但如果将来接入了RPC、MQ消息带异常信息传递这个问题就够你喝一壶。4.3 给异常加时间戳的玩法热词里有自定义异常时间戳方便快递定位这个思路值得展开说说。我在实际项目中遇到过一种情况一个订单在被多个消费者重复处理时异常堆栈一模一样根本分不清是哪一次调用产生的。后来我在通用异常类里增加了一个时间戳字段在异常创建时记录public class BizException extends RuntimeException { private final String traceId; private final long timestamp System.currentTimeMillis(); public BizException(String message, String traceId) { super(message); this.traceId traceId; } }配合日志链路追踪traceId和高精度时间戳每次异常都带上“哪个请求、什么时间、哪一步”三个关键信息。排查重复消费、并发覆盖、重试风暴这类问题时一翻日志就能定位到具体的那次执行剩下的就是按图索骥了。不过提醒一下异常栈和异常对象如果很频繁地创建确实会有性能开销堆栈填充所以不建议在每个循环里都主动new异常。我所用的方式是在全局异常处理的边界统一包装而不是让每一层都带着异常跑。5. 异常排查方法论怎么把这堆堆栈变成线索5.1 从堆栈信息里精准定位根因的三步法遇到一个不熟悉的异常很多新手会直接复制报错信息去搜索引擎。这不丢人但效率太低。正确姿势是先把堆栈分成三段来看第一段是异常类型和消息。比如NullPointerException: Cannot invoke String.length() because name is null这一行已经把答案说了一半。第二段是at com.xxx.ClassName.methodName(ClassName.java:123)这是当时的执行路径。看堆栈是从上往下看的最顶上的方法就是异常发生的地方越往下是调用链越上层。第三段是Caused by:这是根因异常链。很多时候你看到的异常只是表象真正的错误被包装在cause链里一直往里挖才是根因。实际排查的时候我的习惯是先看Caused by链的最后一层然后从那里反向梳理调用链看数据从哪来、在哪一步被改变成了非法状态。有个场景特典型——某异常是NumberFormatException消息显示For input string: 12,345你一查就知道是格式化数字时把带逗号的字符串直接parseInt了根源是数据源头没有清理格式。5.2 线上OOM和栈溢出的排查实战茶话StackOverflowError本质是方法调用深度超过了JVM线程栈的大小。最常见的触发场景是递归没有出口比如一个递归删除文件目录的方法在遇到循环软链时死循环了或者依赖注入出现循环依赖A对象要B对象B对象要A对象不断创建。而OutOfMemoryError则是堆内存满了。线上遇到OOM按这个顺序排查先看是不是内存泄漏用jmap -dump:formatb,fileheap.hprof pid导出堆快照再用MATMemory Analyzer Tool或JProfiler分析大对象和GC Roots引用链看是否有大集合元素无限增长比如缓存没设置过期时间、批量查询结果集一次性加载太多看是否有连接/线程泄漏连接池配置太小加上获取后没释放线程池队列堆积确认JVM参数是否合理堆大小是否设置过小、有没有开启GC日志这里多说一句生产环境最好在启动参数里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/usr/local/logs/让JVM在OOM时自动导出堆快照。否则等你想起来要dump的时候进程已经重启过了现场全没了。这个参数救过我至少三次。5.3 排查日志里那些被忽视的细节日志是异常排查的第一工具但大多数人记日志的方式都有问题。总结几个最常见的坑只打message不打堆栈log.error(出错了{}, e.getMessage())这条日志只能看个大概没有堆栈就等于没有作案现场在日志里拼字符串用log.error(订单 orderId 处理失败)如果参数是Object且toString抛异常会掩盖原始错误应该用占位符log.error(订单{}处理失败, orderId, e)日志里没有业务追踪ID排查的时候不知道这条异常对应哪个请求链路回头找半天。建议在入口Filter里生成traceId放进MDC打印日志时自动带上日志规范这个事建议团队在一开始就定好规矩不然后面靠堆代码的方式补成本和复杂度都会上升。我见过最痛苦的一次排查异常是偶发的日志里全是参差错落的堆栈片段没有traceId、没有入参快照几十台机器轮询日志才拼凑出问题路径最后定位到一个并发更新上。从那以后凡是核心链路日志里必须带traceId和关键入参摘要。6. 面试和考级中的异常考点别踩这些坑6.1 面试官最爱问的那些异常问题Java异常是面试必考内容这里列几个我面试时一定会问的角度Error和Exception的区别这个属于送分题但要答出深度就要补充Error是JVM层的严重问题通常无法恢复Exception是应用层的可处理问题。如果再追问见过OutOfMemoryError吗怎么排查的就是考察实战经验了。受检异常和非受检异常怎么选面试官想听的不是定义而是你的设计思路。标准回答加分的点是自己设计的框架API中如果调用方可能因为外部环境问题而需要重试或降级用受检异常如果是调用方传参或逻辑导致的bug用运行时异常。try-catch-finally里return的执行顺序这是一个经典的坑。很多人知道finally会在return前执行但没注意到如果finally里有return会覆盖try里的return。public int test() { try { return 1; } finally { return 2; } }这个方法的返回值是2。同理如果finally里修改了返回的引用类型对象的状态比如往集合里加元素也会影响返回值。但如果finally里对基本类型返回值重新赋值不影响。这个细节非常容易答错建议亲手跑一遍。一个方法可以同时catch IOException和Exception吗答案是可以但顺序必须IOException在前Exception在后否则编译不通过。同时还考察了多异常捕获和异常合并的语法。线上突然OOM你怎么办这块考察实操回答要包含确认是OOM还是别的异常、用jstat/jmap/jstack看JVM状态、导出堆栈分析、排查泄漏源、临时调参止血、从代码层面根治。能把这些串成完整流程的候选人基本是有实战经验的。6.2 重写方法时异常声明的那些约束这里有一个经常被问也可以用来考别人的细节子类重写父类方法时能不能抛新的受检异常答案是不能。想象一下这种场景class Parent { public void process() throws IOException {} } class Child extends Parent { Override public void process() throws SQLException {} // 编译错误 }如果编译器允许子类抛新的受检异常那么通过父类引用调用时原本调用方只准备了catch IOException结果数倍于这个可能性的SQLException飞出来调用方根本没有准备程序就崩了。所以Java设计者的逻辑是子类只能缩小异常范围不能扩大这样可以保证面向接口编程的语义安全。运行时异常不受这个限制因为编译期本来也没强制调用方处理。这个特性在实际项目中踩过坑的不在少数尤其是在继承框架基类编写业务代码时如果父类方法声明了受检异常子类实现却想抛一个更精确的业务异常就得小心设计。我自己的建议是框架约束内尽量避免在重写方法中引入新的受检异常要么用运行时异常包装要么在方法内消化。6.3 网上争议话题异常对性能的影响到底大不大Java社区经常讨论异常的性能问题结论是异常的创建确实有成本但这个成本主要来自堆栈填充而不是其他环节。一次异常的构造大概会在微秒量级触发几十个栈帧的记录高并发场景下大量异常确实会产生可观的CPU开销。不过这句话不能读成尽量不用异常。现代JVM对异常做了大量优化HotSpot的逃逸分析在某些场景下会省略堆栈填充业务系统性能瓶颈几乎不可能单纯因为异常而产生。真正需要避免的不是使用异常而是用异常做正常流程控制——比如用try-catch去判断集合里有没有某个元素或者在循环中大量抛出异常作为业务分支判断。这类用法不仅可读性差也会让后续接管的人抓狂。务实的选择是正常业务流程返回结果或错误码真正的异常场景不可预料的错误才抛异常。两件事分开做性能和不性能都是次要的代码干净才是主要的。最后再分享一个锦囊生产环境的日志里如果有英文的异常栈直接看头一行基本能定位问题类型如果同时看到多个线程的线程dump信息那就要警惕是不是死锁或锁竞争问题。Java异常这个领域内容很多但核心方法论就是一套——理解异常类型、掌握处理机制、熟练看堆栈、做好日志记录。这个基本功打扎实了不管是日常开发还是线上救火你都能比别人快一步定位到问题根源。