代码、内容参考来自于张秀宏大佬的自己动手写Java虚拟机 (Java核心技术系列)以及尚硅谷宋红康:JVM全套教程。
异常处理是Java语言非常重要的一个语法。
1.异常处理概述
在Java语言中,异常可以分为两类:Checked异常和Unchecked异常。
- Unchecked异常包括java.lang.RuntimeException、java.lang.Error以及它们的子类,其他异常都是Checked异常。
- 所有异常都最终继承自java.lang.Throwable。如果一个方法有可能导致Checked异常抛出,则该方法要么需要捕获该异常并妥善处理,要么必须把该异常列在自己的throws子句中,否则无法通过编译。Unchecked异常没有这个限制。
请注意,Java虚拟机规范并没有这个规定,这只是Java语言的语法规则。异常可以由Java虚拟机抛出,也可以由Java代码抛出。
- 当Java虚拟机在运行过程中遇到比较严重的问题时,会抛出java.lang.Error的某个子类,如StackOverflowError、OutOfMemoryError等。程序一般无法从这种异常里恢复,所以在代码中通常也不必关心这类异常。
- 一部分指令在执行过程中会导致Java虚拟机抛出java.lang.RuntimeException的某个子类,如NullPointerExceptionIndexOutOfBoundsException等。这类异常一般是代码中的bug导致的,需要格外注意。
2.异常抛出
在Java代码中,异常是通过throw关键字抛出的。
Java虚拟机规范的3.12节给了一个例子:

查看java.lang.Exception或RuntimeException的源代码就可以知道,它们的构造函数都调用了超类java.lang.Throwable的构造函数。Throwable的构造函数又调用了fillInStackTrace()方法记录Java虚拟机栈信息,这个方法的代码如下:

fillInStackTrace()借助另外一个本地方法也就是重载后的fillInStackTrace(int)方法才能访问Java虚拟机栈,代码如下:

之后再实现他,先给它一个空的实现。
在\native\java\lang目录下创建Throwable.go文件,在其中注册fillInStackTrace(int)方法,代码如下:
package lang
import (
"jvmgo/ch10/native"
"jvmgo/ch10/rtda"
)
const jlThrowable = "java/lang/Throwable"
func init() {
native.Register(jlThrowable, "fillInStackTrace", "(I)Ljava/lang/Throwable;", fillInStackTrace)
}
// private native Throwable fillInStackTrace(int dummy);
// (I)Ljava/lang/Throwable;
func fillInStackTrace(frame *rtda.Frame) {
}
3.异常处理表
- 起始位置
- 结束位置
- 程序计数器记录的代码处理的偏移地址
- 被捕获的异常类在常量池中的索引
举例:
public void tryCatch(){
try{
File file = new File("d:/hello.txt");
FileInputStream fis = new FileInputStream(file);
String info = "hello!";
}catch (FileNotFoundException e) {
e.printStackTrace();
}
catch (RuntimeException e){
e.printStackTrace();
}
}
可以看到,到 goto 38 (+16),会跳转出,那22到38的代码呢?
异常表:

如图:如果在Start PC 到 End PC 中出现异常就跳转到 对应的Handler PC
思考:
//思考:如下方法返回结果为多少?
public static String func() {
String str = "hello";
try{
return str;
}
finally{
str = "崩坏三天下第一";
}
}
public static void main(String[] args) {
System.out.println(func());//hello
}
如图,在finally前已经return了

异常处理表是Code属性的一部分,它记录了方法是否有能力处理某种异常。回顾一下方法的Code属性,它的结构如下:

异常处理表的每一项都包含3个信息:处理哪部分代码抛出的异常、哪类异常,以及异常处理代码在哪里。具体来说,start_pc和end_pc可以锁定一部分字节码,这部分字节码对应某个可能抛出异常的try{}代码块。catch_type是个索引,通过它可以从运行时常量池中查到一个类符号引用,解析后的类是个异常类,假定这个类是X。如果位于start_pc和end_pc之间的指令抛出异常x,且x是X(或者
X的子类)的实例,handler_pc就指出负责异常处理的catch{}块在哪里。
当上面的方法通过athrow指令抛出异常时,Java虚拟机首先会查找该方法的异常处理表,看它能否处理该异常。如果能,则跳转到相应的字节码开始异常处理。假设该方法无法处理异常,Java虚拟机会进一步查看它的调用者的异常处理表。假设也无法处理异常,Java虚拟机会继续查找它的调用者的调用者的异常处理表。这个过程会一直继续下去,直到找到某个异常处理项,或者到达Java虚拟机栈的底部。
把这部分逻辑放在athrow指令中。
在\rtda\heap目录下创建exception_table.go文件,在其中定义ExceptionTable类型,代码如下:
package heap
import "jvmgo/ch10/classfile"
// 别名
type ExceptionTable []*ExceptionHandler
type ExceptionHandler struct {
startPc int
endPc int
handlerPc int
// 若catchType为nil,则表示处理所有异常
catchType *ClassRef
}
// 把class文件中的异常处理表转换成ExceptionTable类型,创建异常处理表
func newExceptionTable(entries []*classfile.ExceptionTableEntry, cp *ConstantPool) ExceptionTable {
table := make([]*ExceptionHandler, len(entries))
for i, entry := range entries {
table[i] = &ExceptionHandler{
startPc: int(entry.StartPc()),
endPc: int(entry.EndPc()),
handlerPc: int(entry.HandlerPc()),
catchType: getCatchType(uint(entry.CatchType()), cp),
}
}
return table
}
// 从运行时常量池中查找类符号引用
func getCatchType(index uint, cp *ConstantPool) *ClassRef {
// index为0表示捕获所有异常
if index == 0 {
return nil // catch all
}
return cp.GetConstant(index).(*ClassRef)
}
// 搜索异常处理表
func (self ExceptionTable) findExceptionHandler(exClass *Class, pc int) *ExceptionHandler {
for _, handler := range self {
// jvms: The start_pc is inclusive and end_pc is exclusive
//处理try{}语句块中抛出的异常 try{]语句块包含startPc,但不包含endPc
if pc >= handler.startPc && pc < handler.endPc {
if handler.catchType == nil {
return handler
}
//通过它从运行时常量池中查到一个类符号引用,解析后的类是个异常类
catchClass := handler.catchType.ResolvedClass()
//如果catchClass是exClass(或者exClass的子类)的实例
if catchClass == exClass || catchClass.IsSuperClassOf(exClass) {
return handler
}
}
}
return nil
}解释一下上面的代码:
ExceptionTable只是[]*ExceptionHandler的别名

ExceptionHandler的定义




下面修改Method结构体,在里面增加异常处理表。打开ch10\rtda\heap\method.go文件,给Method结构体添加exceptionTable字段,代码如下:
type Method struct {
ClassMember
maxStack uint
maxLocals uint
code []byte
exceptionTable ExceptionTable
argSlotCount uint
}然后修改copyAttributes()方法,从Code属性中复制异常处理表,代码如下:
func (self *Method) copyAttributes(cfMethod *classfile.MemberInfo) {
if codeAttr := cfMethod.CodeAttribute(); codeAttr != nil {
self.maxStack = codeAttr.MaxStack()
self.maxLocals = codeAttr.MaxLocals()
self.code = codeAttr.Code()
self.exceptionTable = newExceptionTable(codeAttr.ExceptionTable(),
self.class.constantPool)
}
}继续编辑method.go文件,给Method结构体添加FindExceptionHandler()方法,FindExceptionHandler()方法调用ExceptionTable.findExceptionHandler()方法搜索异常处理表,如果能够找到对应的异常处理项,则返回它的handlerPc字段,否则返回–1。代码如下:
func (self *Method) FindExceptionHandler(exClass *Class, pc int) int {
handler := self.exceptionTable.findExceptionHandler(exClass, pc)
if handler != nil {
return handler.handlerPc
}
return -1
}
4.实现athrow指令
1、抛出异常指令:
(1)athrow指令
在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。除了使用throw语句显示抛出异常情况之外, JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在 idiv或 ldiv指令中抛出ArithmeticException异常。
(2)注意
正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时, Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上。
异常及异常的处理:
过程一:异常对象的生成过程 —>throw(手动 / 自动)指令 athrow
过程二:异常的处理:抓抛模型。 try-catch-finally 使用异常表
清晰明了,不再赘述。
public void throwZero(int i){
if(i == 0){
throw new RuntimeException("参数值为0");
}
}
对比一下,下面的两个代码
public void throwOne(int i){
if(i == 1){
throw new RuntimeException("参数值为1");
}
}
public void throwOne(int i) throws RuntimeException,IOException{
if(i == 1){
throw new RuntimeException("参数值为1");
}
}
没什么变化,但多了Exceptions属性

