5
头图

一、前言

最近在一次写代码的时候,出现了一个低级错误,但凡对异常有些了解,也不至于写出这样的代码:

try {
    //不应该直接在try语句块中抛异常,catch直接获取后,相当于异常没抛出去
    throw new ThirdPlatformException("第三方平台异常");
} catch {
    
}

说明自己对异常的处理机制和异常处理的规范都不太了解,趁着这次出现的问题,来好好学习Java异常的体系机制和原理。这篇文章主要通过三个部分阐释Java异常

  • Java中异常的分类,异常的处理机制
  • 异常的处理规范和实战,如何利用Springboot框架处理异常
  • 从JVM的角度分析异常机制,包括try-catch, try-finally, try-with-resource的字节码分析

二、什么是异常-异常处理机制

在Java中,异常就是指Java程序运行中出现的问题,比如网络资源读取失败,空指针,使用非法数组索引等等。而我们日常看到的各种xxxException类就是对这些异常的描述,他们都是派生于Throwable类(表示可以抛出这个异常)的一个类的实例,下面就来详细介绍一下Java中的异常分类:

2.1 异常分类

image.png

  • Throwable类:是所有错误和异常的超类,其中包括该线程执行堆栈的快照,提供printStackTrace()等接口用用获取堆栈异常等等信息。
  • Error类及其子类:表示运行应用程序中出现了严重错误,一般表示代码运行中的非代码错误。当此类异常发生时,应用程序不应该去处理此类错误。因此我们也不应该实现任何新的Error子类的。
  • Exception类及其子类是程序本身可以捕获并且可以处理的异常。也是在日常写代码中接触的最多的一类异常,主要有两类:运行时异常(RuntimeException)编译异常(非运行时异常)

    • 运行时异常(RuntimeException):主要是RuntimeException类及其子类,比如NullPointerExceptionIndexOutOfBoundsException等。这类异常的特点是Java编译器不会检查,即便没有使用异常处理,代码也会编译通过
    • 非运行时异常:除RuntimeException类及其子类外的Exception子类,比如IOExceptionSQLException等。这类异常Java编译器肯定会检查,如果不作异常处理,代码就不能编译通过。

上面提到Exception时,有些异常不会被编译通过。所以对于整个异常体系来说,在是否能被Java编译器检查的角度,又分为可查异常不可查异常

  • 可查异常(Checked Exception):在编译器就会被检查,如果这类异常不处理,则代码编译不通过。也就是Exception中的非运行时异常——除RuntimeException之外的Exception子类。这类异常很好发现,在IDE软件中如果有问题,就会报红:
  • 不可查异常(Unchecked Exception):这类异常编译器不会检查,在运行时才可能抛出该异常,主要有Exception中的运行时异常和Error及其子类,在运行过程中出现问题才会报错,一般在日志中才能见到:

除了这些JDK自带的异常外,我们同样可以自定义异常,通过继承相关的异常类

  • 自定义检查异常

    class CheckedException extends Exception{}
  • 自定义非检查异常

    class UnCheckedException extends RuntimeException{}

    这些自定义检查异常的效果和JDK中自带的异常相同,自定义的检查异常不处理的话,同样会编译不通过。

2.2 异常关键字

  • try(监听异常):主要用于监听try语句块的代码,当其中的代码出现异常,就会被抛出,需要和catchfinally等其他关键字一起使用
  • catch(捕获异常):用来捕获异常,在捕获try语句块的异常后会执行该语句块中的代码
  • finally(总是会被执行):无论是否有异常,都会执行该语句块中的代码。
  • throw(抛出异常):抛出相关异常,如前言中的:

    throw new ThirdPlatformException("第三方平台异常");

    然而在大多数情况中,都不需要手动抛出异常,一方面在调用JDK资源类时,已经处理过异常;另一方面在业务代码中,都会统一自定义异常类,所以尽量做捕获异常或者向上抛。

  • throws(声明异常):在方法上声明可能会抛出的异常,作用是将异常传递给合适的处理程序,比如:

    public interface LargeModelSession throws RuntimeException{}
  • trycatchfinally都不能单独使用,只能是try-catchtry-finally或者try-catch-finally

2.3 异常的处理

image.png
把大象放入冰箱需要几步?类似的,对于程序中的异常处理,可以分为发现异常,传递异常和处理异常这三步:

  • 第一步 发现异常(捕获)

    • 通过try块来监听异常
    • 方法头中使用throws显示声明可能会抛出的异常
  • 第二步 传递异常

    • try块的异常抛给catch,或者不处理直接到达finally
    • throws抛给该方法的调用者
    • throw直接new一个异常实例抛出
  • 第三步 处理异常

    • try-catch类型则直接在catch块中处理异常
    • throws则需要方法调用者进行处理

