上一篇【第57篇】数组和字符串测试——综合演练
下一篇【第59篇】反射实现——Class.forName 的 Go 版
摘要
第 8 章结束时,jvmgo 身上贴着三块"创可贴",全都指向同一个短板:本地方法。
第 9 章来拆它们。这一篇先讲机制:
- 为什么需要本地方法——Java 做不到的事:系统调用、硬件访问、历史遗留的 C 库。
- JNI 为什么复杂——以及我们为什么不用它。
- 本地方法注册表——
className~methodName~descriptor三元组做 key。 - 0xFE 指令的妙用——本地方法没有字节码,怎么在栈上执行?答案是给它"注入"一段两字节的 code:
0xFE+ 对应的返回指令。
这是全书设计上最巧妙的一处:用 JVM 规范预留的保留指令,把"本地方法调用"这个完全不同的机制,无缝嫁接到了已有的解释器循环上。
一、为什么 JVM 必须有本地方法
1.1 Java 做不到的事
Java 语言是自足的——语法完备、标准库庞大。但有些事它在语言层面做不到:
| 场景 | 为什么 Java 做不到 |
|---|---|
| 文件 I/O、网络通信 | 需要系统调用(open/read/socket),纯 Java 无法发起 |
| 线程创建与调度 | 需要操作系统的线程 API(pthread_create) |
| 获取当前时间 | System.currentTimeMillis()最终是gettimeofday() |
| 内存复制 | System.arraycopy()需要memcpy级别的性能 |
| 图形、硬件、驱动 | 直接和硬件打交道 |
| 复用已有 C/C++ 库 | 大量历史代码是 C/C++ 写的 |
所以 JVM 必须提供一条"逃逸通道":当 Java 做不到时,可以调用用其他语言写的函数。这就是本地方法(native method)。
publicfinalclassSystem{publicstaticnativevoidarraycopy(Objectsrc,intsrcPos,Objectdest,intdestPos,intlength);publicstaticnativelongcurrentTimeMillis();}publicclassObject{publicfinalnativeClass<?>getClass();publicnativeinthashCode();protectednativeObjectclone()throwsCloneNotSupportedException;}publicfinalclassString{publicnativeStringintern();}注意这些方法的声明:只有签名,没有方法体(用;结尾,不是{ })。它们的实现在 class 文件之外。
1.2 本地方法在 class 文件里长什么样
publicnativeinthashCode();编译后的method_info:
access_flags: ACC_PUBLIC | ACC_NATIVE (0x0001 | 0x0100 = 0x0101) name_index: "hashCode" descriptor_index: "()I" attributes_count: 0 ← 没有 Code 属性!关键:本地方法没有Code属性。
这带来一个问题:第 7 章的InvokeMethod会给方法创建一个栈帧,而栈帧需要maxStack/maxLocals/code三个字段——本地方法全都没有。
这就是 9.2 节要解决的问题。
1.3 为什么我们的实现"不能"真的用 JNI
真实的 JVM 用JNI(Java Native Interface)实现本地方法:
// C 代码JNIEXPORT jint JNICALLJava_java_lang_Object_hashCode(JNIEnv*env,jobject this){return(*env)->GetObjectHashCode(env,this);}JNI 的完整机制包含:
- 一套 C 头文件(
jni.h)和类型系统(jobject/jclass/jint/jstring…) - 名字修饰规则(
Java_<全限定类名>_<方法名>,_转义) JNIEnv函数表(几百个函数:FindClass/GetFieldID/CallVoidMethod…)- 局部/全局引用管理(GC 相关)
- 异常处理(
ThrowNew/ExceptionOccurred) - 动态库加载(
System.loadLibrary→dlopen)
这套东西有几千行 C 头文件和上千页规范。对一个教学项目来说完全不现实。
而且我们有更简单的路:jvmgo 是Go 写的,本地方法可以直接用 Go 函数实现,不需要跨语言调用:
真实 JVM: Java 字节码 ──JNI──▶ C 函数 (跨语言、跨 ABI、需要 JNIEnv) jvmgo: Java 字节码 ──直接调用──▶ Go 函数 (同进程、同语言、共享数据结构)Go 函数可以直接操作*rtda.Frame,读写局部变量表和操作数栈——比 JNI 简单太多了。
书里说:
Java 虚拟机规范并没有规定如何实现和调用本地方法,这给了我们充分的空间来发挥自己的想象力。
这句话是第 9 章的"许可证"——规范只说"本地方法的行为应该是这样的",至于怎么实现,随你。
二、本地方法注册表
2.1 数据结构
// ch09/native/registry.gopackagenativeimport"jvmgo/ch09/rtda"typeNativeMethodfunc(frame*rtda.Frame)varregistry=map[string]NativeMethod{}三行代码,两个设计决策:
①NativeMethod是一个函数类型
typeNativeMethodfunc(frame*rtda.Frame)签名是func(frame *rtda.Frame)——接收一个栈帧,没有返回值。
书里解释:
这个
frame参数就是本地方法的工作空间,也就是连接 Java 虚拟机和 Java 类库的桥梁。
本地方法怎么接收参数?从frame.LocalVars()读。
本地方法怎么返回结果?往frame.OperandStack()压。
没有返回值,是因为返回值通过操作数栈传递——和第 7 章的返回指令做的事一样。
对比一下 JNI:
// JNI 版本JNIEXPORT jint JNICALLJava_java_lang_Object_hashCode(JNIEnv*env,jobject this);// 参数通过 JNIEnv + 变参传递,返回值是 C 类型// jvmgo 版本funchashCode(frame*rtda.Frame)// 参数从 frame.LocalVars() 读,返回值压进 frame.OperandStack()jvmgo 的版本和字节码指令的签名完全一样(第 5 章的Execute(frame *rtda.Frame))——这不是巧合,是有意的统一:本地方法和字节码指令站在同一个抽象层次上。
② 用 map 做注册表
简单直接,无需初始化顺序管理。
2.2 key 的设计:三元组
funcRegister(className,methodName,methodDescriptorstring,method NativeMethod){key:=className+"~"+methodName+"~"+methodDescriptor registry[key]=method}为什么需要三个字段?因为方法名会重载:
publicnativevoidprintln(intx);publicnativevoidprintln(longx);publicnativevoidprintln(Strings);光靠className + methodName区分不了,必须加上方法描述符。
为什么用~做分隔符?因为 Java 类名里的.和/、描述符里的( ) ; [ L都不包含~,不会歧义。
key 示例:
java/lang/Object~getClass~()Ljava/lang/Class; java/lang/Object~hashCode~()I java/lang/String~intern~()Ljava/lang/String; java/lang/System~arraycopy~(Ljava/lang/Object;ILjava/lang/Object;II)V java/lang/System~currentTimeMillis~()J java/lang/Class~getName0~()Ljava/lang/String;2.3 注册时机:Go 的 init()
// ch09/native/java/lang/Object.gopackagelangimport"jvmgo/ch09/native"import"jvmgo/ch09/rtda"funcinit(){native.Register("java/lang/Object","getClass","()Ljava/lang/Class;",getClass)native.Register("java/lang/Object","hashCode","()I",hashCode)native.Register("java/lang/Object","clone","()Ljava/lang/Object;",clone)}Go 的init()函数会在包被加载时自动执行——这是 Go 提供的"模块初始化钩子",相当于 Java 的静态初始化块。
每个包可以有多个init()(甚至在多个文件里各写一个),它们会按顺序执行。这让本地方法可以按类组织文件:
ch09/native/ ├── registry.go ← 注册表 └── java/ └── lang/ ├── Object.go ← init() 注册 Object 的本地方法 ├── Class.go ← init() 注册 Class 的本地方法 ├── String.go ← init() 注册 String 的本地方法 ├── System.go ← init() 注册 System 的本地方法 ├── Float.go ← init() 注册 Float 的本地方法 ├── Double.go ← init() 注册 Double 的本地方法 └── Throwable.go ← init() 注册 Throwable 的本地方法2.4 import for side effect
书里特别强调了一个 Go 的坑:
// ch09/instructions/reserved/invokenative.gopackagereservedimport"jvmgo/ch09/instructions/base"import"jvmgo/ch09/rtda"import"jvmgo/ch09/native"import_"jvmgo/ch09/native/java/lang"// ← 注意这个下划线如果没有任何包依赖
lang包,它就不会被编译进可执行文件,上面的本地方法也就不会被注册。所以需要一个地方导入lang包,把它放在invokenative.go文件中。由于没有显式使用lang中的变量或函数,所以必须在包名前面加上下划线,否则无法通过编译。这个技术在 Go 语言中叫作“import for side effect”。
这是一个很容易踩的坑:
- Go 编译器只链接被引用到的包。
lang包里的函数没有任何地方调用,所以如果不 import,它会被整个剔除。 import _ "xxx"表示"我导入这个包,但不用它的任何标识符,只是为了让它的init()执行"。
Go 里这个模式很常见,最典型的是数据库驱动:
import_"github.com/go-sql-driver/mysql"// 不直接用这个包,但它的 init() 会向 database/sql 注册 "mysql" 驱动jvmgo 的用法异曲同工——向native.registry注册本地方法。
2.5 查找
funcFindNativeMethod(className,methodName,methodDescriptorstring)NativeMethod{key:=className+"~"+methodName+"~"+methodDescriptorifmethod,ok:=registry[key];ok{returnmethod}ifmethodDescriptor=="()V"&&methodName=="registerNatives"{returnemptyNativeMethod}returnnil}funcemptyNativeMethod(frame*rtda.Frame){// do nothing}registerNatives的特殊处理——这是第 7 章那个 hack 的"转正版"。
回顾第 7 章(第 052 篇):
// ch07 的 hackifmethod.IsNative(){ifmethod.Name()=="registerNatives"{thread.PopFrame()}else{panic(...)}}现在改成:找不到就返回emptyNativeMethod——一个什么都不做的函数。效果一样(跳过),但机制统一了:所有本地方法都走注册表,不再有特例分支。
书里的解释:
java.lang.Object等类是通过一个叫作registerNatives()的本地方法来注册其他本地方法的。在本章和后面的章节中,将自行注册所有的本地方法实现。所以像registerNatives()这样的方法就没有太大的用处。为了避免重复代码,这里统一处理。
为什么registerNatives在真实 JVM 里是必须的?因为 HotSpot 的本地方法是用 C 写的,需要通过registerNatives告诉 JVM “这些方法在这个 .so 里”。我们直接用 Goinit()注册,不需要这层间接。
三、0xFE 指令:没有字节码怎么执行
3.1 问题
第 7 章的InvokeMethod:
funcInvokeMethod(invokerFrame*rtda.Frame,method*heap.Method){thread:=invokerFrame.Thread()newFrame:=thread.NewFrame(method)// ← 需要 maxStack / maxLocalsthread.PushFrame(newFrame)// ... 传参}NewFrame要按maxStack和maxLocals分配操作数栈和局部变量表。而本地方法没有Code属性,这两个字段是 0。
而且loop()会:
reader.Reset(frame.Method().Code(),pc)// ← code 是 nilopcode:=reader.ReadUint8()// ← 越界 panic所以本地方法需要"假的" code、maxStack、maxLocals。
3.2 解法:注入字节码
// ch09/rtda/heap/method.gofuncnewMethod(class*Class,cfMethod*classfile.MemberInfo)*Method{method:=&Method{}method.class=class method.copyMemberInfo(cfMethod)method.copyAttributes(cfMethod)md:=parseMethodDescriptor(method.descriptor)method.calcArgSlotCount(md.parameterTypes)ifmethod.IsNative(){method.injectCodeAttribute(md.returnType)// ← 关键}returnmethod}func(self*Method)injectCodeAttribute(returnTypestring){self.maxStack=4// todoself.maxLocals=self.argSlotCountswitchreturnType[0]{case'V':self.code=[]byte{0xfe,0xb1}// returncase'D':self.code=[]byte{0xfe,0xaf}// dreturncase'F':self.code=[]byte{0xfe,0xae}// freturncase'J':self.code=[]byte{0xfe,0xad}// lreturncase'L','[':self.code=[]byte{0xfe,0xb0}// areturndefault:self.code=[]byte{0xfe,0xac}// ireturn}}给本地方法注入一段"两字节的字节码":
[0xFE][0xB1] ← 返回 void 的本地方法 [0xFE][0xAC] ← 返回 int 的本地方法 [0xFE][0xAD] ← 返回 long 的本地方法 ...- 第 1 字节
0xFE:调本地方法(我们自己定义的invokenative) - 第 2 字节:对应的返回指令
为什么用 0xFE?因为 JVM 规范预留了三个操作码:
| opcode | 助记符 | 用途 |
|---|---|---|
0xCA | breakpoint | 调试器用 |
0xFE | impdep1 | “implementation dependent 1” |
0xFF | impdep2 | “implementation dependent 2” |
规范明确规定:这三个操作码不应出现在正常的 class 文件中,保留给 JVM 实现自己用。
书里说:
Java 虚拟机规范预留了两条指令,操作码分别是
0xFE和0xFF。下面将使用0xFE指令来达到这个目的。
用规范的"保留位"实现自定义扩展——这是 JVM 规范留给实现者的合法后门。
3.3 三个字段的取值
self.maxStack=4// todoself.maxLocals=self.argSlotCountmaxStack = 4——书里解释:
本地方法帧的操作数栈至少要能容纳返回值,为了简化代码,暂时给
maxStack字段赋值为 4。
4个 slot 能容纳最大的返回值(long / double 占 2 slot),还有余量给本地方法内部临时用。书里标了// todo——因为严格来说应该根据返回类型精确计算(void=0,long/double=2,其他=1),但 4 够用了。
maxLocals = argSlotCount——书里解释:
本地方法帧的局部变量表只用来存放参数值,所以把
argSlotCount赋给maxLocals字段刚好。
本地方法没有方法体,不会有局部变量,只有参数。参数占多少 slot,maxLocals就是多少。
3.4 图解:本地方法的执行流程
以System.currentTimeMillis()为例(()J,返回 long):
① newMethod() 时注入: maxStack = 4 maxLocals = 0 (静态方法,无参数) code = [0xFE, 0xAD] (invokenative + lreturn) ② invokevirtual/invokestatic 调用它: InvokeMethod() ├─ newFrame = thread.NewFrame(method) │ ├─ operandStack = newOperandStack(4) │ └─ localVars = newLocalVars(0) └─ PushFrame(newFrame) (没有参数,不传参) ③ loop() 第一轮: pc = 0 reader.Reset([0xFE, 0xAD], 0) opcode = 0xFE inst = INVOKE_NATIVE{} FetchOperands() (无操作数) frame.SetNextPC(1) ← nextPC 指向第二字节 0xAD inst.Execute(frame) ← 执行! ④ INVOKE_NATIVE.Execute: nativeMethod := FindNativeMethod("java/lang/System", "currentTimeMillis", "()J") nativeMethod(frame) → currentTimeMillis(frame): millis := time.Now().UnixNano() / int64(time.Millisecond) frame.OperandStack().PushLong(millis) ← 结果压栈 ⑤ loop() 第二轮: pc = 1 opcode = 0xAD → LRETURN LRETURN.Execute(frame) ├─ currentFrame := thread.PopFrame() ├─ invokerFrame := thread.TopFrame() ├─ retVal := currentFrame.OperandStack().PopLong() ← 取出刚压入的值 └─ invokerFrame.OperandStack().PushLong(retVal) ← 推进调用者栈完美闭环:
调用者 ──▶ [本地方法帧] ──▶ 0xFE 调 Go 函数(结果压栈) ──▶ 返回指令(结果转交给调用者,弹帧)本地方法自己不需要关心返回——它只管把结果压进操作数栈,后面的返回指令会处理。这个分工和第 7 章的 Java 方法完全一致。
3.5 为什么这个设计漂亮
方案对比:
| 方案 | 做法 | 问题 |
|---|---|---|
| A. 特判 | InvokeMethod里判断IsNative(),不走解释器直接调函数 | 要自己处理返回值传递、栈帧弹出,重复第 7 章的逻辑 |
| B. 注入字节码(jvmgo) | 给本地方法注入[0xFE, 返回指令] | 需要占用一个保留 opcode |
| C. 真实 JVM | JNI,本地方法在 C 栈上执行 | 复杂,且要处理 GC、异常、线程状态切换 |
方案 B 的优雅之处:
- 零特判——
loop()完全不知道自己执行的是本地方法还是 Java 方法 - 复用已有机制——参数传递(第 050 篇)、返回值传递(第 051 篇)、栈帧弹出,全都是现成的
- 本地方法的签名 = 字节码指令的签名——都是
func(frame *rtda.Frame)
代价:每个本地方法调用会多执行一条指令(返回指令)。这个开销可以忽略。
四、invokenative 指令
4.1 指令定义
// ch09/instructions/reserved/invokenative.gopackagereservedimport"jvmgo/ch09/instructions/base"import"jvmgo/ch09/rtda"import"jvmgo/ch09/native"import_"jvmgo/ch09/native/java/lang"typeINVOKE_NATIVEstruct{base.NoOperandsInstruction}放在新建的reserved包里——因为0xFE是"保留指令",给它一个专门的包,语义清晰。
4.2 Execute
func(self*INVOKE_NATIVE)Execute(frame*rtda.Frame){method:=frame.Method()className:=method.Class().Name()methodName:=method.Name()methodDescriptor:=method.Descriptor()nativeMethod:=native.FindNativeMethod(className,methodName,methodDescriptor)ifnativeMethod==nil{methodInfo:=className+"."+methodName+methodDescriptorpanic("java.lang.UnsatisfiedLinkError: "+methodInfo)}nativeMethod(frame)}注意frame.Method()—— 这里拿的是当前帧自己的方法(也就是那个 native 方法),不是被调用者。因为0xFE就是本地方法"方法体"的第一条指令。
UnsatisfiedLinkError——第 9 章要记的第一个异常:
// 真实 JVM 里System.loadLibrary("不存在的库");// java.lang.UnsatisfiedLinkError: no 不存在的库 in java.library.path在我们的实现里,遇到没注册的本地方法就抛它。这是调试 jvmgo 时最常见的错误——跑一个用到新 JDK 类的程序,十有八九会看到:
panic: java.lang.UnsatisfiedLinkError: java/lang/Thread.currentThread()Ljava/lang/Thread;看到这个不要慌,说明 jvmgo 跑对了,只是这个本地方法还没实现。
4.3 factory 注册
// ch09/instructions/factory.gocase0xfe:return&reserved.INVOKE_NATIVE{}别忘了——第 7 章、第 8 章都强调过这一点。
五、删除第 7 章的 hack
书里 9.2 节第一句话:
第 7 章用一段 hack 代码来跳过本地方法的执行。现在,终于可以把这段代码删除了!
// ch09/instructions/base/method_invoke_logic.gofuncInvokeMethod(invokerFrame*rtda.Frame,method*heap.Method){thread:=invokerFrame.Thread()newFrame:=thread.NewFrame(method)thread.PushFrame(newFrame)argSlotSlot:=int(method.ArgSlotCount())ifargSlotSlot>0{fori:=argSlotSlot-1;i>=0;i--{slot:=invokerFrame.OperandStack().PopSlot()newFrame.LocalVars().SetSlot(uint(i),slot)}}// ← 删掉了 IsNative() 的 hack 分支}InvokeMethod现在对所有方法一视同仁——Java 方法和本地方法走完全相同的建帧 + 传参流程。区别只在于:Java 方法的code来自 class 文件,本地方法的code是注入的[0xFE, 返回指令]。
书里的得意之情溢于言表:
除了删除上面的
InvokeMethod()函数中的 hack 代码之外,不用做任何修改。
这就是好设计的回报——第 7 章的时候把公共逻辑抽到base.InvokeMethod,现在只需要删代码,不需要改架构。
六、本地方法的包结构
第 9 章(及第 10、11 章)陆续实现的本地方法:
ch09/native/ ├── registry.go (9.1) NativeMethod 类型 + registry + Register/Find └── java/ └── lang/ ├── Object.go (9.5) getClass / hashCode / equals / toString │ (9.6) clone │ (9.7) notify / notifyAll / wait(未实现) ├── Class.go (9.3.5) getPrimitiveClass / getName0 / │ desiredAssertionStatus0 │ (9.3) forName0 ├── String.go (9.4.4) intern ├── System.go (9.4.2) arraycopy │ (11.2) initProperties / setIn0 / setOut0 / setErr0 ├── Float.go (9.4.3) floatToRawIntBits / intBitsToFloat ├── Double.go (9.4.3) doubleToRawLongBits / longBitsToDouble └── Throwable.go (10.5) fillInStackTrace(括号里是书中对应的小节。第 9 章本篇讲 9.1-9.2,后面的文章依次覆盖。)
七、和真实 JNI 的对比
| 维度 | JNI(HotSpot) | jvmgo |
|---|---|---|
| 实现语言 | C/C++ | Go |
| 参数传递 | JNIEnv*+ 变参 /va_list | frame.LocalVars() |
| 返回值 | C 返回值 | frame.OperandStack() |
| 类型系统 | jobject/jint/jstring… | 直接用 Go 类型 |
| 名字修饰 | Java_java_lang_Object_hashCode | 字符串 keyjava/lang/Object~hashCode~()I |
| 注册方式 | 静态(名字查找)或RegisterNatives() | Goinit()+Register() |
| 库加载 | System.loadLibrary→dlopen | 编译期链接(无需加载) |
| GC 交互 | 局部引用 / 全局引用 / 弱全局引用 | 无(Go 的 GC 自动管理) |
| 异常 | ThrowNew/ExceptionOccurred/ExceptionClear | panic |
| 执行位置 | C 栈(JVM 要保存 Java 栈状态) | Java 虚拟机栈(用 0xFE 指令) |
| 方法发现失败 | UnsatisfiedLinkError | UnsatisfiedLinkError(一致) |
最有意思的是"执行位置"这一行:
真实 JNI 执行本地方法时,当前的 Java 栈帧是"暂停"的,CPU 跑在 C 栈上。所以 JNI 有一堆规则(不能在本地方法里直接操作 Java 对象、要用 JNI 函数访问、要注意 GC 安全点…)。
而 jvmgo 的本地方法就在 Java 虚拟机栈的当前帧上执行,直接用 Go 操作frame——既简单又安全(因为 Go 有 GC,不会悬垂指针)。
本篇小结
第 9 章开篇,本地方法调用的机制搭建完成:
- 为什么需要本地方法——Java 做不到系统调用、硬件访问、复用 C 库。本地方法在 class 文件里只有签名,没有
Code属性,所以maxStack/maxLocals/code三个字段全空。 - 不用 JNI——因为 jvmgo 是 Go 写的,本地方法可以直接用 Go 函数实现,操作
*rtda.Frame,比 JNI 简单几个数量级。规范只规定行为,不管实现。 - 本地方法注册表——
map[string]NativeMethod,key 是className~methodName~descriptor三元组(描述符用于区分重载)。注册靠 Go 的init(),加载靠import _ "xxx"(import for side effect),否则包会被编译器剔除。 - 0xFE 指令的妙用——给本地方法注入两字节字节码
[0xFE, 返回指令]。0xFE是 JVM 规范预留的impdep1,合法可用。maxStack = 4、maxLocals = argSlotCount。本地方法只管把结果压进操作数栈,返回指令负责转交和弹帧。 - 方案之美——解释器
loop()零改动、零特判,完全复用第 7 章的参数传递和返回值传递机制。第 7 章那个IsNative()的 hack 可以删掉了。 UnsatisfiedLinkError——遇到未注册的本地方法时抛出。这是调试 jvmgo 最常见的错误,看到它说明"跑对了,只是还没实现"。
下一篇(第 059 篇)开始实现第一批本地方法:反射。getClass()、Class.getName()、Class.forName()、以及Object.extra字段和Class.jClass字段如何建立"类和对象的双向关系"。
上一篇【第57篇】数组和字符串测试——综合演练
下一篇【第59篇】反射实现——Class.forName 的 Go 版