字节码层面学习Java异常底层原理

1,100 阅读25分钟

公司最近狠抓代码质量,

各种代码扫描插件的报告中,

异常的问题何其多,

回头审视组内的代码,

发现确实没有太在意异常这个东西,

对异常一深究,

发现大有乾坤。


前言

Java中的异常,就是Java程序在运行过程中出现的非正常情况。Java有着完备的异常体系,先通过下图来看一下。

Java异常-Java异常体系UML图

ThrowableJava中所有异常的顶层父类,Java虚拟机抛出的异常或者Java代码throw抛出的异常,都需要是Throwable或者Throwable的子类的实例。

Throwable的实现有两个大类:ExceptionError,简要说明如下。

  1. ExceptionJava应用程序能够处理的异常,Java应用程序可以捕获的异常;
  2. ErrorJava应用程序不能够处理的异常,Java应用程序不应该捕获的异常。

关于ExceptionError,还有一个重要的点需要关注,如下所示。

  1. Exception以及Exception的非RuntimeException子类称为检查异常
  2. RuntimeException及其子类,和Error都称为未检查异常

检查异常就是在编译时会校验应用程序对异常的处理:是否通过throws关键字进行了抛出,是否通过try-catch进行了捕获处理。

未检查异常则是编译时不会校验应用程序对异常的处理,因为这些异常是属于不应该发生的异常,所以应用程序不应该尝试去抛出或者捕获这些异常,而是应该避免这些异常的产生。

关于ExceptionError,本文后续会进行详细的分析,这里不再展开讨论。

本文关于Java异常体系的分析,脑图如下所示。

Java异常-Java异常体系研究思维导图

正文

一. Throwable

1. 概念说明

ThrowableJava中所有异常的父类,只有Throwable或者Throwable子类的实例才能够被throws或者catch,换言之,Java中的异常都是Throwable

Throwable有三个重要字段,如下所示。

  1. detailMessage。当前Throwable的描述信息,描述这个异常的具体细节,这个字段不是必须的,可以为空;
  2. stackTrace。异常的堆栈信息,是一个StackTraceElement数组,在创建Throwable时就会生成当前线程当前时刻的堆栈信息,以类似于快照的形式保存在stackTrace中;
  3. cause。导致当前Throwable发生的Throwable,也就是导致当前异常发生的原始异常,初始状态时cause等于当前Throwable自身,此时表明没有原始异常或者原始异常未知,可以在构造函数中传入原始异常或者调用initCause() 方法来指定原始异常。

那么相应的,Throwable提供的四个构造函数就是在对上述三个字段做初始化处理,构造函数如下所示。

public Throwable() {
    // 记录堆栈信息
    fillInStackTrace();
}

public Throwable(String message) {
    // 记录堆栈信息
    fillInStackTrace();
    // 记录异常描述信息
    detailMessage = message;
}

public Throwable(String message, Throwable cause) {
    // 记录堆栈信息
    fillInStackTrace();
    // 记录异常描述信息
    detailMessage = message;
    // 记录异常的原始异常
    this.cause = cause;
}

public Throwable(Throwable cause) {
    // 记录堆栈信息
    fillInStackTrace();
    // 将原始异常的描述信息作为当前异常的描述信息
    detailMessage = (cause == null ? null : cause.toString());
    // 记录异常的原始异常
    this.cause = cause;
}

在重载的四个构造方法中,都会调用fillInStackTrace() 方法,该方法用于将当前线程的栈帧的信息记录到Throwable中,下面分析一下该方法的使用方式。

首先有如下一个简单的Java程序。

@Slf4j
public class FillInStackTraceTest {

    private Throwable throwable;

    @Test
    public void 测试栈帧信息填充() {
        methodA();
    }

    public void methodA() {
        throwable = new Throwable();
        methodB();
    }

    public void methodB() {
        methodC();
    }

    public void methodC() {
        log.info("打印异常", throwable);
    }

}

运行程序,打印如下所示。

11:55:17.168 [main] INFO com.lee.learn.exception.FillInStackTraceTest - 打印异常
java.lang.Throwable: null
    at com.lee.learn.exception.FillInStackTraceTest.methodA(FillInStackTraceTest.java:17)
    at com.lee.learn.exception.FillInStackTraceTest.测试栈帧信息填充(FillInStackTraceTest.java:13)
    ......

在执行new Throwable() 时,就会将这一刻的线程的栈帧的信息保存到ThrowablestackTrace字段上,此时如果将methodC() 方法改造如下。

public void methodC() {
    throwable.fillInStackTrace();
    log.info("打印异常", throwable);
}

再次运行程序,打印如下所示。

11:58:14.632 [main] INFO com.lee.learn.exception.FillInStackTraceTest - 打印异常
java.lang.Throwable: null
    at com.lee.learn.exception.FillInStackTraceTest.methodC(FillInStackTraceTest.java:26)
    at com.lee.learn.exception.FillInStackTraceTest.methodB(FillInStackTraceTest.java:22)
    at com.lee.learn.exception.FillInStackTraceTest.methodA(FillInStackTraceTest.java:18)
    at com.lee.learn.exception.FillInStackTraceTest.测试栈帧信息填充(FillInStackTraceTest.java:13)
    ......

异常的堆栈信息不再是创建异常时的堆栈信息,而是在最后一次调用fillInStackTrace() 方法时那一刻的线程的堆栈信息。

2. 异常链

如果对于异常的处理有如下需求。

  1. 基于捕获的异常抛出新的异常;
  2. 新的异常能够保存有原始异常的信息。

这种异常处理场景称为异常链,如下是一个简单示例。

@Slf4j
public class ExceptionChainTest {

    @Test
    public void 测试异常链() {
        try {
            methodA();
        } catch (Throwable t) {
            log.error("执行方法A发生了异常", t);
        }
    }

    private void methodA() throws Throwable {
        try {
            methodB();
        } catch (Throwable t) {
            throw new Throwable("方法A抛出的异常", t);
        }
    }

    private void methodB() throws Throwable {
        try {
            methodC();
        } catch (Throwable t) {
            throw new Throwable("方法B抛出的异常", t);
        }
    }

    private void methodC() throws Throwable {
        throw new Throwable("方法C抛出的异常");
    }

}

运行上述程序,打印如下。

12:01:47.745 [main] ERROR com.lee.learn.exception.ExceptionChainTest - 执行方法A发生了异常
java.lang.Throwable: 方法A抛出的异常
    at com.lee.learn.exception.ExceptionChainTest.methodA(ExceptionChainTest.java:22)
    at com.lee.learn.exception.ExceptionChainTest.测试异常链(ExceptionChainTest.java:12)
    ......
Caused by: java.lang.Throwable: 方法B抛出的异常
    at com.lee.learn.exception.ExceptionChainTest.methodB(ExceptionChainTest.java:30)
    at com.lee.learn.exception.ExceptionChainTest.methodA(ExceptionChainTest.java:20)
    .......
