Java异常、断言和日志

2,337 阅读16分钟

1.处理错误

需有效的理智处理错误;

1.1异常分类

  • 异常:

首先说明一下什么是异常。异常是程序在运行过程中发生由于外部问题(如硬件错误、输入错误)等导致的程序异常事件;(在Java等面向对象的编程语言中)异常本身是一个对象,产生异常就是产生了一个异常对象(引子百度百科);说白了,程序运行时,发生的不被期望的事件,它阻止了程序按照程序员的预期正常执行,这就是异常;

  • Java中异常对象都派生于Throwable类的一个实例(Java标准裤内建了一些通用的异常,这些类以Throwable为顶层父类);Throwable又派生出Error和Exception两类;

Java异常层次结构示意图:

  • Error(Error类以及他的子类的实例):Java运行时系统内部错误或资源耗尽错误(JVM本身的错误);此类错误不能被程序员通过代码处理,不应抛出此类对象;
  • Exception(Exception以及他的子类):代表程序运行时发送的各种不期望发生的事件;可以被Java异常处理机制使用,是异常处理的核心;
    • RuntimeException:程序错误导致的异常;
    • IOException(其他异常):类似I/O错误这类问题导致的异常;

非受查(unchecked)异常:派生于Error类或RuntimeException类的所有异常;javac在编译时,不会提示和发现这样的异常,不要求在程序处理这些异常;所以如果愿意,我们可以编写代码处理(使用try...catch...finally)这样的异常,也可以不处理;对于这些异常,我们应该修正代码,而不是去通过异常处理器处理(代码处理的结果比异常处理的结果高效很多);这样的异常发生的原因多半是代码写的有问题;如除0错误ArithmeticException,错误的强制类型转换错误ClassCastException,数组索引越界ArrayIndexOutOfBoundsException,使用了空对象NullPointerException等等; 受查(checked)异常:除了非受查异常以外的异常(除了Error 和 RuntimeException的其它异常);javac强制要求程序员为这样的异常做预备处理工作(使用try...catch...finally或者throws);在方法中要么用try-catch语句捕获它并处理,要么用throws子句声明抛出它,否则编译不会通过;这样的异常一般是由程序的运行环境导致的;因为程序可能被运行在各种未知的环境下,而程序员无法干预用户如何使用他编写的程序,于是程序员就应该为这样的异常时刻准备着;如SQLException, IOException,ClassNotFoundException等;

  • 读懂异常:异常是在执行某个函数时引发的,而函数又是层级调用,形成调用栈的,因为,只要一个函数发生了异常,那么他的所有的caller都会被异常影响;当这些被影响的函数以异常信息输出时,就形成的了异常追踪栈;异常最先发生的地方,叫做异常抛出点

1.2声明受查异常

一个方法不仅需要告诉编译器将要返回什么值,还要告诉编译器有可能发生什么错误;自己编写方法时,不必将所有可能抛出的异常都进行声明,只是在以下4种情况时应该抛出异常: ①调用一个抛出受查异常的方法;如:FileInputStream构造器; ②程序运行过程中发现错误,且利用throw语句抛出一个受查异常③程序出现错误,如:a[-1]=0,抛出ArrayIndexOutOfBoundException; ④Java虚拟机和运行时库出现的内部错误

  • ①、②必须告诉调用这个方法的程序员有可能抛出异常;若没有处理器捕获此异常,当前执行的线程会结束;
  • 总之,一个方法必须声明所有可能抛出的受查异常,而非受查异常要么不可控制(Error),要么就应该避免发生(RuntimeException);如果方法没有声明所有可能发生的受查异常,编译器就会发出一个错误消息;
  • 注意:子类中覆盖父类的方法所抛出的异常不能比父类抛出的异常更通用,此方法或是不抛出异常、或是抛出更特定的异常;说白了,当子类重写父类的带有 throws声明的函数时,其throws声明的异常必须在父类异常的可控范围内——用于处理父类的throws方法的异常处理器,必须也适用于子类的这个带throws方法;这是为了支持多态;如:父类方法throws的是2个异常,子类就不能throws3个及以上的异常;父类throws IOException,子类就必须throws IOException或者IOException的子类;

1.3抛出异常

  • 抛出异常时首先考虑抛出什么类型的异常;抛出语句为(以读取文件抛出异常为例):
//第一种形式
throw new EOFException;

//第二种形式
EOFException e = new EOFException();
throw e;

//完整形式
String readData(Scanner in) throws EOFException{
    //...
    while(//...){
        if(!in.hasNext()) {
            if(n < len) {
                String gripe = "Content-length:" + len + ", Received:" + n;
                throw new EOFException(gripe);
            }
        }
    }

    return s;
}
  • 对于一个已经存在的异常类的抛出:

①找到一个合适的异常类;②创建这个类的一个对象;③将对象抛出; 注意:一旦方法抛出异常,此方法不能返回到调用者;

1.4创建异常类

若找不到标准异常类描述清楚或解决问题,此时需要创建自己的异常类;简单的说就是定义一个派生于Exception的类,或派生于Exception子类的类;

//定义好异常类之后就可以正常使用了,其中派生出的异常一般都有一个默认构造器和一个带有详细描述信息的构造器(详细的构造器可参见jdk源码)
//如:定义一个派生于IOException的类
public class FileFormatException extends IOException {
    public FileFormatException(){

    }

    public FileFormatException(String gripe){
        super(gripe);
    }
}

2.捕获异常

抛出异常简单,而捕获异常复杂些,需要周密的计划;而有些代码必须捕获异常;

2.1捕获异常

若某个异常发生时,没有进行捕获,程序会终止执行;捕获异常时必须设置try/catch语句块,最简单的try语句为:

try{
    code
    more code
    more code
}catch (ExceptionType e){
    handler for this type
}
//其中若try语句块中的任何代码抛出了一个在catch子句中说明的异常类,那程序直接跳过try语句块的其余代码,直接执行catch子句中处理的代码;若方法中抛出的异常在catch语句中没有其对应的异常类型,方法会立刻退出(设计者要充分考虑异常);
  • 读取数据的典型程序代码:
//使用异常捕获的方式处理异常
public void read(String filename){
    try{
        InputStream in = new FileInputStream(filename);
        int b;
        while((b = in.read()) != -1){
            //process input
        }
    }catch (IOException exception){
        exception.printStackTrace();
    }
}

//使用抛出的方式处理异常
public void read(String filename) throws IOException{
    InputStream in = new FileInputStream(filename);
    int b;
    while((b = in.read()) != -1){
        //process input
    }
}
  • 通常,应该捕获那些知道如何处理的异常,而将那些不知道怎么处理的异常继续进行传递!!!
  • 例外,超类中的方法没有抛出异常,所以子类中覆盖超类方法的方法不能抛出异常,若有异常必须进行捕获;
  • 不允许在子类的throws说明符中出现超过超类方法所列出的异常类范围;

2.2捕获多个异常

在一个try语句块中可以捕获多个异常类型,并对不同类型的异常做出不同的处理。可以按照下列方式为每个异常类型使用一个单独的catch子句:

try{
    //code that might throw exception
}catch (FileNotFoundException e){
    //获得对象更多信息
    e.getMessage();
    //emergency action for missing files
}catch (UnknownHostException e){
    //得到详细的错误信息
    e.getClass().getName();
    //emergency action for unknown hosts
}catch (IOException e){
    //emergency action for all other I/O problems
}
//其中同一个catch子句中可以捕获多个异常类型;异常动作一样时可以合并catch子句;
  • 注释:捕获多个异常时,异常变量隐含为final变量
  • 注释:捕获多个异常不仅会让你的代码看起来更简单,还会更高效;生成的字节码只包一个对应公共catch子句的代码块;由于异常匹配是按照从上往下顺序匹配,而第一个匹配到的catch的会得到执行,所以有多个catch匹配时应该当把子类异常放在前边,父类异常放在后边;

2.3再次抛出异常与异常链

catch子句中可以抛出一个异常,目的是改变异常的类型; 场景:若调用了个子系统,且不关心子系统发生的错误细节,只想知道子系统有没有发生错误;

//捕获异常并再次抛出的基本方法
try{
    //access the database
}catch(SQLException e){
    throw new ServletException("database error:" + e.getMessage());
}

//更好的方式:原始异常设置为新异常
//建议使用此技术,此方式会抛出子系统的高级异常,不会丢失原始异常的细节
try{
    //access the database
}catch (SQLException e){
    Throwable se = new ServletException("database error");
    se.initCause(e);
    throw se;
}
//调用此方法者捕获到此异常后,可用下面语句重新获得原始异常:
Throwable e = se.getCause();
  • 若方法中发生了一个受查异常,且不许抛出此异常。这时我们可以捕获此受查异常,将其包装成运行时异常;
//只想记录一个异常,再将它重新抛出,而不做任何改变:
try{
    //access the database
}catch (Exception e){
    logger.log(level, message, e);
    throw e;
}

2.4finally子句

  • 背景:代码抛出异常后,终止方法中剩余代码的处理,并退出此方法的执行;若此方法获得了一些本地资源,退出方法前必须清理本地资源;如:关闭文件、断开网络……;
  • 解决方式:
    1. 捕获并重新抛出所有异常:正常代码和异常代码中都要清楚所有分配的资源;
    2. finally子句:无论是否有异常被捕获,finally子句中的代码都被执行;如:
//此程序将在所有情况下关闭文件
InputStream in = new FileInputStream(...);
try{
    //code that might throw exception
}catch(IOException e){
    //show error message
}finally{
    in.close();
}

上面代码有三种情况: ①代码未抛出异常:执行try和finally语句块中的所有代码; ②代码抛出catch子句中捕获的异常:执行try中抛异常之前的代码、异常匹配的catch子句代码和finally子句中的代码; ③代码抛出catch子句中未捕获的异常:执行try中抛异常之前的代码和finally子句中的代码;

  • 只有try和finally语句的情况:try中是否出现异常都会执行finally中的代码;
//使用finally子句关闭资源是个不错的选择
InputStream in = ...;
try{
    //code might throw exception
}finally {
    in.close();
}
  • 强烈建议解耦try/catch和try/finally语句块(可提高代码的清晰度),如:
/**
 * 解耦try/catch和try/finally语句块:
 * 此种设计不仅清楚,且还有一个功能,将会报告finally子句中出现的错误
 */
InputStream in = ...;
//外层try语句块只有一个职责,确保报告出现的错误
try{
    //内层try语句块只有一个职责,确保关闭输入流
    try{
        //code that might throw exceptions
    }finally{
        in.close();
    }
}catch(IOException e){
    //show error message
}
  • 警告:finally子句包含return语句时:如果try子句中有return语句,而try子句使用return语句退出时;finally子句中的return将被执行,并且覆盖try子句中的return语句的结果;简而言之,try子句中的return执行后,finally子句中的return也会执行且覆盖try子句中的return语句;如:
/**
 * n=2时,return 0;
 * 如果调用f(2),那么try语句块的计算结果为r = 4,并执行return语句然而,
 * 在方法真正返回前,还要执行finally子句;finally子句将使得方法返回0,
 * 这个返回值覆盖了原始的返回值4;
 * 也就是说:try...catch...finally中的return 只要能执行,就都执行了,他们共同向同一个内存地址(假设地址是0x80)写入返回值,
 * 后执行的将覆盖先执行的数据,而真正被调用者取的返回值就是最后一次写入的;
 * @param n
 * @return
 */
public static int f(int n){
    try{
        int r = n * n;
        return n;
    }finally {
        if(n == 2)
            return 0;
    }
}
  • 注意:finally中的return会覆盖try和catch(若有)中的return,会抑制try和catch(若有)中异常;同样finally中的异常也会覆盖try和catch(若有)中的异常;因此建议:①不要在finally中使用return(将尽量将所有的return写在函数的最后面,而不是try ... catch ... finally中);②不要在finally中抛出异常;③减轻finally的任务,不要在finally中做一些其它的事情,finally块仅仅用来释放资源是最合适的;
  • 疑问:万一在finally子句中关闭资源时抛出异常怎么办?此时若try子句中会有异常,finally子句中的异常会覆盖try子句中的异常;若代码写成try和finally中都要抛异常,会显得非常麻烦;

2.5带资源的try语句

Java SE 7中关闭资源;

  • AutoCloseable接口:
    • 拥有void close() throws Exception方法;
  • Closeable接口:AutoCloseable的子接口,也包含close方法,但此方法会抛出IOException;
  • 带资源的try语句(try-with-resources)的最简单形式为:
//try块退出时,自动调用res.close()
try(Resource res = ...){
    //work with res
}
  • 读取一个文件所有单词的例子:
//退出此块时,或存在一个异常时,自动调用res.close()方法;好像使用了finally块一样;
try(Scanner in = new Scanner(new FileInputStream("/usr/share/dict/words"), "UTF-8")){
    while(in.hasNext()){
        System.out.println(in.next());
    }
}
//带多个资源的情况:
try(Scanner in = new Scanner(new FileInputStream("/usr/share/dict/words"), "UTF-8");
    PrintWriter out = new PrintWriter("out.txt")){
    while(in.hasNext()){
        out.println(in.next().toUpperCase());
    }
}
  • 带资源的try语句中的,close方法抛出的异常会被“抑制”,而原来的异常会重新抛出;
  • 当然带资源的try语句自身也可以有catch子句和finally子句,这些子句会在关闭资源后执行;

2.6分析堆栈轨迹元素

/**
 * 递归阶乘函数的堆栈情况
 */
public class StackTraceTest {
    public static int factorial(int n){
        System.out.println("factorial(" + n + "):");
        Throwable t = new Throwable();
        StackTraceElement[] frames = t.getStackTrace();
        for(StackTraceElement f: frames)
            System.out.println(f);
        int r;
        if(n <= 1)
            r = 1;
        else
            r = n * factorial(n - 1);
        System.out.println("return " + r);
        return r;
    }

    public static void main(String[] args){
        Throwable t = new Throwable();
        StringWriter out = new StringWriter();
        t.printStackTrace(new PrintWriter(out));
        String description = out.toString();
        System.out.println("description:" + description);

//        Scanner in = new Scanner(System.in);
//        System.out.print("Enter n:");
//        int n = in.nextInt();
//        factorial(n);
    }
}

3.使用异常机制的技巧

  1. 异常处理不能代替简单的测试:与执行简单的测试相比,捕获异常所花费的时间大大超过了前者,因此使用异常的基本规则是:只在异常情况下使用异常机制
  2. 不要过分地细化异常
  3. 利用异常层次结构
    • 不要只抛出RuntimeException异常:应寻找更加适当的子类或创建自己的异常类;
    • 不要只捕获Throwable异常,否则,会使程序代码更难读、更难维护;
    • 不要为逻辑错误抛出异常
    • 将一种异常转换为另一种更加适合的异常时不要犹豫:在解析某个文件中的一个整数时,捕获NumberFormatException异常,然后将它转换成IOException或MySubsystemException的子类;
  4. 不要压制异常:如果认为异常非常重要,应该对它们进行处理;
  5. 在检测错误时,“苛刻”要比放任更好:无效参数调用一个方法时,抛出一个异常比返回虚拟数值要好;因为虚拟数值在后边某段代码处还会引起异常从而使得异常处理和找Bug变得比较难;总之该抛异常还是要抛的;
  6. 不要羞涩于传递异常:有些异常需要抛出,让高层次的方法通知用户发生了错误,或者放弃不成功的命令更加适宜;
  • 可理解为,“早抛出,晚捕获”;

4.使用断言

4.1断言的概念

首先我得了解一下什么是断言;断言是一种机制,允许在测试期间向代码中插入一些检査语句;当代码发布时,这些插人的检测语句将会被自动地移走;

  • 关键字assert
    • assert 条件:

    结果为false,抛出AssertError异常;

    • assert 条件:表达式;

    结果为false,表达式将被传入AssertionError的构造器,并转换成一个消息字符串;

  • 其中“表达式”部分的唯一目的是产生一个字符串消息,AssertionError对象并不存储表达式的值,所以不能在以后得到它;
  • 默认情况下,断言被禁用;可以在运行程序时用添加-enableassertions或-ea选项启用(在IDEA中可在Configuration选项中的VM options栏中填写此参数);

4.2使用断言完成参数检查

  • 断言的使用场景:
    • 断言失败是致命的、不可恢复的错误;
    • 断言检查只用于开发和测试阶段;

5.记录日志

  • 产生背景:使用System.out.println()做辅助观察程序运行比较麻烦;
  • 优点:简单好用~哈哈哈

5.1基本日志

  • 生成简单的日志
    • 使用全局日志记录器调用info方法:Logger.getGlobal().info("File->Open menu item selected");
    • 取消所有日志:Logger.getGlobal().setLevel(Level.OFF);

5.2高级日志

开发软件时不要把所有的日志都记录到一个全局日志记录器中,而是可以自定义日志记录器;

  • getLogger方法创建或获取记录器:

    private static final Logger myLogger = Logger.getLogger("com.basicofjava");其中未被任何变量引用的日志记录器可能会被垃圾回收;可用一个静态变量存储日志记录器的一个引用防止被回收;

6.调试技巧

背景:假设编写了一个程序,并对所有的异常进行了捕获和恰当的处理,然后,运行这个程序,但还是出现问题,现在该怎么办呢(从来没有遇到过这种情况的小伙伴,可以跳过此部分)?

  • 一些调试的建议:
    1. 打印或记录任意变量的值:System.out.println("x=" + x); Logger.getGlobal().info("x=" + x);
    2. 每个类中放置一个单独的main方法,可对本类进行单元测试:只需创建少量对象调用所有方法,并检测有没有错误;
    3. 使用JUnit框架:junit.org/junit5/
    4. 使用日志代理(logging proxy):
    5. 利用Throwable类提供的printStackTrace方法(可从任何一个异常对象中获取堆栈情况):可以用catch捕获后printStackTrace()或直接在代码的任何位置插入Thread.dumpStack();
    6. —般来说,堆栈轨迹显示在System.err上;也可以利用printStackTrace(PrintWriter s)方法将它发送到一个文件中;如:
StringWriter out = new StringWriter();
new Throwable().printStackTrace(new PrintWriter(out));
String description = out.toString();
System.out.println("description:" + description);
7. …… 

7.总结

Java中有三种处理系统错误的机制:①抛出异常;②使用断言;③日志