Java关键字之try、catch、finally

1 前言

这三个关键字常用于捕捉异常的一整套流程,try 用来确定需要捕获异常的代码的执行范围,catch 捕捉可能会发生的异常,finally 用来执行一定要执行的代码块。除此之外,我们还需要清楚,每个语句块如果发生异常会怎么办,让我们来看下面这个例子:


public class TryCatchFinallyDemo {
    private static Logger log = Logger.getLogger("TryCatchFinallyDemo");
    public static void testCatchFinally() {
        try {
            log.info("try is run");
            if (true) {
                throw new RuntimeException("try exception");
            }
        } catch (Exception e) {
            log.info("catch is run");
            if (true) {
                throw new RuntimeException("catch exception");
            }
        } finally {
            log.info("finally is run");
        }
    }
    public static void main(String[] args) {
        testCatchFinally();
    }
}

这个代码演示了在 try、catch 中都遇到了异常的情况,从输出结果可以看出来:代码的执行顺序为:try -> catch -> finally。


六月 27, 2020 2:30:04 下午 com.l1fe1.exception.TryCatchFinallyDemo testCatchFinally
信息: try is run
六月 27, 2020 2:30:04 下午 com.l1fe1.exception.TryCatchFinallyDemo testCatchFinally
信息: catch is run
六月 27, 2020 2:30:04 下午 com.l1fe1.exception.TryCatchFinallyDemo testCatchFinally
信息: finally is run
Exception in thread "main" java.lang.RuntimeException: catch exception
    at com.l1fe1.exception.TryCatchFinallyDemo.testCatchFinally(TryCatchFinallyDemo.java:17)
    at com.l1fe1.exception.TryCatchFinallyDemo.main(TryCatchFinallyDemo.java:25)

此外,我们还可以看出两点:

  1. finally 先执行后,再抛出 catch 的异常;
  2. 最终捕获的异常是 catch 的异常,try 抛出来的异常已经被 catch 吃掉了,所以当我们遇见 catch 也有可能会抛出异常时,我们可以先打印出 try 的异常,这样 try 的异常在日志中就会有所体现。

2. try语句

在 try 关键字之后紧跟着的 Block 被称为 try 语句 的 try 块。

在 finally 关键字之后紧跟着的 Block 被称为 try 语句 的 finally 块。

try 语句可以有 catch 子句,这些子句也被称为异常处理器。catch 子句有且只有一个参数,这个参数被称为异常参数。异常参数可以将它的类型表示成单一的类类型(uni-catch子句):

try {
    // try block
} catch (Exception e) {
    // uni-catch block
}

也可以表示成两个或者更多类类型(multi-catch子句)的联合体(这些类型称为可选择项)。联合体中的可选择项在语法上用 | 隔开:

try {
    // try block
} catch (ArithmeticException | ArrayIndexOutOfBoundsException e) {
    // multi-catch block
}

用来表示异常参数的每个类类型都必须是 Throwable 类 或 Throwable 的子类,否则就会产生编译错误。

如果类型变量被用来表示异常参数的类型,会产生编译错误。

如果类型联合体包含两个可选项Di和Dj(i ≠ j),其中Di是Dj的子类型,那么就会产生编译错误。例如如下语句:

try {
    // try block
} catch (ArithmeticException | Exception e) {
    // multi-catch block
}

会产生 Types in multi-catch must be disjoint: 'java.lang.ArithmeticException' is a subclass of 'java.lang.Exception' 的编译错误。

multi-catch子句的异常参数如果没有被显式声明为 final,那么就会被隐式声明为 final。如果显式或隐式声明为 final 的异常参数在 catch 子句体内被赋值,那么就会产生编译错误。

try {
    // try block
} catch (ArithmeticException | ArrayIndexOutOfBoundsException e) {
    // Cannot assign a value to final variable 'e' 编译错误
    e = new ArithmeticException();
}

uni-catch子句的异常参数从来都不会被隐式声明为 final,但是它可以被显式声明为 final 或是有效的 final(未被显式声明为 final 但未对它重新赋过值)。

隐式 final 的异常参数是因其声明的特性而是 final 的,而有效的 final 的异常参数是因其被使用方式的特性而是 final 的。multi-catch子句的异常参数隐式的声明为 final,因此永远不会作为赋值操作的左操作数而出现,但是它不会被认为是有效的 final。

如果uni-catch子句的异常参数被显式声明为 final 的,那么移除 final 修饰符会引入编译时错误。这是因为这样的异常参数尽管仍旧是有效的 final,但是再也不能被像 catch 子句体中声明的匿名类和局部类这样的类引用了。另一方面,如果没有任何编译时错误,那么可以在将来变更程序,使得异常参数被重新赋值,这时它就不再是有效的 final 了。

