别再被忽悠!finally代码真的一定执行?

5 阅读17分钟

别再被忽悠!finally代码真的一定执行?

开篇引入

在 Java 编程的世界里,异常处理是保障程序稳定性和健壮性的关键环节。而finally块,作为异常处理机制的重要组成部分,常常被我们寄予 “无论如何都会执行” 的期望。在日常开发中,我们频繁地使用finally块来关闭文件流、数据库连接等资源,就像这样:


FileInputStream fis = null;
try {
    fis = new FileInputStream("test.txt");
    // 执行文件读取操作
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (fis != null) {
        try {
            fis.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这段代码中,finally块确保了文件输入流fis无论在读取过程中是否发生异常,都能被正确关闭,避免了资源泄漏的风险。然而,finally中代码真的一定会被执行吗?这个看似简单的问题,背后却隐藏着许多容易被忽视的细节和特殊情况 。今天,就让我们一起深入探究finally块的执行奥秘,揭开它神秘的面纱。

常规认知与直觉

在大多数 Java 开发者的认知里,finally块就像是一个忠诚的 “守护者”,无论try块中是一帆风顺还是遭遇异常的狂风暴雨,它里面的代码都会坚定不移地执行。这种认知并非毫无根据,在日常开发的绝大多数场景中,finally块确实表现得十分可靠。

先来看一个简单的无异常情况的示例:


public class FinallyNormalExample {
    public static void main(String[] args) {
        try {
            System.out.println("try块中的代码开始执行");
            int result = 2 + 3;
            System.out.println("计算结果:" + result);
        } finally {
            System.out.println("finally块中的代码一定会执行");
        }
    }
}

运行上述代码,输出结果如下:


try块中的代码开始执行
计算结果:5
finally块中的代码一定会执行

在这个例子中,try块顺利执行完毕,没有任何异常抛出,随后finally块中的代码紧接着被执行 ,一切都如我们所预期的那样。

再看一个有异常发生的情况:


public class FinallyExceptionExample {
    public static void main(String[] args) {
        try {
            System.out.println("try块中的代码开始执行");
            int result = 10 / 0; // 故意制造一个除零异常
            System.out.println("计算结果:" + result);
        } catch (ArithmeticException e) {
            System.out.println("捕获到异常:" + e.getMessage());
        } finally {
            System.out.println("finally块中的代码一定会执行");
        }
    }
}

运行结果为:


try块中的代码开始执行
捕获到异常:/ by zero
finally块中的代码一定会执行
Exception in thread "main" java.lang.ArithmeticException: / by zero
	at FinallyExceptionExample.main(FinallyExceptionExample.java:6)

try块中抛出ArithmeticException异常时,catch块捕获并处理了该异常,然后finally块中的代码依然被执行。这进一步加深了我们对 “finally中代码一定会执行” 这一观点的信任,让我们在编写代码时,放心地将资源释放等重要操作放在finally块中。 但事实真的如此绝对吗?接下来,让我们一起深入探索那些可能打破常规认知的特殊情况。

打破认知:不执行的场景

(一)System.exit () 的影响

System.exit() 是一个用于终止 Java 虚拟机(JVM)的方法。一旦在trycatch块中调用了System.exit() ,JVM 会立即停止运行,跳过所有未执行的代码,包括finally块中的代码。这是因为System.exit() 会触发 JVM 的关闭序列,直接终止当前进程,使得后续的字节码指令不再被调度执行 。看下面这个示例代码:


public class SystemExitFinallyExample {
    public static void main(String[] args) {
        try {
            System.out.println("try块中的代码开始执行");
            System.exit(0); // 调用System.exit()终止JVM
            System.out.println("这行代码不会被执行");
        } catch (Exception e) {
            System.out.println("捕获到异常:" + e.getMessage());
        } finally {
            System.out.println("finally块中的代码不会执行");
        }
    }
}

运行上述代码,输出结果只有:


try块中的代码开始执行

可以看到,System.exit(0) 被调用后,JVM 立即终止,finally块中的代码没有得到执行的机会。这种情况在一些需要强制程序退出的场景中,比如命令行工具中处理特定的退出指令时,如果不小心在try块中调用了System.exit() ,就可能导致资源无法正确释放,从而引发潜在的问题。 所以,除非你明确知道程序需要立即终止,并且已经处理好了所有的资源清理工作,否则应尽量避免在try块中调用System.exit()

(二)JVM 致命错误

当 JVM 在执行trycatch块的过程中遭遇致命错误(Fatal Error),如OutOfMemoryError(内存溢出错误)、StackOverflowError(栈溢出错误)等,并且这些错误未被捕获时,finally块也不会被执行。这是因为致命错误会导致 JVM 崩溃,程序的执行流程被直接中断,无法继续执行finally块中的代码 。以OutOfMemoryError为例,看下面这段代码:


public class OutOfMemoryFinallyExample {
    public static void main(String[] args) {
        try {
            // 不断创建对象,耗尽内存,引发OutOfMemoryError
            List<byte[]> list = new ArrayList<>();
            while (true) {
                list.add(new byte[1024 * 1024]);
            }
        } catch (Exception e) {
            System.out.println("捕获到异常:" + e.getMessage());
        } finally {
            System.out.println("finally块中的代码不会执行");
        }
    }
}

在运行上述代码时,随着不断创建大数组,JVM 的堆内存会逐渐耗尽,最终抛出OutOfMemoryError 。此时,JVM 会直接崩溃,finally块中的代码无法执行 。这种情况虽然不常见,但一旦发生,往往会导致程序异常终止,资源无法正常释放,甚至可能影响系统的稳定性。因此,在编写代码时,应尽量避免可能导致致命错误的操作,如合理控制内存使用,避免无限递归等。

(三)线程强制中断

虽然Thread.stop() 方法已经被废弃,但在一些遗留代码中可能仍然存在误用的情况。当一个线程在执行try-finally代码块时,如果被Thread.stop() 强制中断,finally块中的代码可能不会被执行。这是因为Thread.stop() 会立即终止线程的执行,不会等待finally块中的清理逻辑完成 。来看一个示例:


public class ThreadStopFinallyExample {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            try {
                System.out.println("线程开始执行try块");
                while (true) {
                    // 模拟线程执行任务
                }
            } finally {
                System.out.println("finally块中的代码可能不会执行");
            }
        });
        thread.start();
        try {
            Thread.sleep(1000); // 主线程休眠1秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread.stop(); // 强制终止线程,该方法已废弃,仅用于演示
    }
}

在这个例子中,thread.stop() 被调用后,线程会立即终止,finally块中的代码大概率不会被执行。在现代 Java 编程中,应该使用协作式中断(Cooperative Interruption)的方式来停止线程,即通过调用interrupt() 方法设置线程的中断标志,然后在线程内部通过检查中断标志来决定是否终止线程执行,这样可以确保finally块中的清理逻辑有机会执行。

(四)操作系统强制终止

在操作系统层面,如果使用kill -9 命令(在 Linux 系统中)或通过任务管理器强制结束 Java 进程,Java 程序将无法正常执行finally块中的代码。这是因为kill -9 会发送一个强制终止信号(SIGKILL)给进程,操作系统会立即终止该进程,Java 程序没有机会执行任何清理操作 。例如,当你在 Linux 终端中运行一个 Java 程序,然后使用kill -9 <进程ID> 命令终止该进程时,程序中的finally块不会被执行。这种情况通常发生在系统出现严重故障或需要紧急终止进程时,但它会导致程序中的资源无法正常释放,可能会对系统造成一些不良影响。所以,在生产环境中,应尽量避免使用kill -9 命令来终止 Java 进程,而是通过正常的程序逻辑或使用kill -15 (SIGTERM 信号,允许程序进行优雅关闭)来终止进程。

finally 中 return 的特殊情况

(一)覆盖返回值现象

finally块中存在return语句时,会无条件地覆盖trycatch中的return值,这一特性往往会打破我们对正常返回逻辑的预期 。看下面这个示例代码:


public class FinallyReturnOverrideExample {
    public static int test() {
        try {
            System.out.println("try块中的代码执行");
            return 1;
        } catch (Exception e) {
            System.out.println("捕获到异常:" + e.getMessage());
            return 2;
        } finally {
            System.out.println("finally块中的代码执行");
            return 3;
        }
    }

    public static void main(String[] args) {
        int result = test();
        System.out.println("最终返回值:" + result);
    }
}

运行上述代码,输出结果为:


try块中的代码执行
finally块中的代码执行
最终返回值:3

在这个例子中,try块中的return 1原本应该是方法的返回值,但由于finally块中存在return 3 ,最终方法返回的是finally块中的返回值。这是因为在执行trycatch中的return语句时,JVM 会先暂存返回值,但并不会立即返回 。接着,它会去执行finally块中的代码。如果finally块中也有return语句,那么 JVM 会忽略之前暂存的返回值,直接返回finally块中的返回值 。这种覆盖返回值的现象在实际开发中容易引发错误,尤其是当我们没有充分意识到finallyreturn的影响时。比如在一个复杂的业务方法中,原本在try块中计算好了正确的返回值,却因为finally中意外添加的return语句,导致返回了错误的结果,而且这种错误很难被察觉,给程序调试带来极大的困难。

(二)隐式覆盖风险

即使finally块中没有显式的return语句,但如果存在一些具有副作用的操作,比如修改对象的字段、静态变量等,一旦在后续的维护中不小心在finally块中添加了return语句,就可能会改变原有的逻辑,引发难以追踪的bug 。看下面这个示例:


class Data {
    public int value = 10;
}

public class FinallyImplicitOverrideExample {
    public static Data test() {
        Data data = new Data();
        try {
            System.out.println("try块中的代码执行");
            data.value = 20;
            return data;
        } catch (Exception e) {
            System.out.println("捕获到异常:" + e.getMessage());
            return data;
        } finally {
            System.out.println("finally块中的代码执行");
            data.value = 30; // 这里修改了对象的字段,存在隐式风险
        }
    }

    public static void main(String[] args) {
        Data result = test();
        System.out.println("最终返回对象的值:" + result.value);
    }
}

在当前代码中,finally块只是修改了Data对象的value字段,方法最终返回的是try块中返回的data对象 ,输出结果为:


try块中的代码执行
finally块中的代码执行
最终返回对象的值:30

假设在后续的维护中,有人在finally块中添加了return data; ,代码变为:


class Data {
    public int value = 10;
}

public class FinallyImplicitOverrideExample {
    public static Data test() {
        Data data = new Data();
        try {
            System.out.println("try块中的代码执行");
            data.value = 20;
            return data;
        } catch (Exception e) {
            System.out.println("捕获到异常:" + e.getMessage());
            return data;
        } finally {
            System.out.println("finally块中的代码执行");
            data.value = 30;
            return data; // 新增的return语句,改变了原逻辑
        }
    }

    public static void main(String[] args) {
        Data result = test();
        System.out.println("最终返回对象的值:" + result.value);
    }
}

此时,方法的返回逻辑被改变,虽然代码表面上看起来只是在finally块中添加了一行代码,但实际上却可能影响到整个业务逻辑 。这种隐式覆盖风险在团队开发中尤为常见,不同的开发者对代码的理解和修改可能会导致这种潜在的bug 。因此,在编写finally块时,除了要避免显式的return语句外,对于可能会影响返回值的副作用操作也要格外小心,尽量保持finally块的纯粹性,只进行资源清理等必要操作。

finally 执行机制揭秘

要深入理解finally块的执行原理,我们需要从 JVM 字节码层面一探究竟 。Java 编译器在编译包含finally块的代码时,会施展一个巧妙的 “魔法”:将finally块中的代码复制多份,然后小心翼翼地插入到各个可能的控制流出口之后 。

try块正常执行结束时,编译器会在其末尾插入一份finally块的代码。假设我们有如下简单代码:


public class FinallyNormalEndExample {
    public static void main(String[] args) {
        try {
            System.out.println("try块正常执行");
        } finally {
            System.out.println("finally块执行");
        }
    }
}

反编译后的字节码指令(简化示意)如下:


public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3                  // String try块正常执行
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      11: ldc           #5                  // String finally块执行
      13: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      16: return

可以看到,在try块正常执行结束(执行完打印 “try 块正常执行” 的指令后),紧接着就插入了执行finally块打印 “finally 块执行” 的指令 。

try块中抛出异常,被catch块捕获时,编译器会在每个catch块的末尾也插入一份finally块的代码。例如:


public class FinallyCatchExample {
    public static void main(String[] args) {
        try {
            int result = 10 / 0;
        } catch (ArithmeticException e) {
            System.out.println("捕获到异常:" + e.getMessage());
        } finally {
            System.out.println("finally块执行");
        }
    }
}

反编译后的字节码(简化)如下:


public static void main(java.lang.String[]);
    Code:
       0: bipush        10
       2: iconst_0
       3: idiv
       4: istore_1
       5: goto          18
       8: astore_1
       9: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      12: new           #6                  // class java/lang/StringBuilder
      15: dup
      16: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
      19: ldc           #8                  // String 捕获到异常:
      21: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      24: aload_1
      25: invokevirtual #10                 // Method java/lang/ArithmeticException.getMessage:()Ljava/lang/String;
      28: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      31: invokevirtual #11                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      34: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      37: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      40: ldc           #12                 // String finally块执行
      42: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      45: return
      46: astore_2
      47: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      50: ldc           #12                 // String finally块执行
      52: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      55: aload_2
      56: athrow

在这个字节码中,当try块抛出ArithmeticException异常,被catch块捕获并执行完打印异常信息的操作后(指令 34),就插入了执行finally块打印 “finally 块执行” 的指令(指令 37 - 42) 。

trycatch块中存在return语句时,编译器会在return表达式求值后、真正返回前插入finally块的代码 。比如:


public class FinallyReturnExample {
    public static int test() {
        try {
            return 1;
        } finally {
            System.out.println("finally块执行");
        }
    }
}

反编译字节码(简化):


public static int test();
    Code:
       0: iconst_1
       1: istore_0
       2: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       5: ldc           #3                  // String finally块执行
       7: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      10: iload_0
      11: ireturn

这里,先将返回值 1 存储到局部变量(指令 0 - 1),然后执行finally块中的打印操作(指令 2 - 7),最后再从局部变量中取出返回值并返回(指令 10 - 11) 。

正是通过这种在字节码层面的精心 “布局”,编译器确保了在大多数正常情况下,无论try块的执行路径如何,finally块中的代码都有机会被执行 。这也就解释了为什么在一般情况下,即使trycatch块中存在return语句,也无法阻止finally块的执行,因为finally块的代码已经被提前 “安插” 在了返回路径上 。 但这种机制也并非无懈可击,当遇到如System.exit()、JVM 致命错误等特殊情况时,这些特殊操作会直接打破正常的控制流和字节码执行顺序,导致finally块中的代码被跳过,这也让我们更加深刻地认识到finally块执行的条件性和复杂性 。

实践建议与最佳实践

通过前面的探讨,我们已经清楚地认识到finally块的复杂性和潜在风险。在实际编程中,为了确保程序的健壮性和可维护性,我们需要遵循一些最佳实践 。

(一)避免在 finally 中 return 或 throw

如前所述,finally块中的returnthrow语句会对程序的正常流程和异常处理产生意想不到的影响 。因此,应严格避免在finally块中使用return语句返回值或使用throw语句抛出异常。如果需要在finally块中进行一些操作后返回特定的值,可通过在trycatch块中设置一个局部变量,在finally块中对其进行修改,最后在方法末尾统一返回该变量 。例如:


public class FinallyBestPracticeExample {
    public static int test() {
        int result = -1;
        try {
            System.out.println("try块中的代码执行");
            result = 1;
            return result;
        } catch (Exception e) {
            System.out.println("捕获到异常:" + e.getMessage());
            result = 2;
            return result;
        } finally {
            System.out.println("finally块中的代码执行");
            result = 3; // 这里可以修改result的值,但不使用return
        }
        // return result; // 统一在方法末尾返回,确保逻辑清晰
    }

    public static void main(String[] args) {
        int result = test();
        System.out.println("最终返回值:" + result);
    }
}

这样,既保证了finally块中的操作能够执行,又避免了finallyreturn带来的覆盖风险,使代码的返回逻辑更加清晰可控 。

(二)使用 try - with - resources 语句

Java 7 引入的try - with - resources语句为我们提供了一种更加优雅和安全的资源管理方式 。对于实现了AutoCloseable接口的资源,如文件流、数据库连接、网络套接字等,使用try - with - resources语句可以自动关闭资源,无需显式地在finally块中调用close()方法,从而大大减少了因忘记关闭资源而导致的资源泄漏问题 。

来看一个使用try - with - resources读取文件的示例:


import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class TryWithResourcesExample {
    public static void main(String[] args) {
        try (BufferedReader br = new BufferedReader(new FileReader("test.txt"))) {
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,BufferedReader实现了AutoCloseable接口,在try块执行完毕后,无论是正常结束还是抛出异常,BufferedReader都会自动关闭,无需手动编写finally块来处理关闭操作 。

try - with - resources语句还支持同时管理多个资源,只需在括号中用分号分隔声明多个资源即可 。例如:


import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class MultipleResourcesTryWithResourcesExample {
    public static void main(String[] args) {
        try (FileInputStream fis = new FileInputStream("input.txt");
             FileOutputStream fos = new FileOutputStream("output.txt")) {
            int data;
            while ((data = fis.read()) != -1) {
                fos.write(data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这个示例中,FileInputStreamFileOutputStream都会在try块结束时自动关闭,并且它们的关闭顺序与声明顺序相反,这符合资源依赖关系的清理逻辑 。

此外,try - with - resources语句在处理异常时也更加智能 。如果在try块中抛出异常,同时在关闭资源时也抛出异常,那么try块中的异常会被作为主异常抛出,而关闭资源时产生的异常会被 “压制”(suppressed),可以通过Throwable.getSuppressed()方法获取这些被压制的异常,避免了关键异常被资源关闭异常覆盖的问题 。 总之,try - with - resources语句以其简洁性、安全性和智能的异常处理机制,成为了 Java 开发中资源管理的首选方式,在实际编程中应优先使用它来管理实现了AutoCloseable接口的资源 。

总结回顾

在 Java 编程中,finally块作为异常处理机制的重要部分,虽然在大多数情况下会被执行,为我们提供了可靠的资源清理和收尾操作保障,但并非绝对。通过本文的深入探讨,我们了解到在遇到System.exit()调用、JVM 致命错误、线程被强制中断以及操作系统强制终止进程等特殊场景时,finally块中的代码可能无法执行,这提醒我们在编写代码时要充分考虑这些极端情况,做好资源管理和异常处理的应急预案 。

同时,finally块中return语句的存在会无条件覆盖trycatch中的return值,带来返回值逻辑混乱和异常被吞的风险,甚至一些看似无害的副作用操作也可能在后续维护中因return语句的添加而引发难以察觉的bug 。我们必须谨慎对待finally块中的代码编写,遵循最佳实践,避免在finally中使用returnthrow语句,优先使用try - with - resources语句来管理资源,确保程序的健壮性和可维护性 。

希望通过本文,大家能对finally块有更全面、深入的理解,在今后的 Java 开发中,合理运用finally块,编写出更加稳定、可靠的代码。如果在实际开发中遇到与finally块相关的有趣案例或问题,欢迎在评论区留言分享,让我们一起共同学习,共同进步 。