常见的异常捕获主要有以下几种:

  • try-catch
  • try-catch-finally
  • try-finally
  • try-with-resource

2.3.1try-catch

try-catch语句中可以通过多个catch捕获多个异常类型,并做不同的处理,并且也可以在一个catch中捕获不同的异常:

/**1. 多个catch捕获多个异常类型*/
try{
    //监视的可能会出现异常的代码
} catch(Exception1 e1){
    //Exception1类型的异常处理
} catch(Exception2 e2){
    //Exception1类型的异常处理
}
/**2. 一个catch捕获多个异常类型*/
try{
    //监视的可能会出现异常的代码
} catch(Exception1 |Exception2 e2){
    //Exception1和Exception2类型的异常处理
} 

当程序发生异常后,会按照异常从上到下(多个catch)或者从左到右(一个catch)的顺序依次匹配,匹配成功后就直接在该catch中进行处理。

2.3.2try-catch-finally

try-catch-finally类型的特点是不管try块中是否监听到异常,finally块中的语句都会被执行:

  • 有异常:try块出现异常->抛给catch块执行->执行finally块中代码
  • 无异常:try块没有异常->执行finally块中代码

    try {                        
      //监视可能会出现异常的代码                
    } catch(Exception e) {   
      //捕获异常并处理   
    } finally {
      //一定会执行的代码
    }

    finally块主要用在IO读取、数据库连接、运行清理等需要关闭的场景

2.3.3try-finally

try-finally比try-catch-finally更加直接,try块的代码出现异常不予处理,立即执行finally块中代码,一般用于不需要捕获异常的代码:

//截取DefaultMBeanServerInterceptor中的部分代码
final ResourceContext context = unregisterFromRepository(resource, instance, name);
try {
    if (instance instanceof MBeanRegistration)
        postDeregisterInvoke(name,(MBeanRegistration) instance);
} finally {
    context.done();//无论是否出现问题直接执行
}

2.3.4try-with-resource

try-with-resource是JDK1.7后引入的,如果一个类实现了AutoCloseable接口,那么这个类就可以写在try后的括号中,并且能再try-catch块执行后自动执行close方法,也就不用再写finally块
它实际上将try-catch-finally简化成try-catch,在编译时会转化成try-catch-finally语句,主要包含三个部分:

  • try(声明需要关闭的资源)
  • try块和catch块

    try(Connection conn = newConnection()) {
      conn.sendData()
    }catch(Exception e) {
     e.printStackTrace();
    }

三、异常该怎么处理-异常的实践

异常到底该如何处理,首先可以借鉴一下国内优秀开发团队的异常处理经验,也就是异常处理规范:

3.1 异常处理规范

对于异常处理实践规范,最著名的就是阿里Java异常处理规约,在此基础上也请教了公司经验丰富的同事,总结列出如下异常处理规范:

3.1.1 空指针、数组越界等能通过预检查的异常就不要用异常处理

异常类其实也是一种资源消耗,如果我们能够通过预先逻辑判断,检查出来可能会发生的问题,就可以避免使用异常:image.png
类似的,在空指针问题的处理上,应该对远程调用的对象要做好预先检查和处理:
image.png

3.1.2 捕获异常时要注意区分异常类型,尽量捕获具体的异常

//比如知道会出现ArithmeticException,却捕获RuntimeException异常
try{
    int a = 3/0;
} catch(RuntimeException e){ //
    ...
}

3.1.3 捕获异常后应该用语言描述具体错误信息,包括相关的参数,而不是什么都不做

如果在catch后什么都不做,相当于把异常给吞了,这个异常什么也没有干,还消耗创建异常类的资源,因此捕获了异常后一定要描述清楚错误信息

//可以使用e.printStackTrace(),但是在日志中无法查看具体的信息
//因此可以尝试使用日志框架来打印错误信息
logger.error("说明信息,异常信息:{}", e.getMessage(), e)

3.1.4 抛出异常信息时,注意要正常传递异常信息

  • 不要抛出和捕获异常完全不同的异常

    try{
      ...
    }catch(ArithmeticException e) {
      throw new NullPointerException(e); //抛出和捕获异常完全不同的异常,会导致异常转译错误
    }
  • 不要抛出比捕获异常更抽象的异常

    try{
      ...
    }catch(ArithmeticException e) {
      throw new RuntimeException(e); //RuntimeException是ArithmeticException父类,传递过程会丢失异常信息
    }
  • 不要抛出和捕获异常完全相同的异常

    try{
      ...
    }catch(ArithmeticException e) { //与其抛出完全相同的异常,还不如直接处理打印异常信息
      throw new ArithmeticException(e); 
    }

3.1.5 抛出包装异常时,要注意不要抛弃原始异常信息

