Java 基础 - 异常机制详解(下)

Java异常是Java提供的一种识别及响应错误的一致性机制,java异常机制可以使程序中异常处理代码和正常业务代码分离,保证程序代码更加优雅,并提高程序健壮性。本文综合多篇文章后,总结了Java 异常的相关知识,希望可以提升你对Java中异常的认知效率。

异常实践

提示
在 Java 中处理异常并不是一个简单的事情。不仅仅初学者很难理解,即使一些有经验的开发者也需要花费很多时间来思考如何处理异常,包括需要处理哪些异常,怎样处理等等。这也是绝大多数开发团队都会制定一些规则来规范进行异常处理的原因。

当你抛出或捕获异常的时候,有很多不同的情况需要考虑,而且大部分事情都是为了改善代码的可读性或者 API 的可用性。

异常不仅仅是一个错误控制机制,也是一个通信媒介。因此,为了和同事更好的合作,一个团队必须要制定出一个最佳实践和规则,只有这样,团队成员才能理解这些通用概念,同时在工作中使用它。

这里给出几个被很多团队使用的异常处理最佳实践。

只针对不正常的情况才使用异常

异常只应该被用于不正常的条件,它们永远不应该被用于正常的控制流。《阿里手册》中:【强制】Java 类库中定义的可以通过预检查方式规避的RuntimeException异常不应该通过catch 的方式来处理,比如:NullPointerException,IndexOutOfBoundsException等等。

比如,在解析字符串形式的数字时,可能存在数字格式错误,不得通过catch Exception来实现

  • 代码1

if (obj != null) {
  //...
}
  • 代码2

try { 
  obj.method(); 
} catch (NullPointerException e) {
  //...
}

主要原因有三点:

  • 异常机制的设计初衷是用于不正常的情况,所以很少会会JVM实现试图对它们的性能进行优化。所以,创建、抛出和捕获异常的开销是很昂贵的。

  • 把代码放在try-catch中返回阻止了JVM实现本来可能要执行的某些特定的优化。

  • 对数组进行遍历的标准模式并不会导致冗余的检查,有些现代的JVM实现会将它们优化掉。

在 finally 块中清理资源或者使用 try-with-resource 语句

当使用类似InputStream这种需要使用后关闭的资源时,一个常见的错误就是在try块的最后关闭资源。

  • 错误示例

public void doNotCloseResourceInTry() {
    FileInputStream inputStream = null;
    try {
        File file = new File("./tmp.txt");
        inputStream = new FileInputStream(file);
        // use the inputStream to read a file
        // do NOT do this
        inputStream.close();
    } catch (FileNotFoundException e) {
        log.error(e);
    } catch (IOException e) {
        log.error(e);
    }
}

问题就是,只有没有异常抛出的时候,这段代码才可以正常工作。try 代码块内代码会正常执行,并且资源可以正常关闭。但是,使用 try 代码块是有原因的,一般调用一个或多个可能抛出异常的方法,而且,你自己也可能会抛出一个异常,这意味着代码可能不会执行到 try 代码块的最后部分。结果就是,你并没有关闭资源。

所以,你应该把清理工作的代码放到 finally 里去,或者使用 try-with-resource 特性。

  • 方法一:使用 finally 代码块

与前面几行 try 代码块不同,finally 代码块总是会被执行。不管 try 代码块成功执行之后还是你在 catch 代码块中处理完异常后都会执行。因此,你可以确保你清理了所有打开的资源。

public void closeResourceInFinally() {
    FileInputStream inputStream = null;
    try {
        File file = new File("./tmp.txt");
        inputStream = new FileInputStream(file);
        // use the inputStream to read a file
    } catch (FileNotFoundException e) {
        log.error(e);
    } finally {
        if (inputStream != null) {
            try {
                inputStream.close();
            } catch (IOException e) {
                log.error(e);
            }
        }
    }
}
  • 方法二:Java 7 的 try-with-resource 语法

