上一篇【第57篇】数组和字符串测试——综合演练下一篇【第59篇】反射实现——Class.forName 的 Go 版摘要第 8 章结束时jvmgo 身上贴着三块创可贴全都指向同一个短板本地方法。第 9 章来拆它们。这一篇先讲机制为什么需要本地方法——Java 做不到的事系统调用、硬件访问、历史遗留的 C 库。JNI 为什么复杂——以及我们为什么不用它。本地方法注册表——className~methodName~descriptor三元组做 key。0xFE 指令的妙用——本地方法没有字节码怎么在栈上执行答案是给它注入一段两字节的 code0xFE 对应的返回指令。这是全书设计上最巧妙的一处用 JVM 规范预留的保留指令把本地方法调用这个完全不同的机制无缝嫁接到了已有的解释器循环上。一、为什么 JVM 必须有本地方法1.1 Java 做不到的事Java 语言是自足的——语法完备、标准库庞大。但有些事它在语言层面做不到场景为什么 Java 做不到文件 I/O、网络通信需要系统调用open/read/socket纯 Java 无法发起线程创建与调度需要操作系统的线程 APIpthread_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_infoaccess_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 用JNIJava 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.gopackagenativeimportjvmgo/ch09/rtdatypeNativeMethodfunc(frame*rtda.Frame)varregistrymap[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.gopackagelangimportjvmgo/ch09/nativeimportjvmgo/ch09/rtdafuncinit(){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.gopackagereservedimportjvmgo/ch09/instructions/baseimportjvmgo/ch09/rtdaimportjvmgo/ch09/nativeimport_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()VmethodNameregisterNatives{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 章的InvokeMethodfuncInvokeMethod(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.classclass 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.maxStack4// todoself.maxLocalsself.argSlotCountswitchreturnType[0]{caseV:self.code[]byte{0xfe,0xb1}// returncaseD:self.code[]byte{0xfe,0xaf}// dreturncaseF:self.code[]byte{0xfe,0xae}// freturncaseJ:self.code[]byte{0xfe,0xad}// lreturncaseL,[: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助记符用途0xCAbreakpoint调试器用0xFEimpdep1“implementation dependent 1”0xFFimpdep2“implementation dependent 2”规范明确规定这三个操作码不应出现在正常的 class 文件中保留给 JVM 实现自己用。书里说Java 虚拟机规范预留了两条指令操作码分别是0xFE和0xFF。下面将使用0xFE指令来达到这个目的。用规范的保留位实现自定义扩展——这是 JVM 规范留给实现者的合法后门。3.3 三个字段的取值self.maxStack4// todoself.maxLocalsself.argSlotCountmaxStack 4——书里解释本地方法帧的操作数栈至少要能容纳返回值为了简化代码暂时给maxStack字段赋值为 4。4个 slot 能容纳最大的返回值long / double 占 2 slot还有余量给本地方法内部临时用。书里标了// todo——因为严格来说应该根据返回类型精确计算void0long/double2其他1但 4 够用了。maxLocals argSlotCount——书里解释本地方法帧的局部变量表只用来存放参数值所以把argSlotCount赋给maxLocals字段刚好。本地方法没有方法体不会有局部变量只有参数。参数占多少 slotmaxLocals就是多少。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, 返回指令]需要占用一个保留 opcodeC. 真实 JVMJNI本地方法在 C 栈上执行复杂且要处理 GC、异常、线程状态切换方案 B 的优雅之处零特判——loop()完全不知道自己执行的是本地方法还是 Java 方法复用已有机制——参数传递第 050 篇、返回值传递第 051 篇、栈帧弹出全都是现成的本地方法的签名 字节码指令的签名——都是func(frame *rtda.Frame)代价每个本地方法调用会多执行一条指令返回指令。这个开销可以忽略。四、invokenative 指令4.1 指令定义// ch09/instructions/reserved/invokenative.gopackagereservedimportjvmgo/ch09/instructions/baseimportjvmgo/ch09/rtdaimportjvmgo/ch09/nativeimport_jvmgo/ch09/native/java/langtypeINVOKE_NATIVEstruct{base.NoOperandsInstruction}放在新建的reserved包里——因为0xFE是保留指令给它一个专门的包语义清晰。4.2 Executefunc(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)ifnativeMethodnil{methodInfo:className.methodNamemethodDescriptorpanic(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:returnreserved.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())ifargSlotSlot0{fori:argSlotSlot-1;i0;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 的对比维度JNIHotSpotjvmgo实现语言C/CGo参数传递JNIEnv* 变参 /va_listframe.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/ExceptionClearpanic执行位置C 栈JVM 要保存 Java 栈状态Java 虚拟机栈用 0xFE 指令方法发现失败UnsatisfiedLinkErrorUnsatisfiedLinkError一致最有意思的是执行位置这一行真实 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]NativeMethodkey 是className~methodName~descriptor三元组描述符用于区分重载。注册靠 Go 的init()加载靠import _ xxximport 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 版