Java的异常机制
简介
异常:异常并不是指语法错误,而是指程序运行过程中可能出现的错误,有一部分错误是在编写代码时就可以预料到有可能出现的,所以在编译时必须处理,例如,要读取一个外部文件,就有可能遇到文件不存在的情况,在代码中就需要处理这种情况,文件不存在,就是一个可能出现的异常,在Java中就是FileNotFoundException。
异常机制:异常机制就是当程序出现错误,程序安全退出的机制。异常处理是衡量一门语言是否成熟的标准之一
异常处理机制的好处:增加Java语言的健壮性;把异常流程的代码和正常业务代码分开,让程序更简洁。
异常的处理方式:Java 通过面向对象的方法来处理异常,具体分为两个步骤
- 抛出异常:在一个方法的运行过程中,如果发生了异常,则这个方法会产生代表该异常的一个对象,并把它交给运行时的系统,这个动作称为抛出异常。
- 捕获异常:运行时系统在方法的调用栈中查找,直到找到能够处理该类型异常的代码,这一个过程称为捕获(catch)异常,如果找不到,就把异常交给虚拟机处理。
基本使用
异常分类
编译期异常:由外部错误引起的异常。正确的程序在运行中,很容易出现的、情理可容的异常状况,在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。Java编译器会检查这种异常,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过,所以它又称受检查的异常
运行期异常:由程序内部错误引起的异常。在编译期不会检查这些异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。
JVM产生的异常:这种异常Java程序无法处理,只能打印异常信息并退出程序
异常类的体系
Throwable类:所有异常类型的祖先类,只有继承了 Throwable 的类才能被 throw 或 catch
Error类:Throwable类的直接子类,通常表示jvm异常,如堆栈溢出,
Exception类:Throwable类的直接子类,用于Java程序可能出现的异常情况,如果用户要创建自定义异常类,也是继承Exception类。Exception 下又分为运行时异常和非运行时异常。
- RuntimeException:运行时异常类,在程序运行过程中,由程序本身的错误引起的异常,如NullPointerException、IndexOutOfBoundsException,在编译期不会检查这些异常。一个异常类如果继承自RuntimeException,在方法中抛出后不需要在方法签名上进行声明,
- 非运行时异常:指的是RuntimeException类及其子类以外的异常,从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能通过编译。
相关源码:除了核心父类Throwable,存储了异常的堆栈信息,其它相关子类都比较简单,只是调用父类的构造方法,传入自己独有的异常描述信息,
处理异常的语法
异常处理涉及到的关键字:
- try:可能出现异常的语句,放在try块中
- catch:捕获并处理异常,
- finally:finally块中的语句是在任何情况下都必须执行的代码,除非jvm退出,
- throw:在方法体中抛出异常实例,
- throws:在方法签名上声明可能会出现的异常
- assert:断言。严格来讲assert应该是用在单元测试中的,正常代码中如果想要assert关键字生效,需要在程序启动时特殊配置,鉴于有时候会在代码中遇到通过assert来进行断言,处理异常情况的案例,这里做个介绍,但是正常代码中不应该出现assert关键字
抛出异常 throws、throw
throws:当一个方法产生一个它不处理的异常时,那么就需要在该方法的头部声明这个异常,以便将该异常传递到方法外部进行处理,使用 throws 在方法声明处声明一个异常。
使用 throws 声明异常的思路是:
- 当前方法不知道如何处理这种类型的异常,该异常应该由向上一级的调用者处理;
- 如果 main 方法也不知道如何处理这种类型的异常,也可以使用 throws 声明抛出异常,该异常将交给 JVM 处理。
- JVM 对异常的处理方法是,打印异常的跟踪栈信息,并中止程序运行,这就是程序在遇到异常后自动结束的原因。
throw 抛出异常:语法 throw new ExceptionType,在方法体中抛出一个异常对象,只有继承了RuntimeException的类才可以被throw抛出。
案例:throw和throws,在下面的程序中,要求用户在运行程序时,向程序传入一个参数,程序会把这个参数作为一个文件名来解析,如果用户没有传入参数,那么就抛出一个运行时异常,如果根据文件名无法找到文件,程序不会处理这个异常,而是会按照默认方式抛给虚拟机。
public class Test3 {public static void main(String[] args) throws FileNotFoundException {if (args.length == 0) {throw new RuntimeException("请输入文件名");}String fileName = args[0];FileInputStream fileInputStream = new FileInputStream(fileName);}
}
这里演示了throw和throws的基本使用,throw,用于抛出一个运行时异常,在本案例中,程序启动时,用户可能输入了参数,也可能没有输入参数,如果没有输入参数,程序无法继续,就要抛出一个运行时异常,提醒用户,然后终止程序。throws,用于在方法上声明一个编译时异常,在本案例中,用户输入的文件名, 根据它可能会找到一个文件也可能不会,这是编译时可以预料到的,所以是一个编译时异常,如果无法处理,需要在方法上声明该异常。
这里详细解释一下,为什么在这里没有输入参数属于运行时异常:因为这属于程序使用逻辑错误,是开发者或用户违反了程序的预期使用方式,程序的设计意图是 “必须传入文件名参数才能运行”,如果没有传入,本质上是 “调用方式错误”,属于开发者或用户的疏漏。这类错误理论上是可以提前避免的,比如在文档中明确要求传入参数,或者在测试阶段覆盖这种情况,因此Java不强制要求编译时处理,而是归类为运行时异常,由开发者在逻辑上保证避免。
为什么 “文件不存在” 是编译时异常?文件不存在会抛出编译时异常,因为这属于程序外部环境导致的可预见情况,与程序逻辑无关,且无法通过代码完全避免,即使开发者严格要求传入文件名,也无法保证用户输入的文件一定存在,可能用户输错文件名、文件被删除、路径错误等,这是程序运行时依赖的外部资源的状态决定的,属于不可控但可预见的场景。Java 设计编译时异常的目的就是强制开发者提前考虑并处理这类情况,比如提示用户 “文件不存在,请检查路径”,否则程序可能在运行时毫无征兆地崩溃。因此编译器会强制要求处理。
总结:编译时异常和运行时异常的区别,运行时异常是程序内部的逻辑错误,可以避免,编译时异常是程序外部的错误,在程序内无法避免,需要在编译时考虑这种情况。
捕获异常 try catch finally
上面的代码演示了如何抛出异常,抛出异常是交给虚拟机处理,用户也可以自己处理这些异常,把异常信息进行友好输出,非致命异常,可以吞掉,让程序继续进行。使用try、catch、finally来实现异常处理。
格式:
try{// 可能发生异常的语句,try 块中声明的变量仅仅是局部变量,外部无法访问
} catch(ExceptionType | ExceptionType2 [, ....] e) {// 处理异常语句
}[.....
][finally{// 无论有没有异常都会执行的语句。
}]
在 try 块后可以跟多个 catch 块,每一个 catch 块所对应的异常范围应该是越来越大。
执行顺序:
- 如果在catch块中抛出异常:先执行finally中的语句,然后再抛异常
- 在 finally 块中抛出的任何异常都会覆盖掉在其前面由 try 或者 catch 块抛出异常。包含 return 语句的情形相似。鉴于此,除必要情况下,应该尽量避免在 finally 块中抛异常或者包含 return 语句。
案例1:继续上一个案例,使用try catch处理编译时异常,捕获异常,输出对用户友好的异常信息,然后程序继续或退出。
public class Test4 {public static void main(String[] args) {if (args.length == 0) {throw new RuntimeException("请输入文件名");}String fileName = args[0];try {FileInputStream fileInputStream = new FileInputStream(fileName);} catch (FileNotFoundException e) {System.out.println("文件 " + fileName + " 不存在,程序退出");}}
}
案例2:finally的使用。网络编程中的一个案例,在finally块中关闭socket、输入流等资源
public static void processBusiness(Socket socket) {InputStream inputStream = null;try {// 业务处理,读取输入inputStream = socket.getInputStream();int len;while ((len = inputStream.read(bytes)) != -1) {String input = new String(bytes, 0, len);System.out.println("[" +Thread.currentThread().getName()+ "]客户端发来的数据:"+ input);}} catch (IOException e) {e.printStackTrace();} finally {// 关闭资源if (inputStream != null){try {socket.shutdownInput();inputStream.close();socket.close();} catch (IOException e) {e.printStackTrace();}}}
}
案例3:多个catch块
public static Long convertDateStrToTimestamp(String date) {SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");try {Date dateObj = formatter.parse(date);return dateObj.getTime();} catch (ParseException e) {return 0L;} catch (Exception e) {return -1L;}
}
从上往下,异常范围应该越来越大。
案例4:在一个catch块中捕获多个异常
public static Long convertDateStrToTimestamp(String date) {SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");try {FileInputStream fileInputStream = new FileInputStream(date);Date dateObj = formatter.parse(date);return dateObj.getTime();} catch (ParseException | FileNotFoundException e) {return 0L;} catch (Exception e) {return -1L;}
}
如果一个try块中涉及到多个编译时异常,可以把采用这种写法,但是通常不推荐。
案例5:在finally块中return返回值。
public class Test5 {public static void main(String[] args) {Long l = convertDateStrToTimestamp("2025-02-28");System.out.println("l = " + l);}public static Long convertDateStrToTimestamp(String date) {SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");try {Date dateObj = formatter.parse(date);return dateObj.getTime();} catch (ParseException e) {return 0L;} finally {return -1L;}}
}
结果:finally块中的return会覆盖掉正确的返回值,所以尽量不要在finally中return返回值
assert 断言
断言:assert,如果希望在不满足某些条件时阻止代码的执行,就可以考虑用断言来阻止它。断言在软件开发中是一种常用的调试方式,断言用于保证程序最基本、关键的正确性。
断言语法:assert <boolean expression> [: "<message>"]
java 1.4 增加了‘断言’的特性,断言是为了调试程序,并不是发布程序的一部分,默认情况下 jvm 是关闭断言的,如果想要使用断言调试程序,需要手动打开断言程序
不要在正式代码中使用断言,只在单元测试中使用,如果需要启用,程序运行时添加虚拟机参数 -ea
案例:这是在junit框架中使用的
@Test
public void test2() {Properties properties = System.getProperties();assert properties != null;
}
如果properties为null,抛出断言异常
java.lang.AssertionErrorat org.wyj.LearnAssertTest.test2(LearnAssertTest.java:28)at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)at java.lang.reflect.Method.invoke(Method.java:498)at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)at org.junit.runners.ParentRunner.run(ParentRunner.java:363)at org.junit.runner.JUnitCore.run(JUnitCore.java:137)at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:69)at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38)at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11)at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35)at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:232)at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)
自动资源管理
java 7引入了自动资源管理的特性,用于自动关闭实现了AutoCloseable接口的资源,无需手动在finally块中关闭,这大大简化了资源管理的代码,并减少了资源泄露的风险。
格式:
try (声明或初始化资源语句) {// 可能会出现异常的语句
}catch(ExceptionType var){// 异常处理语句
}[....]
内部机制:
- try 语句中声明的资源被隐式声明为 final,资源的作用局限于 try 语句,可以在 try 语句中声明或初始化多个资源,用 ‘;’ 隔开即可,需要关闭的资源必须实现了AutoClosable 或 Closable 接口。
- 声明资源变量的括号中,不可以为已有的成员变量赋值
- 资源按照声明相反的顺序关闭,最后声明的资源最先关闭。
源码:
// AutoCloseable接口
public interface AutoCloseable {void close() throws Exception;
}// Closeable接口
public interface Closeable extends AutoCloseable {public void close() throws IOException;
}
使用案例:
public static void main(String[] args) {// 自动关闭 BufferedReader 和 FileReadertry (FileReader fr = new FileReader("test.txt");BufferedReader br = new BufferedReader(fr)) {String line;while ((line = br.readLine()) != null) {System.out.println(line);}} catch (IOException e) {throw new RuntimeException(e);}// 资源会在 try 块结束后自动关闭
}
传统写法:
public static void main(String[] args) {BufferedReader br = null;try {br = new BufferedReader(new FileReader("test.txt"));// 使用资源...} catch (IOException e) {e.printStackTrace();} finally {if (br != null) {try {br.close(); // 需要嵌套 try-catch} catch (IOException ex) {ex.printStackTrace();}}}
}
自定义异常
在实际开发中,自定义异常可以更加清晰地表达业务,团队通常会针对不同的情况,定义专属的异常类。自定义异常可以更精准地描述业务逻辑中的错误场景,提高代码的可读性和可维护性。
定义不同类型的异常:
- 自定义一个编译时异常:需要继承于java.lang.Exception
- 自定义一个运行时异常:需要继承于java.lang.RuntimeException
异常类的编写:在自定义异常中,调用父类的构造方法,传入一个字符串,描述异常信息,同时异常类还可以承载其它的异常信息,例如异常编码等。
案例1:自定义运行时异常,代表一个业务异常,所有的业务异常都使用当前异常类
public class BizException extends RuntimeException {private String code;public BizException() {}public BizException(String message) {super(message);this.code = "1"; // 默认错误码是系统错误}public BizException(String code, String message) {super(message);this.code = code;}public BizException(String code, String message, Throwable cause) {super(message, cause);this.code = code;}public BizException(String code, Throwable cause) {super(cause);this.code = code;}public String toString() {return "code:" + this.code + ", msg:" + this.getMessage();}/*** 业务异常不需要收集线程的整个异常栈信息,重写fillInStackTrace方法,,可以减少业务异常抛出导致的开销*/@Overridepublic synchronized Throwable fillInStackTrace() {return this;}
}
当前异常类中自定义了异常码,不同的异常码代表不同的异常,同时,还会抑制堆栈信息的打印,因为业务异常通常不是系统问题,不需要堆栈信息,但是这种情况下要注意,每个业务异常都必须是独一无二的,否则可能会混淆。
处理异常的原则
为一个基本操作定义一个 try-catch 块:不要将几百行代码放到一个 try-catch 块中,异常只能用于非正常情况,try-catch的存在也会影响性能,尽量缩小try-catch的代码范围;
尽量在循环之外使用try-catch
尽量避免运行时异常的出现,如NullPointerException,提前做非空判断
如果方法需要被上层API调用,方法中的编译时异常最好抛出,如果方法是最外层的方法,必须处理异常
明确异常的级别,异常是否需要阻塞主流程,例如,用户在页面修改数据,后端要做两个改动,一是保存数据库,二是记录日志,如果保存数据库时发生异常,那么必须阻塞流程,向用户抛出友好的异常信息,如果是记录日志发生异常,可以不用阻塞流程,而是选择把异常吞掉,仅仅打印异常日志,然后查看日志来解决问题即可。这就是异常的级别,异常是主流程相关还是和主流程无关,是否需要阻塞主流程。
需要为异常提供说明文档:可以参考Java doc,如果自定义了异常或某一个方法抛出了异常,应该在文档注释中详细说明;
常见的异常类
编译时异常:
- IOException :IO异常
- ParseException:日期解析失败等
- ClassNotFoundException:类不存在
运行时异常:
- NullPointerException:空指针异常,对空对象进行除了赋值以外的任何操作都会报空指针异常,比如调用空对象的方法,对空对象中的字段进行赋值
- ArrayIndexOutOfBoundsException:数组的索引越界异常,操作数组时使用的索引超出了数组的数据范围会出现;
- NumberFormatException:数字格式化异常,把非数字的数据类型转换为数字类型时使用了非法的转换对象;
- IllegalArgumentException:传入的参数不正确
- ArithmeticException:算术异常
- ClassCastException:类转换异常
- IllegalArgumentException:非法参数异常
- IndexOutOfBoundsException:下标越界异常
- SecurityException:安全异常
编码过程中的常见告警
- ‘InputStream’ used without ‘try-with-resources’ statement: 'InputStream’不带’try-with-resources’语句使用,这是编译器的一个警告,对于InputStream的使用应该放在 try with resources 语句中
- dangling javadoc document:悬空的Java文档。文档注释只有添加到类上或方法上,才可以生成文档,添加到其它地方和普通注释的效果是一样的。当把一个文档注释添加到方法内的时候,就会报这个告警