在抛出异常时,可以自定义异常信息然后抛出,但这个时候尽量传入完整捕获异常的异常信息

try{
    ...
}catch(ArithmeticException e) { //自定义包装异常应该传入完整的异常信息
    throw new MyException(e.getMessage()); //错误
    throw new MyException(e);              //正确
}

3.1.6 不要在记录异常信息同时抛出异常

try{
    ...
}catch(Exception e) {
    logger.error("有异常,小心",e);
    throw new NullPointerException(e); //会产生多条日志信息,两者选一条即可
}

在记录异常信息的同时又抛出异常,会产生多条的日志信息,而且在后期如果出现异常,日志也不太好分析

3.1.7 捕获多个异常时,应该从具体到抽象,优先捕获具体异常

try {
  ...
} catch (NumberFormatException e) { //NumberFormatException是IllegalArgumentException的子类
    logger.error(e);
} catch (IllegalArgumentException e) {
    logger.error(e)
}

3.1.8 不要在finally块中使用return语句

finally语句相当于嵌入try块和catch块中
image.png

3.1.9 在 finally 块中或者使用 try-with-resource 语句关闭资源

利用finally块关闭资源

FileInputStream inputStream = null;
try {
    File file = new File("./test.txt");
    inputStream = new FileInputStream(file);
} catch (FileNotFoundException e) {
    logger.error(e);
} finally {
    if (inputStream != null) { //如果finally中发现异常,可以继续用
        try {
            inputStream.close();
        } catch (IOException e) {
            logger.error(e);
        }
    }
}

利用try-with-resource关闭资源,注意该资源类必须实现AutoCloseable接口

File file = new File("./test.txt");
try (FileInputStream inputStream = new FileInputStream(file);) {
} catch (FileNotFoundException e) {
    logger.error(e);
} catch (IOException e) {
    logger.error(e);
}

3.2 项目异常处理

3.2.1 SpringBoot项目中的异常处理

下面我创建一个项目来详细讲解SpringBoot中的异常,项目详细地址[Link]()为:

1.BasicExceptionController转发到异常页面

比如每当我们访问其他网页出现问题时,总会跳转到404页面,这就是一种处理异常的方式。一旦系统全局中出现异常,SpringBoot就会请求异常错误,然后通过BasicExceptionController来处理这个请求,并让当前页面跳转至对应的异常页面。例如我在项目中没有创建任何接收网络请求的controller,这个时候在浏览器发起请求,那么springboot框架会转发请求并跳转至默认异常页面:
image.png
image.png
用的最多的场景是在网络请求中出现问题,比如喜闻乐见的404页面,就是对这种异常的一种处理与反馈。

2.@ExceptionHandler处理局部异常

该注解用在某个控制器类中的方法上,可以集中处理不同类型的异常。如果该控制器类中的其他方法抛出对应异常,该注解方法都能拦截并处理:

@Controller
@RequestMapping("/exception")
public class ExceptionController {

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    /**
     * 在一个方法中统一处理异常,在该类下的其他方法出现异常,都会在该方法中处理
     * @return
     */
    @ExceptionHandler({ArithmeticException.class, NullPointerException.class})
    public ModelAndView testExceptionHandler(Exception ex) {
        ModelAndView mv = new ModelAndView();
        mv.addObject("ex", ex);
        if (ex instanceof ArithmeticException) {
            mv.setViewName("ArithmeticException");
            logger.info("当前是ArithmeticException异常");
        } else if (ex instanceof NullPointerException){
            mv.setViewName("NullPointerException");
            logger.info("当前是NullPointerException异常");
        } else{
            mv.setViewName("error");
            logger.info("当前是error异常");
        }
        return mv;
    }

    /**
     * 运算式异常
     * @return
     */
    @GetMapping("/arthmetic")
    @ResponseBody
    public String testExceptionHandler2() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
        return "testExceptionHandler";
    }

    /**
     * 空指针异常
     * @return
     * @throws NullPointerException
     */
    @GetMapping("/nullPointer")
    @ResponseBody
    public String testExceptionHandler3() throws NullPointerException{
        String string = new String();
        string = null;
        java.lang.String s = string.toString();
        logger.info(s);
        return "testNullPointer";
    }
}

对该ExceptionController中的方法进行请求测试,得到如下结果:

GET http://localhost:8080/exception/arthmetic
GET http://localhost:8080/exception/nullPointer
----------------------------------------------
INFO 31092 --- [nio-8080-exec-2] c.e.s.controller.ExceptionController     : 当前是ArithmeticException异常
INFO 31092 --- [nio-8080-exec-7] c.e.s.controller.ExceptionController     : 当前是NullPointerException异常

此外,其他的controller类可以通过继承ExceptionController来获取到异常处理的方法

@Controller
@RequestMapping("/first")
public class FirstController extends ExceptionController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException1")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}

