Java 程序中最常用的除了基本类型的变量,就数得上字符串了吧,String 在 Java 中并不是基本类型,而是一个引用类型,但是它又很特别,所以特意对 JVM 中的 String 进行讲解。但是不得不承认的是,我的示例中是用 java 来构建的 JVM,起初的目的就是为了快速实现该 JVM,快速的了解 JVM 内部原理,虽然达到了目标,但是在实现该 JVM 内部的一些细节时,总有些不尽人意的地方,这是当初所没想到的,例如本节字符串池的实现,以及后面本地方法的实现上,所写的代码都显得有点挫。不过对于理解 JVM 的内部原理,依然有很大帮助。
字符串在不同位置所使用的编码
回想整个 JVM 对字符串处理的过程,有两个个关键的地方对字符串进行了处理。下面的讲解中出现了几种编码,如果你对编码不是很了解,请先阅读理解各种字符编码 ,以便后续内容的理解。
class 文件中的字符串常量->JVM
class 文件中解析常量池时,在 class 文件中字符串常量是以 MUTF-8
编码的形式保存的,我们读取文件时,需要进行额外的处理。
所谓的额外处理就是说当我们把常量池中ConstantUtf8Info
类型的常量加载到内存中,该字符串在 JVM 中用哪种编码来表示。这其实取决于我们的 JVM 是用什么语言来写的,如果我们用 go 语言来实现 JVM,go 语言中的字符串是用utf-8
的编码,所以 class 文件中的字符串常量被加载到 JVM 中之后,其表现形式就是 utf-8
;而我的 JVM 是用 java 语言来写的,java 语言中字符串是用unicode
的编码,所以 class 文件中的字符串常量被加载到 JVM 中之后,其表现形式就是 unicode
;无论是utf-8
还是unicode
,都需要将MUTF-8
格式的字节按照目标格式进行转换,才可以被正确的使用,否则就是乱码。如果你用的是另一种语言来写 JVM,而该语言中字符串的编码恰好为MUTF-8
,那么你就不用进行转换,直接读取字符串到内存就好了。
可不幸的是,没有哪种编程语言中字符串是用的MUTF-8
编码。这种编码格式也太不常用了,目前也搞不清楚为何 class 文件中要单独用 MUTF-8
编码来保存字符串。既然是用 java 实现的 JVM,那么在解析 class 文件中的字符串常量时,我们是进行了编码的转换的:具体代码在ConstantUtf8Info.java 中
1 | public class ConstantUtf8Info extends ConstantInfo { |
JVM->Java 应用程序
上面一步是将 class 文件中常量池的字符串加载到 JVM 中了,但这还不是最终目的,我们最终的目的是在应用程序中使用该字符,一个最简单的例子:
1 | void f(){ |
上述方法中,我们的目标就是打印出 s 所代表的“hello”字符串。要能正确的打印出“hello”,首先要明白,我们在代码中写了“hello”这个字符串之后,通过javac
的处理,“hello”会存储到 class 文件中了。我们真正用的是 class 文件。那么上一步已经把 class 中的“hello”加载到 JVM 中了,接下来程序要使用真实的 java 程序中,其 String 是用unicode
来表示的,因此还需要将 JVM 中的“hello”转换为unicode
编码。万幸的是,我们的 JVM 是用 java 来实现的,所以从 JVM 中获取对应的字符串,本身就已经是unicode
编码了,无须任何转换就可以实现,但是这也是我所不满的地方,因为真实的 JVM 并不是用 java 来实现的,所以这里无法具体模拟具体转换的细节了(后续可能会考虑用 c++ 对本虚拟机进行重构,那么这个问题自然就遇到了。)
而底层真正实现这一转换是在下面即将介绍的 ldc 指令中实现的!
class 文件常量池中的 ConstantStringInfo 常量 VS ConstantUtf8Info 常量
在分析 class 文件中的常量池中,我们介绍了ConstantStringInfo
和ConstantUtf8Info
这两个常量,但是其区别我们并没有做出说明,放着这一节进行说明,可能更合适。
我们知道 ConstantUtf8Info 常量是真正存放字符串的,而 ConstantStringInfo 常量中并没有字符串,而是保存了一个常量池的索引,该索引指向的正是一个 ConstantUtf8Info 常量。不知道你有没有想过为什么会有一个 ConstantStringInfo 的常量这么麻烦,要多一步才能获取到真正的字符串,全部都用 ConstantUtf8Info,省去中间一步的过程,岂不更快?
这里就涉及到 class 文件中ConstantUtf8Info
常量保存的字符串是什么内容了,可以分为两类:
- 类名,方法名,变量名,描述符,这些字符串是用来确定加载哪个类,调用哪个方法,使用哪个变量的,是供 JVM 内部使用的。
- 在 java 程序中显示声明的 String 类型的变量,是供 java 程序使用的。
我们写 java 程序其实只关心我们定义的 String 类型的变量,而 String 类型的变量在 class 文件中就是用 ConstantStringInfo
常量来表示的。这就解释了为什么都是表示字符串,使用两种常量进行区分。这里主要是把 JVM 使用的字符串和 java 应用程序使用的字符串所区分开。
两类字符串的赋值时期也是不同的,JVM 内部使用的像类名,方法名,描述符等字符串是在 class 文件转为 Zclass 对象时,进行的赋值。而ConstantStringInfo
常量是在 class 文件中的常量池转换为 运行时常量池时进行的转换,此时 RuntimeConstantPool 中的字符串就不在是再通过索引指向另一个常量了,而是保存的真正的常量。下面是常量池转换时,对ConstantStringInfo
常量的处理:
1 | case ConstantInfo.CONSTANT_String: |
String 变量详解
前面介绍ConstantStringInfo
常量时,我说过在 java 代码中定义的 String 类型的变量,都会在 class 文件中保存为ConstantStringInfo
常量,但是那个表述并不准确。并不是代码中任何一个 String 类型的变量,都会被保存在 class 文件中的ConstantStringInfo
常量中。
我们看如下两个 String 类型的变量,已经其对应的字节码
- 直接定义字符串
1
2
3
4
5
6
7
8void f0(){
String s0 = "a#4$";
}
//该方法对应的字节码
0 ldc #3 <a#4$>
2 astore_1
3 return
通过查看字节码,我们发现如果是定义的直接字面量”a#4$”的 String 类型的变量,那么该字符串”a#4$”就会在 class 文件中保存为ConstantStringInfo
常量,同时在运行时,被转为运行时常量池中的常量,ldc 指令表示将当前运行时常量池中索引为 3 的变量压入操作数栈,随后再将该变量保存到本地变量表索引为 1 的位置。
结论:String 类型的变量,在赋值时使用的是直接字面量,则会在 class 文件中保存为ConstantStringInfo
常量。
- 通过字符串连接符
+
定义字符串通过查看字节码,我们发现用字符串连接符1
2
3
4
5
6
7
8void f1(){
String s1 = "a#" + "4$";
}
//该方法对应的字节码
0 ldc #3 <a#4$>
2 astore_1
3 return+
定义字符串,最终同样是想定义”a#4$”的字符串,其对应的字节码和直接定义”a#4$”是完全一样的,之所以会产生这样的结果,完全是编译器帮我们做的优化。
结论:使用字符串连接符+
定义字符串,如果连接的子串也是直接字面量,那么编译器会帮我们做优化,其结果和定义连接后的字面量的字符串结果是完全一样的。
- 通过变量定义字符串
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18void f2(){
String tmp = "4$";
String s2 = "a#" + tmp;
}
//该方法对应的字节码
0 ldc #3 <4$>
2 astore_1
3 new #4 <java/lang/StringBuilder>
6 dup
7 invokespecial #5 <java/lang/StringBuilder.<init>>
10 ldc #6 <a#>
12 invokevirtual #7 <java/lang/StringBuilder.append>
15 aload_1
16 invokevirtual #7 <java/lang/StringBuilder.append>
19 invokevirtual #8 <java/lang/StringBuilder.toString>
22 astore_2
23 return
通过查看字节码,我们同样是定义一个最终字符串为”a#4$”的变量,但是使用字符串连接符时,一个子串使用变量替代了之前的直接字面量,发现字节码有很大的不同,同样是编译器在背后做了手脚,其发现如果一个通过+
定义的字符串中,如果子串有变量,那么编译器会偷偷的产生一个 StringBuilder 类,而+
符号也会被转换为 StringBuilder 的 append 方法。最终拼接好的字符串是刚刚的 StringBuilder 调用了其 toString 方法,而查看 StringBuilder 的 toString 方法,其内部是重新创建了一个 String 的对象:
1 |
|
结论:使用字符串连接符+
定义字符串,如果连接的子串中包含变量,那么该字符串不会在 class 文件中保存为ConstantStringInfo
常量,而是通过 StringBuilder 在方法运行时动态生成的一个全新的 String 变量。
字符串池 StringPool
通过上述三种定义字符串的创建过程,我们可以发现一个现象,虽然最终字面量是一样的,但是如果用==
比较结果可能是不一样的。
1 | String s1 = "a#4$"; |
通过该程序的结果,我们可以发现 s1 和 s2 都是直接从运行时常量池相同的位置获取的值,所以其用 ==
比较得到的结果是 true,因为本来就是同一个对象。但 s3 并不是从运行时常量池直接取的值,而是在方法内部通过 StringBuilder 的 toString 方法获取的一个新的 String 对象,所以和 s1 比较,自然得到的结果就是 false 了。
这里要注意的是:在运行时常量池保存的字符串类型的常量,其实保存的并不是字符串本身,而是一个 key !这个 key 是 JVM 中 StringPool 的一个 key,由该 key 对应的 value 得到的值才是真正 Java 中 String 变量。
这样设计的原因是:String 是一个类,该类中保存了字符串的值,本质上是一个 char 类型的数组,但是 String 和 char[] 又不是对等的,char[] 是 String 的主要组成部分,但是 String 毕竟是一个类,其内部还有其它的成员和方法来操作自身的 char[]。同时 String 又是一个不可变类,也就是说如果一个 String 类型的变量确定之后,其内部的 char[] 的元素是不可以改变的,是不可变的(只读的),同时 String 类型的变量在程序中又很常用,所以在 JVM 中定义了一块内存,叫做字符串池——StringPool。注意这个 StringPool 是整个应用程序所共享的,里面存放的就是在 class 文件中被定义为ConstantStringInfo
常量所对应的真正的字符串,而在运行时常量池中保存的所谓的字符串常量,其实只是 StringPool 的一个引用。这样设计的目的,可以达到一个应用中所有相同的字符串常量是唯一的,从而达到节约 JVM 内存空间的目的。
观察字符串赋值的语句可以发现,在上例的(1)和(2)中,都是使用 ldc 指令,将运行时常量池中获取到字符串常量,注意:此时从运行时常量池获取到的字符串为 JVM 中的字符串,而不是 java 应用程序中的编码,真正实现这一转换过程的是在 ldc 命令中进行的转换,具体实现过程可参考 LDC.java
这里简述一下 StringPool 的实现过程,其内部是一个 HashMap,key 为 JVM 中的字符串,value 为 java 引用程序中可是使用的 String,注意这个 String 是指包含了 char[] 的一个 Object,而不单单是 char[]。如果 key 相同,那么得到的 Object 就是同一个。当然,如果当前 map 中不存在 key,那么就添加该 (key,value)对。
这也就解释了为什么上例中 s1==s2。同时 s3 对应的 Object 并不不是从 StringPool 中获取的,而是在当前 Frame 中重新 new 的一个对象。所以 s1!=s3。
同时注意到 s1==s3.intern()
。String 中有一个 intern 方法,该方法的作用是以 s3 的字面量为 key,从 StringPool 中寻找对应的值,如果不存在则添加,并返回对应的 object。因为 s3 的字面量和 s1 是相同的,所以从 StringPool 中返回值的话,得到的 object 和 s1 是一样的。
具体实现请参考 StringPool.java
但是这里的实现,并不如意。原因还是本 JVM 是采用 java 实现的,导致字符串在 JVM 中的编码和 java 应用程序中的编码是一样的,这一个转换有些多此一举,但是为了模拟 StringPool 的功能,还是添加了对应的代码。同时要注意,String 在 JVM 中也是一个 Zobject,通过 key 获取到的不应该是一个 java 中的 String,而应该是 JVM 中的 Zobject。