程序运行出错时,Java 不会直接崩溃,而是抛出一个”异常对象”来描述到底发生了什么。本篇把异常体系和 File 类的文件操作这两块基础一次讲透,它们是后续 IO 学习的地基。

本篇目标

  • 看懂 Throwable / Error / Exception 的继承体系,分清受检异常与非受检异常
  • 掌握 try-catch-finally 的执行细节,避开 finally 中 return 的陷阱
  • 分清 throw 与 throws 的分工,会定义自己的业务异常
  • 用 File 类完成文件、目录的创建、判断、遍历和按名过滤

异常体系:一张继承图

Java 中所有”程序的问题”都继承自 Throwable,它有两个儿子:

  • Error:JVM 层面的严重问题,比如内存溢出(OutOfMemoryError)、栈溢出(StackOverflowError)。这类问题程序无能为力,只能听天由命,也没法用 catch 补救。
  • Exception:程序可以处理的异常。它又分两支:
    • RuntimeException 及其子类:非受检异常(运行时异常),如 NullPointerException、ArrayIndexOutOfBoundsException。编译器不强制处理,本质是代码逻辑写错了,正确做法是改代码而不是到处 catch。
    • 其余 Exception 子类:受检异常(编译期异常),如 IOException、ParseException。编译器强制要求处理,不处理编译直接不通过。

判断口诀:RuntimeException 家族是”程序员的锅”,其余受检异常是”环境的锅”。

JVM 默认如何处理异常

如果异常一路没人处理,最终会抛给 JVM。比如 int result = 10 / 0; 这行代码执行时,JVM 会创建 ArithmeticException 对象,里面装着异常名称、错误信息(/ by zero)和出错位置,然后把它抛给调用者。main 方法也处理不了,就抛给 JVM 自己:JVM 把这些信息打印到控制台(也就是我们常看到的红色堆栈信息),随后终止整个程序。

靠 JVM 兜底显然不行——一行代码出错,后面所有代码都不执行了。所以要自己用 try-catch 把异常接住,让程序出错后还能体面地继续运行。

try-catch-finally 的执行细节

基本格式:

try {
int[] arr = {1, 2, 3};
System.out.println(arr[3]); // 抛出 ArrayIndexOutOfBoundsException
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("数组越界了");
} finally {
System.out.println("无论是否异常,我都会执行");
}

几个要点:

  1. try 中某一行抛出异常后,try 内该行之后的代码不再执行,直接进入匹配的 catch。
  2. catch 可以有多个,按从上到下的顺序匹配;父类异常的 catch 必须放在子类之后,否则子类 catch 永远执行不到,编译报错。JDK 7 之后还可以把并列的异常合并:catch (ArithmeticException | ArrayIndexOutOfBoundsException e)。
  3. finally 无论是否发生异常都会执行,唯一例外是先执行了 System.exit(0) 让 JVM 退出。它的典型用途是释放资源(IO 流、数据库连接),这一点在下一篇 IO 里会反复用到。

finally 中 return 的陷阱

经典面试题:catch 里有 return,finally 还会执行吗?

public static int getNum() {
int a = 10;
try {
return a; // 返回前,先把返回值 10 记下来
} finally {
a = 20; // 修改无效,返回的还是 10
}
}

答案是:finally 会执行,时机在 return 计算完返回值之后、方法真正返回之前,所以 finally 里修改基本类型的局部变量并不影响已记下的返回值。但如果 finally 里也写了 return,它会直接覆盖 try/catch 中的返回值——这是大坑,实际开发中 finally 里永远不要写 return。顺带区分三个长得像的关键字:final 是修饰符,finally 是异常处理块,finalize 是 Object 留给垃圾回收用的方法。

Throwable 的三个常用方法

catch 住异常后,通过异常对象可以拿到三类信息:getMessage() 返回构造时传入的异常原因字符串;toString() 返回”异常类名 + 原因”;printStackTrace() 把类名、原因和完整调用栈打印到控制台,是排查问题时最常用的一个。

throw 与 throws

  • throws:写在方法声明上,后面跟异常类名,可以跟多个、用逗号分隔。含义是”我可能出这个问题,调用者你看着办”,把处理责任交给调用者。
  • throw:写在方法体内,后面跟异常对象,一次只能抛一个。含义是”情况不对,我主动把异常扔出去”。