注意一下,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。这样是看不到对应的字节码指令。
public void throwArithmetic() {
int i = 10;
int j = i / 0;
System.out.println(j);
}
整体代码
func (self *OperandStack) Clear() {
self.size = 0
for i := range self.slots {
self.slots[i].ref = nil
}
}func (self *Thread) ClearStack() {
self.stack.clear()
}func (self *Stack) clear() {
for !self.isEmpty() {
self.pop()
}
}athrow属于引用类指令,在\instructions\references目录下创建athrow.go文件,在其中定义athrow指令,代码如下:
package references
import (
"jvmgo/ch10/instructions/base"
"jvmgo/ch10/rtda"
"jvmgo/ch10/rtda/heap"
"reflect"
)
// Throw exception or error
type ATHROW struct{ base.NoOperandsInstruction }
func (self *ATHROW) Execute(frame *rtda.Frame) {
// athrow指令操作数为异常对象,从操作数栈中弹出
ex := frame.OperandStack().PopRef()
// 如果引用为空,抛出NullPointerException异常
if ex == nil {
panic("java.lang.NullPointerException")
}
thread := frame.Thread()
if !findAndGotoExceptionHandler(thread, ex) {
// 若没有找到异常处理代码块
handleUncaughtException(thread, ex)
}
}
// 查找并跳转到异常处理代码
func findAndGotoExceptionHandler(thread *rtda.Thread, ex *heap.Object) bool {
for {
// 获取当前帧
frame := thread.CurrentFrame()
// 当前正在执行指令的地址为NextPC - 1
pc := frame.NextPC() - 1
// 查找异常处理表
handlerPC := frame.Method().FindExceptionHandler(ex.Class(), pc)
// 查找到异常处理代码块地址
if handlerPC > 0 {
// 获取当前操作数栈
stack := frame.OperandStack()
// 清空操作数栈
stack.Clear()
// 将异常对象引用入栈
stack.PushRef(ex)
// 设置执行下一条指令的地址为异常处理代码块的起始位置
frame.SetNextPC(handlerPC)
return true
}
// 遍历java虚拟机栈帧,若当前帧没有找到可用异常处理表,则弹出,执行上一帧
thread.PopFrame()
// 如果虚拟机栈空了,则退出循环
if thread.IsStackEmpty() {
break
}
}
return false
}
// todo
func handleUncaughtException(thread *rtda.Thread, ex *heap.Object) {
// 清空JVM虚拟机栈
thread.ClearStack()
// 打印Java虚拟机栈信息
jMsg := ex.GetRefVar("detailMessage", "Ljava/lang/String;")
goMsg := heap.GoString(jMsg)
println(ex.Class().JavaName() + ": " + goMsg)
stes := reflect.ValueOf(ex.Extra())
for i := 0; i < stes.Len(); i++ {
ste := stes.Index(i).Interface().(interface {
String() string
})
println("\tat " + ste.String())
}
}代码解释
athrow指令的操作数是一个异常对象引用,从操作数栈弹出。先从操作数栈中弹出异常对象引用,如果该引用是null,则抛出NullPointerException异常,否则看是否可以找到并跳转到异常处理代码。