如果你的资源实现了 AutoCloseable 接口,你可以使用这个语法。大多数的 Java 标准资源都继承了这个接口。当你在 try 子句中打开资源,资源会在 try 代码块执行后或异常处理后自动关闭。

public void automaticallyCloseResource() {
    File file = new File("./tmp.txt");
    try (FileInputStream inputStream = new FileInputStream(file);) {
        // use the inputStream to read a file
    } catch (FileNotFoundException e) {
        log.error(e);
    } catch (IOException e) {
        log.error(e);
    }
}

尽量使用标准的异常

代码重用是值得提倡的,这是一条通用规则,异常也不例外。

重用现有的异常有几个好处:

  • 它使得你的API更加易于学习和使用,因为它与程序员原来已经熟悉的习惯用法是一致的。

  • 对于用到这些API的程序而言,它们的可读性更好,因为它们不会充斥着程序员不熟悉的异常。

  • 异常类越少,意味着内存占用越小,并且转载这些类的时间开销也越小。

Java标准异常中有几个是经常被使用的异常。如下表格:

异常

使用场合

IllegalArgumentException

参数的值不合适

IllegalStateException

参数的状态不合适

NullPointerException

在null被禁止的情况下参数值为null

IndexOutOfBoundsException

下标越界

ConcurrentModificationException

在禁止并发修改的情况下,对象检测到并发修改

UnsupportedOperationException

对象不支持客户请求的方法

虽然它们是Java平台库迄今为止最常被重用的异常,但是,在许可的条件下,其它的异常也可以被重用。例如,如果你要实现诸如复数或者矩阵之类的算术对象,那么重用ArithmeticException和NumberFormatException将是非常合适的。如果一个异常满足你的需要,则不要犹豫,使用就可以,不过你一定要确保抛出异常的条件与该异常的文档中描述的条件一致。这种重用必须建立在语义的基础上,而不是名字的基础上。

最后,一定要清楚,选择重用哪一种异常并没有必须遵循的规则。例如,考虑纸牌对象的情形,假设有一个用于发牌操作的方法,它的参数(handSize)是发一手牌的纸牌张数。假设调用者在这个参数中传递的值大于整副牌的剩余张数。那么这种情形既可以被解释为IllegalArgumentException(handSize的值太大),也可以被解释为IllegalStateException(相对客户的请求而言,纸牌对象的纸牌太少)。

对异常进行文档说明

当在方法上声明抛出异常时,也需要进行文档说明。目的是为了给调用者提供尽可能多的信息,从而可以更好地避免或处理异常。

在 Javadoc 添加 @throws 声明,并且描述抛出异常的场景。

/**
* Method description
* 
* @throws MyBusinessException - businuess exception description
*/
public void doSomething(String input) throws MyBusinessException {
   // ...
}

同时,在抛出MyBusinessException 异常时,需要尽可能精确地描述问题和相关信息,这样无论是打印到日志中还是在监控工具中,都能够更容易被人阅读,从而可以更好地定位具体错误信息、错误的严重程度等。

优先捕获最具体的异常

大多数 IDE 都可以帮助你实现这个最佳实践。当你尝试首先捕获较不具体的异常时,它们会报告无法访问的代码块。

但问题在于,只有匹配异常的第一个 catch 块会被执行。 因此,如果首先捕获 IllegalArgumentException ,则永远不会到达应该处理更具体的 NumberFormatException 的 catch 块,因为它是 IllegalArgumentException 的子类。

总是优先捕获最具体的异常类,并将不太具体的 catch 块添加到列表的末尾。

你可以在下面的代码片断中看到这样一个 try-catch 语句的例子。 第一个 catch 块处理所有 NumberFormatException 异常,第二个处理所有非 NumberFormatException 异常的IllegalArgumentException 异常。

public void catchMostSpecificExceptionFirst() {
    try {
        doSomething("A message");
    } catch (NumberFormatException e) {
        log.error(e);
    } catch (IllegalArgumentException e) {
        log.error(e)
    }
}

不要捕获 Throwable 类

Throwable 是所有异常和错误的超类。你可以在 catch 子句中使用它,但是你永远不应该这样做!