异常处理器会按照从左到右的顺序被考虑是否合适:最靠前的可以接受异常的 catch 子句将被抛出的异常对象当作其引用参数而接收。

multi-catch 子句可以被看作是uni-catch 子句序列。即异常参数类型表示为联合体D1 | D2 | ... | Dn的catch子句等价于 n 个异常类型分别是D1,D2,...,Dn的 catch 子句序列。在这 n 个 catch 子句的每个 Block 中,异常参数的声明类型都是lub(D1,D2,...,Dn)。例如,下面的代码:


try {
   ... throws ReflectiveOperationException ...
}
catch (ClassNotFoundException | IllegalAccessException ex) {
 // ... body ...
}

在语义上等价于下面的代码:


try {
  ... throws ReflectiveOperationException ...
}
catch (final ClassNotFoundException ex1) {
 final ReflectiveOperationException ex = ex1;
 // ... body ...
}
catch (final IllegalAccessException ex2) {
 final ReflectiveOperationException ex = ex2;
 // ... body ...
}

其中,具有两个可选项的 multi-catch 子句已经被转译成两个分离的 catch 子句,每个对应一个选项。Java 编译器既不要求也不推荐以这种方式通过重复代码来编译 multi-catch 子句,因为在 class 文件中无需重复就可以表示 multi-catch 子句。

2.1 try-catch 的执行

不带 finally 块的 try 语句是由先执行 try 块而开始的。然后有以下选择:

  • 如果 try 块的执行正常结束,那么就不会有更进一步的动作。
  • 如果 try 块的执行因为一个值为 v 的 throw 对象而结束,那么会有以下选择:
    • 如果 v 的运行时类型与 try 语句的任何 catch 子句的可捕获异常类是赋值兼容的,那么第一个(最左边)的 catch 子句将被选中执行,值 v 被赋值给这个 catch 子句的参数,然后有以下两种情况:
      • 如果该块正常结束,那么该 try 语句正常结束。
      • 如果该块因某个原因而异常结束,那么该 try 语句也会以同样的原因而异常结束。
    • 如果 v 的运行时类型与 try 语句的任何 catch 子句的可捕获异常类都不是赋值兼容的,那么该 try 语句就会因为一个 v 值的 throw 对象而异常结束。
  • 如果 try 块的执行因某个原因而猝然结束,那么该 try 语句也会以同样的原因而猝然结束。


class BlewIt extends Exception {
    BlewIt() { }
    BlewIt(String s) { super(s); }
}
public class TestTryCatch {
    static void blowUp() throws BlewIt { throw new BlewIt(); }
    public static void main(String[] args) {
        try {
            blowUp();
        } catch (RuntimeException r) {
            System.out.println("Caught RuntimeException");
        } catch (BlewIt b) {
            System.out.println("Caught BlewIt");
        }
    }
}

在这里,BlewIt 异常是 blowUp 方法抛出的。在 main 方法体中的 try-catch 语句有两个 catch 子句。异常的运行时类型是 BlewIt,它对 RuntimeException 类型的变量是不可赋值的,但是它对 BlewIt 类型的变量是可赋值的,因此这个示例的输出为:


Caught BlewIt

2.2 try-finally 和 try-catch-finally 的执行

带 finally 块的 try 语句也是由先执行 try 块而开始的。然后有以下选择:

  • 如果 try 块的执行正常结束,那么 finally 块就会被执行:
    • 如果 finally 块正常结束,那么 try 语句正常结束。
    • 如果 finally 块因某个原因而猝然结束,那么 try 语句会因同样的原因而猝然结束。
  • 如果 try 块的执行因为一个值为 v 的 throw 对象而猝然结束:
    • 如果 v 的运行时类型与 try 语句的任何 catch 子句的可捕获异常类是赋值兼容的,那么第一个(最左边)的 catch 子句将被选中执行,值 v 被赋值给这个 catch 子句的参数,然后有以下选择:
      • 如果该 catch 块正常结束,那么 finally 块就会被执行。然后有以下两种情况:
        • 如果该 finally 块正常结束,那么 try 语句正常结束。
        • 如果该 finally 块因某个原因而猝然结束,那么 try 语句会因同样的原因而猝然结束。
      • 如果该 catch 块因为某个原因 R 而猝然结束,那么 finally 块就会被执行。然后有以下两种情况:
        • 如果 finally 块正常结束,那么该 try 语句就会因为 R 而猝然结束。
        • 如果 finally 块因为某个原因 S 而猝然结束,那么该 try 语句就会因为 S 而猝然结束(并且原因 R 会被丢弃)。
    • 如果 v 的运行时类型与 try 语句的任何 catch 子句的可捕获异常类都不是赋值兼容的,那么 finally 块就会被执行。然后有以下选择:
      • 如果 finally 块正常结束,那么 try 语句就会因为一个值为 v 的 throw 对象而猝然结束。
      • 如果 finally 块因为某个原因 S 而猝然结束,那么该 try 语句就会因为 S 而猝然结束(并且值为 V 的 throw 对象会被丢弃和忘记)。
  • 如果 try 的执行因为任何其他原因 R 而猝然结束,那么 finally 块就会被执行:
    • 如果 finally 块正常结束,那么该 try 语句就会因为 R 而猝然结束。
    • 如果 finally 块因为某个原因 S 而猝然结束,那么该 try 语句就会因为 S 而猝然结束(并且原因 R会被丢弃)。


