5.1 处理错误

5.1.1 异常分类

Java中,所有异常对象都是派生于Throwable类的一个实例。异常的继承结构如下图所示:
20171010184746692.jpg其中Throwable继承于Object,是所有异常的父类,它的下层又分为ErrorException,详细概念参考上图。

5.1.2 声明检查性异常

检查性异常通俗来说就是对于一段代码,如果不进行异常检查,编译器就会直接报错。这类代码的特点应该是异常发生的概率大,但是又并非代码逻辑本身的问题。比如一段读取文件的代码,逻辑本身并无问题,但是不能保证读取文件时文件一定存在或文件一定有内容。

也许可以在代码中检测文件确实存在再去读取,但是能保证检测完到读取的这期间,文件一定不会被删除吗?

一个方法必须声明所有的检查性异常,如果方法中可以产生多种检查性异常,则用逗号将多个异常类型隔开。在两种情况下必须对异常类型进行声明:

  1. 方法中存在会产生检查性异常的代码;
  2. 方法中调用了其他会产生检查性异常的方法

声明检查性异常使用throws关键字,注意要和throw关键字区分,后者是抛出异常。

  1. public Image loadImage(String s) throws FileNotFoundException, EOFException{
  2. ...
  3. }

此外还需要注意两点:

  • 如果子类覆盖了父类方法,则子类方法声明的异常类型不能比父类方法声明的异常类型更为通用;
  • 如果父类方法没有声明检查性异常,则子类方法也不能声明

5.1.3 抛出异常

抛出异常很简单,分为三步:

  • 找到问题属于的异常类型
  • 创建该类型的对象
  • 使用throw将其抛出

当一个方法抛出异常后,该方法就立刻停止执行,不会返回到该方法的调用者了。

5.1.4 创建异常类

创建自定义的异常类可以更加准确的描述Java自带的异常类无法描述的问题。只需定义一个类继承于Exception或它的子类即可。

此外,通常需要给自定义异常类提供两个构造器,一个为默认构造器,另一个为带参构造器(用来提供更加详细的信息)。

  1. class FileFormatException extends IOException{
  2. public FileFormatException(){}
  3. public FileFormatException(String gripe){
  4. super(gripe) //会调用父类Throwable的toString()
  5. }
  6. }

5.2 捕获异常

5.2.1 捕获异常

使用try-catch语句对异常进行捕获,其中将可能发生异常的代码块放在try语句中,当异常产生时,程序会直接跳到catch子句中判断该异常类型是否被能够被捕获,如果可以就进入catch语句中对异常进行处理。

如果要将异常传递给方法的调用者,就需要在方法头声明异常类型。对于不知道怎么处理的异常,通常这么进行处理。

如果要捕获多个类型的异常,需要使用多个catch对不同的异常类型分别进行处理。不过,假如对多个异常类型有同样的处理方法,那么可以用一个catch去完成。在Java 7中,支持了这种处理方法,不过要求为捕获的多个异常类型间不能存在继承关系。

  1. try{
  2. code
  3. more code
  4. ...
  5. }
  6. catch(FileNotFoundException | UnknownHostException e){
  7. handle code
  8. ...
  9. }
  10. method introduction:
  11. e.getMsessage() // 获得异常对象的详细信息
  12. e.getClass().getName() // 获得异常对象的实际类型

5.2.2 再次抛出异常和异常链

catch子句中也能抛出异常,这样做的目的是改变抛出异常的类型。

所谓异常链指的是:在catch子句中抛出了别的异常类型,但是仍然想保存下原始的异常信息。此时需要使用以下处理方法:

  1. try{
  2. code
  3. }
  4. catch(SQLException original){
  5. ServletException e = new ServletException('database error');
  6. e.initCause(original); // 将原始异常作为实际抛出异常对象产生的原因
  7. throw e;
  8. }

当捕获到该异常时,可以使用如下语句获取原始异常:
Throwable original = caughtException.getCause();

5.2.3 finally子句

finally的作用是用于释放资源,在IO流操作和数据库操作中会见到。无论是否捕获到异常,finally子句都会执行。所以处理异常的完整语法格式为try-catch-finally

