09 | 异常恢复,付出的代价能不能少一点?

你好,我是范学雷。今天,我们接着讨论Java的错误处理。这一讲,是上一次我们讨论的关于错误处理问题的继续和升级。

就像我们上一次讨论到的,Java的异常处理是一个对代码性能有着重要影响的因素。所以说,Java错误处理的缺陷和滥用也成为了一个热度始终不减的老话题。但是,Java的异常处理,有着天生的优势,特别是它在错误排查方面的作用,我们很难找到合适的替代方案。

那有没有可能改进Java的异常处理,保持它在错误排查方面的优势的同时,提高它的性能呢?这是一个又让马儿跑,又让马儿不吃草的问题。不过,这并不妨碍我们顺着这个思路,找一找其中的可能性。

我们还是先从阅读案例开始,来试着找一找其中的蛛丝马迹吧。

阅读案例

要尝试解决一个问题,我们首先要做的,就是把问题梳理清楚,定义好。我们先来看看Java异常处理的三个典型使用场景。

下面的这段代码里,有三个不同的异常使用方法。在分别解析的过程中,你可能会遇到几个疑问,不过别急,带着这几个问题,我们最后来一一解读。

package co.ivi.jus.stack.former;

import java.security.NoSuchAlgorithmException;

public class UseCase {
    public static void main(String[] args) {
        String[] algorithms = {"SHA-128", "SHA-192"};
        
        String availableAlgorithm = null;
        for (String algorithm : algorithms) {
            Digest md;
            try {
                md = Digest.of(algorithm);
            } catch (NoSuchAlgorithmException ex) {
                // ignore, continue to use the next algorithm.
                continue;
            }
            
            try {
                md.digest("Hello, world!".getBytes());
            } catch (Exception ex) {
                System.getLogger("co.ivi.jus.stack.former")
                        .log(System.Logger.Level.WARNING,
                             algorithm + " does not work",
                             ex);
                continue;
            }
            
            availableAlgorithm = algorithm;
        }
        
        if (availableAlgorithm != null) {
            System.out.println(availableAlgorithm + " is available");
        } else {
            throw new RuntimeException("No available hash algorithm");
        }
    }
}

可恢复异常

第一种就是可恢复的异常处理。

这是什么意思呢?对于代码里的异常NoSuchAlgorithmException来说,这段代码尝试捕获、识别这个异常,然后再从异常里恢复过来,继续执行代码。我们把这种可以从异常里恢复过来,继续执行的异常处理叫做可恢复的异常处理,简称为可恢复异常。

为了深入理解可恢复异常,我们需要仔细地看看NoSuchAlgorithmException这个异常的处理过程。这个处理的过程,其实就只有一行有效的代码,也就是catch语句。

} catch (NoSuchAlgorithmException nsae) {
    // ignore, continue to use the next algorithm.
}

只要catch语句能够捕获、识别到这个异常,这个异常的生命周期就结束了。catch只需要知道异常的名字,而不需要知道异常的调用堆栈。不使用异常的调用堆栈,也就意味着这样的异常处理,极大地消弱了Java异常在错误排查方面的作用。

既然可恢复异常不使用异常的调用堆栈,是不是可恢复异常就不需要生成调用堆栈了呢?这是我们提出的第一个问题。

从Java异常的性能基准测试结果看,我们知道,生成异常的调用堆栈是异常处理影响性能的最主要因素。如果不需要生成调用堆栈,那么Java异常的处理性能就会有成百上千倍的提升。所以,如果我们找到了第一个问题的答案,我们就解决了可恢复异常的性能瓶颈。

不可恢复异常

好了,我们再回头看看第二个使用场景。对于代码里的异常RuntimeException来说,上面的代码并没有尝试捕获、识别它。这个异常直接导致了程序的退出,并且把异常的信息和调用堆栈打印了出来。

Exception in thread "main" java.lang.RuntimeException: No available hash algorithm
	at co.ivi.jus.stack.former.UseCase.main(UseCase.java:27)

这样的异常处理方式导致了程序的中断,程序不能从异常抛出的地方恢复过来。我们把这种方式,叫做不可恢复的异常处理,简称为不可恢复异常。

