前言
最近想给自己之前写过的测试代码加些注释,以方便以后查看的时候可以知道自己当时测试的初衷,以及结果的原因,但是最后还是决定写成笔记,不怕丢了,这篇笔记主要是来自之前看过的一本样书《java特种兵》里面的一个例子。当时觉得这个例子还挺有意思的,所以就自己拿出来跑一跑,并记一下笔记。
字符串比较例子及讲解
例子及运行结果
先看例子代码:
@Test
public void example1() {
String a = "a";
String b = "b";
String c = "a" + "b";
String d = a + "b";
String e = a + b;
String f = new String("ab");
StringBuilder g = new StringBuilder("ab");
System.out.println(c==d);
System.out.println(c==e);
System.out.println(c==f);
System.out.println(c==g.toString());
System.out.println(c==g.toString().intern());
}
运行结果:
用 javap 查看例子字节码
如果对以上的代码及答案毫无疑问的看客就可以撤了,后面的内容对你来说应该没什么营养,不要浪费了时间。实际上我刚开始看到这个例子的时候,我也只对最后一条的比较结果为 true
有把握,前4条,我也不确定答案是啥,所以,我就把java源文件编译成class字节码文件,然后用 javap
反汇编工具看一下,java编译器会把这个例子底层编译成什么指令。如下,我只复制出来相关代码的部分,其他像常量池还有构造函数部分等内容我就把他删掉了,不然太长了。(具体的 javap
指令为 javap -verbose XXX.class
)
public void example1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=8, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: ldc #3 // String b
22: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
25: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
28: astore 4
30: new #5 // class java/lang/StringBuilder
33: dup
34: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
37: aload_1
38: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
41: aload_2
42: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
45: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
48: astore 5
50: new #9 // class java/lang/String
53: dup
54: ldc #4 // String ab
56: invokespecial #10 // Method java/lang/String."<init>":(Ljava/lang/String;)V
59: astore 6
61: new #5 // class java/lang/StringBuilder
64: dup
65: ldc #4 // String ab
67: invokespecial #11 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
70: astore 7
72: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
75: aload_3
76: aload 4
78: if_acmpne 85
81: iconst_1
82: goto 86
85: iconst_0
86: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
89: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
92: aload_3
93: aload 5
95: if_acmpne 102
98: iconst_1
99: goto 103
102: iconst_0
103: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
106: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
109: aload_3
110: aload 6
112: if_acmpne 119
115: iconst_1
116: goto 120
119: iconst_0
120: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
123: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
126: aload_3
127: aload 7
129: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
132: if_acmpne 139
135: iconst_1
136: goto 140
139: iconst_0
140: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
143: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
146: aload_3
147: aload 7
149: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
152: invokevirtual #14 // Method java/lang/String.intern:()Ljava/lang/String;
155: if_acmpne 162
158: iconst_1
159: goto 163
162: iconst_0
163: invokevirtual #13 // Method java/io/PrintStream.println:(Z)V
166: return
SourceFile: "TestOperandStack.java"
字符串赋值的反汇编指令的分析
这里拆分成多张图来解释,如下图,为了方便即使不懂字节码指令的看客,也加入了必要的指令解释,所以就是说,如第一行代码,String a = "a";
在 java 底层指令分成两步,即先从常量池中获取字符串 “a” 的引用然后压入操作数栈顶,然后将操作数栈顶元素出栈,保存引用到第1个局部变量表。
第二、三行代码,如法炮制。
题外知识点:(第三行源代码String c = "a" + "b";
字符串 “a” 加上字符串 “b” 的操作,在指令中直接获取常量池的 “ab” 字符串,因为这里是编译器优化,java 编译器编译源文件的时候,就已经将这种字面量的运算操作,直接计算了,然后将结果直接替换原来的代码)
String d=a+“b” 的反汇编指令分析
第四行代码,如下图所示
在上图中,我们发现, java 的源代码中并没有创建 StringBuilder 的对象,而在 javap 的反汇编指令中,new 了一个 StringBuilder 对象出来。从这点我们可以看出,jvm 运行编译期间当代码类似于 String c = a + "b"
这种字符串变量与字面量相加时,会将指令转化为使用 StringBuilder 的 append 去拼接字符串。
在学习上,我们不仅要做到知其然,更要知其所以然,不断深挖思考,才能越过表象,而看清本质。为什么 jvm 要对字符串相加做这种转化?当然,最终目的肯定是为了提高性能。(对于这种技术的优化,目的当然不外乎就是两个,要么是方便调用者的使用,要么是提高应用的性能。)那么关于这个问题,jvm 是如何做到提升性能的呢?我们不妨从 “假如不做这种转化,会出现什么不好的结果?” 这个方向去思考。
看如下一段简单的代码片段,如果 jvm 编译时没有对字符串相加转化成 StringBuilder 对象 append 拼接。那下面的代码会有什么结果?(这里还是需要了解下简单的字节码指令)
String a = "this is a";
String b = "question";
String c = a + " easy " + b;
首先,我们知道,常量池会有 3 个字符串,即为 "this is a"
、 "question"
与" easy "
,然后第一、二行代码就会将两个字符串分别从常量池中压入操作数栈栈顶,并保存到局部变量表,重点就是这第三行代码,首先变量 a
从局部变量表中压入栈顶,然后 " easy "
从常量池中压入栈顶,接着就会调用字符串相加指令,弹出栈顶的两个元素,然后产出一个中间产物 "this is a easy "
的字符串压入栈顶,最后就是变量 b
从局部变量表中压入栈顶,再调用字符串相加指令,产出结果 "this is a easy question"
然后保存到局部变量表。所以,在这个过程中,就会多创建一个临时的字符串(题外话:临时的字符串是不会装载进常量池的),随着字符串相加的链越长,产出的临时字符串也就越多,那么占用应用的内存也就越多,而字符串的数据是放在堆中,并不是随着栈帧结束而回收的,是要通过垃圾回收器去回收的,而垃圾回收的频率越频繁,则应用的吞吐量也就越低。而字符串相加转化成使用 StringBuilder 的 append 拼接就不会有上面说的问题了吗?或者说不会有其他的问题吗?这个问题先保留,思考下,后面再分析。
返回看下一条指令,上图中的 new
指令后面的 dup
指令,这里还是要再多思考一个问题,为什么此处需要调用 dup
复制栈顶?
dup
指令跟 new
指令实际上是联用的,因为 new
指令只是创建了对象空间,而还没有为对象进行初始化,即调用构造函数,也即栈顶需要弹出调用构造方法的对象的引用,而需要注意的是,此时局部变量表中并没有 new
出来 StringBuilder 对象的引用,所以才需要调用 dup
指令,以便调用了构造函数以后,操作数栈的栈顶还是 StringBuilder 对象的引用。
那么,为什么调用 new
指令之后,需要让操作数栈栈顶,多一个对象的引用呢?
我们知道,一般情况下,java 代码创建一个对象一般还会用一个变量去接收这个对象引用,比如说,Cat cat = new Cat();
再回到反汇编指令中去,即调用了构造函数之后,需要有一个 astore
指令去弹出栈顶,将引用类型的栈顶元素保存到局部变量表。
所以说, new
指令之后的 dup
指令,是为了下一步调用构造函数而复制的对象引用。
(PS:可以自己写个测试类,去验证一下,如果仅仅只是写 new Cat();
的 java 代码,而不用变量接收的话,用 javap 去查看 class文件,指令中还是会先 new
,再 dup
,然后调用 invokespecial
指令调用构造函数,然后后续还会调一个 pop
指令,pop
指令就是弹出操作数栈栈顶。因为就是在操作数栈中复制多一个对象的引用,最后没有变量去接收,用不到该对象引用,又将其弹出栈顶了。)
invokespecial
指令,即调用构造函数,之后下一个指令就是 aload_1
就加载变量 a
进栈顶,接下来就是 invokevirtual
指令,该指令是用于调用对象的实例方法,根据对象的实际类型进行分派,而由指向常量池的项可知,调用的方法为 StringBuilder.append()
将变量 a
拼接进 StringBuilder
对象,而后又将字符串 "b"
从常量池从压入栈顶,然后到22指令行,再一次调用 StringBuilder.append()
,将字符串 "b"
拼接进 StringBuilder
对象。而此时,字符串已经拼接完成,下一步的指令还是 invokevirtual
指令,但调用的方法是 StringBuilder.toString()
,这时,我们先来看一下 StringBuilder.toString()
的 java 源码
如图所示, StringBuilder.toString()
方法源码的返回值为 new String(value, 0, count)
,那么例子中的第一个字符串比较的答案就显而易见了, 变量 c
指向的是常量池的字符串 "ab"
的引用,而变量 d
指向的是重新在堆中创建的 String
对象的引用,变量 c
跟变量 d
的引用地址不一样,不是同一个对象,所以比较结果为 false
。(引用类型的 ==
比较,比较的是引用地址,同一个对象则返回true,反之则为false)
同理, c==e
、 c==f
、 c==g.toString()
的结果也为 false
。
intern()函数
关于 intern()
函数,因为它是 java 的 native
方法,所以要了解 intern()
函数只能通过其文档,如下图所示,是 jdk6
的中文文档中的解释,先看返回值,即该方法会在字符串池比对内容(equals),将内容相同的字符串引用返回。字符串池就是全局常量池,存放字面量的引用的池子。也即,例子中 c==g.toString().intern()
的结果为 true
,因为变量 c
与 g.toString().intern()
指向的都是常量池中 “ab” 的引用。
因为 jdk6
与 jdk7
中常量池设计的不同,导致 intern()
函数结果的表现也不同,如下的代码,
@Test
public void example3() {
String a = "a";
String b = new String("a");
b.intern();
System.out.println(a==b);
String c = new StringBuilder().append("a").append("b").toString();
c.intern();
String d = "ab";
System.out.println(c==d);
}
example3()
的测试结果,在 jdk6
中结果为
false
false
根据文档中的解释,intern()
函数的返回值即为常量池的引用,但是其实这个函数还有“探测”的功能,即
(1)当常量池中不存在字面量,如”abc”这个字符串的引用,将这个对象的引用加入常量池,返回这个对象的引用;
(2)当常量池中存在字面量,如”abc”这个字符串的引用,返回这个对象的引用。
所以,在 example3()
中 a==b
结果为 false
的原因就是,由于当调用 b.intern()
这一行代码时,字符串 “a”
已经在常量池中存在了(第一行代码),所以此时变量 a
指向的是常量池中的引用,而变量 b
指向的是堆中创建的 String
对象的引用。
而 example3()
的 c==d
在 jdk6
中为 false
的原因,是因为 jdk6
在常量池中添加字符串时,是重新复制一个对象出来,然后再返回常量池中的引用。
(再次强调,String c = new StringBuilder().append("a").append("b").toString();
这行代码,字符串 "ab"
是没有装载进常量池中的)
所以,当 c.intern();
调用时,此前全局常量池中并没有字符串 "ab"
,而调用 intern()
之后,在 jdk6
中将变量 c
复制一个对象进全局常量池,并返回这个对象的引用,所以此时字符串 "ab"
加入了常量池,但是指向的引用与变量 c
并不是同一个引用,即 c==d
为 false
。
而在 jdk7
以后的版本结果为
false
true
jdk7
以后,常量池被放到了堆中,且当有字符串加入常量池时,是在堆中创建的字符串对象,然后将该对象的引用,加入到常量池 ,所以变量 c
与变量 d
指向的是同一个堆中的引用,即 c==d
为 true
。