但其实除了try语句是必须的外,其他两个都不是必须的。简单来说,当一段代码产生异常后,如果不需要进行处理,就无须catch部分;如果不需要做资源释放,就无须finally部分。try的作用是检测子句代码是否产生异常并捕获异常对象,catch子句则描述了如何处理捕获到的异常对象。

当需要关闭资源时。还有一种功能更强的写法(也更建议使用这种写法,因为可以解耦合try/catchtry/finally)

InputStream in= ...;
try{
    try{
        code that might throw exception
    }
    finally{
        in.close()
    }
}
catch(IOException e){
    show error message
}

内层try保证了资源的关闭,外层try保证了异常信息的展示,不仅如此还能够报告finally子句中的错误(更强大)。

5.2.4 try-with-Resources语句

这是一个语法糖,在需要关闭资源时,推荐使用这种方式。它的好处在于可以自动关闭资源而不需要像之前那样在finally子句中去手动关闭。有点类似于Python中的with语句。

try-with-resources语句是一个声明了一个或多个资源的try语句。所谓资源是一个对象,在程序使用完毕后必须关闭它。try-with-resources语句能够确保在语句结束后,所有资源都能被关闭。所有实现了java.lang.AutoCloseable接口的对象,包括那些实现了java.io.Closeable的对象,都可以作为资源使用。(CloseableAutoCloseable的子类,它们都有一个共同的方法close)。

举个栗子,以下代码使用BufferedReader对象的readline()读取文件的第一行,该类实现了AutoCloseable接口。作为一个资源,BufferedReader在程序执行完后是必须被关闭的。

static String readFirstLineFromFile(String path) throws IOException {
    try (BufferedReader br =
                   new BufferedReader(new FileReader(path))) {
        return br.readLine();
    }
}

可以看到,资源的声明语句紧跟在try关键字之后的括号内,由于资源对象被声明在try-with-resources语句中,所以不论try语句正常结束还是异常结束,资源都会被关闭(调用brclose())。

在Java 7之前,会使用try-catch-finally语句来处理这种需要关闭资源的代码,但是为什么不用这种方式了呢?

static String readFirstLineFromFileWithFinallyBlock(String path)
                                                     throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));
    try {
        return br.readLine();
    } finally {
        br.close();
    }
}

如果try语句和finally语句都产生了异常,那么方法readFirstLineFromFileWithFinallyBlock就会抛出finally语句中产生的异常,try语句产生的异常就被抑制了(surpressed)。在Java 7之后,就可以检测被抑制的异常了。

try-catch-Resources语句中,如果try块抛出了一个异常,close方法也抛出了一个异常,那么try块中抛出的异常就会重新被抛出,而close方法抛出的异常会被抑制(这里和传统的使用finally是反的…)。但是并不希望被抑制的异常被忽视,因为这可能引发极其不易发现的bug。所以Throwable接口提供了一个方法addSurpressed,作用显而易见,就是将被抑制的异常附加在原来的异常上。

如果对这些被抑制的异常感兴趣,可以使用getSurpressed()获取。

5.2.5 分析堆栈轨迹元素

堆栈轨迹是程序执行过程中某个特定点上所有挂起的方法调用的一个列表。

这种描述我个人更能接受一些,毕竟方法调用就是一系列入栈出栈的过程。在Java 8及以前,通过Throwable类的printStackTrace()来访问堆栈轨迹的文本描述信息。此外该类还提供了getStackTrace()来获取堆栈轨迹的信息,该方法返回一个StackTraceElement[]StackTraceElement表示StackTrace中的一个元素,属性包括方法调用者的类名、方法名、文件名以及调用的行数。

下例是在递归计算阶乘的过程中,打印出程序的堆栈轨迹:

package stackTrace;

import java.util.Scanner;

/**
 * @author tianyichen <tianyichen@kuaishou.com> Created on 2021-05-31
 */
public class StackTraceTest {

