【Java】【重要机制】详解异常机制

举报
huahua.Dr 发表于 2022/10/23 20:10:09 2022/10/23
【摘要】 一、什么是异常及异常分类程序在运行期间发生的不正常事件,它会打断指令的正常流程。异常都是发生在程序的运行期,编译出现的问题叫语法错误。分为两类异常检查时异常(checked exception): 在编译阶段就能发现的异常,必须在编译阶段处理的异常,使用try-catch机制或者throws可容错处理,如调用某个库函数时,该库函数会在某些场景抛出异常,那么在你的代码里面就必须进行处理,否则会...

一、什么是异常

程序在运行期间发生的不正常事件,它会打断指令的正常流程。异常都是发生在程序的运行期,编译出现的问题叫语法错误。

分为两类异常:

  • 检查时异常(checked exception): 在编译阶段就能发现的异常,必须在编译阶段处理的异常,使用try-catch机制或者throws可容错处理,如调用某个库函数时,该库函数会在某些场景抛出异常,那么在你的代码里面就必须进行处理,否则会编译不通过。常见的检查时异常有(IO异常及sql异常)。
  • 运行时异常(runtime exception): 我们可以不处理。当出现这样的异常时,总是由虚拟机接管。比如:我们从来没有人去处理过NullPointerException异常,它就是运行时异常,并且这种异常还是最常见的异常之一。我们也可以使用try catch机制去处理,但是我们需要尽量通过预检查方式规避的运行时异常,不应该通过try catch机制进行处理,如NullPointerException 、IndexOutOfBoundsException 等。

二、java中的异常分类

Java的异常是class,它的继承关系如下:

继承关系:

(1)Throwable是异常体系的根,它继承自Object

(2)Throwable有两个体系:Error和Exception

  • Error:表示在运行时出现严重的错误,程序对此一般无能为力,例如:
    • OutOfMemoryError:内存耗尽,在运行中出现常见的三种情况:
      • 永久区内存溢出(PermGenspace),是由于程序中使用了大量的jar或class,使JVM装载类的空间不够。解决办法是增加JVM中的XX:PermSize和XX:MaxPermSize参数的大小或者清理应用程序中web-inf/lib下的jar。
      • java堆内存空间溢出( heap space),由于JVM创建的对象太多,在进行垃圾回收之间,虚拟机分配的到堆内存空间已经用满了;解决办法是检查程序,看是否有死循环或不必要地重复创建大量对象或者增加Java虚拟机中Xms(初始堆大小)和Xmx(最大堆大小)参数的大小
      • 系统内存溢出,无法为新线程分配内存(unable to create new native thread),由于创建线程数超过了操作系统的限制,解决方法是排查应用是否创建了过多的线程或者调整操作系统线程数阈值、增加机器内存、减小堆内存(线程不在堆内存上创建,线程在堆内存之外的内存上创建。所以如果分配了堆内存之后只剩下很少的可用内存,依然可能遇到java.lang.OutOfMemoryError: unable to create new native thread。)。
    • NoClassDefFoundError:无法加载某个Class,是由于JVM在编译时能找到合适的类,而在运行时不能找到合适的类导致的错误,解决办法:
      • 对应的Class在java的classpath中不可用,检查classpath路径的类文件
      • 你可能用jar命令运行你的程序,但类并没有在jar文件的manifest文件中的classpath属性中定义
      • 可能程序的启动脚本覆盖了原来的classpath环境变量
      • 因为NoClassDefFoundError是java.lang.LinkageError的一个子类,所以可能由于程序依赖的原生的类库不可用而导致
      • 如果你工作在J2EE的环境,有多个不同的类加载器,也可能导致NoClassDefFoundError。
    • StackOverflowError:栈溢出,一般由于特定代码段中的递归太深/未终止/无限递归而引发的或者方法中有大量局部变量。解决办法:仔细检查堆栈跟踪,以识别行号的重复模式
  • Exception:是运行时的错误,它可以被捕获并处理。