请求该接口后,同样会调用继承类中定义好的异常处理方法:

GET http://localhost:8080/first/testException1
----------------------------------------------
INFO 13860 --- [nio-8080-exec-2] c.e.s.controller.ExceptionController     : 当前是ArithmeticException异常

这样如果其他的controller需要处理同样的异常,就必须继承该异常controller,会显得比较麻烦。能够使用更加优雅的方式,让全局所有的controller应用该异常类的处理方法嘛?有的,可以通过@ControllerAdvice+@ExceptionHandler:

3.@ControllerAdvice + @ExceptionHandler处理全局异常

我们可以通过定义一个全局的异常处理类,在这个类中加上@ControllerAdvice注解,并在方法中加上@ExceptionHandler注解,来处理所有Controller的异常:

public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @ExceptionHandler({ArithmeticException.class, NullPointerException.class})
    public ModelAndView testExceptionHandler(Exception ex) {
        ModelAndView mv = new ModelAndView();
        mv.addObject("ex", ex);
        if (ex instanceof ArithmeticException) {
            mv.setViewName("ArithmeticException");
            logger.info("当前是全局ArithmeticException异常");
        } else if (ex instanceof NullPointerException){
            mv.setViewName("NullPointerException");
            logger.info("当前是全局NullPointerException异常");
        } else{
            mv.setViewName("error");
            logger.info("当前是全局error异常");
        }
        return mv;
    }
}

单独定义一个controller,如果发生异常,也能通过全局异常处理类进行处理:

public class SecondController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException2")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}

请求测试结果:

INFO 33660 --- [nio-8080-exec-1] c.e.s.controller.ExceptionController     : 当前是全局ArithmeticException异常
4. 配置SimpleMappingExceptionResolver类处理全局异常

同样也可以定义一个全局异常类,来处理全局异常,和@ControllerAdvice+ @ExceptionHandler不同点是在全局异常类上加一个@Configuration注解,并将SimpleMappingExceptionResolver注入Spring容器中:

@Configuration
public class GlobalException {

    @Bean
    public SimpleMappingExceptionResolver getSimpleMappingExceptionResolver() {
        SimpleMappingExceptionResolver resolver = new SimpleMappingExceptionResolver();
        Properties properties = new Properties();
        //设置异常类型并映射到不同的jsp页面
        properties.put("java.lang.ArithmeticException", "ArithmeticException");
        properties.put("java.lang.NullPointerException", "NullPointerException");
        //将配置文件映射到resolver中
        resolver.setExceptionMappings(properties);
        return resolver;
    }
}

在项目中添加ArithmeticException.jspNullPointerException.jsp文件,同样发送请求测试:

<%@ page language="java" contentType="text/html; charset=Utf-8"
         pageEncoding="Utf-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="Utf-8">
    <title>ArithmeticException</title>
</head>
<body>
    发生ArithmeticException异常
</body>
</html>
GET http://localhost:8080/first/testException1
@Controller
@RequestMapping("/first")
public class FirstController extends ExceptionController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException1")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}

image.png

5. 实现HandlerExceptionResolver接口处理全局异常

需要实现HandlerExceptionResolver接口,并全局配置:

@Configuration
public class HandlerExceptionResolverImpl implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        ModelAndView modelAndView = new ModelAndView();
        if (ex instanceof NullPointerException) {
            modelAndView.setViewName("NullPointerException");
            return modelAndView;
        } else if (ex instanceof ArithmeticException) {
            modelAndView.setViewName("ArithmeticException");
            return modelAndView;
        }
        return modelAndView;
    }
}

配置接口和jsp文件,进行测试验证:

<%@ page language="java" contentType="text/html; charset=Utf-8"
         pageEncoding="Utf-8"%>
<!DOCTYPE html>
<html>
<head>
    <meta charset="Utf-8">
    <title>ArithmeticException</title>
</head>
<body>
    实现接口:发生ArithmeticException异常
</body>
</html>
@Controller
@RequestMapping("/first")
public class FirstController{

    private static final Logger logger = LoggerFactory.getLogger(ExceptionController.class);

    @RequestMapping("/testException1")
    public void testFirst() throws ArithmeticException{
        logger.error(String.valueOf(1/0));
    }
}
GET http://localhost:8080/first/testException1

image.png

6. 利用Spring AOP进行异常处理

同样,作为spring框架的核心,我们可以使用切面来对异常进行处理:

@Aspect
@Component
public class WebRequestExceptionAspect {

    private static final Logger logger = LoggerFactory.getLogger(WebRequestExceptionAspect.class);
    //拦截带有@RequestMapping的注解方法
    @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
    private void webRequestPointcut() {

    }

