一:异常体系概述

什么是异常?

异常(Exception),是一个在程序执行期间发生的事件。程序的运行中,难免会遇到一些错误,这些错误可能是程序员写出的一些 bug,甚至是超出程序员可控范围内的系统级错误。为了能够及时并有效地处理这些程序中的错误,Java 引入了异常类。

image.png

如上图所示,Java 中的两大类异常 Error 和 Exception 都继承自父类 Throwable,Throwable 翻译为可抛出的,旨在问题发生时,可以选择将问题抛出,让调用者处理,所以 Error 和 Exception 的子类都具有可抛性,实现可抛性的两个关键字为:throwsthrow

我们先来看一下 Error 和 Exception。

  • Error


Error表示系统级的错误,一般是指与虚拟机相关的问题,由虚拟机生成并抛出,常见的虚拟机错误有:OutOfMemoryErrorStackOverflowError 等等。

这两种错误是要求大家务必掌握的。

StackOverflowError,即栈溢出错误,一般无限制地递归调用会导致 StackOverflowError 的发生,所以,再一次提醒大家,在写递归函数的时候一定要写 base case,否则就会导致栈溢出错误的发生。

如示例程序:

  1. public class StackOverflowErrorTest {
  2. public static void foo(){
  3. System.out.println("StackOverflowError");
  4. foo();
  5. }
  6. public static void main(String[] args) {
  7. foo();
  8. }
  9. }


该程序会导致抛出 StackOverflowError

OutOfMemoryError,即堆内存溢出错误,导致 OutOfMemoryError 可能有如下几点原因:

  1. JVM启动参数内存值设定过小
  2. 代码中存在死循环导致产生过多对象实体
  3. 内存中加载的数据量过于庞大,一次从数据库取出过多的数据也会导致堆溢出
  4. 集合类中有对对象的引用,使用完后未清空,使得JVM无法回收


示例程序:

  1. public class OutOfMemoryErrorTest {
  2. public static void main(String[] args) {
  3. while (true){
  4. new Thread(() -> {
  5. try {
  6. Thread.sleep(1000);
  7. } catch (InterruptedException e) { }
  8. }).start();
  9. }
  10. }
  11. }


本示例代码为一个死循环,不断创建新的线程,该代码运行会抛出 OutOfMemoryError。

  • Exception

Exception表示异常,通俗地讲,它表示如果程序运行正常,则不会发生的情况。

Exception可以划分为

  • 运行时异常(RuntimeException)
  • 非运行时异常

或者也可以划分为:

  • 受检查异常(CheckedException)
  • 不受检查异常(UncheckedException)

实际上,运行时异常就是不受检查异常。

什么是运行时异常(RuntimeException),或者说什么是不受检查异常(UncheckedException)呢?

通俗地讲,不受检查异常是指程序员没有细心检查代码,造成例如:空指针,数组越界等情况导致的异常。这些异常通常在编码过程中是能够避免的。并且,我可以在代码中直接抛出一个运行时异常,程序编译不会出错:

  1. public class Test {
  2. public static void main(String[] args) {
  3. throw new IllegalArgumentException("wrong");
  4. }
  5. }

什么是受检查异常呢?

受检查异常是指在编译时被强制检查的异常。受检查异常要么使用try-catch语句进行捕获,要么使用throws 关键字向上抛出,否则是无法通过编译的。常见的受检查异常有:FileNotFoundException,SQLException等等 。

throw 与 throws

throw
  • throw 关键字作用在方法内,表示抛出具体的异常,由方法内的语句进行处理
  • throw 抛出的是一个异常的对象
  • 语法:throw(异常对象)

如上面我们已经给出的示例:

  1. public class Test {
  2. public static void main(String[] args) {
  3. throw new IllegalArgumentException("wrong");
  4. }
  5. }