Caused by: java.lang.Throwable: 方法C抛出的异常
    at com.lee.learn.exception.ExceptionChainTest.methodC(ExceptionChainTest.java:35)
    at com.lee.learn.exception.ExceptionChainTest.methodB(ExceptionChainTest.java:28)
    ......

最终在打印方法A抛出的异常时,打印出了其原始异常也就是方法B抛出的异常,进而又打印出了其原始异常也就是方法C抛出的异常。

已知Throwable有一个重要字段叫做cause,表示当前异常的原始异常,其签名如下。

private Throwable cause = this;

Java中的异常链就是依靠cause字段完成。由字段签名可知,在异常初始状态下,cause字段就是异常本身,此时表示没有原始异常或者原始异常未知,然后Throwable提供了如下三个方法来设置cause,实现如下。

// 构造方法中设置cause
public Throwable(String message, Throwable cause) {
    fillInStackTrace();
    detailMessage = message;
    this.cause = cause;
}

// 构造方法中设置cause
public Throwable(Throwable cause) {
    fillInStackTrace();
    detailMessage = (cause==null ? null : cause.toString());
    this.cause = cause;
}

// 异常创建出来后也可以通过initCause()方法设置cause
public synchronized Throwable initCause(Throwable cause) {
    // 初始化原始异常只能调用一次
    if (this.cause != this) {
        throw new IllegalStateException("Can't overwrite cause with " +
                                        Objects.toString(cause, "a null"), this);
    }
    // 不能让异常的原始异常就是异常本身
    if (cause == this) {
        throw new IllegalArgumentException("Self-causation not permitted", this);
    }
    this.cause = cause;
    return this;
}

当原始异常设置给cause字段后,可以通过Throwable提供的如下方法进行获取。

public synchronized Throwable getCause() {
    return (cause == this ? null : cause);
}

如果cause为异常本身,那么getCause() 方法返回空,表示没有原始异常或者原始异常未知,如果cause不是异常本身,那么getCause() 方法返回原始异常。

3. 异常堆栈

每一个Throwable在创建时,都会调用fillInStackTrace() 方法来将当前线程的栈帧信息保存一份到stackTrace字段中作为异常堆栈信息,然后Throwable提供了若干方法来操作其保存的堆栈信息,下面分别进行说明。

Throwable提供了如下方法来获取堆栈,实现如下。

public StackTraceElement[] getStackTrace() {
    return getOurStackTrace().clone();
}

实际是调用到getOurStackTrace() 方法拿堆栈信息,如下所示。

private synchronized StackTraceElement[] getOurStackTrace() {
    // 如果stackTrace未设置或者stackTrace设置为null且backtrace不为null
    // 此时基于backtrace来拿到堆栈信息
    if (stackTrace == UNASSIGNED_STACK ||
        (stackTrace == null && backtrace != null)) {
        int depth = getStackTraceDepth();
        stackTrace = new StackTraceElement[depth];
        for (int i=0; i < depth; i++) {
            stackTrace[i] = getStackTraceElement(i);
        }
    } else if (stackTrace == null) {
        // stackTrace和backtrace同为null
        // 此时返回空数组
        return UNASSIGNED_STACK;
    }
    return stackTrace;
}

此外,Throwable还提供了如下三个方法来打印堆栈信息,实现如下。

public void printStackTrace() {
    printStackTrace(System.err);
}

public void printStackTrace(PrintStream s) {
    printStackTrace(new WrappedPrintStream(s));
}

public void printStackTrace(PrintWriter s) {
    printStackTrace(new WrappedPrintWriter(s));
}

三个方法最终都会调用到printStackTrace(PrintStreamOrWriter s) 进行打印,如下所示。

private void printStackTrace(PrintStreamOrWriter s) {
    Set<Throwable> dejaVu =
        Collections.newSetFromMap(new IdentityHashMap<Throwable, Boolean>());
    dejaVu.add(this);

    synchronized (s.lock()) {
        // 打印当前异常的堆栈信息
        s.println(this);
        StackTraceElement[] trace = getOurStackTrace();
        for (StackTraceElement traceElement : trace) {
            s.println("\tat " + traceElement);
        }

        // 打印抑制的异常的堆栈信息
        for (Throwable se : getSuppressed()) {
            se.printEnclosedStackTrace(s, trace, SUPPRESSED_CAPTION, "\t", dejaVu);
        }

        // 如果有原始异常则打印原始异常的堆栈信息
        Throwable ourCause = getCause();
        if (ourCause != null) {
            ourCause.printEnclosedStackTrace(s, trace, CAUSE_CAPTION, "", dejaVu);
        }
    }
}

最后再观察一下stackTrace字段,签名如下。

private StackTraceElement[] stackTrace

即堆栈信息是以StackTraceElement数组的形式保存在stackTrace中,每一个StackTraceElement都表示一个栈帧元素,数组下标为0的StackTraceElement表示栈顶的栈帧元素,并且除了栈顶的栈帧元素以外,其余栈帧元素都表示方法调用的执行点,而栈顶的栈帧元素则表示生成堆栈信息的执行点。

StackTraceElement有如下四个字段。

// 栈帧元素的执行点所属方法的所属类的全限定名
private String declaringClass;
// 栈帧元素的执行点所属方法的方法名
private String methodName;
// 栈帧元素的执行点所属的Java文件名
private String fileName;
// 栈帧元素的执行点的代码行号
private int lineNumber;

StackTraceElementtoString() 方法中会将上述四个字段拼接成我们常见的异常堆栈信息,如下所示。

public String toString() {
    return getClassName() + "." + methodName +
        (isNativeMethod() ? "(Native Method)" :
         (fileName != null && lineNumber >= 0 ?
          "(" + fileName + ":" + lineNumber + ")" :
          (fileName != null ?  "("+fileName+")" : "(Unknown Source)")));
}

那么最终会有如下四种打印形式。

// 正常情况
com.lee.MyTest.method(MyTest.java:25)
// 行号不可用
com.lee.MyTest.method(MyTest.java)
// 文件名和行号均不可用
com.lee.MyTest.method(Unknown Source)
// 文件名和行号均不可用
// 并且执行点所属方法是本地方法
com.lee.MyTest.method(Native Method)

4. 异常性能研究

现在已经知道,如果创建一个Throwable,就会调用到fillInStackTrace() 方法来获取当前线程的栈帧信息,这一爬栈操作是异常整个生命周期中十分耗时的一个环节。

下面做一个测试,循环一百万次创建Throwable,并统计执行耗时,测试代码如下所示。

private static final int BATCH = 100 * 10000;

@Test
public void 循环一百万次创建Throwable() {
    long beginTime = System.currentTimeMillis();
    for (int i = 0; i < BATCH; i++) {
        Throwable throwable = new Throwable();
    }
    long endTime = System.currentTimeMillis();
    System.out.println("执行耗时:" + (endTime - beginTime));
}

运行测试程序,打印如下。

执行耗时:1573

现在实现一个Throwable的子类,也就是自定义一个异常类,同时重写ThrowablefillInStackTrace() 方法,如下所示。

public class PerformanceThrowable extends Throwable {

    public PerformanceThrowable() {
        super();
    }

    public PerformanceThrowable(String message) {
        super(message);
    }

    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }

}

在重写的fillInStackTrace() 方法中直接返回异常自己,而不再去获取堆栈信息,此时再添加如下测试程序。

@Test
public void 循环一百万次创建PerformanceThrowable() {
    long beginTime = System.currentTimeMillis();
    for (int i = 0; i < BATCH; i++) {
        Throwable throwable = new PerformanceThrowable();
    }
    long endTime = System.currentTimeMillis();
    System.out.println("执行耗时:" + (endTime - beginTime));
}

运行测试程序,打印如下。

执行耗时:41

可见性能提升了几十倍,但是缺点就是没有堆栈信息,现在再看如下一个测试程序。

@Test
public void 打印PerformanceThrowable() {
    Throwable throwable = new PerformanceThrowable("我是没有堆栈的异常");
    log.error("打印没有堆栈的异常", throwable);
}

运行测试程序,打印如下。

13:29:30.430 [main] ERROR com.lee.learn.exception.PerformanceTest - 打印没有堆栈的异常
com.lee.learn.exception.PerformanceThrowable: 我是没有堆栈的异常

可见异常打印时,没有堆栈信息。

PerformanceThrowable是通过继承Throwable并重写了fillInStackTrace() 方法来放弃保存堆栈以提升性能,在JDK1.7Throwable中提供了如下一个protected构造方法来更优雅的设置是否保存堆栈,如下所示。

protected Throwable(String message, Throwable cause,
                    boolean enableSuppression,
                    boolean writableStackTrace) {
    if (writableStackTrace) {
        fillInStackTrace();
    } else {
	// 设置stackTrace为null则不会再去获取堆栈信息
        stackTrace = null;
    }
    detailMessage = message;
    this.cause = cause;
    if (!enableSuppression) {
        suppressedExceptions = null;
    }
}

现在基于上述构造方法实现一个不要堆栈信息的异常类,如下所示。

public class ElegantPerformanceThrowable extends Throwable {

    public ElegantPerformanceThrowable(String message, Throwable cause,
                                       boolean enableSuppression,
                                       boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }

}

测试程序如下所示。

@Test
public void 循环一百万次创建ElegantPerformanceThrowable() {
    long beginTime = System.currentTimeMillis();
    for (int i = 0; i < BATCH; i++) {
        Throwable throwable = new ElegantPerformanceThrowable(
            null, null, false, false);
    }
    long endTime = System.currentTimeMillis();
    System.out.println("执行耗时:" + (endTime - beginTime));
}

运行测试程序,打印如下。

执行耗时:27

那么现在知道了,创建异常时去获取堆栈信息是有比较重的性能开销的,同时也有一些手段可以避免这个性能开销,但是代价就是会丢失异常发生时的堆栈信息,那么这其实就是一个取舍问题,对于这个问题,可以依据是否需要异常堆栈信息进行判断,例如基于异常做流程控制,那么堆栈信息肯定是不需要的,但是通常,堆栈信息对于异常来说是十分重要的,不到万不得已不要轻易丢弃,如果真的因为异常影响到了程序性能,考虑一下是否是在代码某个地方创建了大量的异常。

最后,正是由于异常创建时去获取堆栈信息带来的巨大性能开销,Hotspot对部分运行时异常提供了Fast Throw机制,让这部分运行时异常被抛出时十分丝滑,这点在本文后续会详细进行说明。

二. 检查异常

1. 概念说明

检查异常,也就是Checked Exception,在编译时编译器会检查程序对检查异常的处理,如果没有正确处理检查异常,那么是无法通过编译的。

  • 哪些异常是校验异常

Exception以及Exception的非RuntimeException子类称为检查异常

  • 如何正确处理校验异常才能通过编译器检查
  1. 捕获校验异常
  2. 抛出校验异常

2. 使用示例

现在定义一个Exception的子类,如下所示。

public class CheckedException extends Exception {

    public CheckedException() {
        super();
    }

    public CheckedException(String message) {
        super(message);
    }

    public CheckedException(String message, Throwable cause) {
        super(message, cause);
    }

    public CheckedException(Throwable cause) {
        super(cause);
    }

    protected CheckedException(String message, Throwable cause, 
                               boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
    
}

上述异常满足是Exception的子类且不是RuntimeException的子类,所以上述异常是一个校验异常

现在有如下一个方法,实现如下。

public class CheckedExceptionTest {

    private void methodB() {
        throw new CheckedException();
    }

}

methodB() 方法中发生了异常,此时执行如下指令。

javac CheckedExceptionTest.java CheckedException.java

编译时会打印如下错误信息。

CheckedExceptionTest.java:6: 错误: 未报告的异常错误CheckedException; 必须对其进行捕获或声明以便抛出

现在更改CheckedExceptionTest的实现,如下所示。

@Slf4j
public class CheckedExceptionTest {

    @Test
    public void 测试校验异常的捕获和抛出() {
        try {
            methodA();
        } catch (CheckedException e) {
            log.error("捕获校验异常", e);
        } finally {
            log.info("Finally代码块执行");
        }
    }

    private void methodA() throws CheckedException {
        methodB();
    }

    private void methodB() throws CheckedException {
        throw new CheckedException("我是校验异常");
    }

}

运行测试程序,打印如下。

13:37:45.428 [main] ERROR com.lee.learn.exception.CheckedExceptionTest - 捕获校验异常
com.lee.learn.exception.CheckedException: 我是校验异常
    at com.lee.learn.exception.CheckedExceptionTest.methodB(CheckedExceptionTest.java:25)
    at com.lee.learn.exception.CheckedExceptionTest.methodA(CheckedExceptionTest.java:21)
    at com.lee.learn.exception.CheckedExceptionTest.测试校验异常的捕获和抛出(CheckedExceptionTest.java:12)
    ......
13:37:45.430 [main] INFO com.lee.learn.exception.CheckedExceptionTest - Finally代码块执行

也就是某个方法中产生了校验异常时,假如要抛出这个校验异常,则需要在方法签名上通过关键字throws来声明抛出这个校验异常,假如当前能够处理这个校验异常,则通过try-catch语句来捕获这个校验异常。

3. throw和throws

throwthrows相信大家是常用,并且也知道throw用于在方法中往外抛出异常,throws用于声明一个方法往外抛出的异常,但是再仔细想一下,这两个指令的运作机制是什么呢,为什么可以将一个异常从方法中往外抛出,这里就得看一下throwthrows相关的字节码了。

首先编译上一小节中的CheckedExceptionTest.java文件,得到CheckedExceptionTest.class文件,然后执行javap -v -p CheckedExceptionTest,在反编译class文件输出的方法表信息中,找到methodB() 方法的信息,如下所示。

private void methodB() throws com.lee.learn.exception.CheckedException;
    descriptor: ()V
    flags: ACC_PRIVATE
    Code:
        stack=3, locals=1, args_size=1
            0: new           #6		// #6表示CheckedException
            3: dup
            4: ldc           #10	// #10表示"我是校验异常"这个字符串
            6: invokespecial #11	// #11表示CheckedException的<init>方法
            9: athrow
        LineNumberTable:
            line 25: 0
        LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      10     0  this   Lcom/lee/learn/exception/CheckedExceptionTest;
    Exceptions:
        throws com.lee.learn.exception.CheckedException

简单的分析一下methodB() 方法的字节码指令。

