15. 异常处理

空~2022年8月12日
  • java
大约 35 分钟

15. 异常处理

改进的错误恢复机制是提高代码健壮性的最强有力的方式。错误恢复在我们所编写的每一个程序中都是基本的要素,但是在 Java 中它显得格外重要,因为 Java 的主要目标之一就是创建供他人使用的程序构件。

发现错误的理想时机是在编译阶段,也就是在你试图运行程序之前。然而,编译期间并不能找出所有的错误,余下的问题必须在运行期间解决。这就需要错误源能通过某种方式,把适当的信息传递给某个接收者——该接收者将知道如何正确处理这个问题。

异常概念

C 以及其他早期语言常常具有多种错误处理模式,这些模式往往建立在约定俗成的基础之上,而并不属于语言的一部分。通常会返回某个特殊值或者设置某个标志,并且假定接收者将对这个返回值或标志进行检查,以判定是否发生了错误。然而,随着时间的推移,人们发现,高傲的程序员们在使用程序库的时候更倾向于认为:“对,错误也许会发生,但那是别人造成的,不关我的事”。所以,程序员不去检查错误情形也就不足为奇了(何况对某些错误情形的检查确实很无聊)。如果的确在每次调用方法的时候都彻底地进行错误检查,代码很可能会变得难以阅读。正是由于程序员还仍然用这些方式拼凑系统,所以他们拒绝承认这样一个事实:对于构造大型、健壮、可维护的程序而言,这种错误处理模式已经成为了主要障碍。

解决的办法是,用强制规定的形式来消除错误处理过程中随心所欲的因素。这种做法由来已久,对异常处理的实现可以追溯到 20 世纪 60 年代的操作系统,甚至于 BASIC 语言中的“on error goto”语句。而 C++的异常处理机制基于 Ada,Java 中的异常处理机制则建立在 C++ 的基础之上(尽管看上去更像 Object Pascal)。

“异常”这个词有“我对此感到意外”的意思。问题出现了,你也许不清楚该如何处理,但你的确知道不应该置之不理,你要停下来,看看是不是有别人或在别的地方,能够处理这个问题。只是在当前的环境中还没有足够的信息来解决这个问题,所以就把这个问题提交到一个更高级别的环境中,在那里将作出正确的决定。

异常往往能降低错误处理代码的复杂度。如果不使用异常,那么就必须检查特定的错误,并在程序中的许多地方去处理它。而如果使用异常,那就不必在方法调用处进行检查,因为异常机制将保证能够捕获这个错误。理想情况下,只需在一个地方处理错误,即所谓的异常处理程序中。这种方式不仅节省代码,而且把“描述在正常执行过程中做什么事”的代码和“出了问题怎么办”的代码相分离。总之,与以前的错误处理方法相比,异常机制使代码的阅读、编写和调试工作更加井井有条。

异常处理机制

Java 的异常处理机制可以让程序具有极好的容错性,让程序更加健壮。当程序运行出现意外情形时,系统会自动生成一个 Exception 对象来通知程序,从而实现将“业务功能实现代码”和“错误处理代码”分离,提供更好的可读性。

使用 try...catch 捕获异常

要明白异常是如何被捕获的,必须首先理解监控区域(guarded region)的概念。它是一段可能产生异常的代码,并且后面跟着处理这些异常的代码。

如果在方法内部抛出了异常(或者在方法内部调用的其他方法抛出了异常),这个方法将在抛出异常的过程中结束。要是不希望方法就此结束,可以在方法内设置一个特殊的块来捕获异常。因为在这个块里“尝试”各种(可能产生异常的)方法调用,所以称为 try 块。它是跟在 try 关键字之后的普通程序块:

try {
    // Code that might generate exceptions
}

当然,抛出的异常必须在某处得到处理。这个“地点”就是异常处理程序,而且针对每个要捕获的异常,得准备相应的处理程序。异常处理程序紧跟在 try 块之后,以关键字 catch 表示:

try {
    // Code that might generate exceptions
} catch(Type1 id1) {
    // Handle exceptions of Type1
} catch(Type2 id2) {
    // Handle exceptions of Type2
} catch(Type3 id3) {
    // Handle exceptions of Type3
}
// etc.

如果执行 try 块里的业务逻辑代码时出现异常,系统自动生成一个异常对象,该异常对象被提交给 Java 运行时环境,这个过程被称为抛出(throw)异常。

异常处理程序必须紧跟在 try 块之后。当异常被抛出时,异常处理机制将负责搜寻参数与异常类型相匹配的第一个处理程序。然后进入 catch 子句执行,此时认为异常得到了处理。一旦 catch 子句结束,则处理程序的查找过程结束。注意,只有匹配的 catch 子句才能得到执行。

img

在通常情况下,如果 try 块被执行一次,则 try 块后只有一个 catch 块会被执行,绝不可能有多个 catch 块被执行。除非在循环中使用了 continue 开始下一次循环,下一次循环又重新运行了 try 块,这才可能导致多个 catch 块被执行。

提示

try 块与 if 语句不一样,try 块后的花括号{...}不可以省略,即使 try 块里只有一行代码,也不可省略这个花括号。与之类似的是,catch 块后的花括号{...}也不可以省略。还有一点需要指出:try 块里声明的变量是代码块内局部变量,它只在 try 块内有效,在 catch 块中不能访问该变量。

异常类的继承体系

Java 提供了丰富的异常类,这些异常类之间有严格的继承关系,图中显示了 Java 常见的异常类之间的继承关系。

img

Java 把所有的非正常情况分成两种:异常(Exception)和错误(Error),它们都继承 Throwable 父类。

Error 错误,一般是指与虚拟机相关的问题,如系统崩溃、虚拟机错误、动态链接失败等,这种错误无法恢复或不可能捕获,将导致应用程序中断。通常应用程序无法处理这些错误,因此应用程序不应该试图使用 catch 块来捕获 Error 对象。在定义该方法时,也无须在其 throws 子句中声明该方法可能抛出 Error 及其任何子类。

public class DivTest {
    public static void main(String[] args) {
        try {
            int a = Integer.parseInt(args[0]);
            int b = Integer.parseInt(args[1]);
            int c = a / b;
            System.out.println("您输入的两个数相除的结果是:" + c);
        } catch (IndexOutOfBoundsException ie) {
            System.out.println("数组越界:运行程序时输入的参数个数不够");
        } catch (NumberFormatException ne) {
            System.out.println("数字格式异常:程序只能接收整数参数");
        } catch (ArithmeticException ae) {
            System.out.println("算术异常");
        } catch (Exception e) {
            System.out.println("未知异常");
        }
    }
}

Java 运行时的异常处理逻辑可能有如下几种情形。

  1. 如果运行该程序时输入的参数不够,将会发生数组越界异常,Java 运行时将调用IndexOutOfBoundsException对应的 catch 块处理该异常。
  2. 如果运行该程序时输入的参数不是数字,而是字母,将发生数字格式异常,Java 运行时将调用NumberFormatException对应的 catch 块处理该异常。
  3. 如果运行该程序时输入的第二个参数是 0,将发生除 0 异常,Java 运行时将调用ArithmeticException对应的 catch 块处理该异常。
  4. 如果程序运行时出现其他异常,该异常对象总是 Exception 类或其子类的实例,Java 运行时将调用 Exception 对应的 catch 块处理该异常。
public class NullTest {
    public static void main(String[] args) {
        Date d = null;
        try {
            System.out.println(d.after(new Date()));
        } catch (NullPointerException ne) {
            System.out.println("空指针异常");
        } catch (Exception e) {
            System.out.println("未知异常");
        }
    }
}

程序调用一个 null 对象的 after()方法,这将引发NullPointerException异常(当试图调用一个 null 对象的实例方法或实例变量时,就会引发NullPointerException异常),Java 运行时将会调用NullPointerException对应的 catch 块来处理该异常;如果程序遇到其他异常,Java 运行时将会调用最后的 catch 块来处理异常。

我们总是把对应 Exception 类的 catch 块放在最后,如果我们把 Exception 类对应的 catch 块排在其他 catch 块的前面,Java 运行时将直接进入该 catch 块(因为所有的异常对象都是 Exception 或其子类的实例),而排在它后面的 catch 块将永远也不会获得执行的机会。

进行异常捕获时不仅应该把 Exception 类对应的 catch 块放在最后,而且所有父类异常的 catch 块都应该排在子类异常 catch 块的后面(简称:先处理小异常,再处理大异常),否则将出现编译错误。

try {
    Object o = null;
} catch (RuntimeException e)    //①
{
    System.out.println("运行时异常");
} catch (NullPointerException ne)     //②
{
    System.out.println("空指针异常");
}

编译上面代码时将会在 ② 处出现已捕获到异常'java.lang.NullPointerException'的错误提示,因为 ① 处的RuntimeException已经包括了NullPointerException异常,所以 ② 处的 catch 块永远也不会获得执行的机会。

多异常捕获

一个 catch 块可以捕获多种类型的异常。

  1. 捕获多种类型的异常时,多种异常类型之间用竖线(|)隔开。
  2. 捕获多种类型的异常时,异常变量有隐式的 final 修饰,因此程序不能对异常变量重新赋值。
public class MultiExceptionTest {
    public static void main(String[] args) {
        try {
            int a = Integer.parseInt(args[0]);
            int b = Integer.parseInt(args[1]);
            int c = a / b;
            System.out.println("您输入的两个数相除的结果是:" + c);
        } catch (IndexOutOfBoundsException | NumberFormatException | ArithmeticException ie) {
            System.out.println("程序发生了数组越界、数字格式异常、算术异常之一");
            // 捕获多异常时,异常变量默认有final修饰
            // ①所以下面代码有错
            // ie = new ArithmeticException("test");
        } catch (Exception e) {
            System.out.println("未知异常");
            // 捕获一种类型的异常时,异常变量没有final修饰
            // ②所以下面代码完全正确
            e = new RuntimeException("test");
        }
    }
}

访问异常信息

当 Java 运行时决定调用某个 catch 块来处理该异常对象时,会将异常对象赋给 catch 块后的异常参数,程序即可通过该参数来获得异常的相关信息。

所有的异常对象都包含了如下几个常用方法。

  1. getMessage():返回该异常的详细描述字符串。
  2. printStackTrace():将该异常的跟踪栈信息输出到标准错误输出。
  3. printStackTrace(PrintStream s):将该异常的跟踪栈信息输出到指定输出流。
  4. getStackTrace():返回该异常的跟踪栈信息。