public class TestTryCatchFinally {
    static void blowUp() throws BlewIt {
        throw new NullPointerException();
    }
    public static void main(String[] args) {
        try {
            blowUp();
        } catch (BlewIt b) {
            System.out.println("Caught BlewIt");
        } finally {
            System.out.println("Uncaught Exception");
        }
    }
}

这个程序会产生以下输出:


Uncaught Exception
Exception in thread "main" java.lang.NullPointerException
    at com.l1fe1.exception.TestTryCatchFinally.blowUp(TestTryCatchFinally.java:5)
    at com.l1fe1.exception.TestTryCatchFinally.main(TestTryCatchFinally.java:9)

blowup 方法抛出的 NullPointerException(RuntimeException的一种)没有被 main 中的任何 catch 语句捕获,因为 NullPointerException 对象对于 BlewIt 类型的变量来说,不是赋值兼容的。finally 子句会被执行,之后执行 main 的线程,也就是该测试程序的唯一线程,将会因为未捕获的异常而终止,这通常会导致在控制台打印异常名和简单的回溯追踪的情况。

2.3 try-with-resources

带资源的 try 语句是用变量(被称为资源)来参数化的,这些资源在 try 块执行之前被初始化,并且会在 try 块执行之后,自动地以与初始化相反地顺序被关闭。当资源会被自动化关闭时,catch 子句 和 finally 子句通常就不是必需的了。


TryWithResourcesStatement:
  try ResourceSpecification Block [Catches] [Finally]
ResourceSpecification:
  ( ResourceList [;] )
ResourceList:
  Resource {; Resource}
Resource:
  {VariableModifeier} UnannType VariableDeclaratorId = Expression

ResourceSpecification 用初始化器表达式声明了一个或多个局部变量作为 try 语句中的 Resource 。

对于ResourceSpecification 来说,声明两个具有相同名字的变量会产生编译错误。

如果 final 作为修饰符在每一个在 ResourceSpecification 中声明的变量中出现了多次,那么就是一个编译时错误。

如果没有被显式地声明为 final ,那么在 ResourceSpecification 中声明的资源会被隐式地声明为final。

在 ResourceSpecification 中声明的变量的类型必须是 AutoCloseable 的子类型,否则就会产生编译时错误。

资源是按照从左到右的顺序初始化的。如果某个资源初始化失败了(即,其初始化器表达式抛出了异常),那么所有已经被带资源的 try 语句初始化的资源都将被关闭。如果所有资源都成功初始化了,那么 try 块会正常执行,然后该带资源的 try 语句的所有非空资源都将被关闭。

资源将以与它们被初始化的顺序相反的顺序被关闭。资源只有在其被初始化为非空值时才会被关闭。在关闭资源时抛出的异常不会阻止其他资源的关闭。如果之前在某个初始化器、try 块或资源关闭中抛出过异常,那么这种异常会被压制。

带有声明了多种资源的 ResourceSpecification 子句的带资源 try 语句会被当作多个带资源的 try 语句对待,其中每个都有一个声明了单一资源的 ResourceSpecification 子句。当带有 n (n > 1) 个资源的带资源 try 语句被转译时,其结果是带有 n - 1 个资源的带资源 try 语句。在 n 次这样的转译之后,就会产生 n 个嵌套的 try-catch-finally 语句,至此所有的转译就结束了。

2.3.1 基本的带资源的 try 语句

不带任何 catch 子句或 finally 子句的带资源的 try 语句被称为基本的带资源的 try语句。

基本的带资源的 try 语句:


try ({VariableModifier} R Identifier = Expression ...)
  Block

其含义是由下面转译成的局部变量声明和 try-catch-finally 语句给出的:


{
  final {VariableModifierNoFinal} R Identifier = Expression;
  Throwable #primaryExc = null;
 
  try ResourceSpecification_tail
    Block
  catch (Throwable #t) {
    #primaryExc = #t;
    throw #t;
  } finally {
    if (Identifier != null) {
      if (#primaryExc != null) {
        try {
          Identifier.close();
        } catch (Throwable #suppressedExc) {
          #primaryExc.addSuppressed(#suppressedExc);
        }
      } else {
        Identifier.close();
      }
    }
  }
}

{VariableModifierNoFinal}是作为不带 final 的{VariableModifier}而定义的(如果它存在的话)。

#t、#primaryExc 和 #suppresedExc 是自动生成的标识符,它们有别于在带资源的 try 语句出现之处位于其作用域中的其他任何标识符(无论是自动生成的还是其他)。

如果 ResourceSpecification 声明了一个资源,那么 ResourceSpecification_tail 就是空的(并且该 try-catch-flnally 语句自身并不是一个带资源的 try 语句)。

如果 ResourceSpecification 声明了 n > 1 个资源,那么在 ResourceSpecification_tail 中就以同样的顺序包含了在 ResourceSpecification 中的第2个、第3个、…、第 n 个资源(并且该 try-catch-finally 语句自身也是一个带资源的 try 语句)。

用于基本的带资源 try 语句的可达性和明确赋值规则由上面的转译隐式地进行了说明。

在只管理单一资源的基本的带资源 try 语句中:

  • 如果资源初始化因为一个 V 值的 throw 对象而猝然结束,那么该带资源的 try 语句也会因 V 值的 throw 对象而猝然结束。
  • 如果资源初始化正常结束,并且 try 块因为一个 V 值的 throw 对象而猝然结束,那么:
    • 如果所有成功初始化过的资源(可能是0个)的自动化关闭都正常结束,那么该带资源的 try 语句就会因 V 值的throw对象而猝然结束。
    • 如果所有成功初始化过的资源(可能是0个)的自动化关闭因V1...Vn值的 throw 对象而猝然结束,那么该带资源的 try 语句就会因 V 值的 throw 对象而猝然结束,剩余的V1...Vn值将被添加到被 V 压制的异常列表中。
  • 如果所有资源的初始化都正常结束,并且 try 块因一个 V 值的 throw 对象而猝然结束,那么:
    • 如果所有初始化过的资源的自动关闭正常结束,那么该带资源的 try 语句就会因 V 值的 throw 对象而猝然结束。
    • 如果一个或多个初始化过的资源的自动关闭因V1...Vn值的 throw 对象而猝然结束,那么该带资源的 try 语句就会因 V 值的 throw 对象而猝然结束,剩余的V1...Vn值将被添加到被 V 压制的异常列表中。
  • 如果每个资源的初始化都正常结束,并且 try 块正常结束,那么:
    • 如果某个初始化过的资源的某次自动关闭因一个 V 值的 throw 对象而猝然结束,并且其他所有初始化过的资源的自动关闭都正常结束,那么该带资源的 try 语句就会因 V 值的 throw 对象而猝然结束。
    • 如果某个初始化过的资源的超过一次的自动关闭因V1...Vn值的 throw 对象而猝然结束,那么该带资源的 try 语句就会因V1而猝然结束,剩余的V2...Vn值将被添加到被 V1 压制的异常列表中(其中V1是从最右边的关闭失败的资源中抛出的异常,而 Vn 是从最左边的关闭失败的资源中抛出的异常)。
2.3.2 扩展的带资源的 try 语句

带有至少一个 catch 子句或 finally 子句的带资源的 try 语句被称为扩展的带资源的 try 语句。

扩展的带资源的 try 语句:

try ResourceSpecification
Block
{Catches}
{Finally}

其含义是由下面转译成的嵌套在try-catch、try-finally 或 try-catch-flnally 语句中的基本的带资源的 try 语句给出的:

try {
    try ResourceSpecification
    Block
}
{Catches}
{Finally}

这种转译的效果就像是将 ResourceSpecification 放置到 try 语句的“内部” 一样。这使得扩展的带资源的 try 语句的 catch 子句可以捕获异常,因为任何资源都会自动地初始化和关闭。

更进一步,所有资源在 finally 块被执行的时刻都已经被关闭(或尝试被关闭),这与 finally 关键词的意图也保持了一致。

3. 面试题

catch 中发生了未知异常,finally 还会执行么?

会的,catch 无论是否发生异常,finally 总会执行,并且 catch 中的异常是在 finally 执行完成之后,才会抛出的。

不过 catch 会吃掉 try 中抛出的异常,为了避免这种情况,在一些可以预见 catch 中会发生异常的地方,先把 try 抛出的异常打印出来,这样从日志中就可以看到完整的异常了。

参考资料

上一篇:一文看懂 K8s 日志系统设计和实践


下一篇:6 个 K8s 日志系统建设中的典型问题,你遇到过几个?