Java异常知识点思考与总结

Java 中的异常可以是方法执行过程中引发的,也可以是通过 throw 语句手动抛出的。一旦程序运行过程中发生了异常,JRE 就会试图寻找异常处理程序来处理异常,用具体的异常对象来包装该异常。

Throwable 类是 Java 异常类的顶层父类,一个对象只有是 Throwable 类的(直接或者间接)实例,它才是一个异常对象,才可以被抛出(throw)或者捕获(catch),才能被异常处理机制识别和处理。除了 JDK 中内建的常用异常类,还允许我们自定义异常。

异常划分

Java异常结构图

可以看到,Throwable 派生出 Error 和 Exception ,这体现了 Java 平台设计者针对不同异常情况的合理分类。其中,Exception 是指应用正常运行中,可以被预料的意外情况,程序捕获后可以进行相应的处理。Error 是指在正常情况下,不大可能出现的情况,而绝大多数的 Error 都会导致程序进入非正常的、不可恢复的状态,Error 类错误通常不可以被捕获,如 OutOfMemoryError、NoClassDefFoundError。

Checked && Unchecked Exception

对于程序来说,异常又可以划分为应检查(checked)异常和不检查(unchecked)异常,应检查异常要求必须在代码里进行显式捕获和处理,javac 会在编译期间就进行检查。

  • 不检查异常(unchecked exception):

不检查异常就是所谓的运行时异常,通常是可以通过编码来避免的一些逻辑错误,包括 Error 和 RuntimeException 以及他们的子类。javac在编译时,不会提示和发现此类异常,即不要求通过代码显示处理这些异常。对于这些异常,我们应该修正代码,而不是通过异常处理器来解决,如除0错误:ArithmeticException,强制类型转换错误:ClassCastException,数组越界异常:ArrayIndexOutOfBoundsException,使用了空对象出现的 NullPointerException 等。

  • 应检查异常(checked exception):

除了 Error 和 RuntimeException 的其它异常。javac 强制要求处理的异常,可以用 try-catch-finally 或 try-with-resources 语句捕获并处理,也可以使用 throws 往上抛出,否则编译不会通过。应检查异常通常是由程序的运行环境所导致的,而这些在程序运行过程中是无法提前预知的,于是代码中就应该为这样的异常提前准备,如SQLException , IOException和ClassNotFoundException 等。

异常栈

函数通常是层级调用的,进而形成调用栈,而异常则是执行某个函数时所引发的。因此,只要在方法调用的过程中发生了异常,那么他的所有 caller 都会被异常影响,当这些被影响的函数以异常信息输出时,就形成的了异常追踪栈(如上图所示)。所以,异常最先发生的地方,也叫做异常抛出点。

try…catch…finally 语句块

    try (ServletOutputStream outputStream = response.getOutputStream()) { // Try-with-resources
        // 1. try块中放可能发生异常的代码
        // 1.1 如果执行完try且不发生异常,则接着去执行finally块和finally后面的代码(如果有的话)
        // 1.2 如果发生了异常,则会先尝试去匹配catch块,最后再执行finally块(如果有的话)
    } catch (ClassCastException | IndexOutOfBoundsException e) {  // Multiple catch
        // 1. 每一个catch块用于捕获并处理一个特定的异常,或者这个异常类型的子类。Java7提供的multiple catch新特性,可以将多个异常声明在一个catch中
        // 2. catch后面的括号定义了异常类型和异常参数。如果异常与之匹配且是最先匹配到的,则虚拟机将使用这个catch块来处理异常
        // 3. 在catch块中可以使用这个块的异常参数来获取异常的相关信息。异常参数是这个catch块中的局部变量,其它块不能访问
        // 4. 如果当前try块中发生的异常在后续的所有catch中都没捕获到,则先去执行finally,然后到这个函数的外部caller中去匹配异常处理器
        // 5. 如果try中没有发生异常,则所有的catch块将被忽略
    } catch (Exception e) {
        // 1. 异常匹配是按照catch块的顺序从上往下寻找的,只有第一个匹配的catch会得到执行。匹配时,不仅运行精确匹配,也支持父类匹配
        // 2. 如果同一个try块下的多个catch异常类型有父子关系,应该将子类异常放在前面,父类异常放在后面,确保每个catch块都有其存在的意义
        // 3. 异常处理就是将执行控制流从异常发生的地方转移到能够处理这种异常的地方。当一个函数的某条语句发生异常时,这条语句的后面的语句就不会再执行了,它失去了焦点
    } finally {   
        // 1. finally块是可选的
        // 2. 无论异常是否发生,异常是否匹配被处理,finally都最终会执行
        // 3. 一个try至少要有一个catch块,否则至少要有1个finally块。但是finally不是用来处理异常的,finally不会捕获异常
        // 4. finally主要做一些资源的清理工作,比如流的关闭,数据库连接的关闭等;Java7及以后的版本中,更是推荐使用try-with-resources这种新特性来简化这些操作 
    }

throws 异常声明

throws 是另一种处理异常的方式,它不同于 try...catch...finally,throws仅仅是将函数中可能出现的异常向调用者声明,其本身并不进行处理。

采取这种异常处理的原因大多是:方法编写者本身不知道如何处理这样的异常,或者说让调用方来处理会更好,从而让调用方来为可能发生的异常负责。

    public void example() throws IOException { 

    }

finally 块

try块中的代码执行完后,finally块是一定执行的。但也有一种比较特殊的情况,就是在这之前执行了System.exit()