    @AfterThrowing(pointcut = "webRequestPointcut()", throwing = "ex")
    public void handleException(Exception ex) {
        //拦截异常并设置对应的异常信息
        String exceptionMsg = StringUtils.isEmpty(ex.getMessage()) ? "出现异常" : ex.getMessage();
        logger.error("发生了异常:{}",exceptionMsg);
    }
}

测试:

GET http://localhost:8881/first/testException1
-------------------------------------------------------------------------------------------------
ERROR 29824 --- [nio-8881-exec-2] c.e.s.e.WebRequestExceptionAspect        : 发生了异常:/ by zero
java.lang.ArithmeticException: / by zero
    at com.ethan.springbootexception.controller.FirstController.testFirst(FirstController.java:21) ~[classes/:na]
    at com.ethan.springbootexception.controller.FirstController$$FastClassBySpringCGLIB$$cfba05ad.invoke(<generated>) ~[classes/:na]
 ...

3.2.2 实际项目中的异常处理

上面提到了在Springboot框架中的异常处理注解,但在实际项目中不仅要在框架层面考虑异常,而且还要在业务代码层面捕获和处理异常,此外需要根据不同业务逻辑、异常类型来分别处理:

  • 业务异常: 用户操作业务时,提示出来的异常信息,这些信息能直接让用户可以继续下一步操作,或者换一个正确操作方式去使用,换句话就是用户可以自己能解决的。比如:“用户没有登录”,“没有权限操作”。
  • 系统异常: 用户操作业务时,提示系统程序的异常信息,这类的异常信息时用户看不懂的,需要告警通知程序员排查对应的问题,如 NullPointerException,IndexOfException。另一个情况就是接口对接时,参数的校验时提示出来的信息,如:缺少ID,缺少必须的参数等,这类的信息对于客户来说也是看不懂的,也是解决不了的,所以将这两类的错误应当统一归类于系统异常。

也就是从用户的角度来看,如果用户能处理则抛出业务异常,不能处理就抛出系统异常。
借用12 | 异常处理:别让自己在出问题的时候变为瞎子-极客时间中的例子来说明:

对于自定义的业务异常,以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 API 调用方;
对于无法处理的系统异常,以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。
@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {
    private static int GENERIC_SERVER_ERROR_CODE = 2000;
    private static String GENERIC_SERVER_ERROR_MESSAGE = "服务器忙,请稍后再试";

    @ExceptionHandler
    public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
        if (ex instanceof BusinessException) {
            BusinessException exception = (BusinessException) ex;
            log.warn(String.format("访问 %s -> %s 出现业务异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, exception.getCode(), exception.getMessage());
        } else {
            log.error(String.format("访问 %s -> %s 出现系统异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
        }
    }
}

这样,能够在异常出现时,方便维护人员根据日志上下文快速解决问题。

四、异常到底是个啥-从JVM角度看异常处理

4.1 创建异常有多慢

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

    private int testTimes;

    public ExceptionTest(int testTimes) {
        this.testTimes = testTimes;
    }

    /**
     * 创建Object对象
     */
    public void newPureObject() {
        long startTime = System.nanoTime();
        for (int i = 0; i < testTimes; i++) {
            new Object();
        }
        System.out.println("创建普通对象时间:" + (System.nanoTime() - startTime));
    }

    /**
     * 创建Exception对象
     */
    public void newException() {
        long startTime = System.nanoTime();
        for (int i = 0; i < testTimes; i++) {
            new RuntimeException();
        }
        System.out.println("创建异常对象时间:" + (System.nanoTime() - startTime));
    }

    /**
     * 创建异常并捕获Exception
     */
    public void catchException() {
        long startTime = System.nanoTime();
        for (int i = 0; i < testTimes; i++) {
            try {
                throw new RuntimeException();
            } catch (RuntimeException e) {
            }
        }
        System.out.println("创建异常并捕获Exception对象时间:" + (System.nanoTime() - startTime));
    }

    public static void main(String[] args) {
        //在1000次循环中三种创建方式的耗时对比
        ExceptionTest exceptionTest = new ExceptionTest(1000);
        exceptionTest.newPureObject();
        exceptionTest.newException();
        exceptionTest.catchException();
    }
}

测试在1000次循环中三种创建方式的耗时对比,测试结果:

--------------------------------------
创建普通对象时间:91200
创建异常对象时间:1131900
创建异常并捕获Exception对象时间:1196900

说明创建一个对象时间是创建普通Object对象12倍,所以在流程业务中,最好不要用异常来进行处理。下面就从字节码角度看看异常的处理:

4.2 从JVM角度看各个异常的处理

下面就来看一看字节码层面的异常处理过程,查看类的字节码信息需要使用jclasslib Bytecode Viewer,我是IDEA环境,直接在plugin搜索安装即可:
image.png

4.2.1 try-catch

首先写一个包含try-catch的方法,并查看其字节码:
image.png
先说说代码对应的字节码含义:

  • 0到5之间表示 System.out.println("查看try-catch的字节码")这段代码
  • 8到16之间表示try{} catch()中的代码块
  • 17到22之间表示catch{}的代码块

重点看try块中的代码:

  • new指令: new 指令用于创建一个新的对象。这里的操作是创建一个RuntimeException的新实例
  • dup指令:dup 指令用于复制栈顶的值。这里的操作是复制栈顶的异常对象引用,以备后续使用
  • invokespecial指令: invokespecial 指令用于调用对象的构造方法。这里的操作是调用RuntimeException的默认构造方法,初始化新创建的异常对象。
  • athrow指令: throw的底层实现指令,其抛出的objectref必须是引用类型,而且是Throwable或其子类。抛出对应的异常后,会在异常表中查找第一个与该异常相匹配的异常类型(也就是捕获异常处),这里抛出的是RuntimeException

具体的解释可以查看此处的JDK文档

The objectref must be of type reference and must refer to an object that is an instance of class Throwable or of a subclass of Throwable. It is popped from the operand stack. The objectref is then thrown by searching the current method (§2.6) for the first exception handler that matches the class of _objectref_, as given by the algorithm in §2.10.
If an exception handler that matches objectref is found, it contains the location of the code intended to handle this exception. The pc register is reset to that location, the operand stack of the current frame is cleared, objectref is pushed back onto the operand stack, and execution continues.
If no matching exception handler is found in the current frame, that frame is popped. If the current frame represents an invocation of a synchronized method, the monitor entered or reentered on invocation of the method is exited as if by execution of a monitorexit instruction (§monitorexit). Finally, the frame of its invoker is reinstated, if such a frame exists, and the objectref is rethrown. If no such frame exists, the current thread exits.
  • astore_1指令:将栈顶的引用类型变量放入局部变量表中索引为1的位置,这里的操作表示将捕获到的RuntimeException存储到局部变量表为1的位置。

那我们再来看看异常表:
Start PC: 开始计数器位置
End PC: 结束计数器位置
Handler PC:发生异常后程序跳转的位置
Catch Type: 捕获异常类型
image.png
具体在字节码层面怎么操作的呢?首先new一个RuntimeException异常类型实例,然后去异常表中查找是否存在这个类型,如果有则跳转到Handler PC的位置,继续执行代码(捕获异常后的处理)

4.2.2 try-catch-finally

在try-catch基础上增加finally代码块,再来查看一下其字节码:
image.png
我们知道无论是否捕获异常,程序都会执行finally中的代码块,那么字节码中如何实现的呢?首先加上finally后,在字节码中出现了两次fianlly代码块中的内容,再来看看异常表:
image.png
发现比try-catch多了一条any类型的记录,这条记录说明在8~25行无论是否抛出异常,都会跳转到36行执行。
下面我们来从字节码的角度,无论是抛出异常还是捕获异常,是否都会执行finally中的代码:

  1. 没有异常

假设执行过程中没有异常,程序会一直沿着字节码往下执行:

  • 0~5行:执行System.out.println("查看try-catch-finally的字节码");
  • 8~25行: 这段没有异常,就无法在异常表中匹配到第一条记录,那么会匹配到第二条any类型,直接跳转到36行
  • 36~47行:执行finally代码块中的代码,如果有异常,就会继续执行athrow指令,最后结束并return
  • 捕获异常

假设执行过程中发生了异常,程序会按照如下顺序执行:

  • 0~5行:执行System.out.println("查看try-catch-finally的字节码");
  • 8~16行:抛出了RuntimeException异常,在异常表中查找到第一条记录,按照异常表显示的16行继续执行
  • 17~33行: 这一段执行了和finally语句相同的语句,最后到goto指令,跳转到47行
  • 47行:执行完成并return

4.2.3 try-finally

再来看看try-finally语句的字节码:
image.png
以及异常表:
image.png
从异常表的记录我们知道,无论是否抛出异常,都会执行finally中的语句。

4.2.4 finally 块和 return 的执行顺序

1. finally 中没有 return, try 或 catch 块中有 return,finally 语句块是在 return 语句执行完,返回之前执行

我们可以用一个例子来说明:

public int testFinally() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        System.out.println("finally语句执行");
    }
}
/**
 * 执行结果:
 *  finally语句执行
 *  1
 */

但是如果在 finally 中对变量进行修改,情况就有些不同:

  • 若 return 的变量类型是基本数据类型,则 finally 中对变量的修改不起作用
  • 若 return 的变量类型是引用数据类型,则 finally 中对变量的修改会成功

我们同样举例来说明:

public int testFinally() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        i = 2;
    }
}
public StringBuilder testFinallyReturn() {
    StringBuilder exception = new StringBuilder("Exception");
    try {
        exception.append("java");
        return exception;
    } finally {
        exception.append("last");
    }
}
public static void main(String[] args) {
    AnalysisExceptionCode analysisExceptionCode = new AnalysisExceptionCode();
    int I = analysisExceptionCode.testFinally();
    System.out.println(I);
    StringBuilder stringBuilder = analysisExceptionCode.testFinallyReturn();
    System.out.println(stringBuilder.toString());
}

执行结果:

1 
Exceptionjavacode

说明finally语句对引用数据类型中的值进行了修改,那么看看两个方法的字节码:
首先是对基本类型(int)的修改:
image.png
最后返回的结果是1,因此finally语句修改值后,并没有对其产生影响,我们再来看看字节码指令

 0 iconst_0 #将0推送到操作数栈上
 1 istore_1 #将操作数栈顶的0出栈,存储到局部变量表1的位置
 2 iconst_1 #将1推送到操作数栈上
 3 istore_1 #将操作数栈顶的1出栈,存储到局部变量表1的位置(1覆盖了之前存储的0)
 4 iload_1  #将局部变量表1位置的1,加载到操作数栈上
 5 istore_2 #将操作数栈顶的1出栈,存储到局部变量表2的位置
 6 iconst_2 #将2推送到操作数栈上
 7 istore_1 #将操作数栈顶的2出栈,存储到局部变量表1的位置(2覆盖了之前存储的1)
 8 iload_2  #将局部变量表2位置的1,加载到操作数栈上
 9 ireturn  #返回操作数栈的值1
 #如果发生异常,则将异常对象存储到局部变量表3的位置
10 astore_3 
11 iconst_2
12 istore_1
13 aload_3
14 athrow

接着再来看看引用类型:
image.png

#这段创建StringBuilder,将对象引用放入操作数栈中,类似于基本类型中的int i = 0
0 new #11 <java/lang/StringBuilder>  //创建一个新的StringBuilder对象
3 dup                                //复制栈顶的引用,以备后续使用
4 ldc #12 <Exception>                //将字符串常量"Exception"加载到操作数栈中
6 invokespecial #13 <java/lang/StringBuilder.<init> : (Ljava/lang/String;)V> //调用StringBuilder的构造方法,将之前加载的字符串常量Exception作为参数传递进去,初始化新创建的StringBuilder对象。
#######################################################################
9 astore_1   //将操作数栈中的对象引用出栈,放入局部变量表的1位置                 
10 aload_1   //将局部变量表1中的对象引用加载到操作数栈中
#######################################################################
#这段类似于try语句块中的 i = 1
11 ldc #14 <java> //将字符串常量"java"加载到操作数栈中
13 invokevirtual #15 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;> //调用StringBuilder对象的append方法,将之前加载的字符串常量作为参数传递进去,实现字符串的拼接
16 pop            //从操作数栈中弹出append方法的返回值
#######################################################################
17 aload_1       //将局部变量表1中的对象引用加载到操作数栈中
18 astore_2      //将栈顶的引用存储到局部变量表中的索引为2的位置。这里是将StringBuilder对象的引用存储到局部变量2的位置
19 aload_1       //将局部变量表中索引为1的对象引用加载到操作数栈中
#######################################################################
#这段类似于finally语句块中的 i = 2
20 ldc #16 <code> //将字符串常量"code"加载到操作数栈中
22 invokevirtual #15 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;> //调用StringBuilder对象的append方法,将之前加载的字符串常量作为参数传递进去,实现字符串的拼接
25 pop           //从操作数栈顶弹出一个值,这里是丢弃append方法的返回值
#######################################################################
26 aload_2      //将局部变量表中索引为2的引用加载到操作数栈中
27 areturn      //将栈顶的对象引用作为返回值返回
#如果有异常,则执行该段代码
28 astore_3
29 aload_1
30 ldc #16 <code>
32 invokevirtual #15 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
35 pop
36 aload_3
37 athrow

其实无论是基本类型和引用类型,其字节码执行流程都大致相同,但是最后finally语句块的修改还是影响到了引用类型,这是因为操作数栈,局部变量表中存储的变量是引用地址,而不是对象本身,因此每次局部变量表的覆盖操作,都影响了对象本身。因此引用类型内部的值才被修改。

2. 如果在 try 和 finally 语句块中都使用 return 则 try 或 catch 中的 return 将会失效

先用例子来看看是否会存在这种现象:

public int testFinally() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        i = 2;
        return i;
    }
}
/**
 * 执行结果:
 *  2
 */

