您的当前位置:首页正文

Java基础(三):Java异常机制以及底层实现原理

2024-11-06 来源:个人技术集锦

?一、异常

☘️1.1 什么是异常

Java异常是程序发生错误的一种处理机制,异常的顶级类是ThrowableThrowable字面意思就是可抛出的,该类是所有的错误和异常的超类,只有Throwable类或者Throwable子类的实例对象才可以被Java虚拟机抛出,或者被Java语句抛出。

?Throwable有两个子类ErrorException

  • Error:表示错误,比如内存溢出。

  • Exception:表示异常,比如IO异常。

?Exception又分为运行时异常和非运行时异常

  • 运行时异常:也叫做非检查异常,在代码中抛出运行时异常时,Java编译器在编译时不会报错,只有在运行的时候才可能会抛出该异常,RuntimeException类和该类的子类都是运行时异常。

  • 非运行时异常:也叫做检查异常或者编译时异常,Java编译器在编译的时候如果发现程序抛出了检查异常,那么编译器就会报错,需要我们在代码中处理,要么捕获,要么继续抛出。没有继承RuntimeException的异常都是非运行时异常。

?1.2 代码示例

1.2.1 运行时异常

运行时异常,比如ArrayIndexOutOfBoundsExceptionNullPointerExceptionRuntimeException,当程序抛出运行时异常时,即使我们没有处理,编译器也不会报错,这种抛出不一定是显示的抛出,更多的是在运行时出现异常之后JVM自动的抛出。

public void runException () {
    throw new RuntimeException();
}

1.2.2 非运行时异常

非运行时异常,比如RuntimeExceptionNoSuchMethodException,当程序抛出了非运行时异常时,我们必须处理,否则编译器编译时就会报错。非运行时异常更多是提供给开发者用于对可能出现的异常情况进行处理。

public void nonRunException () {
    try {
        throw new FileNotFoundException();
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }
}

?1.3 异常处理方法

Java中异常处理方法有两种:继续抛出、捕获处理。

1.3.1 继续抛出

抛出异常有一个关键字:throwsthrows可以抛出多个异常,多个异常类用逗号分隔即可。

当方法中抛出异常之后,如果我们不对异常处理,可以在方法上使用throws抛出异常,这样就不需要方法处理异常,但是抛出异常的方法,需要调用者来处理异常。

1.3.2 捕获处理

异常的另外一种处理办法就是捕获处理,把异常在方法内部就处理掉,这样调用者调用方法时,不需要再处理异常。

捕获异常有几个关键字trycatchfinally

  • try:用于包裹异常的代码

  • catch:用于捕获异常之后的处理,可以有多个catch来捕获不同的异常,也可以用一个catch,不同的异常用符号|来分割

  • finally:无论如何都会执行的代码块

捕获异常有三种格式:

  • 第一种,用try捕获异常,然后在catch中处理。
try {
} catch () {
}
// 也可以有多个catch
try {
} catch () {
} catch () {
}
// 也可以在catch中直接捕获多个异常
try {
} catch (Exception1 | Exception2) { 
}
  • 第二种,用try捕获异常,在catch中处理异常,并且在finally中执行需要执行的代码
try {
} catch () {
} finally {
}
  • 第三种,捕获异常之后不处理,但是要在finally中执行需要执行的代码
try {
} finally {
}

?1.4 try-with-resources

try-with-reourcesJDK1.7出来的新语法,这个语法有两个目的:主要是自动关闭资源,为了减少对finally的依赖,同时使得代码变得简洁一些、同时还可以传递被抑制的异常。

1.4.1 自动关闭资源

我们先说第一个,我们先看一下如果用传统的try-catch是怎么使用的。

如果我们用try-catch-finally来关闭异常,可以发现关闭资源的代码比业务代码还要多,看起来也不是很简洁。