调用堆栈对于不可恢复异常来说至关重要,因为我们可以从异常调用堆栈的打印信息里,快速定位到出问题的代码。毫无疑问,这加快了问题排查,降低了运维的成本。

由于不可恢复异常中断了程序的运行,所以它的性能开销是一次性的。因此,不可恢复异常对于性能的影响,其实我们不用太在意。

使用了异常信息和调用堆栈,又不用担心性能的影响,不可恢复异常似乎很理想。可是,在多大的程度上,我们可以允许程序由于异常中断而退出呢?这是一个很难回答的问题。

试想一下,如果是作为服务器的程序,我们会希望它能一直运行,遇到异常能够恢复过来。所以一般情况下,服务器的场景下,不会使用不可恢复异常。

现在的客户端程序呢?比如手机里的app,如果遇到异常就崩溃,我们就不会有耐心继续使用了。似乎,客户端的程序,也没有多少不可恢复异常的使用场景。

也许,不可恢复异常的使用场景,仅仅存在于我们的演示程序里。高质量的产品里,似乎很难允许不可恢复异常的存在。

既然我们无法忍受程序的崩溃,那么不可恢复异常还有存在的必要吗?这是我们提出的第二个问题。

记录的调试信息

最后,我们再来看看第三个使用场景。对于代码里的异常Exception来说,这段代码尝试捕获、识别这个异常,然后从异常里恢复过来继续执行代码。它是一个可恢复的异常。和第一个场景不同的是,这段代码还在日志里记录了下了这个异常;一般来说,这个异常的调试信息,也就是异常信息和调用堆栈,也会被详细地记载在日志里。

其实,这也是可恢复异常的一个典型的使用场景;程序可以恢复,但是异常信息可以记录待查。

我们再来仔细看看异常信息是怎么记录在案的。为了方便我们观察,我把日志记录的这几行代码单独摘抄了出来。

System.getLogger("co.ivi.jus.stack.former")
        .log(System.Logger.Level.WARNING,
             algorithm + " does not work",
             ex);

我们可以看到,日志记录下来了如下的关键信息:

  1. 在异常捕获的场景下,这个异常的记录方式,包括是否记录(“co.ivi.jus.stack.former”);
  2. 在异常捕获的场景下,这个异常的记录地点(System.getLogger());
  3. 在异常捕获的场景下,这个异常的严重程度(Logger.Level);
  4. 在异常捕获的场景下,这个异常表示的影响(“[algorithm] does not work”);
  5. 异常生成的时候携带的信息,包括异常信息和调用堆栈(ex)。

其中,前四项信息,是在方法调用的代码里生成的;第五项,是在方法实现的代码里生成的。也就是说,记录在案的调试信息,既包括调用代码的信息,也包括实现代码的信息。

如果放弃了Java的异常处理机制,我们还能够获得足够的调试信息吗?换种说法,我们有没有快速定位问题的替代方案?这是我们提出的第三个问题。

改进的共用错误码

刚才,我们通过Java异常处理的三个典型场景,提出了三个棘手的问题:

  • 既然可恢复异常不使用异常的调用堆栈,是不是可恢复异常就不需要生成调用堆栈了?
  • 既然我们无法忍受程序的崩溃,那么不可恢复异常还有存在的必要吗?
  • 我们有没有快速定位问题的替代方案?

带着这三个问题,我们再来看看能不能改进一下我们上一讲里讨论的共用错误码的方案。

共用错误码本身,并没有携带调试信息。为了能够快速定位出问题,我们需要为共用错误码的方案补上调试信息。

下面的两段代码,就是我们要在补充调试信息方面做的尝试。第一段代码,是我们在方法实现的代码里的尝试。在这段代码里,我们使用异常的形式补充了调试信息,包括问题描述和调用堆栈。

public static Returned<Digest> of(String algorithm) {
    return switch (algorithm) {
        case "SHA-256" -> new Returned.ReturnValue(new SHA256());
        case "SHA-512" -> new Returned.ReturnValue(new SHA512());
        case null -> {
            System.getLogger("co.ivi.jus.stack.union")
                    .log(System.Logger.Level.WARNING,
                        "No algorithm is specified",
                        new Throwable("the calling stack"));
            yield new Returned.ErrorCode(-1);
        }
        default -> {
            System.getLogger("co.ivi.jus.stack.union")
                    .log(System.Logger.Level.INFO,
                    "Unknown algorithm is specified " + algorithm,
                            new Throwable("the calling stack"));
            yield new Returned.ErrorCode(-1);
        }
    };
}

第二段代码,是我们在方法调用的代码里的尝试。在这段代码里,我们补充了调用场景的信息。

Returned<Digest> rt = Digest.of("SHA-128");
switch (rt) {
    case Returned.ReturnValue rv -> {
            Digest d = (Digest) rv.returnValue();
            d.digest("Hello, world!".getBytes());
        }
    case Returned.ErrorCode ec ->
        System.getLogger("co.ivi.jus.stack.union")
                .log(System.Logger.Level.INFO,
                        "Failed to get instance of SHA-128");
}

经过这样的调整,类似于使用异常处理的、快速定位出问题的调试信息就又回来了。

Nov 05, 2021 10:08:23 PM co.ivi.jus.stack.union.Digest of
INFO: Unknown algorithm is specified SHA-128
java.lang.Throwable: the calling stack
	at co.ivi.jus.stack.union.Digest.of(Digest.java:37)
	at co.ivi.jus.stack.union.UseCase.main(UseCase.java:10)

Nov 05, 2021 10:08:23 PM co.ivi.jus.stack.union.UseCase main
INFO: Failed to get instance of SHA-128

你一定会有这样的问题。调试信息又回来了,难道不是以性能损失为代价的吗?

是的,使用调试信息带来的性能损失,并不比使用异常性能的损失小多少。不过好在,日志记录既可以开启,又可以关闭。如果我们关闭了日志,就不用再生成调试信息了,当然它的性能影响也就消失了。当需要我们定位问题的时候,再启动日志。这时候,我们就能够把性能的影响控制到一个极小的范围内了。

那么,使用错误码的错误处理方案,是怎么处理我们在阅读案例提到的问题的呢?

其实,每一个问题的处理,都很清晰。我把问题和答案都列在了下面的表格里,你可以看一看。

图片

当然,日志并不是唯一可以记录调试信息的方式。比如说,我们还可以使用更便捷的JFR(Java Flight Recorder)特性。

其实,错误码的调试信息使用方式,更符合调试的目的:只有需要调试的时候,才会生成调试信息。那么,如果继续沿用Java的异常处理机制,调试信息能不能按需开启、关闭呢?这是我们今天的第四个问题,也是提给Java语言设计师的问题。

有了今天这四个问题做铺垫,如果有一天, Java语言的异常能够支持可以开合的异常处理机制了,想必到时候你就不会感到惊讶了。

总结

好,到这里,我来做个小结。刚才,我们了解和讨论了Java异常处理的两个概念:可恢复异常和不可恢复异常。我还给出了在使用错误码的场景下,快速定位问题的替代方案。

这一讲我们并没有讨论新特性,而是我们重点讨论了现在Java异常处理机制的几个热门话题。这节课的重点,是要开拓我们的思维。了解这些热门的话题,不仅可以增加你的谈资,还可以切实地提高你的代码性能和可维护性。

另外,我还拎出了几个今天讨论过的技术要点,这些都可能在你的面试中出现哦。通过这一次学习,你应该能够:

  • 了解可恢复异常和不可恢复异常这两个概念,以及它们的使用场景;
    • 面试问题:你的代码是怎么处理Java异常的?
  • 了解怎么在使用错误码的方案里,添加快速定位出问题的调试信息;
    • 面试问题:你的代码,是怎么定位可能存在的问题的?

对Java错误处理机制的改进,这会是一个持续热门的话题。而能够了解替代方案,并且使用替代方案的软件工程师,现在还不多。如果你能够展示错误处理的替代方案,而且还不牺牲异常处理的优势,这是一个能够在面试里获得主动权,控制话语权的必杀技。

思考题

怎么通过改进Java的异常处理,来获取性能的提升,我们已经花了两讲的时间了。我们提出的这些改进方案,其实依然有很大的提升空间。比如说吧,我们使用了整数表示错误码,这里其实就存在很多问题。

