想必String这个类在每个java程序员入门时都会使其感到困扰一阵子吧,当然我也不例外,那么今天我们就来聊聊String的那些事。
1. 我们经常用的“+”
我们在使用java开发时常会用到String这个类,也常会用“+”来进行字符串的拼接操作:
public class StudyString {
public static void main(String[] args) {
String a = "123";
String b = "456";
String str = a + b;
}
}
这里可能有些人会认为“+”是一种运算符的重载,我们先看一下百度百科上关于运算符重载的定义:
运算符重载,就是对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型。
咋一看似乎java中 “+” 在对数字类型运算时和对字符串类型运算时的区别对待很符合运算符重载的定义啊。但其实并不是这样的,java是不支持运算符重载的,当我们用 “+” 拼接字符串时其实是用到了编译器提供的一个语法糖。
语法糖(Syntactic sugar),也译为糖衣语法,是由英国计算机科学家彼得·约翰·兰达(Peter J. Landin)发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。
简单的说,语法糖就是编译器把复杂的,易出错的代码封装起来,在编码层面提供给程序员的一种简便写法。
为了进一步理解编译器在看到我们用 “+” 拼接字符串时会做什么,我们把上面的代码反编译一下看看:
public class StudyString {
public StudyString() {
}
public static void main(String[] args) {
String a = "123";
String b = "456";
(new StringBuilder()).append(a).append(b).toString();
}
}
可以看到java编译器在编译阶段会将 “+” 转化为StringBuilder类的append方法。
再来看看上面的代码在字节码层面做了什么:
用javap -v StudyString.class > StudyString.txt
反编译后截取以下代码:
0: ldc #2 // 将"123"从常量池推至操作数栈顶
2: astore_1 // 将栈顶第一个引用数值存入第一个本地变量(对a赋值)
3: ldc #3 // 将"456"从常量池推至操作数栈顶
5: astore_2 // 将栈顶第一个引用数值存入第二个本地变量(对b赋值)
6: new #4 // class java/lang/StringBuilder (new关键字为StringBuilder对象开辟内存空间)
9: dup // 将栈顶刚创建的对象的引用复制后再入栈,此时栈顶有两份相同的对象引用
// 因为new关键字创建对象后,jvm会自动调用对象的初始化方法,所以要多一份对象的引用给jvm调用
// 初始化方法,另一份引用才是给我们程序员用的。
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V(调用StringBuilder对象的初始化方法)
13: aload_1 // 将第二个引用类型本地变量推送至栈顶。(这里指的把a推送至栈顶,因为第一个引用类型变量为this)
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
// 调用StringBuiler类的append方法,将a引用的字符串对象拆开,放到自己的char[]中
17: aload_2 // 同13行,对b操作
18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
// 同14行,对b操作
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
// 调用StringBuilder类的toString()方法,生成String字符串(“123456”)
24: astore_3 // 将栈顶引用型数值存入第3个本地变量,,即将在栈顶刚刚生成的字符串引用赋给str变量
25: return // 方法结束,返回
看到这里,想必你对“+”在对字符串的拼接时是如何操作的应该有了一定的认识了,那么我们再来看看下面这个问题:
2. String str = new String(" abc ")创建了几个对象?
这个问题其实很简单,答案是两个,一个在堆中,一个在方法区中。因为“abc”这个字符串字面量在编译期就会被加入到class常量池(即字节码文件反编译后的Constant pool
部分)
Constant pool:
#1 = Methodref #6.#22 // java/lang/Object."<init>":()V
#2 = Class #23 // java/lang/String
#3 = String #24 // abc
#4 = Methodref #2.#25 // java/lang/String."<init>":(Ljava/lang/String;)V
#5 = Class #26 // StudyString
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LStudyString;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 str
#19 = Utf8 Ljava/lang/String;
#20 = Utf8 SourceFile
#21 = Utf8 StudyString.java
#22 = NameAndType #7:#8 // "<init>":()V
#23 = Utf8 java/lang/String
#24 = Utf8 abc
#25 = NameAndType #7:#28 // "<init>":(Ljava/lang/String;)V
#26 = Utf8 StudyString
#27 = Utf8 java/lang/Object
#28 = Utf8 (Ljava/lang/String;)V
上面是String str = new String("abc");
代码编译后再javap反编译后输出代码中Constant pool
的部分。其中#3 = String #24 // abc
就说明了“abc”在编译期被加入了class常量池中。在JVM加载这个.class文件时就会把它放到方法区中的字符串常量池里,所以在常量池中的“abc”就是第一个字符串对象。
我们再来看看main方法中的代码反编译后是什么样的:
0: new #2 // class java/lang/String(在堆中开辟一块内存空间,创建一个String对象)
3: dup
4: ldc #3 // String abc(将字符串常量池中“abc”的引用推至栈顶)
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
// 调用String类的初始化方法,这一步会将上一步栈顶的引用赋给堆中的字符串对象
9: astore_1 // 将堆中的字符串引用赋给栈中的符号引用str
10: return
所以整条String str = new String("abc");
语句执行完在JVM内存结构中就会是这样的情况:
不过要注意的是只有用了new关键字才会在java堆中创建对象,如果直接对符号引用赋值字面量(直接用“”引起来的叫字面量)是不会在堆中创建对象的。如:String str = "abc";
就只会在方法区中创建对象,并直接将方法区中字符串对象的引用赋给方法栈帧中局部表量表中的符号引用(str)
下面我们做几道题,加深一下印象:
String str1 = "abc";
String str2 = new String("abc");
System.out.println(str1 == str2);`
结果为false
,因为==
比较的是两个对象的地址是否相等,而这两个对象一个在堆中,一个在方法区中,所以地址肯定不相等。
String str1 = "abc";
String str2 = "a" + "bc";
System.out.println(str1 == str2);
结果为true
因为这里的String str2 = "a" + "bc";
与之前的append方法不同,这是字符串字面量直接相加,其结果是属于编译期可以确定的值,对于这种情况编译器会将其优化为:String str2 = "abc";
这种形式(final修饰的字符串也会有这种优化),那么我们反编译验证一下:
反编译后可以看到编译器确实将这行语句优化了,那么这时在对str2
赋值时,因为上一行代码String str1 = "abc";
执行过了,所以字符串常量池中已经有了"abc"
所以JVM会直接将其引用赋给str2
,所以str1
和str2
都指向了字符串常量池里的"abc"
字符串对象,这时比较它们的内存地址结果肯定为true
的。
String str1 = "abc";
String str2 = "def";
String str3 = "abcdef";
System.out.println(str3 == (str1 + str2));
结果为false
咋一看,这道题和上道题似乎有些相似,但结果为什么为false
呢,原因就是编译器在编译的时候无法确定str1 + str2
的结果,str1
和str2
对于编译器来说可是变量啊,虽然我们人一眼就能看出结果为"abcdef"
,但编译器无法确定,所以他会去调用StringBuilder的append方法,这就类似文章开头的那个场景了。最终str1 + str2
会在堆中创建出一个新的字符串对象(调用append方法会在堆中创建StringBuilder对象,在append方法执行完后,会调用toString()方法,最终在堆中创建字符串对象)。而str3
引用的对象在方法区里,比较它们的地址结果肯定是为false
的。
3. String的intern()方法到底做了什么
要搞清楚这个问题,先来看这一段代码:
String str = new String("abc") + new String("def");
String str1 = str.intern();
System.out.println(str == str1);
这段代码在jdk1.7及更高版本的JDK的环境下运行结果为false
,在jdk1.6的环境下运行结果为true
3.1 jdk1.6中的intern()方法做了什么
在jdk1.6中当我们调用intern()方法,JVM会拿着调用这个方法的字符串的值去字符串常量池中寻找相同值的字符串。
1. 如果找到了,就返回原来字符串常量池中字符串的引用
2. 如果没找到,就将调用这个方法的字符串的值存入字符创常量池中,并返回其引用
再回过来看上面的代码,在执行intern()方法前,字符串常量池中只有:"abc"
和"def"
,它们两个对象相加产生的新字符串对象并不会放到字符串常量池中,所以当intern()
方法去字符串常量池中查找相同值字符串时,不会找到,于是JVM就将调用intern()
这个方法的字符串的值存入字符创常量池中,并返回其引用给str1
。
jdk1.6内存中结构如下图:
从图中可以看到str
的引用地址和str1
的引用地址不一样,所以用==
比较它们地址时会返回false
3.2 jdk1.7中的intern()方法做了什么
jdk1.7及以后的intern()
方法在实现方式上有所不同,当我们在jdk1.7的环境下去执行intern()
方法时,JVM也会去字符串常量池中寻找相同值的字符串。
1. 如果找到了:返回字符串常量池中字符串的引用
2.如果没找到:将调用intern()方法的字符串的引用添加到字符串常量池中,并返回这个引用
看到这里,我们应该可以发现这两个版本的intern()方法的区别了吧,jdk1.6版本的在没找到的情况下向字符串常量池中添加的是字符串对象,而1.7版本的intern()方法在没找到的情况下向字符串常量池中添加的是字符串的引用。
jdk1.7内存中结构如下图
我们可以发现,当程序运行完后,str
和str1
最后引用的都是同一个地址,即同一个对象,所以用==
去比较它们时返回的结果为true
总结
- 当字符串字面量(用“”引起来的值,编译器的编译阶段就能确定拼接后的值)在用 “+” 做拼接时,编译器会对其优化,在编译阶段就替换为拼接后的结果,当JVM加载字节码文件时直接被加载进字符串常量池。
- 对于字符串类型的变量在用 “+” 做拼接时,编译器无法确定他们拼接后的值,所以不会优化,而是会调用StringBuilder的append方法对其拼接,最后在堆中产生一个新的字符串对象
- 当我们使用字符串字面量去创建字符串时,JVM会先去字符串常量池中找有没有同值得字符串,如果有就返回它的引用。如果没有,就会将该字符串添加到字符串常量池中,并返回其引用。
- intern()方法在jdk1.6和jdk1.7中的实现方式不一样,当在字符串常量池中没有找到同值得字符串时,jdk1.6的intern()会将调用它的字符串添加到常量池中,并返回引用。而jdk1.7的intern()方法向字符串常量池中添加的是调用intern()方法的字符串的引用,并返回它。
参考资料:https://blog.csdn.net/yongfeng596/article/details/54695589