finally块通常用来做资源的释放、关闭文件等操作。良好的编程习惯是:在try块中打开资源,在finally块中清理并释放这些资源,Java7之后更是推荐直接使用try-with-resources。

下面简单总结一下:

  1. finally块没有处理异常的能力,处理异常的只能是catch块;
  2. 在同一try...catch...finally块中 ,如果try中抛出异常,且有匹配的catch块,则先执行catch块,再执行finally块。如果没有catch块匹配,则先执行finally,然后去外面的调用者中寻找合适的catch块;
  3. 在同一try...catch...finally块中 ,try发生异常,且匹配的catch块中处理异常时也抛出异常,后面的finally还是会先执行,最后才去外围调用者中寻找合适的catch块。

补充几点开发建议:

  1. 不要在fianlly中使用return
  2. 不要在finally中向外抛出异常
  3. 不要在finally中做除了释放资源的其它的事情
  4. 用try-with-resources避免finally

自定义异常

扩展自Exception类的自定义异常,属于应检查异常(checked exception)。如果要自定义非检查异常(unchecked exception),继承RuntimeException即可。

通常情况下,自定义的异常应该总是包含如下的构造器,具体可以参考jdk中自带的异常是如何定义的:

  • 一个无参构造函数
  • 一个带有 String 参数的构造函数,并传递给父类的构造函数。
  • 一个带有 String 参数和 Throwable 参数,并都传递给父类构造函数
  • 一个带有 Throwable 参数的构造函数,并传递给父类的构造函数。

IOException

异常案例

异常处理可谓也是一门艺术活。下面列举了一些错误的、常见的异常处理方式,你可以通过阅读代码来提前思考,判断这些异常处理中,具体有哪些不当之处:

示例一

    try {
        Thread.sleep(1000L);
    } catch (Exception e) {
        // do nothing...
    }
  • 尽量不要捕获类似 Exception 这样的通用异常,而是应该捕获特定异常,在这里 Thread.sleep() 抛出的 InterruptedException
  • 不要生吞(swallow)异常,这是异常处理中特别要注意的事情,因为很可能会导致非常难以诊断的诡异情况。如果我们没有把异常抛出来,或者也没有输出到日志(Logger)之类,程序可能在后续代码以不可控的方式结束。没人能够轻易判断究竟是哪里抛出了异常,以及是什么原因产生了异常

示例二

    try {
       // …
    } catch (Exception e) {
        e.printStackTrace();
    }

开发或测试环境中,上面这段代码是没有问题的,但在产品代码中,是绝对不允许这样处理的。来看看printStackTrace()的文档,开头就是“Prints this throwable and its backtrace to the standard error stream”。问题就在这里,在稍微复杂一点的应用中,标准错误流(STERR)并不是个合适的输出选项,因为你很难判断异常到底输出到哪里了。

示例三

    public void test(String fileName) {
        // 提前校验参数是否合法
        Assert.isTrue(!"".equals(fileName) && Objects.nonNull(fileName), "file name is not empty");
        File file = new File(fileName);
        // ...
    }

遵循 Throw early, catch later 原则。如果 fileName 是 null 或者 空字符串,那么后面程序获取文件时肯定会抛出异常。提前校验并且抛出异常,可以更加清晰地反映问题。

示例四

@Service
public class UserService {

   @Resource
   private UserMapper userMapper;
   
   @Transactional
   public void insert(User user) {
       // 插入用户信息
       userMapper.insertUser(user);
       // 手动抛出异常
       throw new SQLException("数据库异常");
   }
}

上述这段代码中,异常并没有被捕获到,所以事务并不会回滚。 Spring Boot 默认的事务规则是遇到运行异常(RuntimeException)和程序错误(Error)才会回滚。而 SQLException 是非运行异常,继承自 Exception。解决上述问题,需要在 @Transactional 注解中使用 rollbackFor 属性来指定异常:@Transactional(rollbackFor = Exception.class)

示例五

@Service
public class UserService {

   @Resource
   private UserMapper userMapper;
   
   @Transactional(rollbackFor = Exception.class)
   public void insert(User user) {
        try {
           // 插入用户信息
           userMapper.insertUser(user);
           // 手动抛出异常
           throw new SQLException("数据库异常");
       } catch (Exception e) {
           // 异常处理逻辑
       }
   }
}

同样的,事务并没有因为抛出异常而回滚,这是因为 try...catch 把异常生吞了,这个细节往往比上面那个坑更加难发现。

示例六

@Service
public class UserService {

   @Resource
   private UserMapper userMapper;
   
   @Transactional(rollbackFor = Exception.class)
   public synchronized void insert(User user) {
       // 插入用户信息
       userMapper.insertUser(user);
   }
   
}

上述代码中,synchronized 并不会生效,原因是因为事务的范围比锁的范围大。加锁的那部分代码执行完之后,锁释放掉了但事务还没结束,此时假设另外一个线程进来了,事务没结束的话,插入动作就会产生脏数据。解决办法有两种,第一,去掉事务(不推荐);第二,在调用该方法的地方加锁,保证锁的范围比事务的范围大即可。

性能分析

从性能的角度来审视一下 Java 的异常处理机制,这里有两个相对昂贵的地方:

  • try-catch 代码段会产生额外的性能开销,换个角度说,它往往会影响 JVM 对代码进行优化,因此建议仅捕获有必要的代码段,尽量不要用一个大的 try 块包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,这远比通常意义上的条件语句(if/else、switch)要低效
  • 每实例化一个 Exception,都会对当前的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个性能开销就不能被忽略了

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券