果然出现了 try 块中 return 的失效现象,再来看看这个方法和不加 return 方法对比的字节码:
image.png
问题在于第8行:

  • finally加上return后,会从局部变量表中加载索引为1的值,这个值被finally中修改的值覆盖了,所以try 中的return指令变相失效
  • 而不加return时,是从局部变量表中加载索引为2的值,这个值是之前没有被finally修改的值,因此try中的return指令有效。

4.2.5 try-with-resource

这里借用Java异常处理和最佳实践(含案例分析)中的例子:通过打包文件来看一下其本质:

public static void zipFile(List<File> fileList) {
    // 文件的压缩包路径
    String zipPath = OUT + "/打包附件.zip";
    // 获取文件压缩包输出流
    try (OutputStream outputStream = new FileOutputStream(zipPath);
         CheckedOutputStream checkedOutputStream = new CheckedOutputStream(outputStream, new Adler32());
         ZipOutputStream zipOut = new ZipOutputStream(checkedOutputStream)) {
        for (File file : fileList) {
            // 获取文件输入流
            InputStream fileIn = new FileInputStream(file);
            // 使用 common.io中的IOUtils获取文件字节数组
            byte[] bytes = IOUtils.toByteArray(fileIn);
            // 写入数据并刷新
            zipOut.putNextEntry(new ZipEntry(file.getName()));
            zipOut.write(bytes, 0, bytes.length);
            zipOut.flush();
        }
    } catch (FileNotFoundException e) {
        System.out.println("文件未找到");
    } catch (IOException e) {
        System.out.println("读取文件异常");
    }
}
实际上这是Java的一种语法糖,查看编译后的代码就知道编译器为我们做了什么,下面是反编译后的代码:
public static void zipFile(List<File> fileList) {
        String zipPath = "./打包附件.zip";

        try {
            OutputStream outputStream = new FileOutputStream(zipPath);
            Throwable var3 = null;

            try {
                CheckedOutputStream checkedOutputStream = new CheckedOutputStream(outputStream, new Adler32());
                Throwable var5 = null;

                try {
                    ZipOutputStream zipOut = new ZipOutputStream(checkedOutputStream);
                    Throwable var7 = null;

                    try {
                        Iterator var8 = fileList.iterator();

                        while(var8.hasNext()) {
                            File file = (File)var8.next();
                            InputStream fileIn = new FileInputStream(file);
                            byte[] bytes = IOUtils.toByteArray(fileIn);
                            zipOut.putNextEntry(new ZipEntry(file.getName()));
                            zipOut.write(bytes, 0, bytes.length);
                            zipOut.flush();
                        }
                    } catch (Throwable var60) {
                        var7 = var60;
                        throw var60;
                    } finally {
                        if (zipOut != null) {
                            if (var7 != null) {
                                try {
                                    zipOut.close();
                                } catch (Throwable var59) {
                                    var7.addSuppressed(var59);
                                }
                            } else {
                                zipOut.close();
                            }
                        }

                    }
                } catch (Throwable var62) {
                    var5 = var62;
                    throw var62;
                } finally {
                    if (checkedOutputStream != null) {
                        if (var5 != null) {
                            try {
                                checkedOutputStream.close();
                            } catch (Throwable var58) {
                                var5.addSuppressed(var58);
                            }
                        } else {
                            checkedOutputStream.close();
                        }
                    }

                }
            } catch (Throwable var64) {
                var3 = var64;
                throw var64;
            } finally {
                if (outputStream != null) {
                    if (var3 != null) {
                        try {
                            outputStream.close();
                        } catch (Throwable var57) {
                            var3.addSuppressed(var57);
                        }
                    } else {
                        outputStream.close();
                    }
                }

            }
        } catch (FileNotFoundException var66) {
            System.out.println("文件未找到");
        } catch (IOException var67) {
            System.out.println("读取文件异常");
        }

    }

在使用try-with-resource时,try(声明需要关闭的资源),并且需要其声明的变量实现AutoCloseable接口,从编译代码可以看到,编译器能帮我们自动关闭资源,这样就可以不用写finally语句块,编译器具体的异常处理过程如下:

  • try 块没有发生异常时,自动调用 close 方法,
  • try 块发生异常,然后自动调用 close 方法,如果 close 也发生异常,catch 块只会捕捉 try 块抛出的异常,close 方法的异常会在catch 中通过调用 Throwable.addSuppressed 来压制异常,但是你可以在catch块中,用 Throwable.getSuppressed 方法来获取到压制异常的数组。

参考资料

  1. Java异常处理和最佳实践(含案例分析)
  2. 12 | 异常处理:别让自己在出问题的时候变为瞎子-极客时间
  3. 透过JVM看Exception本质 - FenixSoft 3.0 - ITeye博客
  4. return 和 finally究竟谁先被执行?

归思君
1.2k 声望209 粉丝

阿里云社区专家博主,华为云社区云享专家,一个会点前端的java工程师。