public void setAge(int age) throws Exception {
if (age < 0 || age > 150) {
throw new Exception("年龄超出合理范围"); // 主动抛出异常对象
}
this.age = age;
}

使用原则:方法内部能处理就 try-catch(后续代码还要继续跑);处理不了或者不该自己处理,就 throws 给上层。还有一个重写时的约束:子类重写父类方法时,抛出的异常不能比父类更”坏”——父类方法抛什么,子类只能抛同样的异常或其子类;父类方法没抛异常,子类就只能自己 try 掉,不能 throws 出去。

自定义异常

JDK 内置异常描述不了业务语义时,自己造一个:继承 Exception 是受检异常,继承 RuntimeException 是非受检异常。想让调用者强制处理就选前者,认为是程序逻辑错误就选后者。

public class AgeOutOfBoundsException extends RuntimeException {
public AgeOutOfBoundsException() {}
public AgeOutOfBoundsException(String message) {
super(message); // 把异常信息交给父类保存
}
}

使用时就一行:throw new AgeOutOfBoundsException(“年龄不能是负数”)。比抛一个含义模糊的通用异常清晰得多,调用者 catch 时也能精确区分。

File 类:路径的抽象

File 类的对象代表的是”一个路径”,路径背后有没有真实文件它并不关心,所以严格说它该叫”路径类”。路径分两种:绝对路径从盘符(Windows)或根目录(Linux/Mac)开始,是固定完整的;相对路径相对于当前工作目录,换了个启动目录就失效,开发中不建议依赖。构造方法有三个:

File f1 = new File("demo/a.txt");                    // 一个完整路径
File f2 = new File("demo", "a.txt"); // 父路径 + 子路径
File f3 = new File(new File("demo"), "a.txt"); // 父 File + 子路径

Windows 路径分隔符是 \,Linux/Mac 是 /。为了跨平台,代码里统一写 / 即可,Java 会自动转换。

常用功能一览

  • 创建:createNewFile() 创建文件,mkdir() 创建单级目录,mkdirs() 连不存在的父目录一起创建
  • 删除:delete() 删除文件或空目录,注意 Java 的删除不走回收站,删了就找不回来
  • 重命名:renameTo(File dest)。目标路径相同就是单纯改名,不同则是改名加移动
  • 判断:exists()、isFile()、isDirectory()、canRead()、canWrite()、isHidden()
  • 获取:getName()、getPath()、getAbsolutePath()、length()(字节数)、lastModified()(毫秒时间戳)
  • 遍历:list() 返回目录下所有名称的 String 数组,listFiles() 返回 File 数组

一个常见错误:调用遍历方法前不判断 isDirectory()。对文件调用 listFiles() 会返回 null,直接 for 循环就是空指针异常。

FilenameFilter:按条件过滤文件

需求:找出某目录下所有 .jpg 文件。listFiles(FilenameFilter) 可以在遍历的同时完成过滤:

File dir = new File("demo");
File[] files = dir.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return name.toLowerCase().endsWith(".jpg");
}
});
for (File f : files) {
System.out.println(f.getName());
}

accept 方法返回 true 的条目才会被收进结果数组。看 list(FilenameFilter) 的源码会发现,它内部其实就是循环调用 accept 做判断,再把通过的名称收集起来——过滤器只是把这段判断逻辑交给了调用者。配合 String 的 endsWith、contains 或正则,过滤条件可以非常灵活。

经典练习

键盘录入一个整数,输出它的二进制形式。要求容错三件事:录入的是小数、是非法字符、超出 int 范围时都要给出提示并重新录入。思路是用 Scanner 接收字符串,用 try-catch 捕获解析异常,超出范围改用 BigInteger 处理。这道题把键盘录入、异常处理和分支判断串在了一起,是对本篇内容最好的检验。

小结

  • 异常体系:Throwable 之下,Error 管不了,Exception 分受检(必须处理)和非受检(改代码)
  • try-catch-finally:catch 从小到大排,finally 几乎必执行,但里面别写 return
  • throw 抛的是对象,throws 做的是声明;自己处理不了就往上抛,业务语义不清就自定义异常
  • File 是路径的抽象,遍历目录前先判空,按条件过滤用 FilenameFilter

下一篇正式进入 IO 流的世界。