ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【DIY系列:Java虚拟机】第55篇:JDK 字符串类——java.lang.String 的秘密

【DIY系列:Java虚拟机】第55篇:JDK 字符串类——java.lang.String 的秘密 上一篇【第54篇】数组相关指令——newarray/aload/astore 全家桶下一篇【第56篇】完善 ldc 和类加载器——让 String “跑起来”摘要数组搞定了现在轮到字符串。字符串在 JVM 里的地位很微妙它不像数组那样有专属指令但 JVM 又必须知道它——因为字符串常量CONSTANT_String是 class 文件的一种常量类型ldc指令必须能把它变成一个对象。这一篇要搞清三件事字符串的三种形态——class 文件里的 MUTF8、运行期的String对象、对象内部的 UTF16char[]。三种编码两次转换。java.lang.String的内部结构——private final char[] value不可变private int hash哈希缓存默认 0。字符串池——internedStrings map[string]*Object以及JString()那个跳过构造函数的 hack。下一篇再讲ldc和类加载器怎么用它。一、字符串的三种形态书里 8.5 节开篇这段话信息量很大值得逐句拆在 class 文件中字符串是以MUTF8格式保存的这一点在 3.3.7 节讨论过。在 Java 虚拟机运行期间字符串以java.lang.String 对象的形式存在而在 String 对象内部字符串又是以UTF16格式保存的。所以一个字符串从磁盘到内存要经历两次编码转换① class 文件磁盘 CONSTANT_Utf8_info: MUTF8 编码的字节序列 例: Hello → 05 00 48 65 6C 6C 6F Modified UTF-8\u0000 编码成 2 字节补充字符用 6 字节代理对表示 │ │ 第 3 章classfile 解析时 │ Go 的 string内存里是 UTF-8 ▼ ② Go 字符串JVM 内部过渡态 goStr : Hello │ │ 本篇JString() 转换 │ UTF8 → UTF32(rune) → UTF16 ▼ ③ Java char[] 数组堆上 Object{ class: [C, data: []uint16{0x0048, 0x0065, 0x006C, 0x006C, 0x006F} } │ │ 塞进 String 对象的 value 字段 ▼ ④ Java String 对象堆上 Object{ class: java/lang/String, data: Slots{ [0]value, [1]hash } }1.1 为什么要转这么多次形态编码理由class 文件MUTF8兼容 ASCII、省空间且和 C 字符串一样以\0结尾HotSpot 是 C 写的方便Go 字符串UTF-8Go 语言原生编码标准库直接支持String 内部UTF-16Java 语言规范规定char是 UTF-16 代码单元String.length()返回 UTF-16 代码单元数为什么不直接用 UTF-8 存 String因为 Java 的char类型在语言规范层面就被定义为UTF-16 代码单元16 位无符号。String.charAt(i)返回charString.length()返回代码单元数——这些都是 O(1) 操作只有定长编码才能做到。用 UTF-8变长 1~4 字节的话charAt(1000)就得从头扫到第 1000 个字符O(n)。有趣的历史Java 诞生于 1995 年那时 Unicode 还只有 BMP基本多文种平面65536 个码位刚好塞进 16 位所以 “16 位定长” 看起来是完美的选择。后来 Unicode 扩充到 21 位Java 只好用代理对surrogate pair表示一个补充字符——char从此不再等于一个字符。JDK 9 引入了Compact Strings优化如果字符串全是 Latin-1 字符内部改用byte[]存储省一半内存。这是后话我们的实现跟着 JDK 8 走一律char[]。1.2 MUTF8 vs UTF-8 的三点差异第 3 章讲过这里快速回顾标准 UTF-8MUTF8\u0000NUL1 字节0x002 字节0xC0 0x80避免和 C 字符串结尾冲突补充字符U10000 以上4 字节6 字节拆成两个 3 字节的代理项分别编码BMP 字符1~3 字节相同对绝大多数场景包括我们的测试程序两者完全一样所以简单的转换实现也能跑通。二、java.lang.String 的内部结构书里直接给了 JDK 源码packagejava.lang;publicfinalclassStringimplementsjava.io.Serializable,ComparableString,CharSequence{/** The value is used for character storage. */privatefinalcharvalue[];/** Cache the hash code for the string */privateinthash;// Default to 0// ... 其他代码}只有两个实例变量但每个都有讲究。2.1 private final char[] value┌──────────────────────────────────────────┐ │ String 对象 │ │ ┌────────────────────────────────────┐ │ │ │ data (Slots) │ │ │ │ [0] value → *Object │──┼──▶ Object{ class: [C, │ │ [1] hash → int32 │ │ data: []uint16 } │ └────────────────────────────────────┘ │ └──────────────────────────────────────────┘三个关键字private—— 外部无法直接访问final—— 引用不可变注意是引用不可变不是数组内容不可变char[]—— UTF-16 代码单元数组字符串不可变是怎么保证的靠三重防线String类本身是final—— 不能被继承防止子类破坏不可变性value是private—— 外部拿不到引用value是final—— 内部也不能换掉这个数组而且所有String方法substring、concat、replace…都返回新对象从不修改原对象。注意一个细节构造函数public String(char value[])里有一句this.value Arrays.copyOf(value, value.length)——拷贝了一份。因为如果直接持有调用方传进来的数组调用方之后修改数组内容字符串就变了。char[]buf{a,b,c};StringsnewString(buf);buf[0]x;// 如果没有 copyOfs 就变成了 xbc2.2 private int hash默认 0这是缓存字段memoization的经典案例publicinthashCode(){inthhash;if(h0value.length0){charval[]value;for(inti0;ivalue.length;i){h31*hval[i];}hashh;}returnh;}第一次调用hashCode()时计算并缓存之后直接返回hash字段因为字符串不可变哈希值永远不会变缓存是安全的为什么默认 0 而不是 -1因为空字符串的哈希值就是 0用 0 表示未计算和空串冲突了——但冲突无害空串每次重算一次代价是 0 次循环。这是个小优化。对我们的实现的影响JString()创建 String 对象时hash字段保持零值即可不需要显式设置。第 6 章prepare()阶段已经把所有静态/实例变量初始化为零值了。三、字符串池intern 机制3.1 什么是字符串池为了省内存JVM 内部维护了一个字符串池string pool内容相同的字符串只保留一份。Stringahello;Stringbhello;StringcnewString(hello);ab// true ← 都指向池里的同一个对象ac// false ← new 出来的一定是新对象ac.intern()// true ← intern() 返回池里的那个String.intern()是个本地方法publicnativeStringintern();书里明确说本节将实现字符串池由于intern()是本地方法所以留到第 9 章实现。JDK 7 的一个变化字符串池从永久代移到了堆。之前JDK 6池在 PermGen 里容易 OOMJDK 7 移到堆后可以被 GC 回收。JDK 8 干脆取消了 PermGen第 6 章讲过的 Metaspace。3.2 jvmgo 的实现一个 Go map// ch08/rtda/heap/string_pool.gopackageheapimportunicode/utf16varinternedStringsmap[string]*Object{}一个全局变量key 是 Go 字符串value 是 Java String 对象。对比一下真实 JVM 的 StringTableHotSpotjvmgo数据结构哈希表StringTable桶 链表Gomap底层也是哈希表容量默认 60013 个桶可配-XX:StringTableSize动态扩容线程安全有锁 读屏障无单线程GC 处理可被回收弱引用永不回收全局 map 强引用位置堆JDK 7堆Go 堆jvmgo 的实现有个明显问题internedStrings是包级全局变量永不清空会持续泄漏。但作为教学实现无妨——而且它保证了相同内容的字符串一定是同一个对象语义上是对的。四、JStringGo 字符串 → Java String 对象funcJString(loader*ClassLoader,goStrstring)*Object{ifinternedStr,ok:internedStrings[goStr];ok{returninternedStr// ① 池里已有直接返回}chars:stringToUtf16(goStr)// ② UTF8 → UTF16jChars:Object{loader.LoadClass([C),chars}// ③ 造 char[] 对象jStr:loader.LoadClass(java/lang/String).NewObject()// ④ 造 String 对象jStr.SetRefVar(value,[C,jChars)// ⑤ 塞进 value 字段internedStrings[goStr]jStr// ⑥ 入池returnjStr}六步其中第 ④⑤ 步是那个投机取巧的 hack。4.1 正常的做法 vs hack正常的 Java 代码要创建一个 String得走StringsnewString(chars);字节码new #2 // class java/lang/String dup aload_1 // chars invokespecial #3 // Method java/lang/String.init:([C)V也就是newinvokespecial init。但 jvmgo 不能这么干因为String.init内部会调用Arrays.copyOf()而Arrays.copyOf()又会调System.arraycopy()——一个本地方法。第 9 章之前我们调不了本地方法。所以书里选择了直接绕过构造函数jStr:loader.LoadClass(java/lang/String).NewObject()// 只分配空间不调用 initjStr.SetRefVar(value,[C,jChars)// 手工塞字段书里对这个 hack 的评价注意这里其实是跳过了 String 的构造函数直接用 hack 的方式创建实例。在前面分析过 String 类的代码这样做虽然有点投机取巧但确实是没有问题的。为什么没有问题因为String只有两个实例变量value我们已经手工设了hash保持 0正确的初始值String没有需要额外初始化的状态我们创建的char[]是全新分配的不会有别名问题等价于构造函数的copyOf但前提是String类的结构必须和 JDK 8 一致。如果哪天 JDK 改了String的内部字段JDK 9 确实改了char[]→byte[]这个 hack 就失效了。这就是为什么 jvmgo 必须用 JDK 8 的 rt.jar。用 JDK 9 跑JString会在SetRefVar(value, [C, ...)处 panicgetField返回 nil。4.2 stringToUtf16funcstringToUtf16(sstring)[]uint16{runes:[]rune(s)// utf32returnutf16.Encode(runes)}两步走Go string (UTF-8 字节序列) │ []rune(s) ← Go 标准库把 UTF-8 解码成 Unicode 码点UTF-32 ▼ []rune (每个元素是一个 Unicode 码点int32) │ utf16.Encode() ← 把码点编码成 UTF-16 代码单元 ▼ []uint16 (每个元素是一个 UTF-16 代码单元)为什么要经过 UTF-32 中转因为 UTF-8 和 UTF-16 都是变长编码直接转很麻烦UTF-32每个码点固定 4 字节是它们的通用中间表示UTF-8 ──▶ Unicode 码点 ──▶ UTF-16 (变长) (定长 32 位) (变长)代理对的产生就在utf16.Encode这一步// U1F600 (GRINNING FACE)runes:[]rune()// [0x1F600] 1 个码点utf16.Encode(runes)// [0xD83D, 0xDE00] 2 个代码单元代理对所以.length()在 Java 里是2不是 1。这是 Java 程序员的经典困惑之一。4.3 GoString反向转换第 056 篇打印字符串时会用到先在这里给出funcGoString(jStr*Object)string{charArr:jStr.GetRefVar(value,[C)returnutf16ToString(charArr.Chars())}funcutf16ToString(s[]uint16)string{runes:utf16.Decode(s)// utf8returnstring(runes)}JString和GoString是一对JString: Go string ──▶ *Object (java/lang/String) UTF8 → rune → UTF16 → char[] → String.value GoString: *Object (java/lang/String) ──▶ Go string String.value → char[] → UTF16 → rune → UTF8五、Object.SetRefVar 与 Class.getFieldJString用到了两个新方法都是第 8 章为了字符串而加的。5.1 SetRefVar / GetRefVar// ch08/rtda/heap/object.gofunc(self*Object)SetRefVar(name,descriptorstring,ref*Object){field:self.class.getField(name,descriptor,false)// false 实例变量slots:self.data.(Slots)slots.SetRef(field.slotId,ref)}func(self*Object)GetRefVar(name,descriptorstring)*Object{field:self.class.getField(name,descriptor,false)slots:self.data.(Slots)returnslots.GetRef(field.slotId)}这是反射的雏形——通过字段名 描述符字符串来读写实例变量。正常途径是通过putfield/getfield指令第 6 章实现的它们拿的是常量池索引 → FieldRef → 解析后的 Field。而SetRefVar是 JVM 内部代码直接按名字找字段相当于 Java 的Field.set()。注意self.data.(Slots)这个断言——如果对一个数组对象调SetRefVar会 panic。这里隐含了这个 Object 一定是普通对象的前提。5.2 Class.getField// ch08/rtda/heap/class.gofunc(self*Class)getField(name,descriptorstring,isStaticbool)*Field{forc:self;c!nil;cc.superClass{for_,field:rangec.fields{iffield.IsStatic()isStaticfield.namenamefield.descriptordescriptor{returnfield}}}returnnil}沿继承链向上找匹配三个条件静态性一致isStatic参数字段名相同字段描述符相同这和第 6 章的lookupFieldputfield/getfield指令用逻辑不同lookupField第 6 章getField第 8 章查找顺序本类 → 接口递归→ 超类递归只沿超类链用途putfield/getfield指令JVM 内部按名字访问是否查接口是否为什么不查接口因为接口的字段都是public static final常量不存在需要沿接口链查找的实例变量。而getField主要用来找String.value这种实例变量。如果getField返回nilSetRefVar会在field.slotId处空指针 panic。所以调用方要确保字段名和描述符完全正确——value[C是 JDK 8String的真实定义。六、本篇代码清单ch08/rtda/heap/ ├── string_pool.go ← 【新增】internedStrings map │ JString() Go → Java │ GoString() Java → Go │ stringToUtf16() UTF8 → UTF32 → UTF16 │ utf16ToString() UTF16 → UTF32 → UTF8 ├── object.go ← 【改】新增 SetRefVar() / GetRefVar() └── class.go ← 【改】新增 getField()新增代码不到 50 行——因为大部分工作对象创建、字段查找、数组表示前几章都做完了。字符串的实现主要是把已有的积木拼起来。七、一个值得思考的问题为什么 JVM 要认识String书里没直接回答这个问题但它很关键。想想看String在 JVM 眼里就是个普通的 Java 类JString()完全可以用普通对象 数组的组合来实现。那为什么 JVM 规范要专门定义CONSTANT_String常量类型还要ldc指令支持它答案为了性能和语义保证。字符串常量必须去重。如果ldc每次都new String(...)那hello出现 100 次就产生 100 个对象。有了常量池 字符串池a b同为字面量时恒为true——这是Java 语言规范保证的语义不是实现细节。switchon String 需要它。Java 7 支持switch (str)编译器先用hashCode()分发再用equals()比较。如果字面量每次都是新对象的快捷判断就失效了。类名的表示。第 6 章讲过class 文件里类名、字段名、方法名、描述符全都存成CONSTANT_Utf8运行时大量需要把字符串变成类。如果每次都新建 String 对象类加载会慢很多。安全。字符串池让String.intern()成为可能而它被广泛用于去重比如数据库驱动、XML 解析器。所以对 JVM 来说String 是半个基本类型——它不是基本类型是对象有方法但 JVM 必须内建支持它。这就是为什么ldc指令能加载int、float和String三种常量第 5 章讲过ldc只支持这三类类和方法句柄要走ldc_w。本篇小结字符串是 JVM 直接支持的第二种特殊类型本篇完成了数据结构层三种形态、两次转换——class 文件里是 MUTF8Go 里是 UTF-8String 对象内部是 UTF-16。JString()完成UTF8 → []rune(UTF32) → []uint16(UTF16)的两步转换UTF-32 作为中间表示是因为两端都是变长编码。String只有两个实例变量——private final char[] value不可变的三重防线类 final 字段 private 字段 final和private int hash哈希缓存默认 0 表示未计算。字符串池——internedStrings map[string]*Object全局、永不回收教学实现的妥协。真实 HotSpot 的 StringTable 是带锁、可 GC、容量可配的哈希表JDK 7 从 PermGen 移到了堆。JString()的 hack——跳过String.init构造函数直接NewObject()SetRefVar(value, [C, jChars)。之所以可行是因为String.init内部会调Arrays.copyOf→System.arraycopy本地方法第 9 章才支持而我们手工创建的 char[] 是全新的等价于copyOf的效果。副作用jvmgo 必须用 JDK 8 的 rt.jarJDK 9 把char[]改成了byte[]hack 会失效。配套方法——SetRefVar/GetRefVar反射雏形按字段名 描述符读写和Class.getField沿超类链查找不查接口因为接口字段都是静态常量。下一篇第 056 篇把字符串接进指令和类加载器让ldc能加载字符串常量、让静态final String常量在prepare()阶段就拿到值、让命令行参数变成String[] args最后让System.out.println(String)能打印出来。上一篇【第54篇】数组相关指令——newarray/aload/astore 全家桶下一篇【第56篇】完善 ldc 和类加载器——让 String “跑起来”
返回列表