public class AccessExceptionMsg {
    public static void main(String[] args) {
        try {
            FileInputStream fis = new FileInputStream("a.txt");
        } catch (IOException ioe) {
            System.out.println(ioe.getMessage());
            ioe.printStackTrace();
        }
    }
}

上面程序调用了 Exception 对象的getMessage()方法来得到异常对象的详细信息,也使用了printStackTrace()方法来打印该异常的跟踪信息。

image-20220812142229828

使用 finally 回收资源

有一些代码片段,可能会希望无论 try 块中的异常是否抛出,它们都能得到执行。这通常适用于内存回收之外的情况(因为回收由垃圾回收器完成),为了达到这个效果,可以在异常处理程序后面加上 finally 子句。完整的异常处理程序看起来像这样:

try {
// The guarded region: Dangerous activities
// that might throw A, B, or C
} catch(A a1) {
// Handler for situation A
} catch(B b1) {
// Handler for situation B
} catch(C c1) {
// Handler for situation C
} finally {
// Activities that happen every time
}

异常处理语法结构中只有 try 块是必需的,catch 块和 finally 块都是可选的,但 catch 块和 finally 块至少出现其中之一,也可以同时出现;可以有多个 catch 块,捕获父类异常的 catch 块必须位于捕获子类异常的后面;但不能只有 try 块;多个 catch 块必须位于 try 块之后,finally 块必须位于所有的 catch 块之后。

class ThreeException extends Exception {}
public class FinallyWorks {
    static int count = 0;
    public static void main(String[] args) {
        while(true) {
            try {
                // Post-increment is zero first time:
                if(count++ == 0)
                    throw new ThreeException();
                System.out.println("No exception");
            } catch(ThreeException e) {
                System.out.println("ThreeException");
            } finally {
                System.out.println("In finally clause");
                if(count == 2) break; // out of "while"
            }
        }
    }
}
/*
    ThreeException
    In finally clause
    No exception
    In finally clause
*/

从输出中发现,无论异常是否被抛出,finally 子句总能被执行。这也为解决 Java 不允许我们回到异常抛出点这一问题,提供了一个思路。如果将 try 块放在循环里,就可以设置一种在程序执行前一定会遇到的异常状况。还可以加入一个 static 类型的计数器或者别的装置,使循环在结束以前能尝试一定的次数。这将使程序的健壮性更上一个台阶。

提示

除非在 try 块、catch 块中调用了退出虚拟机的方法,否则不管在 try 块、catch 块中执行怎样的代码,出现怎样的情况,异常处理的 finally 块总会被执行。

在通常情况下,不要在 finally 块中使用如 return 或 throw 等导致方法终止的语句,(throw 语句将在后面介绍),一旦在 finally 块中使用了 return 或 throw 语句,将会导致 try 块、catch 块中的 return、throw 语句失效。

public class FinallyFlowTest {
    public static void main(String[] args) throws Exception {
        boolean a = test();
        System.out.println(a);
    }

    public static boolean test() {
        try {
            // 因为finally块中包含了return语句
            // 所以下面的return语句失去作用
            return true;
        } finally {
            return false;
        }
    }
}

异常处理的嵌套

finally 块中也包含了一个完整的异常处理流程,这种在 try 块、catch 块或 finally 块中包含完整的异常处理流程的情形被称为异常处理的嵌套。

异常处理流程代码可以放在任何能放可执行性代码的地方,因此完整的异常处理流程既可放在 try 块里,也可放在 catch 块里,还可放在 finally 块里。

异常处理嵌套的深度没有很明确的限制,但通常没有必要使用超过两层的嵌套异常处理,层次太深的嵌套异常处理没有太大必要,而且导致程序可读性降低。

在异常没有被当前的异常处理程序捕获的情况下,异常处理机制也会在跳到更高一层的异常处理程序之前,执行 finally 子句:

class FourException extends Exception {}
public class AlwaysFinally {
    public static void main(String[] args) {
        System.out.println("Entering first try block");
        try {
            System.out.println("Entering second try block");
            try {
                throw new FourException();
            } finally {
                System.out.println("finally in 2nd try block");
            }
        } catch(FourException e) {
            System.out.println(
                    "Caught FourException in 1st try block");
        } finally {
            System.out.println("finally in 1st try block");
        }
    }
}
/*
    Entering first try block
    Entering second try block
    finally in 2nd try block
    Caught FourException in 1st try block
    finally in 1st try block
*/

try-with-resources

