资讯详情

资讯详情

【DIY系列:Java虚拟机】第63篇:异常处理概述——JVM 怎么“接住“异常

上一篇【第62篇】本地方法调用测试与总结——jvmgo 的能力跃迁下一篇【第64篇】异常抛出与异常处理表——athrow 和 ExceptionHandler摘要从第 5 章到现在jvmgo 处理异常的方式一直是panic——抛一个 Go 的 panic打印堆栈程序退出。这意味着try{intxInteger.parseInt(abc);}catch(NumberFormatExceptione){System.out.println(格式错误);// ← 永远执行不到}try-catch在 jvmgo 里完全无效。第 10 章来解决这个问题——这是 jvmgo 的最后一个功能模块也是让它从能跑变成能正确处理错误的关键。这一篇先把概念理清楚Checked vs Unchecked——谁强制你处理谁不强制注意这是 Java 语言规则不是 JVM 规范异常的三种来源——JVM 抛的Error、指令抛的RuntimeException、代码主动throw的throw语句编译成什么——newdupinvokespecialathrow异常对象不普通——构造函数里藏着一个fillInStackTrace()本地方法一、Java 异常的分类1.1 继承体系Throwable │ ┌───────────────┴───────────────┐ │ │ Error Exception │ │ ┌────┴────┐ ┌─────┴──────────────┐ │ │ │ │ OutOfMemory StackOverflow IOException RuntimeException Error Error │ │ FileNotFound ┌─────┼─────┐ Exception │ │ │ NullPointer Index ClassCast Exception OutOfBounds Exception Exception所有异常最终继承自java.lang.Throwable。1.2 Checked vs Unchecked书里的定义异常可以分为两类Checked 异常和Unchecked 异常。Unchecked 异常包括java.lang.RuntimeException、java.lang.Error以及它们的子类其他异常都是 Checked 异常Throwable ├── Error ──── Unchecked不需要声明 │ ├── OutOfMemoryError │ ├── StackOverflowError │ └── NoClassDefFoundError │ └── Exception ├── IOException ── Checked必须处理或声明 ├── ClassNotFoundException ── Checked ├── SQLException ── Checked │ └── RuntimeException ──── Unchecked ├── NullPointerException ├── IndexOutOfBoundsException ├── ClassCastException ├── ArithmeticException └── NumberFormatException规则如果一个方法有可能导致Checked 异常抛出则该方法要么需要捕获该异常并妥善处理要么必须把该异常列在自己的throws子句中否则无法通过编译。Unchecked 异常没有这个限制。// Checked必须处理或声明publicvoidreadFile()throwsIOException{// ✓ 声明FileReaderrnewFileReader(x.txt);}publicvoidreadFile2(){try{newFileReader(x.txt);}catch(IOExceptione){}// ✓ 捕获}publicvoidreadFile3(){newFileReader(x.txt);// ✗ 编译错误}// Unchecked随意publicvoidf(){int[]anull;a[0]1;// ✓ 编译通过运行时 NPE}1.3 一个极其重要的澄清书里特别加了一句请注意Java 虚拟机规范并没有这个规定这只是 Java 语言的语法规则。这句话是本章的题眼值得展开层面Checked/Unchecked 的区分Java 语言有。编译器强制检查throws子句class 文件几乎没有。Code属性里的异常表不区分方法的Exceptions属性只是我可能抛这些的一个注释JVM完全不区分。JVM 从来不检查一个异常是不是被声明过证据JVM 规范里有一句著名的话The Java Virtual Machine does not require that a method declare the exceptions it may throw.也就是说你可以用字节码工具生成一个抛出未声明 Checked 异常的方法JVM 会正常执行它。一个具体的例子——Java 的偷偷抛出 Checked 异常// 用泛型技巧绕过编译器的 Checked 异常检查SuppressWarnings(unchecked)publicstaticTextendsThrowablevoidsneakyThrow(Throwablet)throwsT{throw(T)t;}publicvoidf(){sneakyThrow(newIOException(surprise!));// 编译通过没声明 throws}这在 JVM 层面完全合法因为throw指令根本不检查异常类型。所以第 10 章实现异常处理时我们不需要也不能区分 Checked 和 Unchecked——JVM 只管抛和接类型检查是 javac 的事。这和第 060 篇自动装箱是纯语法糖JVM 不知道是同一个套路Java 语言的很多约束在 JVM 层面并不存在。1.4 为什么 Java 要设计 Checked 异常这是个有争议的设计。支持者认为它强制错误处理Java 是唯一有 Checked 异常的主流语言反对者说它污染代码签名。《Effective Java》第 70-77 条给出的建议可恢复的情况用 Checked 异常调用方应该处理编程错误用 Unchecked 异常比如 NPE、越界是 bug应该修代码而不是 catch不要用Error那是 JVM 层面的问题现代语言Kotlin、C#、Scala、Rust 的Result大多放弃了 Checked 异常——Kotlin 里所有 Java 的 Checked 异常都变成 Unchecked。二、异常的三种来源书里分得很清楚2.1 JVM 抛出的 Error当 Java 虚拟机在运行过程中遇到比较严重的问题时会抛出java.lang.Error的某个子类如StackOverflowError、OutOfMemoryError等。程序一般无法从这种异常里恢复所以在代码中通常也不必关心这类异常。Error触发场景StackOverflowError栈深度超限无限递归OutOfMemoryError堆/元空间耗尽NoClassDefFoundError类加载失败ExceptionInInitializerErrorclinit抛异常UnsatisfiedLinkError本地方法找不到第 058 篇见过VerifyError字节码验证失败注意UnsatisfiedLinkError是Error不是Exception—— 第 058 篇 jvmgo 抛的就是它。2.2 指令抛出的 RuntimeException一部分指令在执行过程中会导致 Java 虚拟机抛出java.lang.RuntimeException的某个子类如NullPointerException、IndexOutOfBoundsException等。这类异常一般是代码中的 bug 导致的需要格外注意。第 5~9 章我们实现了很多这样的检查指令抛的异常篇章idiv/iremArithmeticException除以零第 036 篇getfield/putfieldNullPointerException第 047 篇new/arraylengthNegativeArraySizeException第 054 篇taload/tastoreNullPointerException/ArrayIndexOutOfBoundsException第 054 篇checkcastClassCastException第 047 篇arraycopyNullPointerException/ArrayStoreException/IndexOutOfBoundsException第 061 篇但它们的实现都是panic(...)不是真正的抛异常对象。第 10 章的一个重要课题就是把这些 panic 改成真正抛异常对象。书里其实没有全部改——只实现了athrow指令和栈展开idiv除零之类仍然是 panic。这是本书的取舍。2.3 代码主动抛出的在代码中抛出和处理异常是由athrow指令和方法的异常处理表配合完成的本章将重点讨论这一点。thrownewIllegalArgumentException(参数错);这是第 10 章的主角。三、throw 语句编译成什么3.1 规范里的例子voidcantBeZero(inti){if(i0){thrownewTestExc();}}编译后0 iload_1 // 把参数 1i推入操作数栈顶 1 ifne 12 // 如果 i 不等于 0直接执行 return 指令 4 new #2 // 创建 TestExc 实例把引用推入操作数栈顶 7 dup // 复制 TestExc 实例引用 8 invokespecial #3 // 调用 TestExc 构造函数 11 athrow // 抛出异常 12 return // 方法返回athrow是唯一陌生的指令其余都是老朋友new → 分配对象第 6 章 dup → 复制引用第 5 章 invokespecial → 调构造函数第 7 章 athrow → 抛异常第 10 章← 唯一的新东西注意athrow之后没有 return—— 因为athrow不会正常返回它要么跳到 catch 块要么终止方法。javac 知道这一点所以不会生成不可达代码。athrow的操作数一个异常对象引用从操作数栈弹出。和dup/pop一样是隐式操作数。3.2 异常对象似乎只是普通对象书里提了一个很好的问题从字节码来看异常对象似乎也只是普通的对象通过new指令创建然后调用构造函数进行初始化。这是真的吗答案不是。看Throwable的构造链// java.lang.ExceptionpublicException(Stringmessage){super(message);}// java.lang.ThrowablepublicThrowable(Stringmessage){fillInStackTrace();// ← 关键detailMessagemessage;}publicsynchronizedThrowablefillInStackTrace(){if(stackTrace!null||backtrace!null){fillInStackTrace(0);// ← 调用本地方法stackTraceUNASSIGNED_STACK;}returnthis;}privatenativeThrowablefillInStackTrace(intdummy);// ← 本地方法所以每次new一个异常对象都会调构造函数构造函数调fillInStackTrace()Java 方法fillInStackTrace()调fillInStackTrace(int)本地方法本地方法抓取当前 JVM 栈存起来这就是为什么每个 Java 异常的堆栈轨迹stack trace那么准确—— 它是在异常对象创建时就抓好的不是抛出时抓的。一个实用的推论ExceptionenewException(error);// ← 栈在这里就被抓好了// ...throwe;// 抛出时的栈不影响// 所以这样写是错的栈是创建时的不是抛出时的staticExceptioncachednewException(cached);voidf(){throwcached;}// 栈轨迹会是类初始化时的3.3 为什么 fillInStackTrace 是本地方法因为Java 代码访问不到 JVM 栈。fillInStackTrace()是 Java 方法但它需要遍历当前线程的栈帧——这是 JVM 的内部数据结构Java 语言没有 API 能访问。所以必须fillInStackTrace() ← Java 方法处理状态判断 └──▶ fillInStackTrace(int) ← 本地方法真正抓栈书里说fillInStackTrace()是用 Java 写的必须借助另外一个本地方法才能访问 Java 虚拟机栈这个方法就是重载后的fillInStackTrace(int)方法。这又是一个Java 做不到所以必须本地方法的例子——和第 061 篇的arraycopy、floatToRawIntBits是同一个理由。3.4 第 10 章先给个空实现书里的处理很务实在 10.5 节中我们会真正实现这个方法这里先给它一个空的实现。// ch10/native/java/lang/Throwable.gopackagelangimportjvmgo/ch10/nativeimportjvmgo/ch10/rtdaimportjvmgo/ch10/rtda/heapfuncinit(){native.Register(java/lang/Throwable,fillInStackTrace,(I)Ljava/lang/Throwable;,fillInStackTrace)}// private native Throwable fillInStackTrace(int dummy);funcfillInStackTrace(frame*rtda.Frame){// 在 10.5 节实现}为什么必须注册一个空实现而不是干脆不注册因为不注册会抛UnsatisfiedLinkError第 058 篇的机制—— 一旦new任何异常对象就会崩。而UnsatisfiedLinkError本身就是个Error…… 恶性循环。所以先注册个空的保证能跑10.5 节再填内容。这是渐进式开发的标准做法。注意返回类型是Ljava/lang/Throwable;—— 因为fillInStackTrace(int)返回this。jvmgo 的空实现什么都不做返回值由后面的areturn指令处理会从操作数栈取——但现在栈是空的会取到垃圾值。严格来说这是个 bug但因为调用方fillInStackTrace()里fillInStackTrace(0);的返回值被丢弃了是语句不是赋值所以不影响。10.5 节的实现会正确地把this压栈。四、finally 的实现一段历史书里 10.1 节最后在Java 6之前Oracle 的 Java 编译器使用jsr、jsr_w和ret指令来实现finally子句。从 Java 6 开始已经不再使用这些指令本章不讨论这三条指令。这是个有意思的历史遗留。jsrjump subroutine的实现方式是try { foo(); } finally { bar(); }Java 5 及之前0 aload_0 1 invokevirtual foo 4 jsr 14 // 跳到 finally 代码14 7 goto 20 // 正常结束 10 astore_1 // 异常处理入口 11 jsr 14 // 也要跳到 finally 14 astore_2 // jsr 的返回地址存在这里 15 aload_0 16 invokevirtual bar 19 ret 2 // 跳回返回地址 20 return问题jsr是子程序调用它会让控制流分析变得极其复杂——因为一个jsr可能从多个地方跳来返回地址是运行时的值。这让 class 文件验证器StackMapTable 验证难以静态分析。Java 6 之后改用代码复制0 aload_0 1 invokevirtual foo 4 aload_0 // ← finally 代码被复制一份 5 invokevirtual bar // 正常路径 8 goto 18 11 astore_1 // 异常处理入口 12 aload_0 // ← finally 代码又被复制一份 13 invokevirtual bar // 异常路径 16 aload_1 17 athrow // 重新抛出 18 return代价是字节码变大finally 代码被复制 N 份收益是控制流变得简单验证器好写了。第 5 章讲jsr/ret时说过它们已废弃这里就是原因。jvmgo 不实现它们——反正 Java 6 的 javac 也不生成了。五、第 10 章的实现路线图10.1 异常处理概述 ← 本篇063 ├─ Checked / Unchecked ├─ 三种异常来源 └─ throw 的字节码 fillInStackTrace 10.2 异常抛出 ← 本篇063 └─ Throwable.fillInStackTrace 的空实现 10.3 异常处理表 ← 第 064 篇 ├─ Method.exceptionTable 字段 ├─ ExceptionHandler{ startPc, endPc, handlerPc, catchType } ├─ newExceptionTable() 从 class 文件转换 ├─ catchType 0 表示 catch-allfinally └─ findExceptionHandler() 查找逻辑 10.4 实现 athrow 指令 ← 第 065 篇 ├─ ATHROW.Execute() ├─ findAndGotoExceptionHandler()沿栈查找 清空操作数栈 ├─ handleUncaughtException()打印栈轨迹 └─ OperandStack.Clear() / Thread.ClearStack() 10.5 Java 虚拟机栈信息 ← 第 066 篇 ├─ StackTraceElement{ fileName, className, methodName, lineNumber } ├─ fillInStackTrace() 的真正实现 ├─ distanceToObject() 计算要跳过的帧数 ├─ Thread.GetFrames() / Stack.getFrames() └─ Class.SourceFile() / Method.GetLineNumber() 10.6 测试 ← 第 066 篇 └─ ParseIntTest四步走从数据结构到指令到栈信息最后验收。六、一个重要的认知异常处理的本质把第 10 章要做的所有事抽象成一句话异常处理 栈展开stack unwinding 查表跳转athrow 抛出 │ ▼ while (栈不空) { frame : 当前帧 handlerPC : frame.Method().FindExceptionHandler(异常类, pc) if (handlerPC 0) { 清空 frame 的操作数栈 把异常对象压入栈顶 frame.nextPC handlerPC ← 跳转到 catch 块 return true ← 处理成功 } 弹出 frame ← 栈展开丢弃这一层 } // 栈空了还没找到 打印栈轨迹程序终止三个关键点异常对象在操作数栈上传递——从抛出点传到 catch 块靠的是清空栈 压入异常对象跳转靠nextPC——和第 5 章的Branch()一样只是跳转目标来自异常表找不到就栈展开——逐帧弹出直到找到能处理的帧或者栈空这个机制和第 7 章的方法返回有几分神似方法返回异常处理怎么回弹出当前帧回到调用者逐帧弹出直到找到 handler返回值/异常怎么传压入调用者操作数栈顶压入 handler 所在帧的操作数栈顶目标地址调用者的nextPCinvoke 指令设好的异常表的handlerPc找不到怎么办栈空 → 程序结束栈空 → 打印轨迹程序结束都是操作栈帧序列的游戏区别只是返回是弹一层异常是弹 N 层。本篇小结第 10 章开篇异常处理的概念层Checked vs Unchecked——Error和RuntimeException及其子类是 Unchecked其余是 Checked。这是 Java 语言规则不是 JVM 规范——JVM 从来不检查异常是否被声明athrow指令也不区分类型。所以 jvmgo 实现异常处理时不需要也不能做这个区分。三种异常来源——JVM 抛的ErrorStackOverflowError、OutOfMemoryError、UnsatisfiedLinkError、指令抛的RuntimeExceptionNPE、越界、除零、代码主动throw的。前两类在 jvmgo 里目前都是panic第 10 章主要处理第三类。throw的字节码——newdupinvokespecialathrow只有athrow是新指令。异常对象不普通——Throwable的构造函数会调fillInStackTrace()后者调本地方法fillInStackTrace(int)抓取当前 JVM 栈。所以栈轨迹是异常对象创建时抓的不是抛出时。第 10 章先注册一个空实现保证能跑10.5 节再填。finally的历史——Java 6 之前用jsr/ret子程序调用导致控制流分析困难Java 6 之后改为代码复制。jvmgo 不实现jsr/ret。异常处理的本质——栈展开 查表跳转。和方法返回是同一类操作只是返回弹一层异常弹 N 层。下一篇第 064 篇进入实现异常处理表。ExceptionHandler{startPc, endPc, handlerPc, catchType}四个字段怎么从 class 文件里读出来catchType 0为什么表示 catch-all以及findExceptionHandler()的查找逻辑。上一篇【第62篇】本地方法调用测试与总结——jvmgo 的能力跃迁下一篇【第64篇】异常抛出与异常处理表——athrow 和 ExceptionHandler
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →