异常处理是一种处理运行时错误的机制,是处理运行时错误的有效方法之一,这样可以保留应用程序的常规流程。
Java为异常设计了一套 异常处理机制(Exception Handling),当程序运行过程中发生一些异常情况时,程序不会返回任何值,而是抛出封装了错误信息的异常对象。这样保证程序代码更加优雅,并提高程序的健壮性。
引入异常之后,可以把错误的代码从正常代码中分离出来进行单独处理,这样使代码变得更加整洁;其次,当出现一些特殊情况时,还可以抛出一个检查异常,告知调用者让其处理。
JVM 如何处理异常
默认异常处理:无论何时在方法内部,如果发生异常,该方法都会创建一个称为异常对象的对象,并将其传递给运行时系统(JVM)。
异常对象包含异常的名称和描述,以及发生异常的程序的当前状态。创建异常对象并在运行时系统中处理它称为抛出异常。可能有一个方法列表,这些方法是为了到达发生异常的方法而调用的。这个方法的有序列表称为调用堆栈(Call Stack)。
JVM 处理异常的步骤
JVM 处理异常时将执行如下步骤:
-
运行时系统搜索调用堆栈以查找包含可以处理发生的异常的代码块的方法。代码块称为异常处理程序。
-
运行时系统从发生异常的方法开始搜索,并按照调用方法的相反顺序在调用堆栈中进行搜索。
-
如果找到合适的处理程序,则将发生的异常传递给它。适当的处理程序意味着抛出的异常对象的类型与其可以处理的异常对象的类型相匹配。
-
如果运行时系统搜索了调用堆栈上的所有方法,但没有找到合适的处理程序,则运行时系统将异常对象移交给 默认异常处理程序。抛给当前的Thread,Thread则会终止
该异常处理程序是运行时系统的一部分。这个处理程序以以下格式打印异常信息,并异常地终止程序。
Exception in thread "xxx" Name of Exception : Description
... ...... .. // Call Stack
- 如果当前 Thread 为最后一个非守护线程,且未处理异常,则会导致JVM 终止运行。
Exception Table 异常表
Exception Table
(异常表)是在Java字节码中的一项数据结构。它用于在方法的字节码中记录异常处理器的信息,以便在发生异常时能够正确地进行异常处理。
异常表的每一项记录了一个异常处理器,包括以下信息:
-
start_pc
:异常处理器起始的字节码指令偏移量。它表示该异常处理器在字节码中开始的位置。 -
end_pc
:异常处理器结束的字节码指令偏移量。它表示该异常处理器在字节码中结束的位置(不包括end_pc指向的指令)。 -
handler_pc
:异常处理代码块的起始字节码指令偏移量。它表示当发生异常时将跳转到的异常处理代码块的位置。 -
catch_type
:表示所捕获的异常类型。它是一个指向常量池中异常类的符号引用的索引。如果 catch_type 为0,则表示该异常处理器可以处理所有类型的异常
在方法的字节码中,异常表会按照 start_pc
从小到大的顺序排列。当执行方法过程中发生异常时,Java虚拟机会根据异常表中的信息查找匹配的异常处理器进行处理。
如果发现对应的异常处理器,就会跳转到相应的处理代码块执行;如果没有找到匹配的异常处理器,就将异常抛给上层调用者进行处理。
使用异常表的示例
使用 javap
(用来拆解 class 文件的工具,和 javac 一样由 JDK 提供) 来分析如下代码(需要先使用 javac 编译)
//javap -c Main
public static void simpleTryCatch();
Code:
0: invokestatic #3 // Method testNPE:()V
3: goto 11
6: astore_0
7: aload_0
8: invokevirtual #5
// Method java/lang/Exception.printStackTrace:()V
11: return
Exception table:
from to target type
0 3 6 Class java/lang/Exception
- from(start_pc) 可能发生异常的起始点
- to(end_pc) 可能发生异常的结束点
- target (handler_pc) 上述 from 和 to 之前发生异常后的异常处理者的位置
- type (catch_type) 异常处理者处理的异常的类信息
程序员如何处理异常
自定义异常处理(Customized Exception Handling):Java异常处理通过五个关键字进行管理:try
、catch
、throw
、throws
和finally
。
认为可能引发异常的程序语句包含在 try
块中。如果在 try
块中发生异常,则抛出异常。代码可以捕获此异常(使用 catch
块)并以某种合理的方式处理它。系统生成的异常由 Java 运行时系统自动抛出。要手动抛出异常,使用关键字 throw
。
抛出方法的任何异常都必须由抛出子句指定。任何必须在 try
块完成后执行的代码都放在 finally
块中。
抓抛模型
何为"抓抛"
过程一:“抛”:程序在正常执行的过程中,一旦出现异常,就会在异常代码处生成一个对应异常类的对象, 并将此对象抛出;一旦抛出对象以后,其后的代码就不再执行。
关于异常对象的产生:
- 系统自动生成的异常对象
- 手动的生成一个异常对象,并抛出(throw)
过程二:“抓”:可以理解为异常的处理方式:
try-catch-finally
throws
异常处理关键词
-
在 Java 中,一旦方法抛出异常,系统自动根据该异常对象寻找合适异常处理器(Exception Handler)来处理该异常,把各种不同的异常进行分类,并提供了良好的接口。
-
当一个方法出现异常后便抛出一个异常对象,该对象中包含有异常信息,调用这个对象的方法可以捕获到这个异常并可以对其进行处理。
-
Java 的异常处理涉及了 5 个关键词:
-
try
– 用于监听。将要被监听的代码(可能抛出异常的代码)放在try语句块之内,当try语句块内发生异常时,异常就被抛出。 -
catch
– 用于捕获异常。catch
用来捕获try
语句块中发生的异常。 -
finally
–finally
语句块总是会被执行。它主要用于回收在try
块里打开的物力资源(如数据库连接、网络连接和磁盘文件)。只有
finally
块,执行完成之后,才会回来执行try
或者catch
块中的return
或者throw
语句如果
finally
中使用了return
或者throw
等终止方法的语句,则就不会跳回执行,直接停止。finally
遇见如下情况不会执行:- 在前面的代码中用了
System.exit()
退出程序。 finally
语句块中发生了异常。- 程序所在的线程死亡。
- 关闭 CPU。
- 在前面的代码中用了
-
throw – 用于抛出异常。
-
throws – 用在方法签名中,用于声明该方法可能抛出的异常
-
throw 抛出异常
throw在方法内部抛出一个Throwable 类型的异常。任何 Java 代码都可以通过 throw
语句抛出异常
作为一个合格的程序员,在编写程序时,必须要考虑程序出现问题的情况。
如果代码可能会引发某种错误,可以创建一个合适的异常类实例并抛出它
- 例如,在定义方法时,方法需要接受参数。那么,当调用方法使用接受到的参数时,首先需要先对参数数据进行合法的判断,数据若不合法,就应该告诉调用者,传递合法的数据进来。这时需要使用抛出异常的方式来告诉调用者
如何抛出一个异常
- 创建一个异常对象。封装一些提示信息(信息可以自己编写)。
- 用
throw
抛出这个异常对象,将这个异常对象传递到调用者处,并结束当前方法的执行。
throw new ExceptionName(paramater);
//示例
throw new NullPointerException("要访问的arr数组不存在");
异常对象类可以为内置异常,也可以为用户自定义异常
抛出异常后,需要对异常进行处理,可以使用 try-catch
捕获处理,也可以使用 throws
继续将异常声明出去
通常,应捕获那些知道如何处理的异常,将不知道如何处理的异常继续传递下去
throws 声明异常
如果一个方法可能会出现异常,但没有能力处理这种异常,可以在方法声明处用throws子句来声明抛出异常。
就像汽车在运行时可能会出现故障,汽车本身没办法处理这个故障,那就让开车的人来处理。
throws
和 throw
一般情况下成对出现
-
声明异常:将问题标识出来,报告给调用者。如果方法内通过throw抛出了编译时异常,而没有捕获处理,那么必须通过throws进行声明,告知方法调用者此方法有异常,并让其去处理。
关键字
throws
运用于方法声明之上,用于表示当前方法不处理异常,而是提醒该方法的调用者来处理异常(抛出异常)。 -
定义格式:
throws
语句用在方法定义时声明该方法要抛出的异常类型,如果抛出的是Exception
异常类型,则该方法被声明为抛出所有的异常。多个异常可使用逗号分割:
public static void method() throws IOException, SQLException{
// statements...
}
若父类的方法没有声明异常,则子类继承方法后,也不能声明异常!!!
当方法抛出异常列表的异常时,方法将不对这些类型及其子类类型的异常作处理,而抛向调用该方法的方法,由他去处理。
使用 throws
关键字将异常抛给调用者后,如果调用者不想处理该异常,可以继续向上抛出,但最终要有能够处理该异常的调用者。
就像是汽车坏了,开车的人也不会修理,只能叫修车公司来修理了。
throws抛出异常的规则
-
如果是不可查异常(unchecked exception),即
Error
、RuntimeException
或它们的子类,那么可以不使用throws
关键字来声明要抛出的异常,编译仍能顺利通过,但在运行时会被系统抛出。 -
必须声明方法可抛出的任何可查异常(checked exception)。即如果一个方法可能出现受可查异常,要么用try-catch语句捕获,要么用throws子句声明将它抛出,否则会导致编译错误
-
仅当抛出了异常,该方法的调用者才必须处理或者重新抛出该异常。当方法的调用者无力处理该异常的时候,应该继续抛出,而不是囫囵吞枣。
-
调用方法必须遵循任何可查异常的处理和声明规则。若覆盖一个方法,则不能声明与覆盖方法不同的异常。声明的任何异常必须是被覆盖方法所声明异常的同类或子类。
try,catch,finally 捕获异常
程序通常在运行之前不报错,但是运行后可能会出现某些未知的错误,不想异常出现导致程序终止,或者不想直接抛出到上一级,那么就需要通过 try-catch
等形式进行异常捕获,之后根据不同的异常情况来进行相应的处理。
捕获异常:Java 中对异常有针对性的语句进行捕获,可以对出现的异常进行指定方式的处理。
异常捕获处理的方法通常有:
try-catch
try-catch-finally
try-finally
try-with-resource
(在JDK1.7 Java7 中被引入)
catch语句可以有一个或多个或没有,finally至多有一个,try必要有。
三者也不能单独出现
try-catch
在一个 try-catch
语句块中可以捕获多个异常类型,并对不同类型的异常做出不同的处理。
try{
// 可能出现异常的代码
} catch (FileNotFoundException e){
// 处理 FileNotFoundException 异常
} catch (IOException e){
// 处理 IOException 异常
} catch (FileNotFoundException | UnknownHostException e){
// JDK1.7后,一个 catch 也可以捕获多种同类型异常,用 | 隔开
// 处理 FileNotFoundException 或 UnknownHostException 异常
}
对于多个异常,可以分别处理,也可以多个异常一次捕获,多次处理
多个异常,分别处理
public static void method(){
// 代码块1
try{
// 可能出现异常的代码
} catch (FileNotFoundException e){
// 处理 FileNotFoundException 异常
}
// 代码块2
try{
// 可能出现异常的代码
} catch (IOException e){
// 处理 IOException 异常
}
// ...
}
多个异常,一次捕获,多次处理
public static void method(){
// 其他代码
try{
// 可能出现异常的代码
}catch (ArithmeticException e){
// 处理 ArithmeticException 异常
}catch (ArrayIndexOutOfBoundsException e){
// 处理 ArrayIndexOutOfBoundsException 异常
}catch (Exception e){
// 处理异常
}...
}
一次捕获,多次处理的异常处理方式,要求多个catch中的异常不能相同,并且若 catch
中的多个异常之间有子父类异常的关系,子类异常要求在上面的catch处理,父类异常在下面的catch处理。
try-catch-finally
当方法中发生异常,异常处之后的代码不会再执行,如果之前获取了一些本地资源需要释放,则需要在方法正常结束时和 catch
语句中都调用释放本地资源的代码,显得代码比较繁琐,finally
语句可以解决这个问题。
try{
// 可能出现异常的代码
} catch (Exception e){
// 处理异常
} finally {
// 必然执行的代码
}
执行情况:
-
try 没有捕获到异常时:
try
语句块中的语句逐一被执行,程序将跳过catch
语句块,执行finally
语句块和其后的语句; -
try 捕获到异常,catch 语句块里没有处理此异常的情况:当
try
语句块里的某条语句出现异常时,而没有处理此异常的catch
语句块时,此异常将会抛给 JVM 处理,finally
语句块里的语句还是会被执行,但finally
语句块后的语句不会被执行; -
try 捕获到异常,catch 语句块里有处理此异常的情况:在
try
语句块中是按照顺序来执行的,当执行到某一条语句出现异常时,程序将跳到catch
语句块,并与catch
语句块逐一匹配,找到与之对应的处理程序,其他的catch
语句块将不会被执行,而
try
语句块中,出现异常之后的语句也不会被执行,catch
语句块执行完后,执行finally
语句块里的语句,最后执行finally
语句块后的语句;
try-finally
try-finally
可用在不需要捕获异常的代码,可以保证资源在使用后被关闭
例如,IO 流中执行完相应操作后,关闭相应资源;
使用 Lock 对象保证线程同步,通过finally
可以保证锁会被释放;
数据库连接代码时,关闭连接操作等等
try {
// 代码块
} finally {
// 必定执行的代码
}
// 以Lock加锁为例
ReentrantLock lock = new ReentrantLock();
try {
// 需要加锁的代码
} finally {
lock.unlock(); //保证锁一定被释放
}
执行情况:
-
try块中引起异常,异常代码之后的语句不再执行,直接执行
finally
语句。 -
try块没有引发异常,则执行完
try
块就执行finally
语句。 -
注意在某些情况下
finally
中的语句不会执行
try-with-source
Java 类库中有许多资源需要通过 close
方法进行关闭。比如 InputStream
、OutputStream
等。作为开发人员经常会忽略掉资源的关闭方法,导致内存泄漏。
在 JDK1.7 之前,try-catch-finally
语句是确保资源会被关闭的最佳方法,就算异常或者返回也一样可以关闭资源(手动关闭)。但 finally
中的 close
方法也可能抛出 Exception
从而覆盖掉原始异常
使用 try-with-resources
,可以在 try 块中声明需要管理的资源,然后在程序执行完毕后自动关闭这些资源。
只要是 实现了 AutoCloseable
接口或 Closeable
接口的对象都可以使用 try-with-resource
来实现异常处理和自动关闭资源。这两个接口定义了一个 close()
方法,用于关闭资源。当 try-with-resources
结束时,会自动调用这些资源对象的 close()
方法。
try( //资源的声明和初始化
Resource1 res1 = new Resource1();
Resource2 res2 = new Resource2();
...
){
// 可能出现异常的代码
} catch (Exception e){
// 处理异常
} [finally{ }]
实际上,在编译时也会进行转化为
try-catch-finally
语句。
-
根据需要声明和初始化多个资源。这些资源的作用范围仅限于
try
块,当代码块执行完毕后会自动关闭这些资源 -
如果在
try
块中同时声明多个资源,资源的声明顺序决定了关闭顺序,先声明的资源会先关闭。
Scanner
public final class Scanner implements Iterator<String>, Closeable {
// ...
}
public interface Closeable extends AutoCloseable {
public void close() throws IOException;
}
try
代码块退出时,会自动调用 scanner.close
方法,和把 scanner.close
方法放在 finally
代码块中不同的是:
若 scanner.close
抛出异常,则会被抑制,抛出的仍然为原始异常。被抑制的异常会由 addSusppressed
方法添加到原来的异常,如果想要获取被抑制的异常列表,可以调用 getSuppressed
方法来获取。
先执行 try 还是 fianlly 的 return?
在 Java 中,无论是 try-finally
还是 try-catch-finally
,finally 块中的代码总会在try块中的 return语句执行之前执行。
具体的执行顺序如下:
- 如果
try
块中的return
语句被执行,那么它会将返回值保存在一个临时位置。 - 接下来,
finally
块中的代码会被执行。 - 最后,如果
finally
块中没有发生异常,那么try
块中的return
语句会将之前保存的返回值返回给调用者。
这意味着无论 try
块中的 return
语句是否被执行,finally
块中的代码都会在方法返回之前被执行。但需要注意的是,如果在 finally
块中发生了异常并且没有被捕获,那么该异常将会覆盖之前的异常(如果有的话)。
如果在 finally
块中使用了 return
语句,那么它会覆盖之前保存的返回值,并返回 finally 块中的返回值。因此,在 finally
块中使用 return
语句可能会导致意外的行为,应谨慎使用
自定义异常类
Java 中不同的异常类,分别表示着某一种具体的异常情况,那么在开发中,当Java内置的异常都不能明确的说明异常情况的时候,需要创建自己的异常。
在开发中根据自己业务的异常情况来自己定义异常类。例如:登录系统中,年龄不能为负数,就需要自己定义一个登录异常类
如何自定义异常类
-
继承现有的异常类:
-
自定义一个编译期异常类(Unchecked): 自定义类并继承
java.lang.Exception
-
自定义一个运行时期的异常类(Checked):自定义类并继承于
java.lang.RuntimeException
-
-
提供全局常量
serialVersionUID
:这是一个标识序列化版本的唯一标识符。可以使用默认的值 1L:"
private static final long serialVersionUID = 1L;
如果自定义异常类不会进行序列化和反序列化操作,这个常量可以省略。
-
提供重载的构造器:根据业务的需求,添加适当的构造方法。可以提供不同参数的构造方法,用于初始化异常消息或其他字段。
习惯上,定义一个异常类应包含两个构造函数,一个无参构造函数和一个带有详细描述信息的构造函数(
Throwable
的toString
方法会打印这些详细信息,调试时很有用)
public class MyException extends Exception {
private static final long MY_VERSION_UID = 1L;
public MyException(){ }
public MyException(String message){
super(message);
// 父类方法 getMessage()
}
//将引起异常的原因传给父类
public MyException(String message, Throwable cause){
super(message,cause);
// 父类方法 getCause()
}
}
链式异常 Chained Exception in Java
链式异常将一个异常与另一个异常联系起来,在异常处理过程中,将一个异常作为另一个异常的原因(即引发异常的根本原因)。这可以帮助我们更好地追踪和理解异常发生的原因。
例如:除以零时,方法抛出了一个 ArithmeticException
,但导致异常的实际原因是导致除数为零的I/O错误。该方法只会向调用者抛出算术异常。这样调用者就不会知道异常的实际原因。链式异常在这种情况下使用。
Java 中支持链式异常的 Throwable
类的构造函数:
-
ExceptionName(Throwable cause)
:直接在创建异常对象时设置原因异常,cause是导致当前异常的异常。 -
Throwable(String msg, Throwable cause)
:msg是异常消息,cause是导致当前异常的异常。
Java 中支持链式异常的 Throwable
类的方法:
-
wrap(Throwable cause)
:将原始异常包装成一个新的异常对象,并创建异常链。 -
getCause()
方法:该方法返回异常的实际原因。 -
initCause(Throwable cause)
方法:将一个异常作为另一个异常的原因。这个方法允许我们构建一个异常链。
try {
// 可能出现异常的代码
throw new IllegalArgumentException("无效参数异常");
} catch (Exception e) {
// 创建一个新的异常,将原始异常作为原因
CustomException customexception = new CustomException()
customexcption.initCause(e)
throw customException;
}
通过使用异常链,我们可以逐级地追溯异常的原因和触发点,帮助调试和分析问题。在捕获异常后,可以通过调用getCause()
方法来获取原因异常,并进一步分析异常链。
需要注意的是,异常链的长度是有限制的,因为每个异常都只能有一个原因。如果尝试 为一个已经有原因的异常设置新的原因,将会抛出IllegalStateException
异常。
在处理链式异常时,我们应该注意以下几点:
- 尽量保持异常的原因链不过长,以免造成不必要的复杂性。
- 在异常的处理逻辑中,逐级检查和处理每个原因异常,以确保所有相关的异常得到适当的处理。
- 在日志记录中,可以打印完整的异常链信息,以便进行故障排查和分析。
异常处理中的方法重载
当异常处理涉及到方法覆盖时,就会产生歧义。编译器无法识别应该遵循哪个定义。
可以为两种情况讨论:
-
SuperClass
没有声明异常: 子类只能声明未检查的异常,而不能声明已检查的异常。 -
超类声明了一个异常
-
子类只能声明超类声明的异常的相同或子异常以及任何新的运行时异常,而不是在同一级别或更高级别上的任何新的检查异常。
-
子类也可以不声明异常
-
为何异常耗时
异常处理会导致额外的性能损耗:
-
异常对象的创建:当发生异常时,Java 会创建一个异常对象,其中包含有关异常的信息,例如堆栈跟踪和异常消息。异常对象的创建涉及内存分配和对象初始化,这些操作都需要花费一定的时间和资源。
-
异常处理代码的执行:当发生异常时,程序会跳转到匹配的异常处理代码块,执行与异常相关的逻辑。这可能会中断正常的程序执行流程,并涉及额外的指令执行、堆栈操作等,增加了代码的执行时间和开销。
-
堆栈跟踪的收集:当异常被抛出时,Java 会收集当前的调用栈信息。这包括所有调用过程中的方法名称和行号等信息。堆栈跟踪的收集需要遍历调用栈,获取信息并构建堆栈跟踪数据结构,这可能会消耗一定的时间和计算资源。
-
异常传播和捕获:当异常在方法之间传播时,程序必须检查异常类型并确定是否有匹配的异常处理器,以决定异常是继续传播还是被捕获处理。这个过程涉及异常的检查和匹配,可能需要遍历异常处理器列表,进行条件判断等,这些操作都需要一定的时间和开销。
实例对比
建立一个异常对象,是建立一个普通Object耗时的约20倍(实际上差距会比这个数字更大一些,因为循环也占用了时间),而抛出、接住一个异常对象,所花费时间大约是建立异常对象的4倍。
public class ExceptionTest {
private int testTimes;
public ExceptionTest(int testTimes) {
this.testTimes = testTimes;
}
public void newObject() {
long l = System.nanoTime();
for (int i = 0; i < testTimes; i++) {
new Object();
}
System.out.println("建立对象:" + (System.nanoTime() - l));
}
public void newException() {
long l = System.nanoTime();
for (int i = 0; i < testTimes; i++) {
new Exception();
}
System.out.println("建立异常对象:" + (System.nanoTime() - l));
}
public void catchException() {
long l = System.nanoTime();
for (int i = 0; i < testTimes; i++) {
try {
throw new Exception();
} catch (Exception e) {
}
}
System.out.println("建立、抛出并接住异常对象:" + (System.nanoTime() - l));
}
public static void main(String[] args) {
ExceptionTest test = new ExceptionTest(10000);
test.newObject();
test.newException();
test.catchException();
}
}
运行结果:
建立对象:575817
建立异常对象:9589080
建立、抛出并接住异常对象:47394475