在 Java 7 之前,try 后面总是跟着一个 {,但是现在可以跟一个带括号的定义 ——这里是我们创建的 FileInputStream 对象。括号内的部分称为资源规范头(resource specification header)。现在 in 在整个 try 块的其余部分都是可用的。更重要的是,无论你如何退出 try 块(正常或通过异常),和以前的 finally 子句等价的代码都会被执行,并且不用编写那些杂乱而棘手的代码。这是一项重要的改进。

它是如何工作的?try-with-resources 定义子句中创建的对象(在括号内)必须实现 java.lang.AutoCloseable 接口,这个接口只有一个方法:close()

当在 Java 7 中引入 AutoCloseable 时,许多接口和类被修改以实现它;查看 Javadocs 中的 AutoCloseable,可以找到所有实现该接口的类列表,其中包括 Stream 对象:

public class StreamsAreAutoCloseable {
    public static void main(String[] args) throws IOException {
        try (Stream<String> in = Files.lines(Paths.get("test/src/exceptionDemo/StreamsAreAutoCloseable.java"));
            PrintWriter outfile = new PrintWriter("Results.txt") // [1]
        ) {
            in.skip(5).limit(1).map(String::toLowerCase).forEachOrdered(outfile::println);
        } // [2]
    }
}
  • [1] 你在这里可以看到其他的特性:资源规范头中可以包含多个定义,并且通过分号进行分割(最后一个分号是可选的)。规范头中定义的每个对象都会在 try 语句块运行结束之后调用 close() 方法。
  • [2] try-with-resources 里面的 try 语句块可以不包含 catch 或者 finally 语句而独立存在。在这里,IOExceptionmain() 方法抛出,所以这里并不需要在 try 后面跟着一个 catch 语句块。

Java 5 中的 Closeable 已经被修改,修改之后的接口继承了 AutoCloseable 接口。所以所有实现了 Closeable 接口的对象,都支持了 try-with-resources 特性。

提示

Java 7 几乎把所有的“资源类”(包括文件 IO 的各种类、JDBC 编程的 Connection、Statement 等接口……)进行了改写,改写后资源类都实现了AutoCloseableCloseable接口。

揭示细节

为了研究 try-with-resources 的基本机制,我们将创建自己的 AutoCloseable 类:

class Reporter implements AutoCloseable {
    String name = getClass().getSimpleName();
    Reporter() {
        System.out.println("Creating " + name);
    }
    public void close() {
        System.out.println("Closing " + name);
    }
}
class First extends Reporter {}
class Second extends Reporter {}
public class AutoCloseableDetails {
    public static void main(String[] args) {
        try(
                First f = new First();
                Second s = new Second()
        ) {
        }
    }
}

输出为:

Creating First
Creating Second
Closing Second
Closing First

退出 try 块会调用两个对象的 close() 方法,并以与创建顺序相反的顺序关闭它们。顺序很重要,因为在这种情况下,Second 对象可能依赖于 First 对象,因此如果 First 在第 Second 关闭时已经关闭。 Second 的 close() 方法可能会尝试访问 First 中不再可用的某些功能。

假设我们在资源规范头中定义了一个不是 AutoCloseable 的对象

class Anything {}
public class TryAnything {
    public static void main(String[] args) {
        try(
                Anything a = new Anything()
        ) {
        }
    }
}

正如我们所希望和期望的那样,Java 不会让我们这样做,并且出现编译时错误。

如果其中一个构造函数抛出异常怎么办?

class CE extends Exception {}
class SecondExcept extends Reporter {
    SecondExcept() throws CE {
        super();
        throw new CE();
    }
}
public class ConstructorException {
    public static void main(String[] args) {
        try(
                First f = new First();
                SecondExcept s = new SecondExcept();
                Second s2 = new Second()
        ) {
            System.out.println("In body");
        } catch(CE e) {
            System.out.println("Caught: " + e);
        }
    }
}

输出为:

Creating First
Creating SecondExcept
Closing First
Caught: CE

现在资源规范头中定义了 3 个对象,中间的对象抛出异常。因此,编译器强制我们使用 catch 子句来捕获构造函数异常。这意味着资源规范头实际上被 try 块包围。

正如预期的那样,First 创建时没有发生意外,SecondExcept 在创建期间抛出异常。请注意,不会为 SecondExcept 调用 close(),因为如果构造函数失败,则无法假设你可以安全地对该对象执行任何操作,包括关闭它。由于 SecondExcept 的异常,Second 对象实例 s2 不会被创建,因此也不会有清除事件发生。

如果没有构造函数抛出异常,但在 try 的主体中可能抛出异常,那么你将再次被强制要求提供一个 catch 子句:

class Third extends Reporter {}
public class BodyException {
    public static void main(String[] args) {
        try(
                First f = new First();
                Second s2 = new Second()
        ) {
            System.out.println("In body");
            Third t = new Third();
            new SecondExcept();
            System.out.println("End of body");
        } catch(CE e) {
            System.out.println("Caught: " + e);
        }
    }
}

输出为:

Creating First
Creating Second
In body
Creating Third
Creating SecondExcept
Closing Second
Closing First
Caught: CE

请注意,第 3 个对象永远不会被清除。那是因为它不是在资源规范头中创建的,所以它没有被保护。这很重要,因为 Java 在这里没有以警告或错误的形式提供指导,因此像这样的错误很容易漏掉。实际上,如果依赖某些集成开发环境来自动重写代码,以使用 try-with-resources 特性,那么它们(在撰写本文时)通常只会保护它们遇到的第一个对象,而忽略其余的对象。

最后,让我们看一下抛出异常的 close() 方法:

class CloseException extends Exception {}
class Reporter2 implements AutoCloseable {
    String name = getClass().getSimpleName();
    Reporter2() {
        System.out.println("Creating " + name);
    }
    public void close() throws CloseException {
        System.out.println("Closing " + name);
    }
}
class Closer extends Reporter2 {
    @Override
    public void close() throws CloseException {
        super.close();
        throw new CloseException();
    }
}
public class CloseExceptions {
    public static void main(String[] args) {
        try(
                First f = new First();
                Closer c = new Closer();
                Second s = new Second()
        ) {
            System.out.println("In body");
        } catch(CloseException e) {
            System.out.println("Caught: " + e);
        }
    }
}

输出为:

Creating First
Creating Closer
Creating Second
In body
Closing Second
Closing Closer
Closing First
Caught: CloseException

从技术上讲,我们并没有被迫在这里提供一个 catch 子句;你可以通过 main() throws CloseException 的方式来报告异常。但 catch 子句是放置错误处理代码的典型位置。

请注意,因为所有三个对象都已创建,所以它们都以相反的顺序关闭 - 即使 Closer.close() 抛出异常也是如此。

Checked 异常和 Runtime 异常体系

Java 的异常被分为两大类:Checked 异常和 Runtime 异常(运行时异常)。所有的RuntimeException类及其子类的实例被称为 Runtime 异常;不是RuntimeException类及其子类的异常实例则被称为 Checked 异常。

对于 Checked 异常的处理方式有如下两种。

  1. 当前方法明确知道如何处理该异常,程序应该使用 try...catch 块来捕获该异常,然后在对应的 catch 块中修复该异常。
  2. 当前方法不知道如何处理这种异常,应该在定义该方法时声明抛出该异常。

Runtime 异常则更加灵活,Runtime 异常无须显式声明抛出,如果程序需要捕获 Runtime 异常,也可以使用 try...catch 块来实现。

使用 throws 声明抛出异常

使用 throws 声明抛出异常的思路是,当前方法不知道如何处理这种类型的异常,该异常应该由上一级调用者处理;如果 main 方法也不知道如何处理这种类型的异常,也可以使用 throws 声明抛出异常,该异常将交给 JVM 处理。JVM 对异常的处理方法是,打印异常的跟踪栈信息,并中止程序运行,这就是前面程序在遇到异常后自动结束的原因。

throws 声明抛出只能在方法签名中使用,throws 可以声明抛出多个异常类,多个异常类之间以逗号隔开。throws 声明抛出的语法格式如下:

throws ExceptionClass1 , ExceptionClass2...

throws 声明抛出的语法格式仅跟在方法签名之后,如下例子程序使用了 throws 来声明抛出IOException异常,一旦使用 throws 语句声明抛出该异常,程序就无须使用 try...catch 块来捕获该异常了。

public class ThrowsTest {
    public static void main(String[] args) throws IOException {
        FileInputStream fis = new FileInputStream("a.txt");
    }
}

上面程序声明不处理IOException异常,将该异常交给 JVM 处理,所以程序一旦遇到该异常,JVM 就会打印该异常的跟踪栈信息,并结束程序。

image-20220812183654669

如果某段代码中调用了一个带 throws 声明的方法,该方法声明抛出了 Checked 异常,则表明该方法希望它的调用者来处理该异常。也就是说,调用该方法时要么放在 try 块中显式捕获该异常,要么放在另一个带 throws 声明抛出的方法中。

public class ThrowsTest2 {
    public static void main(String[] args) throws Exception {
        // 因为test()方法声明抛出IOException异常
        // 所以调用该方法的代码要么处于try...catch块中,
        // 要么处于另一个带throws声明抛出的方法中
        test();
    }

    public static void test() throws IOException {
        // 因为FileInputStream的构造器声明抛出IOException异常
        // 所以调用FileInputStream的代码要么处于try...catch块中
        // 要么处于另一个带throws声明抛出的方法中
        FileInputStream fis = new FileInputStream("a.txt");
    }
}

使用 throws 声明抛出异常时有一个限制,就是方法重写时“两小”中的一条规则:子类方法声明抛出的异常类型应该是父类方法声明抛出的异常类型的子类或相同子类方法声明抛出的异常不允许比父类方法声明抛出的异常多。看如下程序。

public class OverrideThrows {
    public void test() throws IOException {
        FileInputStream fis = new FileInputStream("a.txt");
    }
}

class Sub extends OverrideThrows {
    // 子类方法声明抛出了比父类方法更大的异常
    // 所以下面方法出错
    public void test() throws Exception {
    }
}

使用 Checked 异常至少存在如下两大不便之处。

  1. 对于程序中的 Checked 异常,Java 要求必须显式捕获并处理该异常,或者显式声明抛出该异常。这样就增加了编程复杂度。
  2. 如果在方法中显式声明抛出 Checked 异常,将会导致方法签名与异常耦合,如果该方法是重写父类的方法,则该方法抛出的异常还会受到被重写方法所抛出异常的限制。

在大部分情况下,推荐使用 Runtime 异常,而不使用 Checked 异常。尤其当程序需要自行抛出异常时,使用 Runtime 异常将更加简洁。

当使用 Runtime 异常时,程序无须在方法中声明抛出 Checked 异常,一旦发生了自定义错误,程序只管抛出 Runtime 异常即可。

如果程序需要在合适的地方捕获异常并对异常进行处理,则一样可以使用 try…catch 块来捕获 Runtime 异常。

使用 throw 抛出异常

当程序出现错误时,系统会自动抛出异常;除此之外,Java 也允许程序自行抛出异常,自行抛出异常使用 throw 语句来完成(注意此处的 throw 没有后面的 s,与前面声明抛出的 throws 是有区别的)。

异常链

把底层的原始异常直接传给用户是一种不负责任的表现。通常的做法是:程序先捕获原始异常,然后抛出一个新的业务异常,新的业务异常中包含了对用户的提示信息,这种处理方式被称为异常转译。假设程序需要实现工资计算的方法,则程序应该采用如下结构的代码来实现该方法。

public calSal() throws SalException {
    try {
        // 实现结算工资的业务逻辑
              ...
    } catch (SQLException sqle) {
        // 把原始异常记录下来,留给管理员
              ...
        // 下面异常中的message就是对用户的提示
        throw new SalException("访问底层数据库出现异常");
    } catch (Exception e) {
        // 把原始异常记录下来,留给管理员
              ...
        // 下面异常中的message就是对用户的提示
        throw new SalException("系统出现未知异常");
    }
}

这种把捕获一个异常然后接着抛出另一个异常,并把原始异常信息保存下来是一种典型的链式处理(23 种设计模式之一:职责链模式),也被称为“异常链”。

从 JDK 1.4 以后,所有 Throwable 的子类在构造器中都可以接收一个 cause 对象作为参数。这个 cause 就用来表示原始异常,这样可以把原始异常传递给新的异常,使得即使在当前位置创建并抛出了新的异常,你也能通过这个异常链追踪到异常最初发生的位置。如果我们希望上面的 SalException 可以追踪到最原始的异常信息,则可以将该方法改写为如下形式。

public calSal() throws SalException {
    try {
        // 实现结算工资的业务逻辑
              ...
    } catch (SQLException sqle) {
        // 把原始异常记录下来,留给管理员
              ...
        // 下面异常中的sqle就是原始异常
        throw new SalException(sqle);
    } catch (Exception e) {
        // 把原始异常记录下来,留给管理员
              ...
        // 下面异常中的e就是原始异常
        throw new SalException(e);
    }
}

创建SalException对象时,传入了一个 Exception 对象,而不是传入了一个 String 对象,这就需要SalException类有相应的构造器。从 JDK 1.4 以后,Throwable 基类已有了一个可以接收 Exception 参数的方法,所以可以采用如下代码来定义SalException类。

public class SalException extends Exception {
    public SalException() {
    }

    public SalException(String msg) {
        super(msg);
    }

    // 创建一个可以接收Throwable参数的构造器
    public SalException(Throwable t) {
        super(t);
    }
}

抛出异常

如果需要在程序中自行抛出异常,则应使用 throw 语句,throw 语句可以单独使用,throw 语句抛出的不是异常类,而是一个异常实例,而且每次只能抛出一个异常实例。

throw ExceptionInstance;

当在一个普通方法里调用别的方法时发现:“我不知道该如何处理这个异常,但是不能把它'吞掉'或者打印一些无用的消息。”有了异常链,一个简单的解决办法就出现了。可以通过将一个“被检查的异常”传递给RuntimeException 的构造器,从而将它包装进 RuntimeException 里,就像这样:

try {
    // ... to do something useful
} catch(IDontKnowWhatToDoWithThisCheckedException e) {
    throw new RuntimeException(e);
}

你可以不写 try-catch 子句和/或异常说明,直接忽略异常,让它自己沿着调用栈往上“冒泡”,同时,还可以用 getCause() 捕获并处理特定的异常,就像这样:

import java.io.*;
class WrapCheckedException {
    void throwRuntimeException(int type) {
        try {
            switch(type) {
                case 0: throw new FileNotFoundException();
                case 1: throw new IOException();
                case 2: throw new
                        RuntimeException("Where am I?");
                default: return;
            }
        } catch(IOException | RuntimeException e) {
            // Adapt to unchecked:
            throw new RuntimeException(e);
        }
    }
}
class SomeOtherException extends Exception {}
public class TurnOffChecking {
    public static void main(String[] args) {
        WrapCheckedException wce =
                new WrapCheckedException();
        // You can call throwRuntimeException() without
        // a try block, and let RuntimeExceptions
        // leave the method:
        wce.throwRuntimeException(3);
        // Or you can choose to catch exceptions:
        for(int i = 0; i < 4; i++)
            try {
                if(i < 3)
                    wce.throwRuntimeException(i);
                else
                    throw new SomeOtherException();
            } catch(SomeOtherException e) {
                System.out.println(
                        "SomeOtherException: " + e);
            } catch(RuntimeException re) {
                try {
                    throw re.getCause();
                } catch(FileNotFoundException e) {
                    System.out.println(
                            "FileNotFoundException: " + e);
                } catch(IOException e) {
                    System.out.println("IOException: " + e);
                } catch(Throwable e) {
                    System.out.println("Throwable: " + e);
                }
            }
    }
}
/*
FileNotFoundException: java.io.FileNotFoundException
IOException: java.io.IOException
Throwable: java.lang.RuntimeException: Where am I?
SomeOtherException: SomeOtherException
*/

WrapCheckedException.throwRuntimeException() 包含可生成不同类型异常的代码。这些异常被捕获并包装进RuntimeException 对象,所以它们成了这些运行时异常的原因("cause")。

TurnOfChecking 里,可以不用 try 块就调用 throwRuntimeException(),因为它没有抛出“被检查的异常”。但是,当你准备好去捕获异常的时候,还是可以用 try 块来捕获任何你想捕获的异常的。应该捕获 try 块肯定会抛出的异常,这里就是 SomeOtherExceptionRuntimeException 要放到最后去捕获。然后把 getCause() 的结果(也就是被包装的那个原始异常)抛出来。这样就把原先的那个异常给提取出来了,然后就可以用它们自己的 catch 子句进行处理。

一种把被检查的异常用 RuntimeException 包装起来。另一种解决方案是创建自己的 RuntimeException 的子类。这样的话,异常捕获将不被强制要求,但是任何人都可以在需要的时候捕获这些异常。

自定义异常

不必拘泥于 Java 已有的异常类型。Java 异常体系不可能预见你将报告的所有错误,所以你可以创建自己的异常类,来表示你的程序中可能遇到的问题。

用户自定义异常都应该继承 Exception 基类,如果希望自定义 Runtime 异常,则应该继承RuntimeException基类。定义异常类时通常需要提供两个构造器:一个是无参数的构造器;另一个是带一个字符串参数的构造器,这个字符串将作为该异常对象的描述信息(也就是异常对象的getMessage()方法的返回值)。

public class AuctionException extends Exception {
    // ①无参数的构造器
    public AuctionException() {
    }

    // ②带一个字符串参数的构造器
    public AuctionException(String msg) {
        super(msg);
    }
}

② 代码部分创建的带一个字符串参数的构造器,其执行体也非常简单,仅通过 super 来调用父类的构造器,正是这行 super 调用可以将此字符串参数传给异常对象的 message 属性,该 message 属性就是该异常对象的详细描述信息。

如果需要自定义 Runtime 异常,只需将 AuctionException.java 程序中的 Exception 基类改为RuntimeException基类,其他地方无须修改。

catch 和 throw 同时使用

前面介绍的异常处理方式有如下两种。

  1. 在出现异常的方法内捕获并处理异常,该方法的调用者将不能再次捕获该异常。
  2. 该方法签名中声明抛出该异常,将该异常完全交给方法调用者处理。

在实际应用中往往需要更复杂的处理方式——当一个异常出现时,单靠某个方法无法完全处理该异常,必须由几个方法协作才可完全处理该异常。

也就是说,在异常出现的当前方法中,程序只对异常进行部分处理,还有些处理需要在该方法的调用者中才能完成,所以应该再次抛出异常,让该方法的调用者也能捕获到异常。

为了实现这种通过多个方法协作处理同一个异常的情形,可以在 catch 块中结合 throw 语句来完成。

public class AuctionTest {
    private double initPrice = 30.0;

    public static void main(String[] args) {
        AuctionTest at = new AuctionTest();
        try {
            at.bid("df");
        } catch (AuctionException ae) {
            // 再次捕获到bid()方法中的异常,并对该异常进行处理
            System.err.println(ae.getMessage());
        }
    }

    // 因为该方法中显式抛出了AuctionException异常
    // 所以此处需要声明抛出AuctionException异常
    public void bid(String bidPrice) throws AuctionException {
        double d = 0.0;
        try {
            d = Double.parseDouble(bidPrice);
        } catch (Exception e) {
            // 此处完成本方法中可以对异常执行的修复处理
            // 此处仅仅是在控制台打印异常的跟踪栈信息
            e.printStackTrace();
            // 再次抛出自定义异常
            throw new AuctionException("竞拍价必须是数值," + "不能包含其他字符!");
        }
        if (initPrice > d) {
            throw new AuctionException("竞拍价比起拍价低," + "不允许竞拍!");
        }
        initPrice = d;
    }
}

增强的 throw 语句

try {
    new FileOutputStream("a.txt");
} catch (Exception ex) {
    ex.printStackTrace();
    throw ex;         //①
}

上面代码片段 ① 代码再次抛出了捕获到的异常,但这个 ex 对象的情况比较特殊:程序捕获该异常时,声明该异常的类型为 Exception;但实际上 try 块中可能只调用了FileOutputStream构造器,这个构造器声明只是抛出了FileNotFoundException异常。

在 Java7 以前,Java 编译器的处理“简单而粗暴”——由于在捕获该异常时声明 ex 的类型是 Exception,因此 Java 编译器认为这段代码可能抛出 Exception 异常,所以包含这段代码的方法通常需要声明抛出 Exception 异常。

public class ThrowTest2 {
    public static void main(String[] args)
        // Java 6认为①号代码可能抛出Exception异常
        // 所以此处声明抛出Exception异常
        throws Exception {
        try {
            new FileOutputStream("a.txt");
        } catch (Exception ex) {
            ex.printStackTrace();
            throw ex;       //①
        }
    }
}

从 Java7 开始,Java 编译器会执行更细致的检查,Java 编译器会检查 throw 语句抛出异常的实际类型,这样编译器知道 ① 号代码处实际上只可能抛出FileNotFoundException异常,因此在方法签名中只要声明抛出FileNotFoundException异常即可。

public class ThrowTest2 {
    public static void main(String[] args)
        // Java 7会检查①号代码处可能抛出异常的实际类型
        // 因此此处只需声明抛出FileNotFoundException异常即可
        throws FileNotFoundException {
        try {
            new FileOutputStream("a.txt");
        } catch (Exception ex) {
            ex.printStackTrace();
            throw ex;       //①
        }
    }
}

异常跟踪栈

异常对象的printStackTrace()方法用于打印异常的跟踪栈信息,根据printStackTrace()方法的输出结果,我们可以找到异常的源头,并跟踪到异常一路触发的过程。

class SelfException extends RuntimeException {
    SelfException() {
    }

    SelfException(String msg) {
        super(msg);
    }
}

public class PrintStackTraceTest {
    public static void main(String[] args) {
        firstMethod();
    }

    public static void firstMethod() {
        secondMethod();
    }

    public static void secondMethod() {
        thirdMethod();
    }

    public static void thirdMethod() {
        throw new SelfException("自定义异常信息");
    }
}

异常从thirdMethod方法开始触发,传到secondMethod方法,再传到firstMethod方法,最后传到 main 方法,在 main 方法终止,这个过程就是 Java 的异常跟踪栈。

image-20220812200329221

接下来跟踪栈记录程序中所有的异常发生点,各行显示被调用方法中执行的停止位置,并标明类、类中的方法名、与故障点对应的文件的行。

一行行地往下看,跟踪栈总是最内部的被调用方法逐渐上传,直到最外部业务操作的起点,通常就是程序的入口 main 方法或 Thread 类的 run 方法(多线程的情形)。

public class ThreadExceptionTest implements Runnable {
    public static void main(String[] args) {
        new Thread(new ThreadExceptionTest()).start();
    }

    public void run() {
        firstMethod();
    }

    public void firstMethod() {
        secondMethod();
    }

    public void secondMethod() {
        int a = 5;
        int b = 0;
        int c = a / b;
    }
}

程序在 Thread 的 run 方法中出现了ArithmeticException异常,这个异常的源头是ThreadExcetpionTestsecondMethod方法,位于 ThreadExcetpionTest.java 文件的 27 行。这个异常传播到 Thread 类的 run 方法就会结束(如果该异常没有得到处理,将会导致该线程中止运行)。

image-20220812200900756

异常处理规则

成功的异常处理应该实现如下 4 个目标。

  1. 使程序代码混乱最小化。
  2. 捕获并保留诊断信息。
  3. 通知合适的人员。
  4. 采用合适的方式结束异常活动。

不要过度使用异常

过度使用异常主要有两个方面。

  1. 把异常和普通错误混淆在一起,不再编写任何错误处理代码,而是以简单地抛出异常来代替所有的错误处理。
  2. 使用异常处理来代替流程控制。

对于完全已知的错误,应该编写处理这种错误的代码,增加程序的健壮性;对于普通的错误,应该编写处理这种错误的代码,增加程序的健壮性。

应该在下列情况下使用异常:

  1. 尽可能使用 try-with-resource。
  2. 在恰当的级别处理问题。(在知道该如何处理的情况下才捕获异常。)
  3. 解决问题并且重新调用产生异常的方法。
  4. 进行少许修补,然后绕过异常发生的地方继续执行。
  5. 用别的数据进行计算,以代替方法预计会返回的值。
  6. 把当前运行环境下能做的事情尽量做完,然后把相同的异常重抛到更高层。
  7. 把当前运行环境下能做的事情尽量做完,然后把不同的异常抛到更高层。
  8. 终止程序。
  9. 进行简化。(如果你的异常模式使问题变得太复杂,那用起来会非常痛苦也很烦人。)
  10. 让类库和程序更安全。(这既是在为调试做短期投资,也是在为程序的健壮性做长期投资。)

不要使用过于庞大的 try 块

因为 try 块里的代码过于庞大,业务过于复杂,就会造成 try 块中出现异常的可能性大大增加,从而导致分析异常原因的难度也大大增加。

而且当 try 块过于庞大时,就难免在 try 块后紧跟大量的 catch 块才可以针对不同的异常提供不同的处理逻辑。同一个 try 块后紧跟大量的 catch 块则需要分析它们之间的逻辑关系,反而增加了编程复杂度。

避免使用 Catch All 语句

所谓 Catch All 语句指的是一种异常捕获模块,它可以处理程序发生的所有可能异常。

try {
 // 可能引发Checked异常的代码
} catch (Throwable t) {
    // 进行异常处理
    t.printStackTrace();
}

这种处理方式有如下两点不足之处。

  1. 所有的异常都采用相同的处理方式,这将导致无法对不同的异常分情况处理,如果要分情况处理,则需要在 catch 块中使用分支语句进行控制,这是得不偿失的做法。
  2. 这种捕获方式可能将程序中的错误、Runtime 异常等可能导致程序终止的情况全部捕获到,从而“压制”了异常。如果出现了一些“关键”异常,那么此异常也会被“静悄悄”地忽略。

不要忽略捕获到的异常

既然已捕获到异常,那 catch 块理应做些有用的事情——处理并修复这个错误。catch 块整个为空,或者仅仅打印出错信息都是不妥的!

通常建议对异常采取适当措施,比如:

  1. 处理异常。对异常进行合适的修复,然后绕过异常发生的地方继续执行;或者用别的数据进行计算,以代替期望的方法返回值;或者提示用户重新操作……总之,对于 Checked 异常,程序应该尽量修复。
  2. 重新抛出新异常。把当前运行环境下能做的事情尽量做完,然后进行异常转译,把异常包装成当前层的异常,重新抛出给上层调用者。
  3. 在合适的层处理异常。如果当前层不清楚如何处理异常,就不要在当前层使用 catch 语句来捕获该异常,直接使用 throws 声明抛出该异常,让上层调用者来负责处理该异常。