findAndGotoExceptionHandler()从当前帧开始,遍历Java虚拟机栈,查找方法的异常处理表。假设遍历到帧F,如果在F对应的方法中找不到异常处理项,则把F弹出,继续遍历。反之如果找到了异常处理项,在跳转到异常处理代码之前,要先把F的操作数栈清空,然后把异常对象引用推入栈顶。


如果遍历完Java虚拟机栈还是找不到异常处理代码,则handleUncaughtException()函数打印出Java虚拟机栈信息,handleUncaughtException()函数把Java虚拟机栈清空,然后打印出异常信息。由于Java虚拟机栈已经空了,所以解释器也就终止执行了。上面的代码使用Go语言的reflect包打印Java虚拟机栈信息。

Go语言的reflect包:

在Go语言中,reflect包中的ValueOf()函数是用于接收任何类型的变量并返回一个reflect.Value类型的函数。Value类型是reflect包中的一个类型,代表一个任意类型的变量。

reflect.Index() 函数用于获取切片、数组、指向该类型的指针或 interface 的子元素。它返回一个 reflect.Value 类型的值,表示访问元素后的结果。

reflect.Interface()函数接收一个Value类型的参数v,并返回一个interface{}类型的值。该函数返回的interface{}类型值是原始值的一份拷贝,只包含原始值的值部分,不包含类型信息部分。我们需要进行类型断言(type assertion)才能将其转换为具体的类型值。
如何使用reflect.Interface()函数来转换任意类型的变量,并使用类型断言将其转换为具体的类型值。这个过程可以归纳为以下几个步骤:
- 使用reflect.ValueOf()函数将任意类型的变量转换为reflect.Value类型的变量;
- reflect.Value类型的变量调用reflect.Interface()函数,将其转换为一个interface{}类型的值;
- 使用类型断言将interface{}类型的值转换为具体的类型值。
注意,reflect.Interface()函数只能用于导出的变量和方法。
还需要修改\instructions\factory.go文件,在NewInstruction()函数中增加athrow指令的case语句。
5.Java虚拟机栈信息
整体代码
在/rtda/jvm_stack.go文件中添加构造栈帧完整的栈帧的方法:
func (self *Stack) getFrames() []*Frame {
frames := make([]*Frame, 0, self.size)
for frame := self._top; frame != nil; frame = frame.lower {
frames = append(frames, frame)
}
return frames
}
在/rtda/thread中添加上述调用:
func (self *Thread) GetFrames() []*Frame {
return self.stack.getFrames()
}
修改/rtda/heap/class.go文件,给Class结构体添加sourceFile字段,代码如下:
// name, superClassName and interfaceNames are all binary names(jvms8-4.2.1)
type Class struct {
accessFlags uint16
name string // thisClassName
superClassName string
interfaceNames []string
constantPool *ConstantPool
fields []*Field
methods []*Method
sourceFile string
loader *ClassLoader
superClass *Class
interfaces []*Class
instanceSlotCount uint
staticSlotCount uint
staticVars Slots
initStarted bool
jClass *Object
}
也在/rtda/heap/class.go文件中添加SourceFile字段的getter方法。
func (self *Class) SourceFile() string {
return self.sourceFile
}
修改newClass()函数,从class文件中读取源文件名:
func newClass(cf *classfile.ClassFile) *Class {
class := &Class{}
class.accessFlags = cf.AccessFlags()
class.name = cf.ClassName()
class.superClassName = cf.SuperClassName()
class.interfaceNames = cf.InterfaceNames()
class.constantPool = newConstantPool(class, cf.ConstantPool())
class.fields = newFields(class, cf.Fields())
class.methods = newMethods(class, cf.Methods())
class.sourceFile = getSourceFile(cf)
return class
}
源文件名在ClassFile结构的属性表中,getSourceFile()函数提取这个信息,
func getSourceFile(cf *classfile.ClassFile) string {
if sfAttr := cf.SourceFileAttribute(); sfAttr != nil {
return sfAttr.FileName()
}
return "Unknown" // todo
}
修改/classfile/class_file.go文件,添加获取SourceFileAttribute的方法:
func (self *ClassFile) SourceFileAttribute() *SourceFileAttribute {
for _, attrInfo := range self.attributes {
switch attrInfo.(type) {
case *SourceFileAttribute:
return attrInfo.(*SourceFileAttribute)
}
}
return nil
}
修改/rtda/heap/method.go文件:修改Method结构体。给Method结构体添加lineNumberTable字段,改动如下:
type Method struct {
ClassMember
maxStack uint
maxLocals uint
code []byte
exceptionTable ExceptionTable
lineNumberTable *classfile.LineNumberTableAttribute
argSlotCount uint
}
也在/rtda/heap/method.go文件,copyAttributes()方法,从class文件中提取行号表,代码如下:
func (self *Method) copyAttributes(cfMethod *classfile.MemberInfo) {
if codeAttr := cfMethod.CodeAttribute(); codeAttr != nil {
self.maxStack = codeAttr.MaxStack()
self.maxLocals = codeAttr.MaxLocals()
self.code = codeAttr.Code()
self.lineNumberTable = codeAttr.LineNumberTableAttribute()
self.exceptionTable = newExceptionTable(codeAttr.ExceptionTable(),
self.class.constantPool)
}
}
也在/rtda/heap/method.go文件,添加的GetLineNumber()方法,和源文件名一样,并不是每个方法都有行号表。如果方法没有行号表,自然也就查不到pc对应的行号,这种情况下返回–1。本地方法没有字节码,所以返回–2。剩下的情况调用LineNumberTableAttribute结构体的GetLineNumber()方法查找行号,代码如下:
// 和源文件一样,并不是每个方法都有行号表。
func (self *Method) GetLineNumber(pc int) int {
// 本地方法没有字节码,自然就没有行号表,返回-2
if self.IsNative() {
return -2
}
// 没有行号表,返回-1
if self.lineNumberTable == nil {
return -1
}
return self.lineNumberTable.GetLineNumber(pc)
}
修改/classfile/attr_code.go文件,添加如下方法:
func (self *CodeAttribute) LineNumberTableAttribute() *LineNumberTableAttribute {
for _, attrInfo := range self.attributes {
switch attrInfo.(type) {
case *LineNumberTableAttribute:
return attrInfo.(*LineNumberTableAttribute)
}
}
return nil
}
回到\native\java\lang\Throwable.go文件,在其中定义StackTraceElement结构体,代码如下:
package lang
import (
"fmt"
"jvmgo/ch10/native"
"jvmgo/ch10/rtda"
"jvmgo/ch10/rtda/heap"
)
const jlThrowable = "java/lang/Throwable"
// 该结构体用于记录Java虚拟机栈信息
type StackTraceElement struct {
// 类所在的文件名
fileName string
// 声明方法的类名
className string
// 方法名
methodName string
// 帧正在执行哪行代码
lineNumber int
}
func (self *StackTraceElement) String() string {
return fmt.Sprintf("%s.%s(%s:%d)",
self.className, self.methodName, self.fileName, self.lineNumber)
}
func init() {
native.Register(jlThrowable, "fillInStackTrace", "(I)Ljava/lang/Throwable;", fillInStackTrace)
}
// private native Throwable fillInStackTrace(int dummy);
// (I)Ljava/lang/Throwable;
func fillInStackTrace(frame *rtda.Frame) {
// 获取局部变量表第0位,this引用
this := frame.LocalVars().GetThis()
// 将this引用入栈
frame.OperandStack().PushRef(this)
// 创建完整的堆栈打印信息
stes := createStackTraceElements(this, frame.Thread())
// 将完整的堆栈信息赋值到异常类实例(this)的extra字段中,athrow指令中打印了extra信息
this.SetExtra(stes)
}
func createStackTraceElements(tObj *heap.Object, thread *rtda.Thread) []*StackTraceElement {
// 由于栈顶两帧正在执行fillInStackTrace(int)和fillInStackTrace(),所以这两帧也需要跳过
skip := distanceToObject(tObj.Class()) + 2
// 获取跳过的帧之后的完整的Java虚拟机栈
frames := thread.GetFrames()[skip:]
stes := make([]*StackTraceElement, len(frames))
for i, frame := range frames {
stes[i] = createStackTraceElement(frame)
}
return stes
}
// 计算需要跳过的帧(正在执行的异常类的构造函数)
func distanceToObject(class *heap.Class) int {
distance := 0
// 如果有超类,跳帧 + 1
for c := class.SuperClass(); c != nil; c = c.SuperClass() {
distance++
}
return distance
}
// 构造记录Java虚拟机栈信息的结构体
func createStackTraceElement(frame *rtda.Frame) *StackTraceElement {
method := frame.Method()
class := method.Class()
return &StackTraceElement{
fileName: class.SourceFile(),
className: class.JavaName(),
methodName: method.Name(),
lineNumber: method.GetLineNumber(frame.NextPC() - 1),
}
}
代码解释
StackTraceElement结构体用来记录Java虚拟机栈帧信息:lineNumber字段给出帧正在执行哪行代码;methodName字段给出方法名;className字段给出声明方法的类名;fileName字段给出类所
在的文件名。