    /**
     * 递归计算阶乘,并打印出程序的堆栈轨迹 (stack trace)
     */
    public static int factorial(int n) {
        System.out.println("factorial(" + n + "):");
        Throwable t = new Throwable();
        StackTraceElement[] ste = t.getStackTrace();
        for(StackTraceElement s : ste) {
            System.out.println(s);
        }
        int r;
        if (n <= 1) {
            r = 1;
        }else {
            r = factorial(n-1) * n;
        }
        System.out.println("return " + r);
        return r;
    }

    public static void main(String[] args) {
        try (Scanner in = new Scanner(System.in)) {
            System.out.println("enter n: ");
            int n = in.nextInt();
            factorial(n);
        }
    }
}

输出如下:

enter n: 
3
factorial(3):
stackTrace.StackTraceTest.factorial(StackTraceTest.java:15)
stackTrace.StackTraceTest.main(StackTraceTest.java:34)
factorial(2):
stackTrace.StackTraceTest.factorial(StackTraceTest.java:15)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:24)
stackTrace.StackTraceTest.main(StackTraceTest.java:34)
factorial(1):
stackTrace.StackTraceTest.factorial(StackTraceTest.java:15)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:24)
stackTrace.StackTraceTest.factorial(StackTraceTest.java:24)
stackTrace.StackTraceTest.main(StackTraceTest.java:34)
return 1
return 2
return 6

个人理解:开始看到这个输出是一脸懵逼的,仔细看发现了一些规律,但不一定对,只是个人推测。首先计算阶乘的入口在main()的34行,所以此时main()就被挂起了,进入到递归函数factorial中执行,在该函数中第16行获取了堆栈轨迹,也就是说在15行执行完后,函数factorial被挂起,所以此时的堆栈信息数组中被挂起的方法以及行数打印正如输入中的4、5行所示,后面的输出依次类推。

5.3 使用断言

5.3.1 断言的概念

断言对程序进行检查的一种机制。它主要使用在代码开发和测试时期,用于对某些关键数据的判断,如果这个关键数据不是程序所预期的数据,程序就提出警告或退出。

对于断言,Java引入了关键字assert对其进行描述。主要有两种形式:

  • assert condition;
  • assert condition : expression;

两种形式都会计算条件表达式,如果为false,则会抛出一个AssertError异常。第二个形式中的expression的唯一目的是产生一个消息字符串。表达式会作为参数传递给AssertError对象的构造器。

assert x >= 0;  // 可以简单翻译为:我断定x大于0,如果小于0,则抛出异常,用于对x的合理性进行检查

//将x作为的值传递给AssertError对象,以便以后显示
assert x >= 0 : x;

5.3.2 启动和禁用断言

默认情况下,断言是禁用的,即运行代码时,断言部分是不生效的。

在运行程序时使-ea来启动,或者使用-da禁用断言。(ea:enableassertions)

// 启动断言 
java -ea MyApp
// 禁用断言
java -da MyApp

//也可以对某个类或者整个包中启动断言(下面指令将为MyClass类和com.mycompany.mylib包和它的子包中所有类打开断言)
java -ea:MyClass -ea:com.mycompany.mylib MyApp

5.3.3 什么时候使用断言

断言有两个特点:

  1. 断言失败是不可恢复的错误;
  2. 断言检查仅用于开发和测试阶段

所以,首先不该使用断言向程序的其他部分通知发生了可恢复性错误;其次,不该使用断言与程序用户沟通问题。

个人理解:断言作为一种调试工具,主要是对内部错误进行检查而非外部错误,外部出现的各种错误更适合使用异常处理。断言往往针对代码中一些关键地方对一些又显然不会出错的地方进行检查,或者说是对前置条件的一种检查。比如说,某些函数的参数需要满足一定的前置条件,如果不满足肯定是有问题的,那么就可以使用断言对参数的合法性进行检查。调试中如果出现了断言异常,那么能够迅速定位到错误原因。但是实际情况下这种错误又是明显不太会发生的,也许是因为这本身就是一种约定。(哎,说的很乱,这玩意的使用场景确实还需要日后实战中慢慢去理解)

5.4 日志

仅记录JUL的相关知识点。

5.4.1 JUL入门与架构介绍

JUL全称Java util Loggingjava原生的日志框架,使用时不需要另外引用第三方类库,相对其他日志框架使用方便,学习简单,能够在小型应用中灵活使用。
截屏2021-06-03 上午2.37.41.png上图为JUL框架图,其中各部分组件介绍如下:

  • Logger日志记录器。应用程序通过获取Logger对象,调用其API发布日志信息
  • Handler日志处理器。每个Logger都会关联一组HandlersLogger会将日志交给关联的Handlers处理,由Handlers负责将日志做记录。Handlers在此是一个抽象,其具体的实现决定了日志记录的位置是控制台、文件、网络上的其他日志服务或操作系统日志等
  • Formatters:负责对日志事件中的数据进行转换和格式化
  • Filter过滤器。根据实际需要定制哪些信息被记录,哪些被丢弃

总结:用户使用Logger来进行日志记录,Logger持有若干个Handler,日志的输出操作是由Handler完成的。在Handler在输出日志前,由Filter过滤,判断哪些日志级别放行哪些拦截,Handler会将日志内容输出到指定位置(日志文件、控制台等)。Handler在输出日志时会使用Formatters,将输出内容进行排版。

public static void julTest() {
    // 1. 创建一个日志记录器
    Logger logger = Logger.getLogger("jul.JULTest");
    // 2. 进行日志的输出
    logger.info("hello JUL");  // 输出info级别的信息
    logger.log(Level.INFO, "hello JUL");  // 使用log方法输出

    String name = "tianyichen";
    Integer age = 26;
    logger.log(Level.INFO, "用户信息:{0}, {1}", new Object[]{name, age});
}
-----输出-----
六月 03, 2021 2:59:16 上午 jul.JULTest test
信息: hello JUL
六月 03, 2021 2:59:16 上午 jul.JULTest test
信息: hello JUL
六月 03, 2021 2:59:16 上午 jul.JULTest test
信息: 用户信息:tianyichen, 26

5.4.2 日志级别

java.util.logging.Level中定义了日志级别:

  • SEVERE
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST

还有两个特殊的级别:

  • OFF:用来关闭日志记录
  • ALL:启用所有消息的日志记录

其中,INFO为日志的默认级别,即如果不修改日志的默认级别,则日志只会输出INFO及以上级别的日志信息。

public static void testLevel() {
    Logger logger = Logger.getLogger("jul.JULTest");
    logger.severe("severe");
    logger.warning("warning");
    logger.info("info");
    logger.config("config");
    logger.fine("fine");
    logger.finer("finer");
    logger.finest("finest");
}
-----输出-----
六月 03, 2021 11:05:42 上午 jul.JULTest testLevel
严重: severe
六月 03, 2021 11:05:42 上午 jul.JULTest testLevel
警告: warning
六月 03, 2021 11:05:42 上午 jul.JULTest testLevel
信息: info

如果想输出所有日志级别的日志,就需要自定义日志级别配置(硬编码方式),代码如下,可以看出,所有日志级别的日志均会打印在控制台。

public static void testLogConfig() {
    // 获取日志记录器
    Logger logger = Logger.getLogger("jul.JULTest");
    // 关闭该日志记录器的默认日志级别的配置
    logger.setUseParentHandlers(false);
    // 创建一个handler和formatter,因为只向控制台输出,所以创建一个ConsoleHandler对象
    ConsoleHandler consoleHandler = new ConsoleHandler();
    SimpleFormatter formatter = new SimpleFormatter();
    // 进行关联
    consoleHandler.setFormatter(formatter);
    logger.addHandler(consoleHandler);
    // 设置级别
    consoleHandler.setLevel(Level.ALL);
    logger.setLevel(Level.ALL);
    // 测试日志级别配置修改是否生效
    logger.severe("severe");
    logger.warning("warning");
    logger.info("info");
    logger.config("config");
    logger.fine("fine");
    logger.finer("finer");
}
-----输出-----
六月 03, 2021 11:22:12 上午 jul.JULTest testLogConfig
严重: severe
六月 03, 2021 11:22:12 上午 jul.JULTest testLogConfig
警告: warning
六月 03, 2021 11:22:12 上午 jul.JULTest testLogConfig
信息: info
六月 03, 2021 11:22:12 上午 jul.JULTest testLogConfig
配置: config
六月 03, 2021 11:22:12 上午 jul.JULTest testLogConfig
详细: fine
六月 03, 2021 11:22:12 上午 jul.JULTest testLogConfig
较详细: finer