因为有时候,我们可能需要区别不同的错误,这样我们就不能总是使用一个错误码(-1)。如果存在多个错误码,我们怎么知道方法实现的代码返回的错误码是什么呢?编译器能不能帮助我们检查错误码的使用是不是匹配? 比如说错误码的检查有没有遗漏,有没有多余?如果返回的错误码从两个增加到三个,使用该方法的代码能不能自动地检测到?

解决好这些问题,能够大幅度提高代码的可维护性和健壮性。该怎么解决掉这些问题呢?这是我们今天的思考题。

为了方便你阅读,我把需要两个错误码的案例代码放在了下面。一段代码是方法实现的代码,一段代码是方法使用的代码。你可以在这两段代码的基础上改动,看看最后你是怎么处理多个错误码的。

这一段是方法实现的代码。

public static Returned<Digest> of(String algorithm) {
    return switch (algorithm) {
        case "SHA-256" -> new Returned.ReturnValue(new SHA256());
        case "SHA-512" -> new Returned.ReturnValue(new SHA512());
        case null -> {
            System.getLogger("co.ivi.jus.stack.union")
                    .log(System.Logger.Level.WARNING,
                        "No algorithm is specified",
                        new Throwable("the calling stack"));
            yield new Returned.ErrorCode(-1);
        }
        default -> {
            System.getLogger("co.ivi.jus.stack.union")
                    .log(System.Logger.Level.INFO,
                    "Unknown algorithm is specified " + algorithm,
                            new Throwable("the calling stack"));
            yield new Returned.ErrorCode(-2);
        }
    };
}

这一段是方法使用的代码。

Returned<Digest> rt = Digest.of("SHA-128");
switch (rt) {
    case Returned.ReturnValue rv -> {
            Digest d = (Digest) rv.returnValue();
            d.digest("Hello, world!".getBytes());
        }
    case Returned.ErrorCode ec -> {
        if (ec.errorCode() == -1) {
            System.getLogger("co.ivi.jus.stack.union")
                    .log(System.Logger.Level.INFO,
                            "Unlikedly to happen");
        } else {
            System.getLogger("co.ivi.jus.stack.union")
                    .log(System.Logger.Level.INFO,
                            "SHA-218 is not supported");
        }
    }
}

欢迎你在留言区留言、讨论,分享你的阅读体验以及验证的代码和结果。我们下节课再见!

注:本文使用的完整的代码可以从GitHub下载,你可以通过修改GitHubreview template代码,完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见,请提交一个 GitHub的拉取请求(Pull Request),并把拉取请求的地址贴到留言里。这一小节的拉取请求代码,请在异常恢复专用的代码评审目录下,建一个以你的名字命名的子目录,代码放到你专有的子目录里。比如,我的代码,就放在stack/review/xuelei的目录下面。