如果在 catch 子句中使用 Throwable ,它不仅会捕获所有异常,也将捕获所有的错误。JVM 抛出错误,指出不应该由应用程序处理的严重问题。 典型的例子是 OutOfMemoryError 或者 StackOverflowError 。两者都是由应用程序控制之外的情况引起的,无法处理。

所以,最好不要捕获 Throwable ,除非你确定自己处于一种特殊的情况下能够处理错误。

public void doNotCatchThrowable() {
    try {
        // do something
    } catch (Throwable t) {
        // don't do this!
    }
}

不要忽略异常

很多时候,开发者很有自信不会抛出异常,因此写了一个catch块,但是没有做任何处理或者记录日志。
public void doNotIgnoreExceptions() {
    try {
        // do something
    } catch (NumberFormatException e) {
        // this will never happen
    }
}

但现实是经常会出现无法预料的异常,或者无法确定这里的代码未来是不是会改动(删除了阻止异常抛出的代码),而此时由于异常被捕获,使得无法拿到足够的错误信息来定位问题。

合理的做法是至少要记录异常的信息。

public void logAnException() {
    try {
        // do something
    } catch (NumberFormatException e) {
        log.error("This should never happen: " + e); // see this line
    }
}

不要记录并抛出异常

这可能是本文中最常被忽略的最佳实践。

可以发现很多代码甚至类库中都会有捕获异常、记录日志并再次抛出的逻辑。如下:

try {
    new Long("xyz");
} catch (NumberFormatException e) {
    log.error(e);
    throw e;
}

这个处理逻辑看着是合理的。但这经常会给同一个异常输出多条日志。如下:

17:44:28,945 ERROR TestExceptionHandling:65 - java.lang.NumberFormatException: For input string: "xyz"
Exception in thread "main" java.lang.NumberFormatException: For input string: "xyz"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:589)
at java.lang.Long.(Long.java:965)
at com.stackify.example.TestExceptionHandling.logAndThrowException(TestExceptionHandling.java:63)
at com.stackify.example.TestExceptionHandling.main(TestExceptionHandling.java:58)

如上所示,后面的日志也没有附加更有用的信息。如果想要提供更加有用的信息,那么可以将异常包装为自定义异常。

public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}

因此,仅仅当想要处理异常时才去捕获,否则只需要在方法签名中声明让调用者去处理。

包装异常时不要抛弃原始的异常

捕获标准异常并包装为自定义异常是一个很常见的做法。这样可以添加更为具体的异常信息并能够做针对的异常处理。 在你这样做时,请确保将原始异常设置为原因(注:参考下方代码 NumberFormatException e 中的原始异常 e )。Exception 类提供了特殊的构造函数方法,它接受一个 Throwable 作为参数。否则,你将会丢失堆栈跟踪和原始异常的消息,这将会使分析导致异常的异常事件变得困难。

public void wrapException(String input) throws MyBusinessException {
    try {
        // do something
    } catch (NumberFormatException e) {
        throw new MyBusinessException("A message that describes the error.", e);
    }
}

不要使用异常控制程序的流程

不应该使用异常控制应用的执行流程,例如,本应该使用if语句进行条件判断的情况下,你却使用异常处理,这是非常不好的习惯,会严重影响应用的性能。

不要在finally块中使用return。

try块中的return语句执行成功后,并不马上返回,而是继续执行finally块中的语句,如果此处存在return语句,则在此直接返回,无情丢弃掉try块中的返回点。

如下是一个反例:

private int x = 0;
public int checkReturn() {
    try {
        // x等于1,此处不返回
        return ++x;
    } finally {
        // 返回的结果是2
        return ++x;
    }
}

深入理解异常

提示
我们再深入理解下异常,看下底层实现。

JVM处理异常的机制?

提到JVM处理异常的机制,就需要提及Exception Table,以下称为异常表。我们暂且不急于介绍异常表,先看一个简单的 Java 处理异常的小例子。

public static void simpleTryCatch() {
   try {
       testNPE();
   } catch (Exception e) {
       e.printStackTrace();
   }
}

