
开头说实话我刚学Java那会儿第一次见到控制台飘出一大串红彤彤的报错整个人是懵的。什么NullPointerException、ArrayIndexOutOfBoundsException一个比一个长读都读不顺。后来跟着项目写了几个月才慢慢摸清楚异常不是随机的“程序发脾气”它是Java在告诉你程序哪里出了状况并且给了你一个补救的机会。这就是今天想跟你聊的东西——Java异常。如果你正在零基础入门Java或者已经在写代码但还是看到异常就慌那这篇文章就是给你准备的。我会从最基础的概念讲起接着手写代码演示几种异常处理的姿势再聊聊高频异常、常见坑和排查思路。看完之后你至少能做到两件事看到异常不再慌写代码时主动想想“这里要不要处理异常”。1. 异常到底是什么——先别写代码把概念弄明白1.1 从一次崩溃说起Java为什么会抛出异常可以先想象一个生活场景你开车上路车子突然爆胎。爆胎本身不算“天塌了”但你如果没有备用轮胎、不看路况、不减速靠边停车那结果就是失控甚至翻车。Java里的异常就像是车子在行驶过程中向你发出的“爆胎警告”。它不是无缘无故出现的而是JVM运行到某行代码时发现这个操作没法正常继续下去了于是主动停下来抛出一个对象——这个对象就是异常。举个例子你看这段代码int a 10; int b 0; int result a / b;整数除法中除数为0在数学上不成立计算机也不可能算出一个结果。JVM一旦执行到a / b这一步会立刻抛出一个ArithmeticException算术异常告诉你“除数为0了”。如果你什么都不做程序就在这里中断后面的代码全部不执行。这很像是路上爆胎后司机没有采取任何措施结果车子停在路中央交通瘫痪。异常和普通的“错误”还不太一样。普通的业务错误比如用户输入的密码不对这是你写代码时能预料到的完全可以用if判断来处理而异常更多是运行过程中出现的“意外状况”有些你能预料到有些预料不到。Java的异常机制存在的意义就是让程序在遇到这些意外时还能留下一条“安全通道”而不是直接崩溃。1.2 Java异常体系Throwable、Exception、Error一家三代的区别Java把所有的异常情况都封装成了对象这些对象有一个共同的祖先类叫做Throwable。你只需要记住凡是能被try-catch捕获的基本都跟Throwable有关。在Throwable下面又分成两大分支Error和Exception。Error代表的是JVM层面的严重问题比如内存溢出OutOfMemoryError、栈溢出StackOverflowError。这类问题通常不是程序员能在代码里“挽救”的它更像是车辆发动机直接报废已经不是换个轮胎能解决的事了。所以Error一般不建议去捕获也基本捕获不到什么有意义的结果。Exception才是我们日常说的“异常”它下面又分出两派。一派是编译时异常也叫受检异常比如读取文件时的IOException、操作数据库时的SQLException。Java编译器强制要求要么你在这段代码周围写上try-catch要么在方法签名上声明throws否则代码编译都过不去。另一派是运行时异常比如NullPointerException空指针、ArrayIndexOutOfBoundsException数组越界、上面的ArithmeticException。这类异常编译器不管写代码时不强制处理但运行到出错时它会自己蹦出来。为了让你一眼看明白我整理了一张表类别代表性类出现阶段是否需要强制处理典型例子ErrorOutOfMemoryError运行期不强制也基本没法处理JVM内存耗尽受检异常Checked ExceptionIOException、SQLException编译期检查必须try-catch或throws文件不存在、数据库连接失败运行时异常RuntimeExceptionNullPointerException、ArithmeticException运行期不强制但建议预防对象为null、除数为0这张表可以当个速查手册。初学者最容易犯的错就是把所有异常都当成“运行时报错”结果代码写完一编译发现编译器逼着你处理那些受检异常才开始去查资料。1.3 为什么异常处理很重要很多人觉得异常处理不过就是几行try-catch写不写都一样。如果你只是写几百行的练习题确实影响不大但一旦进入真实项目你会发现异常处理直接决定了一个系统的稳定性。举个我自己经历过的例子有一次我们上线了一个定时任务里面有一段读取文件解析数据的代码。因为只是内部工具当时图省事没有做任何异常处理。结果某个配置文件格式被改坏了解析到一半抛异常整个定时任务直接停掉后续所有数据都没处理。最麻烦的是没有日志、没有提示我们排查了整整半天才发现问题出在一个早该被捕获的NumberFormatException上。异常处理的意义至少有三个层面。第一程序不直接崩溃你可以捕获异常、记录日志然后跳过一个错误数据继续处理后面几百条正常数据。第二能给出有意义的提示用户输入非法参数时你抛出一个带说明的异常比系统报一个莫名其妙的英文错误更有价值。第三资源能得到合理释放文件流、数据库连接、网络连接这些资源如果因为异常没有被关闭会越积越多最终拖垮系统。我见过一些刚接触编程的人把异常处理当成“反正写上去也没坏处”的仪式。实际上异常处理是一套需要动脑子的设计什么地方该捕获、什么地方该抛出、抛什么类型、要不要自定义异常这些都是有讲究的。下面我带着你一步步把最核心的代码姿势写一遍。2. 动手写代码异常处理的三种基础姿势2.1 第一个try-catch让程序“有惊无险”先假设你已经装好了JDK能用记事本或者IDEA写代码。我们要做的事情很简单用try-catch把可能出错的代码包起来在catch里告诉用户发生了什么。看这段代码public class Demo01 { public static void main(String[] args) { int a 10; int b 0; try { int result a / b; System.out.println(结果是 result); } catch (ArithmeticException e) { System.out.println(除数不能为0请检查输入); } System.out.println(程序继续执行); } }运行之后你会发现控制台不会报错了而是输出“除数不能为0请检查输入”并且最后一行“程序继续执行”也正常打印出来。这就是try-catch的第一层作用把异常拦截住让程序继续往下走。你可能会问为什么catch后面要写ArithmeticException e这个类型可以理解为“我专门处理哪一类问题”。如果代码里可能抛出多种异常你可以给每种异常各配一个catch分支。e是一个变量名它指向被捕获的异常对象你可以通过e.getMessage()获取异常描述或者用e.printStackTrace()打印堆栈信息。这里有个细节值得记住如果try块中的某一行代码抛出了异常那这行代码后面的同块代码就不会再执行了程序会直接跳到对应的catch。所以不要把一堆操作全塞进try里尽量让try块的范围“小而精确”只包住真正可能出错的那几行否则很难判断到底哪一步出了问题。2.2 finally无论发生什么都要收拾现场try-catch解决了“出问题怎么办”但还有一个更常见的问题如果我在try里打开了一个文件、建立了一个数据库连接中途抛出异常了这些资源怎么关如果只靠catch里的代码去关万一你忘了或者关的时候又出错了资源就白占了。Java提供了finally来解决这事。finally块的特点是不管try里是正常执行完还是抛了异常被catch接住finally里的代码都会执行。你可以把关闭资源、清理现场的操作放在这里。import java.io.File; import java.io.FileReader; import java.io.IOException; public class Demo02 { public static void main(String[] args) { FileReader reader null; try { reader new FileReader(new File(test.txt)); // 这里可能抛IOException } catch (IOException e) { System.out.println(文件读取失败 e.getMessage()); } finally { if (reader ! null) { try { reader.close(); } catch (IOException e) { e.printStackTrace(); } } } } }你看代码里reader.close()本身也可能抛IOException所以在finally里又套了一层try-catch。这种写法在Java 7以前非常常见写起来麻烦但你能通过它理解finally的执行时机。有一点要特别提醒不要在finally里写return更不要在finally里抛异常。假如try里要返回一个结果finally却用return覆盖了逻辑会变得很诡异如果finally里抛出异常还会把原始异常盖住面试里经常拿这个当考点。2.3 多种异常分别捕获精确分类处理一个try后面可以跟多个catch就像一个事故现场备了多种应急预案电路短路有电气预案有人受伤有医疗预案每种情况分开处理。看这段public class Demo03 { public static void main(String[] args) { String[] strings {123, abc, 456}; for (String s : strings) { try { int num Integer.parseInt(s); System.out.println(num); } catch (NumberFormatException e) { System.out.println(字符串无法转数字 s); } catch (NullPointerException e) { System.out.println(字符串为null); } } } }第二个字符串“abc”没法被转换成数字所以第一次循环正常输出123第二次循环就会进入NumberFormatException分支。这种写法让你可以根据不同异常给出不同的提示。写多个catch时有一个坑顺序不能乱。Java要求子类异常在前父类异常在后。比如NumberFormatException是IllegalArgumentException的子类如果你先写catch (IllegalArgumentException e)再写catch (NumberFormatException e)编译器会直接报错因为后一个分支永远不可能执行到。这就好比你先设置了“任何情况都走通用预案”那还单独搞个“具体问题具体预案”干嘛3. 异常处理高级技巧从会用到用好3.1 受检异常与运行时异常面试高频考点刚学异常的时候很多人都会困惑为什么要区分编译时异常和运行时异常我提供一个理解角度受检异常是那些即使你代码写得再认真也无法完全避免的问题。比如你读一个文件文件可能被删除、可能没有权限、可能磁盘坏道这些都不由你的逻辑能控制。Java强制你处理它们是想逼程序员提前思考“如果IO设备出问题了程序该怎么办”。运行时异常则不同。它背后大多是编程逻辑问题——数组越界、空指针、除数为零这些是可以通过写好代码来避免的。所以编译器不强制你处理而是把责任交给你自己你得保证逻辑严谨而不是依赖catch来兜底。面试里经常这么问“受检异常和运行时异常的区别是什么”回答要点有三个。第一编译期是否强制检查受检异常编译时强制要求处理运行时异常不强制。第二典型场景不同受检异常常见于IO、网络、数据库等外部资源操作运行时异常常见于代码自身逻辑缺陷。第三处理理念不同受检异常要求你显式规划“恢复方案”运行时异常更依赖你提前预防。3.2 throws和throw定义问题由谁处理try-catch是在当前代码位置直接把异常拦下来。但有时候你写了一个方法自己也不知道该怎么处理或者你觉得这个异常应该由调用者来决定怎么处理那就可以用throws把异常“抛出去”。这里注意别混淆两个长得差不多的关键字throw和throws。throw是动作你在代码里主动扔出一个异常对象throws是声明写在方法签名后面表示“我这个方法可能会抛出哪些异常调用者自己看着办”。public class Demo04 { public static void main(String[] args) { try { checkAge(17); } catch (IllegalArgumentException e) { System.out.println(调用方来处理异常 e.getMessage()); } } public static void checkAge(int age) throws IllegalArgumentException { if (age 18) { throw new IllegalArgumentException(未成年人不能注册); } System.out.println(注册通过); } }这段代码里checkAge方法声明了throws IllegalArgumentException其实因为它是运行时异常编译器不强制写但写上可以提醒调用者。而throw new IllegalArgumentException(...)是真的把异常给“造”出来了。你可以理解为throw负责“点火”throws负责“挂牌警告”。如果checkAge抛的是受检异常比如IOException那编译器会强制要求要么方法声明throws IOException要么在方法内部try-catch。二选一逃不掉。这一点初学时要刻意去练多写几次被编译器逼着改代码的经历很快就能记住规则。3.3 自定义异常类让代码会“说话”项目写大了之后你会发现光用Java自带的异常类型不够用。比如用户输入密码错误你抛IllegalArgumentException虽然合法但语义太模糊。别人看到这个异常还得猜到底是密码格式不对还是用户名不存在。更好的做法是定义一个专门的业务异常比如LoginException然后在异常信息里写明“密码连续错误三次账户已锁定”。自定义异常非常简单核心就是继承一个合适的父类。如果你希望这个异常必须被处理就继承Exception如果你希望它像空指针一样自由抛出就继承RuntimeException。实际项目里后一种更常见因为这样调用方不会被一大堆强制捕获搞得很烦而且运行时异常更适合表达“业务校验失败”这类状况。public class LoginException extends RuntimeException { public LoginException(String message) { super(message); } public LoginException(String message, Throwable cause) { super(message, cause); } }只写两个构造方法就够了一个接收提示信息一个接收底层原因。这样你在业务代码里可以这么用public class UserService { public void login(String username, String password) { if (username null || username.trim().isEmpty()) { throw new LoginException(用户名不能为空); } if (!admin.equals(username) || !123456.equals(password)) { throw new LoginException(用户名或密码错误); } } }自定义异常最大的好处是它给错误做了一个“命名”。LoginException一看就知道是登录模块的问题OrderTimeoutException一看就知道是订单超时。调试的时候你不需要从几千行日志里翻线索异常类型本身就带着业务上下文。3.4 最佳实践别吞异常也别什么都抓新手最容易踩的坑是把try-catch写成了“抓起来就当没发生”。比如try { FileReader reader new FileReader(test.txt); } catch (IOException e) { }catch后面的花括号是空的。程序确实不会崩文件读不到也没人知道。这种“吞异常”的写法在团队协作里是最让人抓狂的。出问题时日志没有提示没有排查看代码看得头皮发麻。正确的做法是至少记录一句话哪怕只是打印堆栈信息也要让问题“留下痕迹”。另一个常见问题是catch的范围太粗直接catch (Exception e)。这等于把所有可能的问题都一把揽过来看似省事实际把真正的问题类型给抹掉了。统一处理的结果是空指针、类型转换错误、IO失败全部走同一个分支给出的提示必然不精准。我建议只在以下几种情况下使用宽泛的catch一是最外层做兜底避免程序崩溃二是在框架层面做一个统一异常处理三是你要做全局日志记录。还有个关于“粒度”的建议try块范围要小。我见过有人把一整个方法体都包进try里出了异常根本定位不到是哪一行的问题。好的习惯是只包住有风险的调用比如一次性解析、IO操作、数据库访问。如果真的需要一个方法整体都受保护可以拆成多个小方法每个方法内部各自处理。4. 面试与开发中的高频场景真实案例速查4.1 高频异常类型对照与预防方案学异常最好的方式不是背概念而是在实际报错里一次次“面熟”。下面我把平时开发中曝光率最高的一批异常汇总起来列个对照表每条附上最常见的解决思路。异常名称常见触发场景排查思路NullPointerException调用了一个null对象的方法或属性在报错行往上找看哪个对象可能为null用debug在那行打断点确认ArrayIndexOutOfBoundsException访问数组不存在的下标检查循环边界特别是i length这类写法NumberFormatException把非数字字符串用Integer.parseInt转换转换前先校验字符串格式或用正则预过滤ClassCastException强制类型转换时类型不匹配确认对象真实类型常用instanceof先判断IOException文件、网络读写失败检查文件是否存在、路径是否写对、磁盘权限SQLException数据库操作失败看SQL语法、连接配置、字段映射我挑两个细讲一下。NullPointerException是新手送分题也是送命题。它的核心解决办法就是“别让对象为null”。比如从Map里取值可能返回null用户传入的对象可能为null方法返回的对象可能为null。在这些值被使用前你需要加判断。Java 8以后可以用Optional来包装可能为null的值但对于初学者老老实实写if (obj ! null)最直观。ArrayIndexOutOfBoundsException看着吓人其实原因很单纯。比如你写了int[] arr new int[5];合法的下标范围是0到4。如果你循环里写了for (int i 0; i arr.length; i)当i等于5的时候就越界了。正确写法是i arr.length。这属于典型的边界问题写循环前先在草稿纸上算一下下标范围能省很多调试时间。4.2 动手实战一个带异常处理的用户注册模块光看例子不练效果打折。我建议你跟着做一个小的注册模块把异常处理串起来。需求很简单用户输入用户名和密码如果用户名少于3位抛LoginException如果密码少于6位抛LoginException如果用户名已经被占用抛LoginException。同时整个调用过程要用try-catch捕获把提示信息优雅地返回给用户。public class RegisterService { private static final SetString REGISTERED_USERS new HashSet(List.of(admin, zhangsan)); public String register(String username, String password) { if (username null || username.trim().length() 3) { throw new LoginException(用户名不能少于3个字符); } if (password null || password.length() 6) { throw new LoginException(密码长度不能少于6位); } if (REGISTERED_USERS.contains(username)) { throw new LoginException(用户名已被注册); } REGISTERED_USERS.add(username); return 注册成功; } }调用方代码public class RegisterDemo { public static void main(String[] args) { RegisterService service new RegisterService(); try { String msg service.register(admin, 123456); System.out.println(msg); } catch (LoginException e) { System.out.println(注册失败 e.getMessage()); } } }我建议你亲手敲一遍然后把LoginException分别改成继承Exception和继承RuntimeException感受一下编译器给你的不同反馈。这个练习做完你对受检异常和运行时异常的理解会上一个台阶。4.3 面试中异常相关问题的“一句话回答”面试题虽然不能完全靠背但有些基础问题确实适合用“一句话一个小例子”来答。这里列几个常问的你们可以直接当练手Error和Exception的区别Error是JVM层面的严重问题程序一般无法恢复Exception是程序运行中可捕获、可处理的问题。运行时异常和受检异常的区别受检异常编译期强制要求处理运行时异常编译期不检查主要源于代码逻辑缺陷。throw和throws的区别throw是在代码中主动抛出异常对象throws是方法签名上声明该方法可能抛出的异常类型。final、finally、finalize的区别final是修饰符finally是异常处理中的清理块finalize是Object类里的一个方法早已不推荐使用。面试官突然问异常十有八九不是想听你背概念而是看你有没有真实处理过异常。所以回答的时候尽量带上自己写过的例子比如“我在做文件上传模块时用自定义的BusinessException封装了文件格式错误和文件过大两种情况”这种回答比纯理论强得多。5. 实战推进问题排查与避坑指南5.1 学会看懂堆栈信息从第一行开始定位程序报了异常控制台那一大堆红字到底怎么看很多人直接往最下面翻觉得最后一行才是错误。其实核心信息在最上面的异常描述里而真正能帮你定位代码位置的信息是下面那一串“at xxx.xxx.xxx(xxx.java:20)”的堆栈帧。举个例子Exception in thread main java.lang.NumberFormatException: For input string: abc at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:65) at java.base/java.lang.Integer.parseInt(Integer.java:652) at com.example.demo.Demo07.main(Demo07.java:8)第一行告诉你异常类型和原因NumberFormatException出问题字符串是“abc”。最后一行最关键它告诉你异常发生在Demo07.java的第8行调用栈是main - parseInt - forInputString。你只要打开Demo07.java找到第8行基本就能看到是哪句代码把“abc”拿去转数字了。排查异常时不要漫无目的地猜先看“谁是离业务代码最近的栈帧”顺着这条路径往里找错误源头。5.2 排查思路一次真实问题的复盘我分享一个真实经历。当时有个报表导出功能同事反馈说Excel导不出来日志里也没有异常。我第一反应是“异常被吞了”。翻代码找到导出方法果然看到这样的写法try { exportExcel(); } catch (Exception e) { }catch是空的异常信息全被淹没。我临时在这行代码里加了日志打印重新跑一遍发现抛的是FileNotFoundException——导出目录在服务器上不存在。问题是加一行mkdirs()就解决了。这件事给我的教训是排查异常的第一步不是找错误原因而是确认异常有没有被完整记录下来。如果你的代码里有空catch先把日志补上再说。另外生产环境里最好用成熟的日志框架比如Slf4j Logback不要用printStackTrace()因为它的输出不一定进入日志系统查起来费劲。5.3 我踩过的几个异常相关大坑这几个坑我基本都栽过写出来给你们提个醒。第一个坑在finally里再次抛出异常。有一次我在finally里做一个清理操作清理方法声明了throws IOException结果原始业务异常被这个新的IOException盖掉了。排查了一个小时才反应过来。后来我改成在finally里用try-catch包住清理操作确保不会覆盖主异常。第二个坑用getMessage()得到null。有些异常对象确实没有详细信息比如new NullPointerException()默认是没有消息的。你打印e.getMessage()输出是null看起来像没有出错。这种情况要看堆栈信息别只依赖getMessage()。第三个坑捕捉异常的范围太大把程序里本该暴露的Bug也盖住了。早年在项目里图方便方法最外层直接catch (Exception e)结果自己写的一个空指针也被兜住了页面照常显示“系统繁忙”但真正的Bug一直藏在背后没被发现。正确思路是能精确到NumberFormatException就不要用Exception能处理就处理不能处理就往上抛让更高层去决定。第四个坑使用异常来控制正常业务流程。有人会故意抛异常来做跳转比如密码错误就throw new RuntimeException(密码错误)然后在外层catch。不是说完全不能用但如果业务逻辑正常分支也要靠异常来驱动那代码会变得极其难读而且异常本身有性能开销。正常情况下能用if解决的就用if判断。5.4 关于异常日志和团队协作的小建议写代码不是一个人的事异常处理是给小组成员看的。我最在意的一点是异常信息里要写清楚“发生了什么”和“该怎么办”。比如“用户名不能为空”比“参数错误”好一千倍比如“连接数据库超时请检查网络配置”比“Connection failed”更有价值。团队如果统一使用业务异常封装、统一日志格式后面排查问题能省一大半时间。如果你所在团队还没有约定异常处理规范我建议你先从自己的代码做起至少坚持两点不让空catch存活不在catch里只打一句话却没有任何上下文。慢慢地你写出来的异常会越来越“懂人话”别人读你的代码也会觉得舒服。从我现在的经验看异常处理水平基本能反映一个人编码的成熟度。刚入门的时候你可以把它当成一个语法知识点去学写了两三个月后要开始在真实项目中体会“什么时候该捕获、什么时候该抛出、怎么设计异常类型”到了能带新人的阶段你还要想着怎么让异常信息帮助别人快速定位问题。这条路没有捷径但你今天花时间弄懂的每一个异常类型都会在后面的开发日子里回报你。