精选留言

  • Jxin

    2021-12-03 19:00:28

    调试信息能不能按需开启、关闭呢?

    可以,成本比较大。举个例子。

    1.实现生产环境一键镜像,难点是要包含生产所有数据(知识级与操作级都要)
    2.基于OpenTelemetry把日志打印齐全
    3.基于观测数据做链路可视化界面
    4.链路可视化界面支持请求向不同环境重放(入参已有,不过是调指定环境的接口)

    如此一来测试环境就拥有和生产环境一模一样的,你不仅可以在测试环境打印debug日志,还可以直接debug。

    作者回复

    是一个好思路,学习了。

    2021-12-04 00:40:27

  • aoe

    2021-12-03 13:52:44

    这么巧!昨天刚看到 Go 语言处理异常的四种方式,有一种可以参考《Tony Bai · Go 语言第一课》| 22|函数:怎么结合多返回值进行错误处理?原文链接:https://time.geekbang.org/column/article/461821

    策略四:错误行为特征检视策略
    在 Go 标准库中,我们发现了这样一种错误处理方式:将某个包中的错误类型归类,统一提取出一些公共的错误行为特征,并将这些错误行为特征放入一个公开的接口类型中。这种方式也被叫做错误行为特征检视策略。

    以标准库中的net包为例,它将包内的所有错误类型的公共行为特征抽象并放入net.Error这个接口中,如下面代码:

    ```go

    // $GOROOT/src/net/net.go
    type Error interface {
    error
    Timeout() bool // 断网、断电、网络拥堵、丢包等都可以造成超时
    Temporary() bool
    }
    ```

    计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。策略四将稀奇古怪的错误信息进行了抽象分类,将无限种可能转换成了有限种可能(将不可控转换成可控),遵循了开闭原则。

    再回顾本专栏《深入剖析 Java 新特性》04 | 封闭类:怎么刹住失控的扩展性?中提到的怎么判断一个图形是正方形的例子可以帮助理解:特殊的长方形、菱形、梯形、多边形等都是正方形
    作者回复

    哈哈,编程语言本质上都是处理类似的问题,只是设计者的偏好可能不同。

    2021-12-03 16:37:42

  • 鹤涵

    2022-11-27 09:51:12

    如何平衡手动打印异常logger的开发成本和异常性能开销呢?
    作者回复

    首先把异常用对,然后看系统需要。 很难有通用的法则。

    2022-11-29 03:20:21

  • ifelse

    2022-10-10 17:15:19

    学习打卡
  • james

    2022-10-09 15:03:38

    老师,方法实现和方法使用为啥输出两遍日志呢,方法实现里面输出一次不就行了
    作者回复

    一般情况下一次就行。不过有时候,方法的使用需要更好地控制日志信息,也可能会多次输出。

    2022-10-11 02:25:35

  • fatme

    2021-12-12 12:59:03

    老师,对于使用异常的情况。造成其性能下降的原因,是否应该包括两部分活动:一是生成调用栈的调试信息;另一个是把异常沿调用栈向上抛出,直到被捕捉或终止程序?如果是的话,其中哪一个部分造成的性能损耗更厉害呢?
    作者回复

    主要是生成堆栈信息的开销,向上抛的开销要小很多,除非中间有截获代码。

    2021-12-13 02:34:22

  • 零__轨迹

    2021-12-06 11:07:22

    请问老师,参考函数式中的Try来封装有异常的执行结果对官方来说是否可行呢?
    作者回复

    没有明白这个问题是什么意思? 能描述的详细一些吗,或者举个例子?

    2021-12-06 11:40:40

  • bigben

    2021-12-03 13:53:39

    1、System.getLogger("co.ivi.jus.stack.union") .log(System.Logger.Level.INFO, "Unknown algorithm is specified " + algorithm, new Throwable("the calling stack"));
    这个把日志关掉也不能提高多少性能,new Throwable("the calling stack")总是要收集堆栈信息的;
    2、如果继续沿用 Java 的异常处理机制,调试信息可以按需开启、关闭吧,Throwable(String message, Throwable cause,
    boolean enableSuppression,
    boolean writableStackTrace)
    作者回复

    1、是的,我在代码里忽视了这个问题。“new Throwable”应该在日志启用的情况下调用,而不是总是被调用。
    2、是的,这里的调试信息关闭,需要特殊的代码处理,而且性能的影响依然很大(我忘记是数十倍还是数百倍了,不过不再是几千倍的水平了)。我们期望的,是不需要更改代码的,几乎没有性能影响的改进。

    2021-12-03 16:36:02

  • Calvin

    2021-12-03 13:44:53

    思考题我把 ErrorCode 许可类改成了枚举,至于编译器检测使用匹配(遗漏或多余),则是使用 switch 表达式,PR 在:https://github.com/XueleiFan/java-up/pull/16
    作者回复

    嗯,这是一个很好的思路。要是能解决更大范围内的适用性,就更好了。

    2021-12-04 13:02:36

  • 小飞同学

    2021-12-03 10:56:33

    感觉写的比较冗杂,希望各路大神指点下
    Result rt = Digest.of("SHA-256");
    switch (rt) {
    case Result.ErrorCode e -> {
    switch (e.errorCode){
    case -1-> System.out.println("algorithm is null");
    case -2-> System.out.println("algorithm is not support");
    default -> {
    // other errorCode
    }
    }
    }
    case Result.ReturnValue rv -> {
    switch (rv.returnValue){
    case Digest d -> d.digest("Hello, world!".getBytes());
    default -> {
    // other type process
    }
    }
    }
    default -> {
    // other permit class
    }
    };
    作者回复

    想提交一个GitHub的PR吗?

    2021-12-03 16:38:32

  • kimoti

    2021-12-03 06:34:05

    感觉应该用switch表达式来穷举error code
    作者回复

    是一个思路。

    2021-12-03 11:33:44