上面的代码是一个很简单的例子,用来捕获处理一个潜在的空指针异常。

当然如果只是看简简单单的代码,我们很难看出什么高深之处,更没有了今天文章要谈论的内容。

所以这里我们需要借助一把神兵利器,它就是javap,一个用来拆解class文件的工具,和javac一样由JDK提供。

然后我们使用javap来分析这段代码(需要先使用javac编译)

//javap -c Main
 public static void simpleTryCatch();
    Code:
       0: invokestatic  #3                  // Method testNPE:()V
       3: goto          11
       6: astore_0
       7: aload_0
       8: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
      11: return
    Exception table:
       from    to  target type
           0     3     6   Class java/lang/Exception

看到上面的代码,应该会有会心一笑,因为终于看到了Exception table,也就是我们要研究的异常表。

异常表中包含了一个或多个异常处理者(Exception Handler)的信息,这些信息包含如下

  • from 可能发生异常的起始点

  • to 可能发生异常的结束点

  • target 上述from和to之前发生异常后的异常处理者的位置

  • type 异常处理者处理的异常的类信息

那么异常表用在什么时候呢

答案是异常发生的时候,当一个异常发生时

  • 1.JVM会在当前出现异常的方法中,查找异常表,是否有合适的处理者来处理

  • 2.如果当前方法异常表不为空,并且异常符合处理者的from和to节点,并且type也匹配,则JVM调用位于target的调用者来处理。

  • 3.如果上一条未找到合理的处理者,则继续查找异常表中的剩余条目

  • 4.如果当前方法的异常表无法处理,则向上查找(弹栈处理)刚刚调用该方法的调用处,并重复上面的操作。

  • 5.如果所有的栈帧被弹出,仍然没有处理,则抛给当前的Thread,Thread则会终止。

  • 6.如果当前Thread为最后一个非守护线程,且未处理异常,则会导致JVM终止运行。

以上就是JVM处理异常的一些机制。

try catch -finally

除了简单的try-catch外,我们还常常和finally做结合使用。比如这样的代码

public static void simpleTryCatchFinally() {
   try {
       testNPE();
   } catch (Exception e) {
       e.printStackTrace();
   } finally {
       System.out.println("Finally");
   }
}

同样我们使用javap分析一下代码

public static void simpleTryCatchFinally();
    Code:
       0: invokestatic  #3                  // Method testNPE:()V
       3: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
       6: ldc           #7                  // String Finally
       8: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      11: goto          41
      14: astore_0
      15: aload_0
      16: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
      19: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      22: ldc           #7                  // String Finally
      24: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      27: goto          41
      30: astore_1
      31: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #7                  // String Finally
      36: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: aload_1
      40: athrow
      41: return
    Exception table:
       from    to  target type
           0     3    14   Class java/lang/Exception
           0     3    30   any
          14    19    30   any

和之前有所不同,这次异常表中,有三条数据,而我们仅仅捕获了一个Exception, 异常表的后两个item的type为any; 上面的三条异常表item的意思为:

  • 如果0到3之间,发生了Exception类型的异常,调用14位置的异常处理者。

  • 如果0到3之间,无论发生什么异常,都调用30位置的处理者

  • 如果14到19之间(即catch部分),不论发生什么异常,都调用30位置的处理者。

再次分析上面的Java代码,finally里面的部分已经被提取到了try部分和catch部分。我们再次调一下代码来看一下

public static void simpleTryCatchFinally();
    Code:
      //try 部分提取finally代码,如果没有异常发生,则执行输出finally操作,直至goto到41位置,执行返回操作。  

       0: invokestatic  #3                  // Method testNPE:()V
       3: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
       6: ldc           #7                  // String Finally
       8: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      11: goto          41

      //catch部分提取finally代码,如果没有异常发生,则执行输出finally操作,直至执行got到41位置,执行返回操作。
      14: astore_0
      15: aload_0
      16: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
      19: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      22: ldc           #7                  // String Finally
      24: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      27: goto          41
      //finally部分的代码如果被调用,有可能是try部分,也有可能是catch部分发生异常。
      30: astore_1
      31: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #7                  // String Finally
      36: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: aload_1
      40: athrow     //如果异常没有被catch捕获,而是到了这里,执行完finally的语句后,仍然要把这个异常抛出去,传递给调用处。
      41: return