public static void main(String[] args) {
  FileInputStream fileInputStream = null;
  try {
    fileInputStream = new FileInputStream("");
  } catch (FileNotFoundException e) {
    e.printStackTrace();
  } finally {
    try {
      if (fileInputStream != null) {
        fileInputStream.close();
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

现在我们用try-with-resource来改写这个,把需要关闭资源的放到try的括号里面,这样发生了异常会自动关闭。

public static void main(String[] args)  {
       
  try(FileInputStream fileInputStream = new FileInputStream("")) {
    int result = fileInputStream.read();
  } catch (IOException e) {
    e.printStackTrace();
  }
}

那么try-with-resource是怎么实现自动关闭,我们反编译一下字节码,可以看到本质上还是用的try-catch来实现的,只不过是在try里面调用了close

public static void main(String[] args) {
try {
    FileInputStream fileInputStream = new FileInputStream("file.txt");
    Object var2 = null;
    if (fileInputStream != null) {
        if (var2 != null) {
            try {
                fileInputStream.close();
            } catch (Throwable var4) {
                ((Throwable)var2).addSuppressed(var4);
            }
        } else {
            fileInputStream.close();
        }
    }
} catch (IOException var5) {
    IOException e = var5;
    e.printStackTrace();
}

1.4.2 传递抑制的异常

如果tryfinally都发生了异常,并且抛出那么finally中的异常就会覆盖try中的异常。

我们在一个方法里面先抛出一个FileNotFoundException异常,然后再finally里面关闭这个流的时候又会出现NullPointerException异常,在main方法进行捕获之后可以发现最后只打印了NullPointerException异常,而FileNotFoundException并没有打印出来。

public static void main(String[] args) {
    try {
        c();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

public static void c () throws IOException {
    FileInputStream fileInputStream = null;
    try {
        fileInputStream = new FileInputStream("");
    } catch (FileNotFoundException e) {
        throw e;
    } finally {
        try {
            fileInputStream.close();
        } catch (IOException e) {
            throw e;
        }
    }
}

打印出来的异常信息

java.lang.NullPointerException
	at com.lee.study.exception.Demo.c(Demo.java:26)
	at com.lee.study.exception.Demo.main(Demo.java:12)

我们再用try-with-resources改写一下,这里我们需要构造一个自定义的类,实现在关闭的时候也抛出异常。

public static void main(String[] args) {
        try (MyAutoCloseable myAutoCloseable = new MyAutoCloseable();) {
            myAutoCloseable.read();
        }catch (Exception e) {
            e.printStackTrace();
        }
    }
}
// 实现了AutoCloseable接口可以自动的调用close方法
class MyAutoCloseable implements AutoCloseable {

public void read() throws Exception {
    throw new Exception("read");
}

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

打印出来的异常信息

java.lang.Exception: read
	at com.lee.study.exception.MyAutoCloseable.read(Demo.java:22)
	at com.lee.study.exception.Demo.main(Demo.java:12)
	Suppressed: java.lang.Exception: close
		at com.lee.study.exception.MyAutoCloseable.close(Demo.java:27)
		at com.lee.study.exception.Demo.main(Demo.java:13)

我们看下反编译代码,可以看到在finally中关闭的时候调用了一个方法addSuppressed,这个方法就是用来把这些抑制的异常都记录下来。

public static void main(String[] args) {
        try {
            MyAutoCloseable myAutoCloseable = new MyAutoCloseable();
            Throwable var2 = null;

            try {
                myAutoCloseable.read();
            } catch (Throwable var12) {
                var2 = var12;
                throw var12;
            } finally {
                if (myAutoCloseable != null) {
                    if (var2 != null) {
                        try {
                            myAutoCloseable.close();
                        } catch (Throwable var11) {
                            var2.addSuppressed(var11);
                        }
                    } else {
                        myAutoCloseable.close();
                    }
                }

            }
        } catch (Exception var14) {
            Exception e = var14;
            e.printStackTrace();
        }

    }

?1.5 自定义异常

除了Java中提供的异常之外,我们也可以自定义一些异常,自定义异常可以自定义运行时异常和非运行时异常两种方式。

1.5.1 自定义运行时异常

自定义运行时异常之需要继承RuntimeException这个方法即可,这里我们加了一个构造方法,这样可以创建异常对象时加一些描述异常信息。

public class DemoRuntimeException extends RuntimeException{
    public DemoRuntimeException (String message) {
        super(message);
    }
}

1.5.2 自定义非运行时异常

自定义非运行时异常只需要继承Exception,这里也是加了一个构造方法,描述异常信息。

public class DemoNonRuntimeException extends Exception{

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

?二、源码分析

?2.1 变量

// 用来保存栈信息的轨迹
private transient Object backtrace;

// 记录描述异常信息
private String detailMessage;

// 描述这个异常是哪个Throwable导致的,默认是当前Throwable
private Throwable cause = this;

// 记录异常抛出位置的栈信息,每个StackTraceElement代表一个栈
private static final StackTraceElement[] UNASSIGNED_STACK = new StackTraceElement[0];
private StackTraceElement[] stackTrace = UNASSIGNED_STACK;

//用来保存被屏蔽的异常对象,如果在finally中抛出了异常,就会覆盖try中的异常,这个是JDK 1.7引入的新特性
private static final List<Throwable> SUPPRESSED_SENTINEL =
  Collections.unmodifiableList(new ArrayList<Throwable>(0));
private List<Throwable> suppressedExceptions = SUPPRESSED_SENTINEL;

?2.2 构造方法

/**
 * 无参构造方法,只调用了fillInStackTrace方法
 */
public Throwable() {
  fillInStackTrace();
}

/**
 * @param message 描述异常的信息,打印异常信息会一并打印
 */
public Throwable(String message) {
  fillInStackTrace();
  detailMessage = message;
}

/**
 * @param message 描述异常的信息,打印异常信息会一并打印
 * @param cause 当前异常是哪个Throwable导致的
 */
public Throwable(String message, Throwable cause) {
  fillInStackTrace();
  detailMessage = message;
  this.cause = cause;
}

/**
 * @param cause 当前异常是哪个Throwable导致的
 */
public Throwable(Throwable cause) {
  fillInStackTrace();
  detailMessage = (cause==null ? null : cause.toString());
  this.cause = cause;
}

/**
 * @param message 描述异常的信息,打印异常信息会一并打印
 * @param cause 当前异常是哪个Throwable导致的
 * @param enableSuppression 是否开启记录被屏蔽的对象
 * @param writableStackTrace 是否开启记录异常栈信息
 */
protected Throwable(String message, Throwable cause,
                    boolean enableSuppression,
                    boolean writableStackTrace) {
  if (writableStackTrace) {
    fillInStackTrace();
  } else {
    stackTrace = null;
  }
  detailMessage = message;
  this.cause = cause;
  if (!enableSuppression)
    suppressedExceptions = null;
}

?2.3 fillInStackTrace

2.3.1 方法描述

在构造方法中都会先调用一下这个fillInStackTrace方法,这个方法主要是获取栈信息,并且对保存栈信息数组进行清空。

首先会判断当前保存栈信息数组stackTrace和栈信息轨迹backtrace是否为空,如果不为空的话调用nativefillInStackTrace获取栈信息,然后把保存栈信息数组stackTrace清空。

当创建Throwable传入的参数writableStackTracefalsestackTrace就会为空,这样就不会开启异常栈信息的记录。如果你不需要记录栈信息,在自定义异常时可以重新这个方法,直接返回this

public synchronized Throwable fillInStackTrace() {
  if (stackTrace != null || backtrace != null) {
    fillInStackTrace(0);
    stackTrace = UNASSIGNED_STACK;
  }
  return this;
}

private native Throwable fillInStackTrace(int dummy);

2.3.2 代码示例

先看一个例子,在main方法中调用a,并且捕获异常,打印异常信息,在a调用b,并捕获异常,打印异常信息。

public class Demo {

    public static void main(String[] args) {
        try {
            a ();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void a () throws Exception {
        try {
            c();
        } catch (Exception e) {
            e.printStackTrace();
            throw e;
        }
    }

    public static void c () throws Exception {
        throw new Exception("报错了");
    }
}

把异常信息打印出来,发现两次打印的是一样的,也就是捕获到异常又抛出,异常的栈轨迹是一样的。

java.lang.Exception: 报错了
	at com.lee.study.exception.Demo.c(Demo.java:25)
	at com.lee.study.exception.Demo.a(Demo.java:17)
	at com.lee.study.exception.Demo.main(Demo.java:9)
java.lang.Exception: 报错了
	at com.lee.study.exception.Demo.c(Demo.java:25)
	at com.lee.study.exception.Demo.a(Demo.java:17)
	at com.lee.study.exception.Demo.main(Demo.java:9)

我们将代码修改一下,在catch里面调用一下fillInStackTrace方法

public class Demo {

    public static void main(String[] args) {
        try {
            a ();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void a () throws Exception {
        try {
            c();
        } catch (Exception e) {
            e.printStackTrace();
            // 调用一下fillInStackTrace
            e.fillInStackTrace();
            throw e;
        }
    }

    public static void c () throws Exception {
        throw new Exception("报错了");
    }
}

再看一下打印的栈轨迹信息,第二次打印的时候已经没有调用b的轨迹了。其实调用这个方法,相当于是把异常栈的数组给清空了。

java.lang.Exception: 报错了
	at com.lee.study.exception.Demo.c(Demo.java:27)
	at com.lee.study.exception.Demo.a(Demo.java:17)
	at com.lee.study.exception.Demo.main(Demo.java:9)
java.lang.Exception: 报错了
	at com.lee.study.exception.Demo.a(Demo.java:21)
	at com.lee.study.exception.Demo.main(Demo.java:9)

2.3.3 爬栈

有时候我们不需要记录异常栈轨迹信息,有两种方法实现,第一个是在创建ThrowablewritableStackTrace设置为false,第二个是我们可以重写fillInStackTrace方法,直接返回this

定义一个异常,并且调用打印异常信息

public class DemoNonRuntimeException extends Exception{

    public DemoNonRuntimeException (String message) {
        super(message);
    }
}
public class Demo {

    public static void main(String[] args) {
        try {
            c ();
        } catch (DemoNonRuntimeException e) {
            e.printStackTrace();
        }
    }

    public static void c () throws DemoNonRuntimeException {
        throw new DemoNonRuntimeException("报错了");
    }
}

打印的异常信息

com.lee.study.exception.DemoNonRuntimeException: 报错了
	at com.lee.study.exception.Demo.c(Demo.java:17)
	at com.lee.study.exception.Demo.main(Demo.java:9)

如果我们重写fillInStackTrace方法

public class DemoNonRuntimeException extends Exception{

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

    public Throwable fillInStackTrace () {
        return this;
    }
}

public class Demo {

    public static void main(String[] args) {
        try {
            c ();
        } catch (DemoNonRuntimeException e) {
            e.printStackTrace();
        }
    }

    public static void c () throws DemoNonRuntimeException {
        throw new DemoNonRuntimeException("报错了");
    }
}

再打印异常信息,就会发现已经没有栈轨迹信息了。

com.lee.study.exception.DemoNonRuntimeException: 报错了

?2.4 printStackTrace

这个方法是用来打印异常栈信息的方法,我们对这个方法应该是最熟悉的。

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);
  }
}

?2.5 getMessage

用于获取异常描述信息

public String getMessage() {
  return detailMessage;
}

?三、JVM处理异常原理

这里我们从字节码的角度来分析一下异常的实现原理,主要是try-catch的实现原理,因为throws时不处理异常的,只是继续抛出。

这里我们以三个例子来说明异常处理的原理:

?try-catch

?try-catch-finally

?try-catch-finally,并在finallyreturn

?3.1 分析前准备

3.1.1 安装字节码分析工具

因为需要字节码分析异常处理的原理,首先我们需要在idea中安装字节码工具:jclasslib插件。

安装好之后,我们找到需要分析的编译后的class文件,然后选择导航栏的View-Show Bytecode With Jclasslib查看字节码文件。

3.1.2 异常表

Java中,如果一个方法有try-catch处理,在被编译成字节码都会生成一个异常表,异常表是一个表格,每一个都代表一个异常处理。

?起始PC:代表这个异常处理器监控的字节码开始位置。

?结束PC:代表这个异常处理器监控的字节码结束位置(不包括最后一行)。

?跳转PC:代表监控范围内如果发生了异常,跳转到该行字节码去执行。

?3.2 try-catch

3.2.1 示例代码

public class Demo {

    public static void main(String[] args) throws Exception {
        invoke(0, 1);
    }

    public static void invoke (int a, int b) throws Exception {
        try {
            if (b == 0) {
                throw new ArithmeticException();
            }
            int c = a / b;
        } catch (ArithmeticException e) {
            throw new Exception();
        }
    }
}

3.2.2 字节码

 0 iload_1
 1 ifne 12 (+11)
 4 new #3 <java/lang/ArithmeticException>
 7 dup
 8 invokespecial #4 <java/lang/ArithmeticException.<init>>
11 athrow
12 iload_0
13 iload_1
14 idiv
15 istore_2
16 goto 28 (+12)
19 astore_2
20 new #5 <java/lang/Exception>
23 dup
24 invokespecial #6 <java/lang/Exception.<init>>
27 athrow
28 return

3.2.3 异常表

3.2.4 分析

?3.3 try-catch-finally

3.3.1 示例代码

public class Demo {

    public static void main(String[] args) throws Exception {
        invoke(0, 1);
    }

    public static void invoke (int a, int b) throws Exception {
        try {
            if (b == 0) {
                throw new ArithmeticException();
            }
            int c = a / b;
        } catch (ArithmeticException e) {
            throw new Exception();
        } finally {
            System.out.println("执行方法");
        }
    }
}

3.3.2 字节码

 0 iload_1
 1 ifne 12 (+11)
 4 new #3 <java/lang/ArithmeticException>
 7 dup
 8 invokespecial #4 <java/lang/ArithmeticException.<init>>
11 athrow
12 iload_0
13 iload_1
14 idiv
15 istore_2
16 getstatic #5 <java/lang/System.out>
19 ldc #6 <执行方法>
21 invokevirtual #7 <java/io/PrintStream.println>
24 goto 47 (+23)
27 astore_2
28 new #8 <java/lang/Exception>
31 dup
32 invokespecial #9 <java/lang/Exception.<init>>
35 athrow
36 astore_3
37 getstatic #5 <java/lang/System.out>
40 ldc #6 <执行方法>
42 invokevirtual #7 <java/io/PrintStream.println>
45 aload_3
46 athrow
47 return

3.3.3 异常表

3.3.4 分析

  1. 增加了finally之后,在异常表中多了两个异常处理器,并且都是any的,表示任何异常都可以捕获。

  2. 这两个异常处理器最终都跳转到36行执行

3.3.5 为什么finally一定会执行

分两种情况如果代码执行中发生了异常和没有发生异常

  • 发生了异常

    无论时在try还是在catch中,第二个和第三个异常处理器都会捕获到,都会跳转到36行继续执行也就是finally中的内容

  • 没有发生异常

    在编译的时候会把finally的字节码拷贝到try里面代码块的后面,这样也会执行,所以为什么字节码里面有两处finally的内容

?3.4 try-finally-return

3.4.1 示例代码

public class Demo {

    public static void main(String[] args) {
        invoke();
    }

    public static int invoke () {
        int a = 1;
        try {
            a = 2;
            return a;
        } finally {
            a = 3;
            return a;
        }
    }
}

3.4.2 字节码

从字节码分析中分析,当finally中有return之后,会将try里面的覆盖掉,在6-9就是return的内容,而try里面的return已经不见了。

 0 iconst_1 // 将1放入操作数栈
 1 istore_0 // 将1存储到局部变量表第一个位置
 2 iconst_2 // 将2放入操作数栈
 3 istore_0 // 将2存储到局部变量表第一个位置
 4 iload_0  // 加载局部变量表第一个位置元素放入操作数栈
 5 istore_1 // 将2存储到局部变量表第二个位置
 6 iconst_3 // 将3放入操作数栈
 7 istore_0 // 将3存储到局部变量表第一个位置
 8 iload_0 // 加载局部变量表第一个位置元素放入操作数栈
 9 ireturn // 返回
10 astore_2  
11 iconst_3 // 将3放入操作数栈
12 istore_0 // 将3存储到局部变量表第一个位置
13 iload_0 // 加载局部变量表第一个位置元素放入操作数栈
14 ireturn // 返回

?3.5 finally赋值

3.5.1 示例代码

public class Demo {

    public static void main(String[] args) {
        invoke();
    }

    public static int invoke () {
        int a = 1;
        try {
            a = 2;
            return a;
        } finally {
           a = 3;
        }
    }
}

3.5.2 字节码

 0 iconst_1 // 将1放入操作数栈
 1 istore_0 // 将1存储到局部变量表第一个位置
 2 iconst_2 // 将2放入操作数栈
 3 istore_0 // 将2存储到局部变量表第一个位置
 4 iload_0 // 从局部变量表中加载第一个位置元素放入操作数栈
 5 istore_1 // 将2存储到局部变量表第二个位置
 6 iconst_3 // 将3放入操作数栈
 7 istore_0 // 将3存储到局部变量表第一个位置
 8 iload_1 // 从局部变量表中夹在第二哥位置元素放入操作数栈,也就是2
 9 ireturn //返回
10 astore_2
11 iconst_3
12 istore_0
13 aload_2
14 athrow

3.5.3 分析

这里在finally中对a进行赋值,但是在try中返回,最后返回的值是2还是3?

这里返回的是2,虽然finally里面的内容一定会执行,但是从字节码中可以得知,在返回的时候是将局部变量表第二个位置的元素返回的,而3是存储在局部变量表第一个位置。

Top