throws
  • throws 用来声明一个方法可能抛出的所有异常信息
  • throws 声明异常,但不处理,将异常向上传递,谁调用我就交给谁去处理
  • 语法:public void foo() throws Exception1,Exception2...

示例程序:

A

  1. public class A {
  2. public List<String> readFileAllLines(String path) throws IOException {
  3. File file = new File(path);
  4. return Files.readAllLines(file.toPath());
  5. }
  6. }

B

  1. public class B {
  2. public static void main(String[] args) {
  3. A a = new A();
  4. try {
  5. for (String line : a.readFileAllLines("xxx.txt")) {
  6. System.out.println(line);
  7. }
  8. } catch (IOException e) {
  9. e.printStackTrace();
  10. }
  11. }
  12. }

Files.readAllLines() 方法会抛出 IOExcpetion,IOException 是一个 CheckedException,即: Java 预期的异常。

我们可以使用 try…catch 语句进行异常的捕获并处理,或者是像程序示例中那样,使用 throws 关键字将异常继续向上传递。因为 B 的 main 方法中,调用了 A 的 readFileAllLines 方法,所以 B 会接手 A 向上传递的异常,我们还是要么使用 try…catch 语句捕获异常,或是使用 throws 将异常继续向上抛出。

在 main 方法中,我使用了 try…catch 将异常捕获,接下来,我们来看一下 try…catch 与 finally 关键字的使用方法。

try、catch、finally

异常捕获的形式:

  1. try {
  2. // 需要被检测异常的代码
  3. }catch(异常类 e) {
  4. // 处理异常的代码,处理后,程序继续运行
  5. }finally {
  6. // 一定会被执行的代码
  7. // 通常用来释放资源,比如关闭数据库链接等
  8. }

关于 try、catch、finally ,我们需要了解并铭记以下几点:

  • try、catch、finally 中,catch 和 finally 语句块可以省略其中一个,二者不能被同时省略
  • finally 块总会执行么?答案是,不一定。存在一些特殊的情况会导致 finally 块不执行
  • 无论 try 和 catch 代码块中是否有 return 语句,finally 仍然会执行,如果 finally 代码块中也有 return 语句,那么此代码必定会返回 finally 执行的 return 值

我们来看几道关于 try-catch-finally 语句的经典面试题

经典面试题

请说出以下几个程序的执行结果,并解释为什么?

Test1

  1. package com.github.test;
  2. public class Test1 {
  3. public static int test() {
  4. int i = 1;
  5. try {
  6. i++;
  7. } catch (Exception e) {
  8. i++;
  9. } finally {
  10. i++;
  11. }
  12. return i;
  13. }
  14. public static void main(String[] args) {
  15. System.out.println(test());
  16. }
  17. }

Test2

  1. package com.github.test;
  2. public class Test2 {
  3. public static int test() {
  4. int i = 1;
  5. try {
  6. i++;
  7. return i;
  8. } catch (Exception e) {
  9. i++;
  10. } finally {
  11. i++;
  12. }
  13. return i;
  14. }
  15. public static void main(String[] args) {
  16. System.out.println(test());
  17. }
  18. }

Test3

  1. package com.github.test;
  2. public class Test3 {
  3. public static int test() {
  4. int i = 1;
  5. try {
  6. Integer.valueOf("abc");
  7. i++;
  8. } catch (Exception e) {
  9. i++;
  10. return i;
  11. } finally {
  12. i++;
  13. }
  14. return i;
  15. }
  16. public static void main(String[] args) {
  17. System.out.println(test());
  18. }
  19. }

Test4

package com.github.test;

public class Test4 {
    public static int test() {
        int i = 1;
        try {
            i++;
            return i;
        } catch (Exception e) {
            i++;
        } finally {
            i++;
            return i;
        }
    }

    public static void main(String[] args) {
        System.out.println(test());
    }
}

答:

从 Test1~Test4 的执行结果依次为:

3
2
2
3