Catch先后顺序的问题

我们在代码中的catch的顺序决定了异常处理者在异常表的位置,所以,越是具体的异常要先处理,否则就会出现下面的问题

private static void misuseCatchException() {
   try {
       testNPE();
   } catch (Throwable t) {
       t.printStackTrace();
   } catch (Exception e) { //error occurs during compilings with tips Exception Java.lang.Exception has already benn caught.
       e.printStackTrace();
   }
}

这段代码会导致编译失败,因为先捕获Throwable后捕获Exception,会导致后面的catch永远无法被执行。

Return 和finally的问题

这算是我们扩展的一个相对比较极端的问题,就是类似这样的代码,既有return,又有finally,那么finally导致会不会执行

public static String tryCatchReturn() {
   try {
       testNPE();
       return  "OK";
   } catch (Exception e) {
       return "ERROR";
   } finally {
       System.out.println("tryCatchReturn");
   }
}

答案是finally会执行,那么还是使用上面的方法,我们来看一下为什么finally会执行。

public static java.lang.String tryCatchReturn();
    Code:
       0: invokestatic  #3                  // Method testNPE:()V
       3: ldc           #6                  // String OK
       5: astore_0
       6: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: ldc           #8                  // String tryCatchReturn
      11: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      14: aload_0
      15: areturn       返回OK字符串,areturn意思为return a reference from a method
      16: astore_0
      17: ldc           #10                 // String ERROR
      19: astore_1
      20: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      23: ldc           #8                  // String tryCatchReturn
      25: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      28: aload_1
      29: areturn  //返回ERROR字符串
      30: astore_2
      31: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      34: ldc           #8                  // String tryCatchReturn
      36: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      39: aload_2
      40: athrow  如果catch有未处理的异常,抛出去。

异常是否耗时?为什么会耗时?

说用异常慢,首先来看看异常慢在哪里?有多慢?下面的测试用例简单的测试了建立对象、建立异常对象、抛出并接住异常对象三者的耗时对比:

public class ExceptionTest {  
  
    private int testTimes;  
  
    public ExceptionTest(int testTimes) {  
        this.testTimes = testTimes;  
    }  
  
    public void newObject() {  
        long l = System.nanoTime();  
        for (int i = 0; i < testTimes; i++) {  
            new Object();  
        }  
        System.out.println("建立对象:" + (System.nanoTime() - l));  
    }  
  
    public void newException() {  
        long l = System.nanoTime();  
        for (int i = 0; i < testTimes; i++) {  
            new Exception();  
        }  
        System.out.println("建立异常对象:" + (System.nanoTime() - l));  
    }  
  
    public void catchException() {  
        long l = System.nanoTime();  
        for (int i = 0; i < testTimes; i++) {  
            try {  
                throw new Exception();  
            } catch (Exception e) {  
            }  
        }  
        System.out.println("建立、抛出并接住异常对象:" + (System.nanoTime() - l));  
    }  
  
    public static void main(String[] args) {  
        ExceptionTest test = new ExceptionTest(10000);  
        test.newObject();  
        test.newException();  
        test.catchException();  
    }  
}  

运行结果:

建立对象:575817  
建立异常对象:9589080  
建立、抛出并接住异常对象:47394475

建立一个异常对象,是建立一个普通Object耗时的约20倍(实际上差距会比这个数字更大一些,因为循环也占用了时间,追求精确的读者可以再测一下空循环的耗时然后在对比前减掉这部分),而抛出、接住一个异常对象,所花费时间大约是建立异常对象的4倍。

那占用时间的“大头”:抛出、接住异常,系统到底做了什么事情?请参考这篇文章:

参考文章

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

毕设王同学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值