(3)Exception具体有检查时异常和运行时异常

  • 检查时异常:(非RuntimeException【包括IOException、ReflectiveOperationException等等】)这类异常是应用程序逻辑处理的一部分,在编译阶段就应该捕获并处理。例如:
    • EOFException 文件已结束异常
    • FileNotFoundException  文件未找到异常
    • SQLException  操作数据库异常
    • IOException  输入输出异常
    • NoSuchMethodException  方法未找到异常
    • ClassNotFoundException 类找不到异常
    • NamingException  相关jndi服务程序的异常
    • InterruptedException 中断异常
  • 运行时异常:(RuntimeException以及它的子类)还些异常是程序逻辑编写不对造成的,需要人为检查发现并修复程序本身,如果没有修复,编译会通过,但是运行时可能会抛出异常,导致程序终止。例如:
    • NullPointerException  空指针异常
    • ArrayIndexOutOfBoundsException 数组下标越界异常
    • ClassCastException 类型转换异常
    • IndexOutOfBoundsException 索引越界
    • NumberFormatException 数字格式异常
    • IllegalArgumentException 不合法参数,运行过程中传入的参数与约束的不一致
    • ArithmeticException 运算异常,例如在运算过程中传入的被除数为0

Java规定:

  • 必须捕获的异常,包括Exception及其子类,但不包括RuntimeException及其子类,这种类型的异常称为Checked Exception。
  • 不需要捕获的异常,包括Error及其子类,RuntimeException及其子类。

编译器对RuntimeException及其子类不做强制捕获要求,不是指应用程序本身不应该捕获并处理RuntimeException。是否需要捕获,具体问题具体分析。

三、如何处理异常

处理异常的方式也分检查时异常和运行时异常的处理:

  • 检查时异常处理方式:该异常必须处理,否则编译不通过。
    • 捕获:使用try-catch-finally机制进行捕获,常用的重点处理方法
    • 继续抛出:使用throws向上层抛出异常,由上层去处理,该方法比较消极。
  • 运行时异常处理方式
    • 捕获:使用try-catch-finally机制进行捕获,常用的重点处理方法
    • 继续抛出:使用throws向上层抛出异常,由上层去处理,该方法比较消极。
    • 不处理:因为是运行时可能出现的异常,可以选择不做任何处理,但是,容易导致程序提供不了服务,因此不建议不处理。

四、使用try-catch-finally机制存在的问题

使用try-catch-finally机制进行处理时,一般在finally块里面进行最后处理,如资源的释放等等;主要原因是:系统资源有很多种,比如Stream、File、Socket或者DB Connection,它们的总量是有限的,在使用完毕后应该及时地关闭并归还给系统,以防止资源被耗尽的风险。但是在Java 1.7之前,为了保证资源被关闭,通常都将close操作写在finally块中,即使发生异常或者返回也能一样得到调用。这也是在Java 1.7前确保资源被适时关闭的最佳方法。即便如此,还是不够完美,有不少地方被诟病。会存在一些问题:

1)复杂易错:如果代码需要捕获的异常很多,那么代码就变得复杂了,业务代码有被淹没的趋势了

public void test(String src, String dst) {

   try {

       FileInputStream ins = null;

       try {

           ins = new FileInputStream(src);

           OutputStream outs = null;

           try {

               outs = new FileOutputStream(dst);

               byte[] buf = new byte[BUF_SIZE];

               int n;

               while (true) {

                   if (!((n = ins.read(buf)) >= 0)) {

                       outs.write(buf, 0, n);

                  }

              }

          } finally {

               try {

                   if (outs != null) {

                       outs.close();

                  }

              } catch (IOException e) {

                   LOG.error(e.getMessage(), e);

              }

          }

      } finally {

           try {

               if (ins != null) {

                   ins.close();

              }

          } catch (IOException e) {

               LOG.error(e.getMessage(), e);

          }

      }

  } catch (IOException e) {

       LOG.error(e.getMessage(), e);

  }

}

