众所周知,java中有个叫 字符串常量池 的东西,正是常量池的存在,让 String 相比较与其他对象变得有趣起来。
一些题目
题目1:下列每次比较输出的是 true 还是 false
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "a" + "b";
String s5 = "a" + s2;
String s6 = s1 + "b";
String s7 = s1 + s2;
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
System.out.println(s3 == s7);
System.out.println(s4 == s5);
System.out.println(s4 == s6);
System.out.println(s4 == s7);
System.out.println(s5 == s6);
System.out.println(s5 == s7);
System.out.println(s6 == s7);
题目2:下列代码创建了几个对象?
String s = new String("ab");
题目3:下列代码创建了几个对象?
String s = new String("a") + new String("b");
题目4:
String s1 = new String("a");
s1.intern();
String s2 = "a";
System.out.println(s1 == s2); // 输出的是啥?
题目5:
String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); // 输出的是啥?
字节码分析
目前我所知道的从字节码文件得到字节码指令的方式有两种:
- 命令行命令:
javap -v 字节码文件
。
会在命令行输出字节码文件的所有信息,也可以配合 >
将结果保存为文本文件,然后查看。
-
使用 idea 的插件 Jclasslib 。
安装插件后,需要先编译 java 代码,生成字节码文件;然后注意焦点要在要查看的 java 文件里,依次点击菜单栏
View -> Show Bytecode With Jclasslib -> 方法 -> main -> Code
就可以看到字节码指令。使用插件的方式更加方便,而且将 字节码指令 与 局部变量表 分开,看起来也更加方便直观。
1. 字面量定义字符串
代码:
public class StringTest {
public static void main(String[] args) {
String s1 = "abc";
}
}
使用 javap -v 字节码文件
命令可得到如下字节码,但只需要关注Code对应的部分,也就是字节码指令部分
Constant pool: // 常量池部分
#1 = Methodref #4.#20 // java/lang/Object."<init>":()V
#2 = String #21 // abc
#3 = Class #22 // string_test/StringCompare
#4 = Class #23 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 LocalVariableTable
#10 = Utf8 this
#11 = Utf8 Lstring_test/StringCompare;
#12 = Utf8 main
#13 = Utf8 ([Ljava/lang/String;)V
#14 = Utf8 args
#15 = Utf8 [Ljava/lang/String;
#16 = Utf8 s1
#17 = Utf8 Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 StringCompare.java
#20 = NameAndType #5:#6 // "<init>":()V
#21 = Utf8 abc
#22 = Utf8 string_test/StringCompare
#23 = Utf8 java/lang/Object
{
public string_test.StringCompare();
descriptor: ()V
flags: ACC_PUBLIC
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code: // 字节码指令
stack=1, locals=2, args_size=1
0: ldc #2 // String abc
2: astore_1
3: return
LineNumberTable: // 字节码指令行号 与 java代码行号 对应表
line 10: 0
line 11: 3
LocalVariableTable: // 局部变量表
Start Length Slot Name Signature
0 4 0 args [Ljava/lang/String;
3 1 1 s1 Ljava/lang/String;
}
SourceFile: "StringTest.java"
字节码指令及解释为:
0: ldc #2 // 将常量池中序号为2的值(就是“abc”)压入栈顶
2: astore_1 // 将栈顶引用类型值保存到局部变量1中
3: return // 返回
总结:
也就是说,使用字面量方式定义字符串,创建的对象指向常量池中的字符串,二者的地址是相同的
2. new 关键字定义字符串
代码
public class StringTest {
public static void main(String[] args) {
String s1 = new String("abc");
}
}
对应字节码指令 及 解释
0 new #2 <java/lang/String> // 创建新一个新的对象,类型为常量池中序号2位置的类型(这里是java.lang.String)
3 dup // 复制栈顶一个字长的数据,将复制后的数据压栈,
// 这个操做个人猜想是因为引用类型在局部变量表中占两个 slot
4 ldc #3 <abc> // 将常量池中序号为3的数据("abc")压栈
6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
// 调用 java.lang.String.<init> 方法
9 astore_1 // 将栈顶引用类型值保存到局部变量1(s1)中
10 return
总结:
使用 new 关键字会先创建一个新的空对象,然后将常量池中的 字符串的值赋值给这个对象,二者是不同的。所以可以说创建了两个对象。
但是个人觉得常量池中的数据,是在类的加载阶段就创建了,不然为什么可以直接就使用 new 关键字,然后就从常量池里取数据。感觉说创建了两个对象,还是有点牵强。
3. 字符串拼接操作1
public class StringTest {
public static void main(String[] args) {
String s1 = new String("abc") + new String("def");
}
}
字节码指令
0 new #2 <java/lang/StringBuilder> // 创建新一个新的java.lang.StringBuilder对象
3 dup //
4 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
// 调用StringBuilder.<init>方法
7 new #4 <java/lang/String> // 创建新一个新的java.lang.String对象
10 dup //
11 ldc #5 <abc> // 从常量池中加载“abc”,压入栈底
13 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
// 调用String.<init>方法
16 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法,此时StringBuilder为“abc”
19 new #4 <java/lang/String> // 创建新一个新的java.lang.String对象
22 dup //
23 ldc #8 <def> // 从常量池中加载“def”,压入栈底
25 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
// 调用String.<init>方法
28 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法,此时StringBuilder为“abcdef”
31 invokevirtual #9 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
// 调用StringBuilder.toString方法,创建新的String对象“abcdef”
34 astore_1 // 弹出栈顶元素(“abcdef”),将值保存到局部变量1(s1)中
35 return
总结
这里可以发现字符串的拼接,也就是 + 操作,实际上是通过 StringBuiler 的 append 方法实现的。
所以这个过程中创建了:
- 第1个对象:
StringBuiler
对象 - 第2个对象:
new String("abc")
- 第3个对象:
new String("abc")
- 第4个对象:
StringBuiler.toString
方法也会创建一个新的 String 对象
如果把常量池中的两个对象( "abc" 和 "def" )也算上的话,一共就是 6 个对象。
4. 字符串拼接操作2
代码
public class StringTest {
public static void main(String[] args) {
String s1 = "abc";
String s2 = "def";
String s3 = "abc" + "def";
String s4 = s1 + "def";
String s5 = "abc" + s2;
String s6 = s1 + s2;
}
}
在 idea 中打开编译后的 class 文件,也就是执行反编译操作后,结果如下:
public class StringTest {
public StringTest() {
}
public static void main(String[] args) {
String s1 = "abc";
String s2 = "def";
String s3 = "abcdef";
String s4 = s1 + "def";
(new StringBuilder()).append("abc").append(s2).toString();
(new StringBuilder()).append(s1).append(s2).toString();
}
}
/*
这里就可以看见,如果是常量与常量的拼接,比如 "abc" + "def" 在编译阶段就会优化,将两个字符串链接在一起
而变量一旦有字符串变量参与到 + 的操做中,就会调用StringBuilder.append方法
但是这里有出现了一个问题,为什么 s4 对应的 + 操做没有使用 StringBuilder 呢???
*/
字节码指令及解释:
****************************** s1、s2、s3初始化操做 ******************************
0 ldc #2 <abc> // 从常量池中加载“abc”,压入栈底
2 astore_1 // 弹出栈顶元素(“abc”),将值保存到局部变量1(s1)中
3 ldc #3 <def> // 从常量池中加载“def”,压入栈底
5 astore_2 // 弹出栈顶元素(“def”),将值保存到局部变量2(s2)中
6 ldc #4 <abcdef> // 从常量池中加载“abcdef”,压入栈底
8 astore_3 // 弹出栈顶元素(“abcdef”),将值保存到局部变量3(s3)中
****************************** s4初始化操做 ******************************
9 new #5 <java/lang/StringBuilder>
// 1.创建新一个新的java.lang.StringBuilder对象
12 dup // 2.复制栈顶一个字长的数据
13 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
// 3.调用 StringBuilder.<init> 方法,也就是初始化 StringBuilder
16 aload_1 // 4.将局部变量1(s1,也就是“abc”)中装载引用类型值 入栈
17 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 5.调用StringBuilder.append方法,此时 StringBuilder 为 “abc”
20 ldc #3 <def> // 6.从常量池中加载“def”,压入栈底
22 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 7.调用StringBuilder.append方法,此时 StringBuilder 为 “abcdef”
25 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
// 8.调用StringBuilder.toString方法,创建新的String对象“abcdef”
28 astore 4 // 9.弹出栈顶元素(“abcdef”),将值保存到局部变量4(s4)中
****************************** s5初始化操做 ******************************
30 new #5 <java/lang/StringBuilder>
// 1.创建新一个新的java.lang.StringBuilder对象
33 dup //
34 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
// 调用StringBuilder.<init>方法,初始化一个空的调用StringBuilder对象
37 ldc #2 <abc> // 从常量池中加载“abc”,压入栈底
39 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法,此时StringBuilder为“abc”
42 aload_2 // 将局部变量2(s2,也就是"def")中装载引用类型值 入栈
43 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法,此时StringBuilder为“abcdef”
46 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
// 调用StringBuilder.toString方法,创建新的String对象“abcdef”
49 astore 5 // 弹出栈顶元素(“abcdef”),将值保存到局部变量5(s5)中
****************************** s6初始化操做 ******************************
51 new #5 <java/lang/StringBuilder>
// 1.创建新一个新的java.lang.StringBuilder对象
54 dup //
55 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
// 调用StringBuilder.<init>方法,初始化一个空的调用StringBuilder对象
58 aload_1 // 将局部变量1(s1,也就是“abc”)中装载引用类型值 入栈
59 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法追加"abc",此时StringBuilder为“abc”
62 aload_2 // 将局部变量2(s2,也就是"def")中装载引用类型值 入栈
63 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法追加"def",此时StringBuilder为“abcdef”
66 invokevirtual #8 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
// 调用StringBuilder.toString方法,创建新的String对象“abcdef”
69 astore 6 // 弹出栈顶元素(“abcdef”),将值保存到局部变量6(s6)中
71 return
总结
可以看出,除了常量 与 常量之间的拼接创建了一个指向常量池的的对象,其他的 s4、s5、s6 只要有变量参与到字符串的拼接操作中,都会创建 StringBuffer 对象,然后调用 append 方法,最后在用 toString 方法转换从成 String 对象,当然 StringBuffer 的 toString 方法原理还是创建一个新的对象,然后将 字符数组中的值一个一个复制过去。
所以使用 + 进行字符串的过程中创建了大量的中间对象,消耗了大量的内存空间,操作不当的话就很容易造成内存溢出(OOM)。如果在编码阶段就显式的使用 StringBuffer ,就可以避免创建很多无用的中间对象。再进一步优化的话,就可以考虑再一开始就指定 StringBuffer 的长度,避免后面长度不够的扩容操作。
比如下面三个方法:
// 每次拼接操作都要创建创建 StringBuffer 对象,然后调用 toString 方法,还要创建 String 对象
public void method1(int n) {
String str = "";
for (int i = 0; i < n; i++) {
str = str + "a";
}
}
// 创建了一个 StringBuffer 对象,后续如果不够会扩容
public void method2(int n) {
StringBuffer str = new StringBuffer();
for (int i = 0; i < n; i++) {
str.append("a");
}
}
// 自是至终都只创建了一个 StringBuffer 对象
public void method3(int n) {
StringBuffer str = new StringBuffer(n);
for (int i = 0; i < n; i++) {
str.append("a");
}
public static void main() {
int num = 1000000; //堆每个方法都进行1000000次的字符串拼接操作,记录时间
long start = System.currentTimeMillis();
method1(num);
System.out.print("method1用时:" + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
method2(num);
System.out.print("\t\tmethod2用时:" + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
method3(num);
System.out.println("\t\tmethod3用时:" + (System.currentTimeMillis() - start));
// method1用时:130916 method2用时:37 method3用时:27
}
可以发现,从时间的角度看,明显使用 StringBuffer 的 method2 和 method3 短很多。
空间占用情况也可以使用 Scanner 阻塞程序,然后使用 jvisual 等软件查看。
5. String.intern 操作
这是一个 native 方法,源码中的解释为:
When the intern method is invoked, if the pool already contains a string equal to this {@code String} object as determined by the {@link equals(Object)} method, then the string from the pool is returned. Otherwise, this {@code String} object is added to the pool and a reference to this {@code String} object is returned.
大致意思就是 :
当调用intern方法时,如果常量池中已经包含一个与调用者对象相等的字符串,则返回池中的字符串。否则,将此对象将添加到常量池中,并返回对常量池中对象的引用。
看了之前的字节码指令,是不是觉得这句话就很容易理解。
接下来看第四个题目。
题目4代码:
String s1 = new String("a");
s1.intern();
String s2 = "a";
System.out.println(s1 == s2); // 输出的是啥?
按照之前的逻辑,s1 是使用 new 关键字创建的,所以常量池中 和 堆中各有一个值为 "a" 的对象,二者并不相等。
然后调用 s1 的 intern 方法,此时常量池中已经存在 "a" ,并且这里没有接收返回值,所以可以理解为代码s1.intern();
并没有产生任何作用。
最后使用字面量的方式创建 s2 ,所以 s2 是指向常量池中的 "a" 。所以比较 s1 == s2
,结果应该是 false。
执行程序之后结果也确实是 false 。
分析其字节码:
0 new #2 <java/lang/String> // 创建新一个新的java.lang.String对象
3 dup // 复制栈顶一个字长的数据,将复制后的数据压栈
4 ldc #3 <a> // 从常量池中加载“a”,压入栈底
6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
// 调用String.<init>方法
9 astore_1 // 弹出栈顶元素(“a”),将值保存到局部变量1(s1)中
10 aload_1 // 将局部变量1(s1,也就是"a")中装载引用类型值 入栈
11 invokevirtual #5 <java/lang/String.intern : ()Ljava/lang/String;>
// 调用String.intern方法
14 pop // 从栈顶弹出一个字长的数据,猜测是因为前面入栈的是引用类型,占用两个 slot,后面由于要入栈的为字符串,实际存储为int类型的,所以只占一个 slot ,所以要弹出栈顶一个空的位置。
15 ldc #3 <a> // 从常量池中加载“a”,压入栈底
17 astore_2 // 弹出栈顶元素(“a”),将值保存到局部变量2(s2)中
********************* 下面的字节码指令是比较两个变量是否相等,然后输出 *********************
18 getstatic #6 <java/lang/System.out : Ljava/io/PrintStream;>
// 获取静态字段的值,也就是System.out对象
21 aload_1 // 将局部变量1(s1,也就是"a")中装载引用类型值 入栈
22 aload_2 // 将局部变量2(s2,也就是"a")中装载引用类型值 入栈
23 if_acmpne 30 (+7) // 若栈顶两引用类型值不相等,则跳转到30行
26 iconst_1 // 1(int)值入栈
27 goto 31 (+4) // 跳转到31行
30 iconst_0 // 0(int)值入栈
31 invokevirtual #7 <java/io/PrintStream.println : (Z)V>
// 调用println方法,输出1或者0
34 return
可以看出字节码指令与之前分析的结果是大致相同的。
然后再来看第5个题目
题目5代码:
String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); // 输出的是啥?
看了这么多字节码指令,再分析这个题目岂不是有手就行。
首先第一行创建了 new String("a")
、new String("b")
、s1 和一个 StringBuffer 四个对象,常量池里面有 "a" 和 "b" 两个字符串;
再执行 s1.intern()
,将 s1 的值放入常量池中,注意这里也没有接收返回值,所以 s1 还是堆中原本的对象;
使用字面量方式创建 s2 ,毫无疑问 s2 就是指向常量池中的 "ab"。
所以 s1 是堆中的对象,而 s2 是指向常量池中的对象,二者比较的话结果肯定为 false 。
然而程序运行的结果却是 true 。让我一度怀疑是不是计算机出错了。
大家上过小学二年级的一定都知道 jdk 发展历史上的一件比较重大的事件。
在 jdk6 及之前版本,字符串常量池存放在方法区中的永久代;在 jdk7 版本,将字符串常量池被移到了堆中;然后在 jdk8 中,移除了永久代的概念,改为元空间,但字符串常量池任然存放在堆空间中。
那知道这个有什么用呢,我得到的解释是:由于对象是存放在堆空间中,而字符串常量池也是存放在堆空间中,为了空间的节省,执行 s1.intern()
的时候,放入常量池的并不是一个新对象,而是 s1 对象的一个引用。所以后面初始化 s2 的时候,从常量池中取出的字符串就是堆空间的 s1 对象。
所以在 jdk6 的环境下执行,结果就是 false ,在 jdk7 之后的环境下就是 false。
看对应的字节码为:
********************* 1. 创建s1 *********************
0 new #2 <java/lang/StringBuilder> // 创建新一个新的java.lang.StringBuilder对象
3 dup //
4 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
// 调用StringBuilder.<init>方法
7 new #4 <java/lang/String> // 创建新一个新的java.lang.String对象
10 dup //
11 ldc #5 <a> // 从常量池中加载“a”,压入栈底
13 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
// 调用String.<init>方法
16 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法追加"a",此时StringBuilder为“a”
19 new #4 <java/lang/String> // 创建新一个新的java.lang.String对象
22 dup //
23 ldc #8 <b> // 从常量池中加载“b”,压入栈底
25 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
// 调用String.<init>方法
28 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
// 调用StringBuilder.append方法追加"b",此时StringBuilder为“ab”
31 invokevirtual #9 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
// 调用StringBuilder.toString方法,创建新的String对象“ab”
34 astore_1 // 弹出栈顶元素(“ab”),将值保存到局部变量1(s1)中
********************* 2. s1.intern() *********************
35 aload_1 // 将局部变量1(s1,也就是"ab")中装载引用类型值 入栈
36 invokevirtual #10 <java/lang/String.intern : ()Ljava/lang/String;>
// 调用String.intern方法
********************* 3.创建s2 *********************
39 pop // 从栈顶弹出一个字长的数据
40 ldc #11 <ab> // 从常量池中加载“ab”,压入栈底
42 astore_2 // 弹出栈顶元素(“ab”),将值保存到局部变量2(s2)中
********************* 下面的字节码指令是比较两个变量是否相等,然后输出 *********************
43 getstatic #12 <java/lang/System.out : Ljava/io/PrintStream;>
// 获取静态字段的值,也就是System.out对象
46 aload_1 // 将局部变量1(s1,也就是"ab")中装载引用类型值 入栈
47 aload_2 // 将局部变量2(s2,也就是"ab")中装载引用类型值 入栈
48 if_acmpne 55 (+7) // 若栈顶两引用类型值不相等,则跳转到55行
51 iconst_1 // 1(int)值入栈
52 goto 56 (+4) // 跳转到56行
55 iconst_0 // 0(int)值入栈
56 invokevirtual #13 <java/io/PrintStream.println : (Z)V>
// 调用println方法,输出1或者0
59 return
看字节码好像也无法确定 s1.intern
放入常量池的是 s1 的一个引用,并且我对比了这段代码在 jdk6 环境下和 jdk8 的环境下编译产生的字节码文件,是一模一样的。
最后的两个问题
至此,我也只能看到 题目5 在 jdk7 之后的版本执行结果确实为 true,并且 String.intern
方法放入常量池的只是一个引用的说法也的确能够解释清楚。但是我自己也没有验证,也还没想到怎样去验证。
第二个问题是,个人觉得可以确定的常量,比如说 String s1 = "abc"
或者 String s2 = new String("abc")
中的 "abc" ,在类的加载阶段(链接中的解析阶段),和要加载的类名一起放入常量池的,所以在执行代码时,就可以直接从常量池里面加载。
当然这也只是我自己的想法,并没有太多的依据,我也想不到方法去验证是否正确。
字节码指令参考:
https://www.cnblogs.com/longjee/p/8675771.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· 写一个简单的SQL生成工具