下面看fillInStackTrace(),重点在createStackTraceElements()函数里,代码如下:

createStackTraceElements()函数,由于栈顶两帧正在执行fillInStackTrace(int)和fillInStackTrace()方法,所以需要跳过这两帧。这两帧下面的几帧正在执行异常类的构造函数,所以也要跳过,具体要跳过多少帧数则要看异常类的继承层次。计算好需要跳过的帧之后,调用Thread结构体的GetFrames()方法拿到完整的Java虚拟机栈,然后reslice一下就是真正需要的帧。

distanceToObject()函数计算所需跳过的帧数,具体要跳过多少帧数则要看异常类的继承层次。代码如下:

createStackTraceElement()函数根据帧创建StackTraceElement实例,代码如下:

6.测试代码
打开命令行窗口,执行下面的命令编译本章代码。
go install jvmgo\ch10
测试下面的Java程序。
package jvmgo.book.ch09;
public class ParseIntTest {
public static void main(String[] args) {
foo(args);
}
private static void foo(String[] args) {
try {
bar(args);
} catch (NumberFormatException e) {
System.out.println(e.getMessage());
}
}
private static void bar(String[] args) {
if (args.length == 0) {
throw new IndexOutOfBoundsException("no args!");
}
int x = Integer.parseInt(args[0]);
System.out.println(x);
}
}结果:

目前,“Hello,World!”是可以出现在了控制台上。但是,由于java.lang.System类还没有被正确初
始化,而是直接调用System.out.println()方法会导致NullPointerException异常抛出。为此修改了invokevirtual指令,对println()方法做了特殊处理。还是使用的的hack技术。
而目前:class文件验证、内存管理和垃圾回收、类加载器的委派模型、多线程、JIT,等等都没有实现。以后有机会,再来尝试一下。
同时感谢原作者张秀宏大佬的自己动手写Java虚拟机学习,书中的知识对本人的提升很有帮助。
兜兜转转,至少我也实现了一个java虚拟机。
7.参考
尚硅谷宋红康:JVM全套教程:https://www.bilibili.com/video/BV1PJ411n7xZ
周志明:深入理解java虚拟机
张秀宏:自己动手写Java虚拟机 (Java核心技术系列)