2)异常抑制:除了复杂外,常规的try-finally关闭资源,还会导致异常抑制(Suppressed)的问题,也就是异常覆盖异常屏蔽

我们自定义一个资源类Connection,send为业务方法,close为关闭资源方法,各自抛出SendExceptionCloseException

public class ConnectionNormal {

   public void send() throws Exception {

       throw new SendException("send fail.");

  }

   public void close() throws Exception {

       throw new CloseException("close fail");

  }

}

测试代码如下,按照程序逻辑,应该先抛出SendException,再抛出CloseException

public static void main(String[] args) {

   try {

       test();

  } catch (Exception e) {

       e.printStackTrace();

  }

}

​private static void test() throws Exception {

   ConnectionNormal conn = null;

   try {

       conn = new ConnectionNormal();

       conn.send();

  } finally {

       if (conn != null) {

           conn.close();

      }

  }

}

运行后我们发现:

com.CloseExceptionclose fail

SendException明明先被抛出了,但是没有丝毫痕迹,被后抛的CloseException给抑制了,这就是异常抑制,SendException被称为“Suppressed Exception ”。关键的异常信息丢失,这会导致某些bug变得极其隐蔽而难以发现!

五、使用try-with-resource优化try-catch机制

为了解决上面try-catch机制带来的问题,从Java1.7开始,给大家带来了两个好东西:

  • try-with-resource语法糖,让资源的申请关闭更加简洁。
  • Throwable类新增了addSuppressed方法,支持将一个异常附加到另一个之上,从而解决异常抑制。

只要我们使用try-with-resource,默认的就会自动启用addSuppressed,解决异常抑制。

(1) 使用try-with-resource前提

能够借助try-with-resource关闭资源的类必须实现AutoClosable接口,重写close方法。如果是自定义资源类, 为了支持try-with-resource,请务必实现AutoClosable接口。一般来说,常见的公共库的资源类,只要支持Java1.7+,一般都已经实现了AutoClosable接口,比如,JDK中所有的OutputStream都已经实现了:

public abstract class OutputStream implements CloseableFlushable {...}

万事都有例外,如果某个在用的公共库不够与时俱进,里边的资源类没有实现AutoClosable接口,那就用不了try-with-resource,编译也会报错。所以:

在使用try-with-resource之前,请务必确认下资源类是否已经实现了AutoClosable接口。

(2) 使用try-with-resource方法

try-with-resource使用很简单,申请资源的代码写在try后面的()中即可,资源关闭无需显式close。两个资源,仅需要用;隔开即可:

public void test(String src, String dst){

   try (FileInputStream ins = new FileInputStream(src);

       OutputStream outs = new FileOutputStream(dst)) {

       byte[] buf = new byte[BUF_SIZE];

       int n;

       while ((n = ins.read(buf)) >= 0) {

           outs.write(buf, 0, n);

      }

  } catch (IOException e) {

       LOG.error(e.getMessage(), e);

  }

}

(3) try-with-resource机制实现原理

为了更直观感受try-with-resource的自动关闭资源是如何工作,自定义一个资源类。

public class ConnectionAutoClose implements AutoCloseable {

   public void send() throws Exception {

       System.out.println("send ok.");

  }

   @Override

   public void close() throws Exception {

       System.out.println("auto closed.");

  }

}

private static void test2() {

   try (ConnectionAutoClose conn = new ConnectionAutoClose()) {

       conn.send();

  }

   catch (Exception e) {

       e.printStackTrace();

  }

}

运行结果:

send ok.

auto closed.

可见,无需显式调用close,它也被正确的执行了。

再来看下是否解决了异常抑制的问题。修改类使其抛出异常:

public class ConnectionAutoClose implements AutoCloseable{

   public void send() throws Exception {

       throw new SendException("send fail.");

  }

   @Override

   public void close() throws Exception {

       throw new CloseException("close fail");

  }

}

运行结果:

com.SendExceptionsend fail.

at com.ConnectionAutoClose.send(ConnectionAutoClose.java:5)