Test1 ,程序运行到 try 块,执行 i++,因为 try 块里的代码并无异常可以捕捉,所以,程序会直接来到 finally 块,执行 i++,最后返回的结果为 3。

Test2,程序运行到 try 块,执行 i++,因为 finally 块的代码最终会被执行,所以 try 块的 return 语句并不会直接返回,而是使用一个变量来记录此时的返回值。程序来到 finally 块中,执行 i++ ,此时 i 的值为 3,但是最后返回的结果是被记录的返回值 2,所以最终的返回结果为 2。

Test3,程序运行到 try 块,Integer.valueOf("abc"); 会抛出异常,catch 块捕捉到异常后,程序会直接来到 catch 块中,执行 i++。与 Test2 同理,finally 块的代码最终会被执行,所以 catch 块的 return 语句并不会直接返回,而是使用一个变量来记录此时的返回值。程序来到 finally 块中,执行 i++ ,此时 i 的值为 3,但是最后返回的结果是被记录的返回值 2,所以最终的返回结果为 2。

Test4 和 Test2 的不同点在于,Test2 的 return 语句在代码的最后部分,而 Test4 的 return 语句在 finally 块中。我们在上面强调过,如果 finally 代码块中也有 return 语句,那么此代码必定会返回 finally 执行的 return 值。所以,最终的返回结果为 3。

finally 代码块一定会被执行么?

在第二条实践中,我们提出,存在一些特殊的情况会导致 finally 块不执行。

主要有两种情况:

  1. 系统终止
  2. 守护线程被终止


我们来看下示例程序:

示例一:

package com.github.test;

public class Test {

    public static void main(String[] args) {
        foo();
    }

    public static void foo() {
        try {
            System.out.println("In try block...");
            System.exit(0);
        } finally {
            System.out.println("In finally block...");
        }
    }
}

该代码运行的结果为:

In try block...

System.exit(0) 方法会终止当前正在运行的虚拟机,也就是终止了系统,自然而然,也就执行不到 finally 块的代码。

示例二:

package com.github.test;

public class Test {