5.4.3 Logger之间的父子关系

JULLogger之间存在父子关系,这种父子关系通过树状结构存储,JUL在初始化时会创建一个顶层 RootLogger作为所有Logger的父Logger,存储上作为树状结构的根节点。

public static void testParentLogger() {
    Logger logger1 = Logger.getLogger("jul.JULTest");
    Logger logger2 = Logger.getLogger("jul");
    System.out.println(logger1.getParent() == logger2);

    // 设置logger2的日志级别为ALL,logger1的日志级别会继承logger2,因为在目录上存在父子关系
    logger2.setUseParentHandlers(false);
    ConsoleHandler consoleHandler = new ConsoleHandler();
    SimpleFormatter formatter = new SimpleFormatter();
    consoleHandler.setFormatter(formatter);
        logger2.addHandler(consoleHandler);
        logger2.setLevel(Level.ALL);
        consoleHandler.setLevel(Level.ALL);
        logger1.severe("severe");
        logger1.warning("warning");
        logger1.info("info");
        logger1.config("config");
        logger1.fine("fine");
        logger1.finer("finer");
}
-----输出-----
true
六月 03, 2021 2:23:15 下午 jul.JULTest testParentLogger
严重: severe
六月 03, 2021 2:23:15 下午 jul.JULTest testParentLogger
警告: warning
六月 03, 2021 2:23:15 下午 jul.JULTest testParentLogger
信息: info
六月 03, 2021 2:23:15 下午 jul.JULTest testParentLogger
配置: config
六月 03, 2021 2:23:15 下午 jul.JULTest testParentLogger
详细: fine
六月 03, 2021 2:23:15 下午 jul.JULTest testParentLogger
较详细: finer

5.4.4 日志的配置文件

硬编码方式对日志级别进行配置是十分繁琐的,还可以通过修改配置文件方式来对日志级别进行配置。日志的配置文件位置在$JAVA_HOME\jre\lib\logging.properties

首先直观的感受下配置文件的内容:

# 日志处理器,默认输出到控制台
handlers= java.util.logging.ConsoleHandler

# 全局日志处理器的日志级别
.level= INFO

# 对日志文件处理器的配置,包括日志文件的名字,内容格式等
java.util.logging.FileHandler.pattern = %h/java%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
java.util.logging.FileHandler.formatter = java.util.logging.XMLFormatter

# 对控制台日志的一些配置,包括默认级别和formatter
java.util.logging.ConsoleHandler.level = INFO  # 该级别不能低于全局级别
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter

接下来了解为什么修改配置文件能改变日志的行为?
在实例化一个Logger对象时,首先会实例化一个LogManager对象(这是一个单例类),顾名思义它是用来管理Logger对象的。在实例化LogManager时,读取了配置文件的内容。实例化完LogManager后,将Logger添加到LoggerManager。下图为日志原理的解析图。
截屏2021-06-03 下午3.32.19.png实际使用中,可以在src目录下新建一个logging.propertie文件,并填写想要的配置信息,如果要使用新的配置,就直接读取该配置文件即可。

public static void testChangeConf() throws IOException {
    // 读取配置文件
    InputStream in = JULTest.class.getClassLoader().getResourceAsStream("logging.properties");
    // 获得日志管理器对象
    LogManager manager = LogManager.getLogManager();
    // 该日志管理器对象读取指定的配置文件
    manager.readConfiguration(in);

    Logger logger = Logger.getLogger("jul.JULTest");
    logger.severe("severe");
    logger.warning("warning");
    logger.info("info");
    logger.config("config");
    logger.fine("fine");
    logger.finer("finer");
    }