at com.TryWithResource.test2(TryWithResource.java:27)

at com.TryWithResource.main(TryWithResource.java:6)

Suppressedcom.CloseExceptionclose fail

at com.ConnectionAutoClose.close(ConnectionAutoClose.java:10)

at com.TryWithResource.test2(TryWithResource.java:28)

... 1 more

使用try-with-resource确实带了神奇的效果,它是如何做到的呢? 我们可以通过反编译.class一窥究竟。

private static void test2() {

   try {

       ConnectionAutoClose conn = new ConnectionAutoClose();

       Throwable var1 = null;

       try {

           conn.send();

      } catch (Throwable var11) {

           var1 = var11;

           throw var11;

      } finally {

           if (conn != null) {

               if (var1 != null) {

                   try {

                       conn.close();

                  } catch (Throwable var10) {

                       var1.addSuppressed(var10);

                  }

              } else {

                   conn.close();

              }

          }

      }

  } catch (Exception var13) {

       var13.printStackTrace();

  }

}

真相大白!try-with-resource只是一个 语法糖,最终还是编译成常规的try-finally代码,它主要做了以下事情:

1.  添加调用close方法的代码,关闭资源。

2.  使用addSuppressed方法附加异常,消除异常抑制的问题。

4)需要注意的地方

在Java bio中采用了大量的装饰器模式。当调用装饰器的close方法时,本质上是调用了装饰器内部包裹的流的close方法。比如:

public void gzipWrapper(File filethrows IOException {

   try (GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(file))) {

      ...

  }

}

反编译得到:

public void gzipWrapper(File file) throws IOException {

   GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(file));

   Throwable var3 = null;

   try {

      ...

  } catch (Throwable var12) {

       var3 = var12;

       throw var12;

  } finally {

       if (out != null) {

           if (var3 != null) {

               try {

                   out.close();

              } catch (Throwable var11) {

                   var3.addSuppressed(var11);

              }

          } else {

               out.close();

          }

      }

  }

}

可以看到,在finally中仅调用了out.close(),这个out为GZIPOutputStream的实例,并没有显式关闭FileOutputStream资源。

再具体再看下GZIPOutputStream.close内部实现:

public void close() throws IOException {

   if (!closed) {

       finish();   // 注意!! 这里可能抛出Exception

       if (usesDefaultDeflater)

           def.end();

       out.close(); // out为FileOutputStream的流对象

       closed = true;

  }

}

可以看到,正常情况下,它会调用out.close(),关闭被包裹的FileOutputStream。在此前面,还有一个finish方法,如果出错了抛出异常,那么out.close()无法被调用,被包裹的FileOutputStream就无法被关闭了。因此:

为了使程序更加健壮,在try-with-resouce中使用装饰器时,建议显式声明被装饰/包裹对象的引用。

如果代码这样写,fout.close一定会被调用:

public void gzipWrapperRobust(File filethrows IOException {

   try (FileOutputStream fout = new FileOutputStream(file); // 显式声明

        GZIPOutputStream out = new GZIPOutputStream(fout)) {

      ...

  }

}

反编译:

public void gzipWrapperRobust(File file) throws IOException {

   FileOutputStream fout = new FileOutputStream(file);

   Throwable var3 = null;

   try {

       GZIPOutputStream out = new GZIPOutputStream(fout);

       Throwable var5 = null;

       try {

          ...

      } catch (Throwable var28) {

           var5 = var28;

           throw var28;

      } finally {

           if (out != null) {

               if (var5 != null) {

                   try {

                       out.close();

                  } catch (Throwable var27) {

                       var5.addSuppressed(var27);

                  }

              } else {

                   out.close();

              }

          }

      }

  } catch (Throwable var30) {

       var3 = var30;

       throw var30;

  } finally {

       if (fout != null) {

           if (var3 != null) {

               try {

                   fout.close();

              } catch (Throwable var26) {

                   var3.addSuppressed(var26);

              }

          } else {

               fout.close();

          }

      }

  }

}

六、在Java中处理异常的实践总结

对可容错处理的情况使用检查时异常(checked exception),对编程错误使用运行时异常(runtime exception),那么运行时异常就必须做处理,处理的方式常常是try-catch机制,但是有些运行时异常是不建议采用try catch机制进行处理,如NullPointerException 、IndexOutOfBoundsException 等,我们最好是通过预检查手法或者代码检视去修复规避这类异常,说白了就是,能在代码层面防止出现的运行时异常就尽量通过修改代码去解决,不要依赖try-catch机制。只有针对真正异常的情况才使用exception和try-catch机制,同时异常机制不应用来做流程控制和条件控制;只做业务逻辑错误处理即可。总结以下几点关于异常的处理点:

(1)使用try-catch机制处理异常时,必须不能留一个空的catch块忽略异常,同时不要去捕获异常的基类Throwable、Exception、RuntimeException;或者抛出来的异常应该是具体的、与实际调用方法的层次一致。定义异常时应该区分受检异常和运行时异常。抛出异常时,应避免直接抛出RuntimeException ,更不应该直接抛出

Exception 或Throwable 。异常表示程序运行发生了错误,发生异常会中断程序的正常处理流程。不应该使用空的catch块会忽略发生的异常,发生异常要么在catch块中对异常情况进行处理,要么将异常抛出,交由上层调用进行处理。捕获的时候,需要捕获具体的异常,我们才能对对应的异常进行恢复处理。

(2)对应第三方API直接抛出的异常,我们还是需要直接捕获对应的异常,不区分其分类,如Throwable、Exception、RuntimeException、NullPointerException 、IndexOutOfBoundsException 等

(3)对于一些敏感异常信息,我们不能直接抛出或者记录在日志中,例如:FileNotFoundException或者IOException,一抛出后,攻击者就会知道我们具体的异常原因是由于文件路径不对或者文件读取不对,就会恶意地不断传入指定的文件路径来探测底层的文件系统结构,从而会受到工具。处理方法是:对于敏感异常统一抛出自定义异常,记录日志的时候也统一将敏感信息掩盖。同时对一些操作做防护,如白名单、文件路径校验等。

4在finally代码块中,直接使用return、break、continue、throw语句,或由于调用方法的异常未处理,会导致finally代码块无法正常结束。非正常结束的finally代码块会影响try或catch代码块中异常的抛出,也可能会影响方法的返回值。例如现在在循环中的finally块中使用continue,会导致抛出了异常,但是被continue跳过,最后返回了0,导致main方法无法捕获到异常。

public static void main(String[] args) {

try {

System.out.println(test());

} catch (MyException ex) {

// 处理异常

}

}

public static int test() throws MyException {

for (int i = 1; i < 2; i++) {

try {

throw new MyException();

} finally {

continue;

}

}

return 0;

}

5)不要调用System.exit() 终止JVM,System.exit() 会结束当前正在运行的Java虚拟机(JVM),导致拒绝服务攻击。例如,在某个web请求的处理逻辑中调用System.exit() ,会导致web容器停止运行。系统中应避免无意和恶意地调用System.exit();但是在命令行应用中调用System.exit()方法是允许的。

6)  传统的try-finally方式存在复杂易出错和异常抑制(Suppressed)等问题。

7)使用try-with-resource,可以更安全、简洁地申请和关闭资源,同时解决了异常抑制问题。

8try-with-resource是语法糖,其最终仍然会被编译成try-finally方式并调用close方法关闭资源。

9) 为了支持try-with-resource,资源类必须要实现AutoClosable接口,否则无法使用。在使用try-with-resource之前,请务必确认下资源类是否已经实现了AutoClosable接口。

10) try-with-resource使用很简单,申请资源的代码写在try后面的()中即可,无需显式调用close方法来关闭资源。

11)为了使程序更加健壮,在try-with-resouce中使用装饰器时,建议显式声明被装饰/包裹对象的引用。

 

 

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。