前言
最近想给自己之前写过的测试代码加些注释,以方便以后查看的时候可以知道自己当时测试的初衷,以及结果的原因,但是最后还是决定写成笔记,不怕丢了,这篇笔记主要是来自之前看过的一本样书《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 。
本文深入探讨Java中字符串比较的底层实现,包括字符串字面量、StringBuilder拼接、intern()函数的使用及其在不同JDK版本下的表现差异。通过字节码分析,揭示字符串比较背后的技术细节。
3342

被折叠的 条评论
为什么被折叠?