  1. new #6。生成CheckedException的未初始化的对象,在堆上分配内存,并将该对象的引用压入操作数栈;

methodB的new #6

  1. dup。将操作数栈栈顶的CheckedException对象的引用复制一份并压入操作数栈,此时操作数栈的栈顶和次栈顶都是CheckedException对象的引用;

methodB的dup

  1. ldc #10。将我是校验异常这个字符串从常量池中获取出来并压入操作数栈,此时操作数栈的栈顶和次栈顶分别是我是校验异常字符串和CheckedException对象的引用;

methodB的ldc #10

  1. invokespecial #11。执行CheckedException的<init>方法进行CheckedException对象的初始化,<init>方法有两个参数,第一个参数是一个字符串,这里传入操作数栈栈顶的我是校验异常,第二个参数是this,这里传入操作数栈栈顶的CheckedException对象的引用;

invokespecial #11

  1. athrow。将操作数栈栈顶的内容作为异常抛出,这里会将CheckedException对象的引用抛出。

也就是最终创建出来的CheckedException异常对象是通过athrow指令由操作数栈栈顶抛出,然后,再看methodB() 反编译字节码指令中的Exceptions的内容,表明该方法通过throws关键字声明抛出了com.lee.learn.exception.CheckedException异常。

4. try-catch-finally

try-catch-finally相信大家也是常用,用于捕获代码块中抛出的异常并执行相应的操作。现在继续结合字节码指令,探究一下try-catch-finally的作用机制。紧接第3小节,在反编译class文件输出的方法表信息中,找到测试校验异常的捕获和抛出() 方法的信息,部分内容如下所示。

public void 测试校验异常的捕获和抛出();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
        stack=3, locals=3, args_size=1
            0: aload_0
            1: invokespecial #2
            4: getstatic     #3
            7: ldc           #4
            9: invokeinterface #5,  2
            14: goto          55
            17: astore_1
            18: getstatic     #3
            21: ldc           #7
            23: aload_1
            24: invokeinterface #8,  3
            29: getstatic     #3
            32: ldc           #4
            34: invokeinterface #5,  2
            39: goto          55
            42: astore_2
            43: getstatic     #3
            46: ldc           #4
            48: invokeinterface #5,  2
            53: aload_2
            54: athrow
            55: return
        Exception table:
            from    to  target type
               0     4    17   Class com/lee/learn/exception/CheckedException
               0     4    42   any
              17    29    42   any
        LineNumberTable:
            line 12: 0
            line 16: 4
            line 17: 14
            line 13: 17
            line 14: 18
            line 16: 29
            line 17: 39
            line 16: 42
            line 17: 53
            line 18: 55

关注到相较于methodB() 方法,测试校验异常的捕获和抛出() 方法多了一个叫做Exception table的东西,这里就称之为异常表。异常表的作用其实就是定义一段字节码(由fromto表示)执行时如果发生了指定类型的异常(由type指定)则跳转到目标行字节码继续执行,以上面异常表的第一行进行举例说明,如下所示。

  1. from为0,to为4表示第0到3行字节码(这里的toExclusive的),实际就是第0和第1行字节码,也就是对应代码的第12行调用methodA() 方法;
  2. typeClass com/lee/learn/exception/CheckedExceptiontarget为17表示调用methodA() 的过程中如果发生了CheckedException异常,则跳转到第17行字节码继续执行,而对照行号表(LineNumberTable)可知17行字节码对应代码中的第13行,也就是通过catch关键字捕获CheckedException这一行代码。

在异常表的第二行,表示如果调用methodA() 方法时抛出了任何CheckedException之外的异常,则跳转到第42行字节码继续执行,也就是跳转到代码中的finally代码块继续执行。

在异常表的第三行,表示如果在执行catch代码块时抛出了任何异常,则跳转到第42行字节码继续执行,也就是跳转到代码中的finally代码块继续执行。

那么try-catch-finally作用机制主要是体现在字节码层面,具体是体现在字节码的异常表中,异常表声明了一段字节码执行时如果发生特定异常则跳转到某行字节码继续执行,并且无论是哪种异常,也无论异常是否被声明,最终都会跳转到finally代码块对应的字节码。

5. 异常屏蔽

对于try-catch-finally来说,无论try代码块和catch代码块是正常结束还是非正常结束,finally代码块都会执行,那么现在考虑这样一种场景,那就是在try代码块,catch代码块和finally代码块中都抛出异常,那么最终抛出的异常会是哪个代码块抛出的异常呢,下面以一个实例进行说明。

测试代码如下所示。

public class ExceptionShieldTest {