    public static void main(String[] args) {
        Thread thread = new Thread(new Task());
        thread.setDaemon(true);
        thread.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

class Task implements Runnable {

    @Override
    public void run() {
        try {
            System.out.println("In try block...");
            Thread.sleep(5000); // 阻塞线程 5 s
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("In finally block");
        }
    }
}

该程序输出的结果为:

In try block...

Java 的线程分为两类:守护线程(Daemon Thread)和用户线程(User Thread)。

所谓的守护线程是指程序运行的时候在后台提供一种通用服务的线程,比如垃圾回收线程就是一个守护线程。守护线程并不属于程序中不可或缺部分。因此,当所有的用户线程结束时,程序也就终止了,同时会杀死进程中所有的守护线程。

在上面的示例程序中,main 执行完,程序就中止了,所以,在守护线程中 finally 块的代码是不会执行的。

避免在 finally 块中使用 return 语句

通过上面的面试题,我们知道,finally 语句块中,如果有 return 语句,那么程序会优先返回 finally 语句块中 return 的结果,而 try-catch 块里面 return 的语句,或者是抛出的异常都会被丢弃掉。所以,我们应该避免在 finally 块中使用 return 语句。

try-with-resources

《Effective Java》中给出的一条最佳实践是:Item 9: Prefer try-with-resources to try-finally 。

我们知道,Java 类库中包含许多必须通过调用 close 方法手动关闭资源的类,比如:InputStream,OutputStream,java.sql.Connection 等等。在 JDK 1.7 以前,try-finally 语句是保证资源正确关闭的最佳实践。

如示例代码:

// try-finally - No longer the best way to close resources!
static String firstLineOfFile(String path) throws IOException { 
    BufferedReader br = new BufferedReader(new FileReader(path)); 
    try {
        return br.readLine(); 
    } finally {
        br.close(); 
    }
}

但是,当我们向代码中添加更多的资源时,情况就会变得糟糕。因为,有一些资源需要保证按照顺序正确关闭,并且,当我们有很多的资源需要关闭时,finally 代码块的内容就会变得冗长。

从 JDK 1.7 开始,引入了 try-with-resources 语句,这些问题一下子都得到了解决。使用 try-with-resouces 这个构造的前提是,资源必须实现了 AutoCloseable 接口。Java 类库和第三方类库中的许多类和接口现在都实现或继承了 AutoCloseable 接口。

我们来看一下 try-with-resources 的示例:

// try-with-resources - the the best way to close resources!
static String firstLineOfFile(String path) throws IOException { 
    try (BufferedReader br = new BufferedReader(new FileReader(path))) { 
        return br.readLine();
    } 
}

使用 try-with-resources 代替 try-finally 语句后,生成的代码更加简洁,清晰。在代码中有多个资源时,try-with-resources 的优点会更加明显。

所以,请不要再使用 try-finally 关闭资源了,你应该使用的是 try-with-resouces。

抛出 or 捕获 ?

关于异常处理,有一个非常著名的原则叫做:Throw early,Catch late。

Remember “Throw early catch late” principle. This is probably the most famous principle about Exception handling. It basically says that you should throw an exception as soon as you can, and catch it late as much as possible. You should wait until you have all the information to handle it properly.

This principle implicitly says that you will be more likely to throw it in the low-level methods, where you will be checking if single values are null or not appropriate. And you will be making the exception climb the stack trace for quite several levels until you reach a sufficient level of abstraction to be able to handle the problem.

它的含义是,遇到异常,你应该尽早地抛出,并且尽可能晚地捕获它。如果当前方法会抛出一个异常,我们应该判断,该异常是否应该交给这个方法处理,如果不是,那么最好的选择是将这个异常向上抛出,交给更高的调用级去处理它。

这样做的好处是,我们可以打印出更多的异常堆栈信息,从最顶层的逻辑开始逐步向下,清楚地看到方法调用关系,以便我们理清报错原因。

二:异常的栈轨迹

栈轨迹 Stacktrace ,是程序报错时,我们用来排查问题最重要的信息,没有之一!

如示例程序:

package com.github.test;

public class Test {

    public static void main(String[] args) {
        fun1();
    }

    private static void fun1() {
        fun2();
    }

    private static void fun2() {
        fun3();
    }

    private static void fun3() {
        throw new RuntimeException("RuntimeException");
    }
}

运行上述代码,打印出异常栈轨迹为:

Exception in thread "main" java.lang.RuntimeException: RuntimeException
    at com.github.test.Test.fun3(Test.java:18)
    at com.github.test.Test.fun2(Test.java:14)
    at com.github.test.Test.fun1(Test.java:10)
    at com.github.test.Test.main(Test.java:6)

我们可以通过 Stacktrace 打印的信息,轻松地追踪到异常发生的位置,并看到方法调用关系。

三:异常处理实战

这是一个 JDBC 连接 H2 数据库,执行数据库查询操作,并关闭数据库连接的代码样例,以供大家参考。

import java.io.File;
import java.sql.*;

public class DatabaseReader {
    public static void main(String[] args) throws SQLException {
        File projectDir = new File(System.getProperty("basedir", System.getProperty("user.dir")));
        String jdbcUrl = "jdbc:h2:file:" + new File(projectDir, "test").getAbsolutePath();
        System.out.println(jdbcUrl);

        try (Connection connection = DriverManager.getConnection(jdbcUrl, "sa", "");
             PreparedStatement statement = connection.prepareStatement("select * from PULL_REQUESTS where number > ?");
        ) {
            statement.setInt(1, 0);
            ResultSet resultSet = statement.executeQuery();
            while (resultSet.next()) {
                System.out.println(
                        resultSet.getInt(1)
                                + " "
                                + resultSet.getString(2)
                                + " "
                                + resultSet.getString(2));
            }
        }
    }
}