上一篇【第55篇】JDK 字符串类——java.lang.String 的秘密下一篇【第57篇】数组和字符串测试——综合演练摘要第 055 篇造好了JString()这个转换器但它还没人调用。这一篇把它接进三个地方位置作用ldc指令让ldc abc能压入一个真正的 String 对象initStaticFinalVar()让static final String X abc在prepare()阶段就拿到值createArgsArray()让main(String[] args)真的能收到命令行参数再加一个printlnhack 的扩建支持打印字符串第 1 章就出现过的HelloWorld终于能跑起来了。这四处改动的共同点都是把 Go 世界的字符串塞进 Java 世界的桥梁。一、完善 ldc 指令1.1 回顾 ldc第 5 章实现ldc时留了个尾巴——当时常量池里还没有字符串第 6 章才实现运行时常量池所以只处理了int32和float32// ch06/instructions/constants/ldc.gofunc_ldc(frame*rtda.Frame,indexuint){stack:frame.OperandStack()class:frame.Method().Class()c:class.ConstantPool().GetConstant(index)switchc.(type){caseint32:stack.PushInt(c.(int32))casefloat32:stack.PushFloat(c.(float32))// case string: ← 第 8 章补上default:panic(todo: ldc!)}}现在补上string分支// ch08/instructions/constants/ldc.gopackageconstantsimportjvmgo/ch08/instructions/baseimportjvmgo/ch08/rtdaimportjvmgo/ch08/rtda/heapfunc_ldc(frame*rtda.Frame,indexuint){stack:frame.OperandStack()class:frame.Method().Class()c:class.ConstantPool().GetConstant(index)switchc.(type){caseint32:stack.PushInt(c.(int32))casefloat32:stack.PushFloat(c.(float32))casestring:internedStr:heap.JString(class.Loader(),c.(string))stack.PushRef(internedStr)default:panic(todo: ldc!)}}新增 3 行但要理解它得先理解常量池里string是哪来的。1.2 常量池里的 string 从哪来回忆第 6 章的newConstantPoolfuncnewConstantPool(class*Class,cfCp classfile.ConstantPool)*ConstantPool{cp:ConstantPool{class:class,consts:make([]heap.Constant,cpCount)}fori:1;icpCount;i{switchcpInfo:cfCp[i].(type){case*classfile.ConstantIntegerInfo:cp.consts[i]cpInfo.Value()// int32case*classfile.ConstantFloatInfo:cp.consts[i]cpInfo.Value()// float32case*classfile.ConstantLongInfo:cp.consts[i]cpInfo.Value()i// long 占 2 个位置case*classfile.ConstantDoubleInfo:cp.consts[i]cpInfo.Value()icase*classfile.ConstantStringInfo:cp.consts[i]cpInfo.Value()// ← 就是这里Go string// ... 符号引用等}}}CONSTANT_String_info存的是一个u2 的 Utf8 索引而ConstantStringInfo.Value()会顺着这个索引拿到CONSTANT_Utf8_info里的字符串——一个普通的 Gostring。所以ldc从常量池取出的c.(string)是Go 字符串UTF-8必须经JString()转成 Java 对象才能压栈。1.3 完整链路用String s hello;举例从头捋一遍① javac 编译 String s hello; → ldc #2 // String hello ② class 文件常量池 #2 CONSTANT_String_info { string_index: #25 } #25 CONSTANT_Utf8_info { bytes: hello (MUTF8) } ③ classfile 解析第 3 章 ConstantStringInfo.Value() → 顺着 #25 取出 → Go string hello ④ 运行时常量池第 6 章 cp.consts[2] hello Go string ⑤ ldc 执行本篇 c : cp.GetConstant(2) → hello (Go string) heap.JString(loader, hello) ├─ 查 internedStrings没有 ├─ stringToUtf16(hello) → []uint16{104,101,108,108,111} ├─ Object{[C, chars]} → char[] 对象 ├─ LoadClass(java/lang/String).NewObject() ├─ SetRefVar(value, [C, jChars) └─ 入池 stack.PushRef(jStr) ⑥ 操作数栈 [... , *Object(java/lang/String)] ← 栈顶1.4 ldc 的三种类型为什么是这三种ldc只支持int、float、String三类外加 Class 和 MethodHandle/MethodType走ldc_w。为什么因为只有这三种能塞进 1 字节的常量池索引所指向的、大小固定的常量项类型常量池项大小intCONSTANT_Integer4 字节floatCONSTANT_Float4 字节StringCONSTANT_String2 字节是个索引longCONSTANT_Long8 字节 → 需要ldc2_wdoubleCONSTANT_Double8 字节 → 需要ldc2_wlong和double占两个 slot所以单条ldc放不下必须用ldc2_w第 5 章讲过。String 特殊在哪它本身只占 2 字节一个索引但它指向的对象是运行期才创建的——这就是ldc需要配合字符串池的原因同一个索引每次ldc都应返回同一个对象。Stringahello;Stringbhello;// 两次 ldc #2但因为字符串池a b 为 true二、完善类加载器static final String 常量2.1 回顾 initStaticFinalVar第 6 章prepare()阶段讲过只有final static的编译期常量才会真正拿到ConstantValue属性里的值其他静态变量要等clinit。当时的实现支持了 6 种基本类型funcinitStaticFinalVar(class*Class,field*Field){vars:class.staticVars cp:class.constantPool cpIndex:field.ConstValueIndex()slotId:field.SlotId()ifcpIndex0{switchfield.Descriptor(){caseZ,B,C,S,I:val:cp.GetConstant(cpIndex).(int32)vars.SetInt(slotId,val)caseJ:val:cp.GetConstant(cpIndex).(int64)vars.SetLong(slotId,val)caseF:val:cp.GetConstant(cpIndex).(float32)vars.SetFloat(slotId,val)caseD:val:cp.GetConstant(cpIndex).(float64)vars.SetDouble(slotId,val)// case Ljava/lang/String;: ← 第 8 章补上}}}2.2 补上 String 分支funcinitStaticFinalVar(class*Class,field*Field){vars:class.staticVars cp:class.constantPool cpIndex:field.ConstValueIndex()slotId:field.SlotId()ifcpIndex0{switchfield.Descriptor(){// ... 基本类型的 case 不变caseLjava/lang/String;:goStr:cp.GetConstant(cpIndex).(string)jStr:JString(class.Loader(),goStr)vars.SetRef(slotId,jStr)}}}4 行代码书里说代码比较简单就不多解释了。但这里有个值得深挖的点。2.3 为什么 static final String 要在 prepare 阶段赋值考虑publicclassConfig{publicstaticfinalStringNAMEjvmgo;publicstaticfinalintPORT8080;}这两个字段都有ConstantValue属性所以prepare() 阶段第 6 章 NAME → JString(jvmgo) 存入 staticVars[0] PORT → 8080 存入 staticVars[1] clinit 阶段 无 —— javac 不会为只有编译期常量的类生成 clinit关键这个类连clinit都不会生成所以用Config.NAME时getstatic Config.NAME → 触发类初始化检查 → InitClass(Config) → scheduleClinit(): GetClinitMethod() 返回 nil什么都不做 → initSuperClass(): 初始化 Object → 读 staticVars[0]拿到 String 对象 ✓如果不在这里赋值staticVars[0]就是null。2.4 注意只有编译期常量才有 ConstantValuepublicclassDemo{staticfinalStringAabc;// ✓ 编译期常量staticfinalStringBabc;// ✓ 编译器会折叠成 abcstaticfinalStringCnewString(abc);// ✗ 运行期创建staticfinalStringDSystem.getProperty(user);// ✗ 运行期调用staticStringEabc;// ✗ 非 final}只有 A 和 B 有ConstantValue属性在prepare()阶段赋值C、D、E 要在clinit里赋值。这对我们的实现意味着什么C、D、E 会在clinit里执行ldcputstatic走第 ① 节的 ldc 路径也能正常工作。两条路径最终都调用JString()所以同一个字符串在两条路径下得到的是同一个对象——因为都过字符串池。prepare 路径: initStaticFinalVar → JString() → 入池 → staticVars clinit 路径: ldc → JString() → 池里已有直接返回 → putstatic └────────── 同一个 map ──────────┘三、命令行参数createArgsArray3.1 从 main.go 开始// ch08/main.gofuncstartJVM(cmd*Cmd){// ... 其他代码不变ifmainMethod!nil{interpret(mainMethod,cmd.verboseInstFlag,cmd.args)// ← 多传一个 args}else{fmt.Printf(Main method not found in class %s\n,cmd.class)}}// ch08/interpreter.gofuncinterpret(method*heap.Method,logInstbool,args[]string){thread:rtda.NewThread()frame:thread.NewFrame(method)thread.PushFrame(frame)jArgs:createArgsArray(method.Class().Loader(),args)frame.LocalVars().SetRef(0,jArgs)// ← 填进 main 的 0 号 slotdefercatchErr(thread)loop(thread,logInst)}这是全书第一次在解释器里手工准备参数。之前main方法的args一直是null局部变量表全零值所以args.length会 NPE。3.2 createArgsArrayfunccreateArgsArray(loader*heap.ClassLoader,args[]string)*heap.Object{stringClass:loader.LoadClass(java/lang/String)argsArr:stringClass.ArrayClass().NewArray(uint(len(args)))jArgs:argsArr.Refs()fori,arg:rangeargs{jArgs[i]heap.JString(loader,arg)}returnargsArr}四行但用到了前几篇的所有积木① loader.LoadClass(java/lang/String) → 从 rt.jar 加载 String 类第 6 章 ② stringClass.ArrayClass() → java/lang/String → [Ljava/lang/String;第 054 篇的 getArrayClassName → loader.LoadClass([Ljava/lang/String;)第 053 篇的 loadArrayClass ③ .NewArray(uint(len(args))) → 创建 []*Object第 053 篇 ④ jArgs[i] heap.JString(loader, arg) → 每个参数转成 Java String第 055 篇用图表示args [foo, bar] createArgsArray: argsArr: Object{ class: [Ljava/lang/String;, data: []*Object{ ─┬─▶ Object{ class: java/lang/String, │ data: Slots{ value → [C{f,o,o}], │ hash → 0 } } └─▶ Object{ class: java/lang/String, data: Slots{ value → [C{b,a,r}], hash → 0 } } } │ ▼ frame.LocalVars().SetRef(0, argsArr) → main 方法的局部变量表 [0] argsArr所以现在public static void main(String[] args)的args真的是个String[]了。注意SetRef(0, ...)—— 因为main是静态方法局部变量表 0 号 slot 就是第一个参数args没有隐藏的this。这和第 050 篇讲的argSlotCount规则一致。3.3 一个细节为什么不在 InvokeMethod 里做有人可能会问为什么不走标准的InvokeMethod 传参流程而是手工SetRef(0, ...)因为main方法不是被任何指令调用的——它是 JVM 的入口栈上没有任何调用者。普通方法调用 调用者帧的操作数栈上有参数 → InvokeMethod 弹栈 → 填入新帧 main 方法 栈上只有 main 一个帧没有调用者 → 参数只能由解释器手工构造并直接写入局部变量表所有 JVM 实现都是这么做的——HotSpot 的JavaMain函数也是手工构造String[]再调用main。四、让 println 能打印字符串4.1 扩建 _println hack第 051 篇的_println支持 8 种基本类型现在补上String// ch08/instructions/references/invokevirtual.go// hack!func_println(stack*rtda.OperandStack,descriptorstring){switchdescriptor{case(Z)V:fmt.Printf(%v\n,stack.PopInt()!0)case(C)V:fmt.Printf(%c\n,stack.PopInt())case(B)V:fmt.Printf(%v\n,stack.PopInt())case(S)V:fmt.Printf(%v\n,stack.PopInt())case(I)V:fmt.Printf(%v\n,stack.PopInt())case(F)V:fmt.Printf(%v\n,stack.PopFloat())case(J)V:fmt.Printf(%v\n,stack.PopLong())case(D)V:fmt.Printf(%v\n,stack.PopDouble())case(Ljava/lang/String;)V:// ← 新增jStr:stack.PopRef()goStr:heap.GoString(jStr)fmt.Println(goStr)default:panic(println: descriptor)}stack.PopRef()}注意stack.PopRef()在 switch 外面——不管是哪种类型最后都要弹出System.out那个nil的 this。新增的String分支里PopRef()弹出参数String 对象heap.GoString()转成 Go 字符串fmt.Println打印。4.2 GoString// ch08/rtda/heap/string_pool.gofuncGoString(jStr*Object)string{charArr:jStr.GetRefVar(value,[C)returnutf16ToString(charArr.Chars())}funcutf16ToString(s[]uint16)string{runes:utf16.Decode(s)// utf8returnstring(runes)}JString的逆操作第 055 篇讲过。一个陷阱GoString假设传入的jStr一定是个String对象而且内部结构是 JDK 8 的char[] value。如果传的是nulljStr.GetRefVar会 panic。真实 JVM 的println(String)对 null 会打印null字符串我们这里会崩——又一处简化。五、HelloWorld 终于跑起来了5.1 第 1 章的那个程序书里说一切就绪重新编译本章代码然后用 ch08.exe 执行在第 1 章就已经出现过的 HelloWorld 程序。packagejvmgo.book.ch01;publicclassHelloWorld{publicstaticvoidmain(String[]args){System.out.println(Hello, world!);}}5.2 它要走多少路这个只有 3 行的程序从第 1 章到第 8 章需要 jvmgo 具备全部这些能力① 命令行解析第 2 章 ch08.exe -Xjre C:\...\jre jvmgo.book.ch01.HelloWorld → 解析出 cpOption / XjreOption / class ② 类路径查找第 3 章 → 在 ./ 下找到 jvmgo/book/ch01/HelloWorld.class ③ class 文件解析第 3 章 → 魔数、版本号、常量池、访问标志、类索引、字段表、方法表、属性表 ④ 类加载第 6 章 → LoadClass(jvmgo/book/ch01/HelloWorld) → 递归加载超类 java/lang/Object第 8 章走 loadNonArrayClass 读 rt.jar ⑤ 链接准备第 6 章 → 计算静态变量 slot分配空间赋零值 → initStaticFinalVar第 8 章处理 String 常量 ⑥ 找 main 方法第 6 章 → GetMainMethod(): 名字 main 描述符 ([Ljava/lang/String;)V public static ⑦ 解释器启动第 7 章改造版 → NewThread / NewFrame / PushFrame → createArgsArray第 8 章构造 String[] args ⑧ 执行字节码 getstatic java/lang/System.out ← 第 6 章指令 第 7 章类初始化 ldc Hello, world! ← 第 8 章JString 转换 invokevirtual java/io/PrintStream.println ← 第 7 章指令 第 8 章 println hack return ← 第 7 章返回指令 ⑨ 栈空程序退出第 7 章IsStackEmpty9 个环节横跨 8 章。这就是自己动手写 JVM的完整闭环。5.3 执行结果$ goinstalljvmgo/ch08 $ javac-d.HelloWorld.java $ ch08.exe-XjreD:\Java\jdk1.8.0\jrejvmgo.book.ch01.HelloWorld Hello, world!用-verbose:class看看加载了哪些类$ ch08.exe-verbose:class-Xjre...jvmgo.book.ch01.HelloWorld[Loaded jvmgo/book/ch01/HelloWorld from .][Loaded java/lang/Object from D:\Java\jdk1.8.0\jre\lib\rt.jar][Loaded java/lang/System from D:\Java\jdk1.8.0\jre\lib\rt.jar][Loaded java/lang/String from D:\Java\jdk1.8.0\jre\lib\rt.jar][Loaded java/io/PrintStream from D:\Java\jdk1.8.0\jre\lib\rt.jar]Hello, world![C、[Ljava/lang/String;等数组类也会加载但loadArrayClass不走loadNonArrayClass所以不会打印。5.4 一个幽默的对比第 1 章开头作者用这个程序说明写一个 JVM 有多难第 8 章结尾我们用 8 章的功夫让它跑起来了。而它打印的还是同一句话Hello, world!这就是造轮子的魅力——外人看结果毫无变化你自己知道底下多了 8000 行代码和 8 章的认知。六、本篇代码清单ch08/ ├── instructions/ │ ├── constants/ldc.go ← 【改】新增 case string: JString PushRef │ └── references/invokevirtual.go ← 【改】_println 新增 (Ljava/lang/String;)V ├── rtda/heap/ │ ├── class_loader.go ← 【改】initStaticFinalVar 新增 String 分支 │ └── string_pool.go ← 【新增】GoString / utf16ToString ├── interpreter.go ← 【改】interpret 接收 argscreateArgsArray └── main.go ← 【改】startJVM 传 cmd.args改动量约 30 行。但正是这 30 行让 jvmgo 从能算斐波那契数跃迁到能打印字符串——从控制台程序变成了真正的 Java 虚拟机。七、四处改动的共同模式回头看这四处改动它们其实在做同一件事┌──────────────────┐ │ Go 世界 │ │ string (UTF-8) │ └────────┬─────────┘ │ ┌──────────────┼──────────────┐ │ │ │ ldc 指令 initStaticFinalVar createArgsArray 运行时压栈 prepare 阶段 main 参数 │ │ │ └──────────────┼──────────────┘ │ JString() ▼ ┌──────────────────┐ │ Java 世界 │ │ String 对象 │ │ (UTF-16 char[]) │ └────────┬─────────┘ │ GoString() ▼ ┌──────────────────┐ │ fmt.Println │ │ 回到 Go 世界 │ └──────────────────┘JString是下行桥GoString是上行桥。这个模式在真实的 JVM 里也存在只是规模大得多桥真实 JVMjvmgoGo → JavaJNI 的NewStringUTF()JString()Java → GoJNI 的GetStringUTFChars()GoString()第 9 章讲本地方法调用时会更系统地看到这套机制——只不过那时候是双向的而且 Java 代码能主动调用本地方法。本篇小结字符串功能的最后一公里四处改动ldc指令补string分支——常量池里的CONSTANT_String存的是 Go 字符串UTF-8经JString()转成 Java String 对象压栈。因为走字符串池同一个字面量多次ldc得到的是同一个对象a b恒为true。ldc只支持 int/float/String 三类因为 long/double 占两个 slot得走ldc2_w。initStaticFinalVar补 String 分支——static final String X abc在prepare()阶段就拿到值这类类连clinit都不会生成所以必须在这里赋值。只有编译期常量才有ConstantValue属性new String(abc)这类要等clinit。两条路径都调JString()共享同一个字符串池。createArgsArray——main是 JVM 入口栈上没有调用者所以参数不能走InvokeMethod弹栈传递只能由解释器手工构造String[]再SetRef(0, ...)。用到了LoadClass→ArrayClass→NewArray→JString四块积木。_printlnhack 扩建——新增(Ljava/lang/String;)V分支用GoString()反向转换。注意stack.PopRef()弹System.out在 switch 外面所有类型共用。成果第 1 章的HelloWorld终于跑起来了。一行输出背后是 9 个环节、横跨 8 章的完整链路。下一篇第 057 篇是第 8 章的综合测试——用冒泡排序验证数组、用字符串拼接验证 String并总结整章。上一篇【第55篇】JDK 字符串类——java.lang.String 的秘密下一篇【第57篇】数组和字符串测试——综合演练