    @Test
    public void 测试异常屏蔽() {
        try {
            exceptionTest();
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

    private void exceptionTest() throws ExceptionB, ExceptionC {
        try {
            throwExceptionA();
        } catch (ExceptionA e) {
            throwExceptionB();
        } finally {
            throwExceptionC();
        }
    }

    private void throwExceptionA() throws ExceptionA {
        throw new ExceptionA();
    }

    private void throwExceptionB() throws ExceptionB {
        throw new ExceptionB();
    }

    private void throwExceptionC() throws ExceptionC {
        throw new ExceptionC();
    }

    private static class ExceptionA extends Exception {
        public ExceptionA() {
            super("我是异常A");
        }
    }

    private static class ExceptionB extends Exception {
        public ExceptionB() {
            super("我是异常B");
        }
    }

    private static class ExceptionC extends Exception {
        public ExceptionC() {
            super("我是异常C");
        }
    }

}

运行测试程序,打印如下。

我是异常C

测试程序中,exceptionTest() 方法的catch代码块会抛出ExceptionBfinally代码块会抛出ExceptionC,但是其实最终只会抛出ExceptionC,而ExceptionB会被屏蔽,这称作异常屏蔽,同样,从字节码入手观察一下异常屏蔽的发生,exceptionTest() 对应的字节码指令如下所示。

private void exceptionTest() throws com.lee.learn.exception.ExceptionShieldTest$ExceptionB, com.lee.learn.exception.ExceptionShieldTest$ExceptionC;
    descriptor: ()V
    flags: ACC_PRIVATE
    Code:
        stack=1, locals=3, args_size=1
            0: aload_0
            1: invokespecial #7		// 执行throwExceptionA()
            4: aload_0
            5: invokespecial #8		// 执行throwExceptionC()
            8: goto          30
            11: astore_1
            12: aload_0
            13: invokespecial #10	// 执行throwExceptionB()
            16: aload_0
            17: invokespecial #8	// 执行throwExceptionC()
            20: goto          30
            23: astore_2
            24: aload_0
            25: invokespecial #8	// 执行throwExceptionC()
            28: aload_2
            29: athrow
            30: return
        Exception table:
            from    to  target type
               0     4    11   Class com/lee/learn/exception/ExceptionShieldTest$ExceptionA
               0     4    23   any
              11    16    23   any
        LineNumberTable:
            line 18: 0
            line 22: 4
            line 23: 8
            line 19: 11
            line 20: 12
            line 22: 16
            line 23: 20
            line 22: 23
            line 23: 28
            line 24: 30
        LocalVariableTable:
            Start  Length  Slot  Name   Signature
               12       4     1     e   Lcom/lee/learn/exception/ExceptionShieldTest$ExceptionA;
                0      31     0  this   Lcom/lee/learn/exception/ExceptionShieldTest;

下面结合上述字节码指令以及图示,说明一下异常屏蔽的发生。

刚开始时,执行的字节码指令如下所示。

0: aload_0			// 复制局部变量表第0个槽位的数据this到操作数栈栈顶
1: invokespecial #7		// 将操作数栈栈顶数据弹出,作为参数执行throwExceptionA()方法

上述指令执行完毕后,由于执行throwExceptionA() 方法会抛出ExceptionA,所以此时局部变量表和操作数栈如下所示。

异常屏蔽-1

根据异常表的定义,发生ExceptionA时需要跳转到第11行字节码指令继续执行,执行的字节码指令如下所示。

11: astore_1			// 将操作数栈栈顶的数据弹出并存放到局部变量表的第1个槽位        

此时局部变量表和操作数栈如下所示。

异常屏蔽-11

继续执行,执行的字节码指令如下所示。

12: aload_0			// 复制局部变量表第0个槽位的数据this到操作数栈栈顶
13: invokespecial #10		// 将操作数栈栈顶数据弹出,作为参数执行throwExceptionB()方法

上述指令执行完毕后,由于执行throwExceptionB() 方法会抛出ExceptionB,所以此时局部变量表和操作数栈如下所示。

异常屏蔽-13

根据异常表的定义,发生ExceptionB时需要跳转到第23行字节码指令继续执行,执行的字节码指令如下所示。

23: astore_2	// 将操作数栈栈顶的数据弹出并存放到局部变量表的第2个槽位

此时局部变量表和操作数栈如下所示。

异常屏蔽-23

继续执行,执行的字节码指令如下所示。

24: aload_0			// 复制局部变量表第0个槽位的数据this到操作数栈栈顶
25: invokespecial #8	        // 将操作数栈栈顶数据弹出,作为参数执行throwExceptionC()方法

由于第25行字节码指令执行时抛出了ExceptionC,但是异常表没有定义跳转位置,这种情况下会导致当前方法也就是exceptionTest() 方法的栈帧强制弹出,操作数栈栈顶的异常往上层抛出,也就是往上层抛出ExceptionC

现在假设第25行字节码指令执行时不发生异常,那么会继续执行如下字节码指令。

28: aload_2		// 将局部变量表第2个槽位的变量(也就是ExceptionB)复制到栈顶
29: athrow		// 将栈顶的异常抛出,也就是抛出ExceptionB
30: return		// 方法结束并返回

也就是说,exceptionTest() 方法正常应该抛出ExceptionB,但是由于finally代码块中抛出了ExceptionC,最终exceptionTest() 方法抛出的异常是ExceptionC,而ExceptionB就发生了异常屏蔽。

6. 异常抑制

发生异常屏蔽时,会导致被屏蔽的异常信息丢失,要避免异常屏蔽的发生,可以将第5小节中的测试代码进行如下修改。

public class ExceptionSuppressionTest {

    @Test
    public void 测试异常抑制() {
        try {
            exceptionTest();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void exceptionTest() throws ExceptionB, ExceptionC {
        ExceptionB finalException = null;
        try {
            throwExceptionA();
        } catch (ExceptionA exa) {
            try {
                throwExceptionB();
            } catch (ExceptionB exb) {
                finalException = exb;
                throw exb;
            }
        } finally {
            try {
                throwExceptionC();
            } catch (ExceptionC exc) {
                if (finalException != null) {
                    finalException.addSuppressed(exc);
                } else {
                    throw exc;
                }
            }
        }
    }

    private void throwExceptionA() throws ExceptionA {
        throw new ExceptionA();
    }

    private void throwExceptionB() throws ExceptionB {
        throw new ExceptionB();
    }

    private void throwExceptionC() throws ExceptionC {
        throw new ExceptionC();
    }

    private static class ExceptionA extends Exception {
        public ExceptionA() {
            super("我是异常A");
        }
    }

    private static class ExceptionB extends Exception {
        public ExceptionB() {
            super("我是异常B");
        }
    }

    private static class ExceptionC extends Exception {
        public ExceptionC() {
            super("我是异常C");
        }
    }

}

运行测试程序,打印如下。

com.lee.learn.exception.ExceptionSuppressionTest$ExceptionB: 我是异常B
    at com.lee.learn.exception.ExceptionSuppressionTest.throwExceptionB(ExceptionSuppressionTest.java:45)
    at com.lee.learn.exception.ExceptionSuppressionTest.exceptionTest(ExceptionSuppressionTest.java:22)
    at com.lee.learn.exception.ExceptionSuppressionTest.测试异常抑制(ExceptionSuppressionTest.java:10)
    ......
    Suppressed: com.lee.learn.exception.ExceptionSuppressionTest$ExceptionC: 我是异常C
            at com.lee.learn.exception.ExceptionSuppressionTest.throwExceptionC(ExceptionSuppressionTest.java:49)
            at com.lee.learn.exception.ExceptionSuppressionTest.exceptionTest(ExceptionSuppressionTest.java:29)
            ......

上述代码以及运行结果表明,ExceptionC作为抑制异常添加到了ExceptionB中,可以理解为ExceptionB抑制了ExceptionC,但是与异常屏蔽不同的时,异常抑制不会丢失被抑制的异常的信息,例如上述例子中打印ExceptionB的堆栈信息时,会一并将ExceptionC的信息打印出来。

要实现异常抑制,需要使用到Throwable1.7版本提供的addSuppressed(Throwable t) 方法,该方法的入参会作为被抑制异常添加到原异常的suppressedExceptions字段中,而suppressedExceptions实际就是一个Throwable的集合,如下所示。

private List<Throwable> suppressedExceptions = SUPPRESSED_SENTINEL;

所以一个异常可以添加多个抑制异常。

7. try-with-resources

第6小节中的测试代码基于异常抑制解决了异常屏蔽带来的丢失异常信息的问题,但是代码书写起来较为繁琐,因此JDK1.7版本提供了try-with-resources语法糖来解决这个问题。

先看如下例子,是没有使用try-with-resources的示例。

public class TryWithResourcesTest {

    @Test
    public void 不使用语法糖的写法() {
        try {
            exceptionNonSugarTest();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void exceptionNonSugarTest() throws UpdateException, CloseException {
        Connection connection = null;
        UpdateException finalException = null;
        try {
            connection = new Connection();
            connection.update();
        } catch (UpdateException e) {
            finalException = e;
            throw e;
        } finally {
            if (connection != null) {
                try {
                    connection.close();
                } catch (CloseException e) {
                    if (finalException != null) {
                        finalException.addSuppressed(e);
                    } else {
                        throw e;
                    }
                }
            }
        }
    }

    private static class Connection implements AutoCloseable {
        public void update() throws UpdateException {
            throw new UpdateException();
        }

        @Override
        public void close() throws CloseException {
            throw new CloseException();
        }
    }

    private static class UpdateException extends Exception {
        public UpdateException() {
            super("我是更新异常");
        }
    }

    private static class CloseException extends Exception {
        public CloseException() {
            super("我是关闭异常");
        }
    }

}

运行测试程序,打印如下。

com.lee.learn.exception.TryWithResourcesTest$UpdateException: 我是更新异常
    at com.lee.learn.exception.TryWithResourcesTest$Connection.update(TryWithResourcesTest.java:42)
    at com.lee.learn.exception.TryWithResourcesTest.exceptionNonSugarTest(TryWithResourcesTest.java:21)
    at com.lee.learn.exception.TryWithResourcesTest.不使用语法糖的写法(TryWithResourcesTest.java:10)
    ......
    Suppressed: com.lee.learn.exception.TryWithResourcesTest$CloseException: 我是关闭异常
            at com.lee.learn.exception.TryWithResourcesTest$Connection.close(TryWithResourcesTest.java:47)
            at com.lee.learn.exception.TryWithResourcesTest.exceptionNonSugarTest(TryWithResourcesTest.java:28)
            ......

现在基于try-with-resources对上述测试程序进行改进,如下所示。

@Test
public void 使用语法糖的写法() {
    try {
        exceptionSugarTest();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

private void exceptionSugarTest() throws UpdateException, CloseException {
    try (Connection connection = new Connection()) {
        connection.update();
    }
}

重新运行测试程序,打印如下。

com.lee.learn.exception.TryWithResourcesTest$UpdateException: 我是更新异常
    at com.lee.learn.exception.TryWithResourcesTest$Connection.update(TryWithResourcesTest.java:57)
    at com.lee.learn.exception.TryWithResourcesTest.exceptionSugarTest(TryWithResourcesTest.java:51)
    at com.lee.learn.exception.TryWithResourcesTest.使用语法糖的写法(TryWithResourcesTest.java:43)
    ......
    Suppressed: com.lee.learn.exception.TryWithResourcesTest$CloseException: 我是关闭异常
            at com.lee.learn.exception.TryWithResourcesTest$Connection.close(TryWithResourcesTest.java:62)
            at com.lee.learn.exception.TryWithResourcesTest.exceptionSugarTest(TryWithResourcesTest.java:52)
            ......

代码得到了极大的精简,同时执行结果也是符合预期,现在反编译TryWithResourcesTestclass文件,看一下exceptionSugarTest() 方法反编译后的实现,如下所示。

private void exceptionSugarTest() throws TryWithResourcesTest.UpdateException, TryWithResourcesTest.CloseException {
    TryWithResourcesTest.Connection connection = new TryWithResourcesTest.Connection();
    Throwable var2 = null;
    try {
        connection.update();
    } catch (Throwable var11) {
        var2 = var11;
        throw var11;
    } finally {
        if (connection != null) {
            if (var2 != null) {
                try {
                    connection.close();
                } catch (Throwable var10) {
                    var2.addSuppressed(var10);
                }
            } else {
                connection.close();
            }
        }
    }
}

所以try-with-resources本质上就是帮我们做了如下两件事情。

  1. 自动调用AutoCloseable接口的close() 方法来关闭资源;
  2. 基于Throwable#addSuppressed方法实现异常抑制以防止丢失异常信息。

三. RuntimeException

1. 概念说明

RuntimeException,即运行时异常,是JVM正常运行时可以抛出的异常。所有RuntimeException以及RuntimeException的子类都属于未检查异常,但是未检查异常不全是RuntimeException,因为Error及其子类也属于未检查异常。

运行时异常具有未检查异常的所有属性。

  1. 编译器不会对运行时异常的处理做校验;
  2. 运行时异常不需要在方法上通过throws关键字声明抛出;
  3. 运行时异常通常认为是通过优化代码逻辑可以避免的;
  4. 运行时异常通常不需要被try-catch

2. NullPointerException

NullPointerException,即空指针异常。如下情况会抛出空指针异常。

  1. 调用null对象的方法;
  2. 使用null对象的字段;
  3. 获取null数组的长度;
  4. 获取null数组的元素;
  5. 通过throw关键字抛出null异常。

演示一下第5点,测试程序如下所示。

public class NullPointerExceptionTest {

    @Test
    public void 测试抛出空() {
        execute();
    }

    private void execute() {
        throw null;
    }
    
}

运行测试程序,打印如下。

java.lang.NullPointerException
    at com.lee.learn.exception.NullPointerExceptionTest.execute(NullPointerExceptionTest.java:13)
    at com.lee.learn.exception.NullPointerExceptionTest.测试抛出空(NullPointerExceptionTest.java:9)

那么有些时候在判空时,会看到如下两种写法。

if (obj == null) {
    ......
}

if (null == obj) {
    ......
}

上述两种写法,对于防止空指针,是没有说法的。真正的说法见如下示例。

// 由于粗心,少写了一个等于号,竟然编译通过了
Boolean booleanObj1 = false;
if (booleanObj1 = null) {
    ......
}

// 由于粗心,少写了一个等于号,是编译不过的
Boolean booleanObj2 = true;
if (null = booleanObj1) {
    ......
}

3. IndexOutOfBoundsException

IndexOutOfBoundsException,即索引越界异常,相信经常刷题的小伙伴不会陌生。

java.lang包下定义了两个IndexOutOfBoundsException的子类:ArrayIndexOutOfBoundsExceptionStringIndexOutOfBoundsException,分别表示数组索引越界异常和字符串索引越界,下面分别演示这两种异常。

public class IndexOutOfBoundsExceptionTest {

    @Test
    public void 数组索引越界异常() {
        int[] nums = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        System.out.println(nums[10]);
    }

    @Test
    public void 字符串索引越界异常() {
        String str = new String("AAAAA");
        System.out.println(str.charAt(5));
    }

}

运行测试程序,分别打印如下。

java.lang.ArrayIndexOutOfBoundsException: 10
    at com.lee.learn.exception.IndexOutOfBoundsExceptionTest.数组索引越界异常(IndexOutOfBoundsExceptionTest.java:10)
java.lang.StringIndexOutOfBoundsException: String index out of range: 5
    at java.lang.String.charAt(String.java:658)
    at com.lee.learn.exception.IndexOutOfBoundsExceptionTest.字符串索引越界异常(IndexOutOfBoundsExceptionTest.java:16)

4. ClassCastException

ClassCastException,即类型强转异常。该异常均发生于试图将一个对象类型强转为非当前类型的超类,如下是一个示例。

public class ClassCastExceptionTest {

    @Test
    public void 父类强转为子类() {
        People people = new People();
        System.out.println((Chinese) people);
    }

    @Test
    public void 子类强转为父类() {
        Chinese chinese = new Chinese();
        System.out.println((People) chinese);
    }

    @Test
    public void 子类之间强转() {
        People chinese = new Chinese();
        System.out.println((American) chinese);
    }

    private static class People {}
    private static class Chinese extends People {}
    private static class American extends People {}

}

上述中,父类强转为子类()子类之间强转() 两个测试方法运行时,均会报强转错误,报错信息如下所示。

java.lang.ClassCastException: com.lee.learn.exception.ClassCastExceptionTest$People cannot be cast to com.lee.learn.exception.ClassCastExceptionTest$Chinese

java.lang.ClassCastException: com.lee.learn.exception.ClassCastExceptionTest$Chinese cannot be cast to com.lee.learn.exception.ClassCastExceptionTest$American

5. ArithmeticException

ArithmeticException,即算术异常。最常见的抛出场景就是”整数除以整数零“,而如果除数和被除数中只要有一个是浮点数,那么都不会抛出ArithmeticException,但计算结果会是Infinity,表示无穷,示例如下。

public class ArithmeticExceptionTest {

    @Test
    public void 整数除以整零() {
        System.out.println(10 / 0);
    }

    @Test
    public void 浮点数除以零() {
        System.out.println(10.0f / 0);
    }

}

运行测试程序,分别打印如下。

java.lang.ArithmeticException: / by zero
    at com.lee.learn.exception.ArithmeticExceptionTest.整数除以整零(ArithmeticExceptionTest.java:9)
Infinity

6. ArrayStoreException

ArrayStoreException,即数组存储异常。发生于试图将错误类型的对象存储到数组中,示例如下所示。

public class ArrayStoreExceptionTest {

    @Test
    public void 测试数组存储异常() {
        People woman = new Woman();

        People[] men = new Man[10];
        men[0] = woman;
    }

    private static class People {}
    private static class Man extends People {}
    private static class Woman extends People {}

}

运行测试程序,打印如下。

java.lang.ArrayStoreException: com.lee.learn.exception.ArrayStoreExceptionTest$Woman
    at com.lee.learn.exception.ArrayStoreExceptionTest.测试数组存储异常(ArrayStoreExceptionTest.java:12)

7. Fast Thrown机制

那么多运行时异常,为什么唯独介绍上述的五种呢,是因为接下来要分析的Fast Thrown机制。

通常,运行时异常不需要被显示声明抛出,也不建议捕获运行时异常,那么一个运行时异常如果发生了且没有被捕获,那么危害是很大的,所以运行时异常就不应该让其产生,会产生运行时异常的地方,就应该优化代码逻辑。

上述介绍的五种运行时异常,通常均由JVM抛出,而一旦发生由JVM抛出运行时异常的情况,表明代码存在缺陷,存在缺陷的代码在线上运行时,可以假定缺陷代码恒会抛出运行时异常,或者抛出运行时异常的概率很高,而又知道,创建异常会去获取当前线程的堆栈信息,获取堆栈信息需要爬栈,而这一操作十分消耗性能,因此针对这种情况,JVM提供了一种叫做Fast Thrown的机制来避免因为生成大量运行时异常而造成的性能损耗。

Fast Thrown机制,简单来说,就是一旦当JVM识别到某一类特定运行时异常发生多次,此时就会直接抛出预先创建好的没有堆栈的异常对象并抛出。

Fast Thrown机制的好处有两点。

  1. 提升性能。因为不需要获取堆栈信息,所以异常的抛出很快,几乎不影响性能;
  2. 节约内存。因为抛出的异常对象都是预先创建好的同一个异常对象,几乎不消耗堆内存空间。

Fast Thrown机制的缺点也很明显,如下所示。

  1. 拿不到异常的堆栈信息。之所以抛出异常快,正是由于没有去获取堆栈信息,所以打印这些Fast Thrown的异常时,只有异常类型,没有堆栈。

上面还提及,只会有特定类型的运行时异常才会进行Fast Thrown,其实这几种特定的运行时异常就是本节介绍的五种运行时异常,如下所示。

  1. NullPointerException
  2. ArrayIndexOutOfBoundsException
  3. ClassCastException
  4. ArithmeticException
  5. ArrayStoreException

如果不需要Fast Thrown机制,可以通过如下JVM参数进行关闭。

-XX:-OmitStackTraceInFastThrow

上述是关于Fast Thrown的基础,下面稍微进阶一点了解其机制原理。

我们的Java代码经过编译后,得到class文件,然后以二进制流的方式被加载到JVM中,像我们编写的方法,最终呈现在class文件中的形态其实就是字节码指令,当执行方法时,是由JVM的执行引擎来完成,而执行引擎包含解释器(Interpreter)和即时编译器(JIT Compiler),简单说明如下。

  • 解释器:根据Java虚拟机规范对字节码指令进行逐行解释的方式来执行,即将每条字节码指令转换为对应平台的机器码(机器指令码)运行,通过解释器,JVM也是能够认识字节码指令的;
  • 即时编译器:将方法直接编译成对应平台的机器码,通常是针对热点方法进行即时编译,又称为后端运行期编译,即字节码到机器码的编译(前端编译就是源码到字节码的编译)。

JVM实际执行时,执行引擎是解释器和即时编译器配合工作的,这么做的原因有如下两点。

  1. 即时编译器需要预热。因为即时编译器工作的前提是被识别为热点代码,因此这个识别过程也就是预热过程,是需要一定时间的,所以当程序刚启动的这一段时间,通常是解释器在工作,这样可以节约等待即时编译器预热的时间;
  2. 罕见陷阱Uncommon Trap)发生时的后备方案。

当即时编译器识别某一个方法为热点代码时,就会进行即时编译优化,这种即时编译优化是以性能提升为主要目标的,也就是根据统计信息先验规则来得到方法的最有可能的执行逻辑。但是如果即时编译优化后的方法中出现了罕见陷阱(为什么会出现下面会分析),那么此时会进行去优化操作,也就是回退为解释执行的状态(解释执行的方法栈帧替换即时编译的方法栈帧),然后再根据统计信息和先验规则来得到方法的最有可能的执行逻辑并重新进行即时编译优化。

假如我们在程序中有如下一行代码。

param.id

其中param是一个对象,那么JVM在实际执行上述的代码时,为我们做了大量的防御性编程,上面一行代码,对应JVM执行的伪码如下所示。

if (param != null) {
    return param.id;
} else {
    throw new NullPointException();
}

那么当多次执行并且JVM识别到param几乎不为null时,会将上述伪码优化如下。

try {
    return param.id;
} catch (segment_fault) {
    uncommon_trap()
}

优化掉了对param的非空判断,但是如果一旦paramnull,就会导致罕见陷阱的发生,此时会进行去优化,其实去优化也没什么问题,这是一个很正常的操作,但是如果有大量的param为空的情况,就可能会出现频繁的去优化,这对性能是巨大的损耗。

所以当因为param为空导致罕见陷阱的发生从而导致去优化的次数达到一定统计量后,此时JVM会使用预构造的空指针异常来优化异常抛出的逻辑,优化后的伪码如下所示。

try {
    return param.id;
} catch (segment_fault) {
    throw NullPointerException_instance;
}

如此一来,当param不为空时,直接返回paramid字段,当param为空时,直接抛出预构造的空指针异常。这个过程,就是Fast Thrown机制。

下面最后结合部分源码看一下Fast Thrown机制的生效过程。

// 标识当前抛出的异常是否是热点异常
// 被识别为热点异常是Fast Thrown机制的大前提
bool treat_throw_as_hot = false;
ciMethodData* md = method()->method_data();

if (ProfileTraps) {
    // 这里的reason就是去优化的原因
    // 比如空值校验优化导致的去优化
    // 在这里判断当前reason导致的去优化是否达到统计数量
	if (too_many_traps(reason)) {
        // 达到统计数量就设置热点异常标识为true
      	treat_throw_as_hot = true;
    }
    
    ......
    
}

上面是判断异常是否是热点异常的逻辑,下面再看一下判断热点异常类型的逻辑。

if (treat_throw_as_hot && (!StackTraceInThrowable || OmitStackTraceInFastThrow)) {
    // 待抛出的预构造的异常对象
    ciInstance* ex_obj = NULL;
    switch (reason) {
    	// 如果是空值校验优化导致的去优化
        case Deoptimization::Reason_null_check:
            // 抛出预构造的空指针异常
            ex_obj = env()->NullPointerException_instance();
            break;
        // 如果是除零校验优化导致的去优化
        case Deoptimization::Reason_div0_check:
            // 抛出预构造的算术异常
            ex_obj = env()->ArithmeticException_instance();
            break;
        // 如果是数组边界校验优化导致的去优化
        case Deoptimization::Reason_range_check:
            // 抛出预构造的数组索引越界异常
            ex_obj = env()->ArrayIndexOutOfBoundsException_instance();
            break;
        // 如果是类型校验优化导致的去优化
        case Deoptimization::Reason_class_check:
            if (java_bc() == Bytecodes::_aastore) {
            	// 抛出数组存储异常
                ex_obj = env()->ArrayStoreException_instance();
            } else {
            	// 抛出类型强转异常
                ex_obj = env()->ClassCastException_instance();
            }
            break;
        default:
            break;
    }
}

四. Error

1. 概念说明

Error表示应用程序运行过程中发生的致命错误,这个错误通常由JVM抛出,且应用程序无法处理。但是Error及其子类的实例本质上还是对象,同时均继承于Throwable所以我们可以自己在程序中创建出来Error实例然后再通过throw关键字抛出,同时也可以通过catch关键字捕获,但这么做是没有意义的。

2. OutOfMemoryError

官方对OutOfMemoryError的定义是:当JVM由于内存不足而无法分配对象,并且垃圾收集器无法提供更多内存时抛出。

OutOfMemoryError有如下几种分类,也就分别对应发生OutOfMemoryError的几种原因。

  1. 堆内存溢出。堆上剩余内存不足以容纳新对象时抛出,异常信息如下所示。
java.lang.OutOfMemoryError: Java heap space
  1. 元空间内存溢出。元空间剩余内存不足以加载Class时抛出,异常信息如下所示。
java.lang.OutOfMemoryError: Metaspace
  1. 直接内存溢出。剩余直接内存不足时抛出,通常是使用-XX:MaxDirectMemorySize指定了直接内存后并且应用程序中有使用到NIO框架或者不合理的直接内存分配,异常信息如下所示。
java.lang.OutOfMemoryError: Direct buffer memory
  1. GC开销超限。连续多次GC的内存回收率低于2%时抛出,异常信息如下所示。
java.lang.OutOfMemoryError: GC overhead limit exceeded
  1. 无法创建本机线程。无法再继续创建线程时抛出,通常是在容器中通过cgroup限制了最大线程数量同时应用程序中创建的线程又达到了限制的最大值,异常信息如下所示。
java.lang.OutOfMemoryError: unable to create new native thread

3. StackOverflowError

Java中每个线程有栈内存大小限制,栈内存大小决定了方法调用的最大深度,如果超出了方法调用的最大深度,就会抛出StackOverflowError,可以通过-Xss来设置线程栈内存大小。如下是一个简单示例。

public class StackOverflowErrorTest {

    @Test
    public void 测试栈内存溢出() {
        unterminatedRecursion();
    }

    private void unterminatedRecursion() {
        unterminatedRecursion();
    }

}

执行测试程序,运行一小会儿,就会抛出StackOverflowError,如下所示。

java.lang.StackOverflowError
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    at com.lee.learn.exception.StackOverflowErrorTest.unterminatedRecursion(StackOverflowErrorTest.java:13)
    ......

4. NoClassDefFoundError

如果在编译时类能够成功完成编译,但是在运行并加载类时无法完成加载,此时会抛出该异常,示例如下所示。

public class NoClassDefFoundErrorTest {

    @Test
    public void 测试没有类定义异常() {
        Demo.say();
    }

    private static class Demo {
        static {
            int i = 1 / 0;
        }

        public static void say() {
            System.out.println("你好世界");
        }
    }

}

运行测试程序,打印如下。

java.lang.ExceptionInInitializerError
    at com.lee.learn.exception.NoClassDefFoundErrorTest.测试没有类定义异常(NoClassDefFoundErrorTest.java:9)
    ......
Caused by: java.lang.ArithmeticException: / by zero
    at com.lee.learn.exception.NoClassDefFoundErrorTest$Demo.<clinit>(NoClassDefFoundErrorTest.java:14)
    ......

因为加载类时,会执行静态代码块,但是上述示例在静态代码块中抛出了异常,导致类加载失败,所以抛出了NoClassDefFoundError

NoClassDefFoundError比较像的一个异常叫做ClassNotFoundException,官方对ClassNotFoundException的解释是应用程序试图基于全限定名来加载类但在类路径下找不到对应类时抛出,通常对应如下三种调用。

  1. ClassforName() 方法调用;
  2. ClassLoaderfindSystemClass() 方法调用;
  3. ClassLoaderloadClass() 调用。

下面是一个简单的示例。

public class ClassNotFoundExceptionTest {

    @Test
    public void 测试找不到加载类异常() throws ClassNotFoundException {
        Class.forName("com.lee.learn.exception.NotExistClass");
    }

}

运行测试程序,打印如下。

java.lang.ClassNotFoundException: com.lee.learn.exception.NotExistClass
    at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:338)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.java:264)
    at com.lee.learn.exception.ClassNotFoundExceptionTest.测试找不到加载类异常(ClassNotFoundExceptionTest.java:9)

总结

原本是想在本文的最后,再总结一下Java异常使用的最佳实践的,但是后面发现,好像谈异常的最佳实践就是一个很没有意义的事情,因为在Java的异常体系中,存在着Checked ExceptionUnchecked Exception,而有人觉得Checked Exception是优秀的异常设计,有人又觉得其完全是一个败笔,网上的争论也很多,哪方都无法说服对方,最终都落回那一句话:具体情况具体分析。


如果觉得本篇文章对你有帮助,求求你点个赞,加个收藏最后再点个关注吧。创作不易,感谢支持!