別再問我 new 字符串創(chuàng)建了幾個對象了!我來證明給你看!
我想所有 Java 程序員都曾被這個 new String 的問題困擾過,這是一道高頻的 Java 面試題,但可惜的是網(wǎng)上眾說紛紜,竟然找不到標(biāo)準(zhǔn)的答案。有人說創(chuàng)建了 1 個對象,也有人說創(chuàng)建了 2 個對象,還有人說可能創(chuàng)建了 1 個或 2 個對象,但誰都沒有拿出干掉對方的證據(jù),這就讓我們這幫吃瓜群眾們陷入了兩難之中,不知道到底該信誰得。
但是今天,老王就斗膽和大家聊聊這個話題,順便再拿出點(diǎn)證據(jù)。
以目前的情況來看,關(guān)于 new String("xxx")
創(chuàng)建對象個數(shù)的答案有 3 種:
-
有人說創(chuàng)建了 1 個對象; -
有人說創(chuàng)建了 2 個對象; -
有人說創(chuàng)建了 1 個或 2 個對象。
而出現(xiàn)多個答案的關(guān)鍵爭議點(diǎn)在「字符串常量池」上,有的說 new 字符串的方式會在常量池創(chuàng)建一個字符串對象,有人說 new 字符串的時候并不會去字符串常量池創(chuàng)建對象,而是在調(diào)用 intern()
方法時,才會去字符串常量池檢測并創(chuàng)建字符串。
那我們就先來說說這個「字符串常量池」。
字符串常量池
字符串的分配和其他的對象分配一樣,需要耗費(fèi)高昂的時間和空間為代價,如果需要大量頻繁的創(chuàng)建字符串,會極大程度地影響程序的性能,因此 JVM 為了提高性能和減少內(nèi)存開銷引入了字符串常量池(Constant Pool Table)的概念。
字符串常量池相當(dāng)于給字符串開辟一個常量池空間類似于緩存區(qū),對于直接賦值的字符串(String s="xxx")來說,在每次創(chuàng)建字符串時優(yōu)先使用已經(jīng)存在字符串常量池的字符串,如果字符串常量池沒有相關(guān)的字符串,會先在字符串常量池中創(chuàng)建該字符串,然后將引用地址返回變量,如下圖所示:
以上說法可以通過如下代碼進(jìn)行證明:
public class StringExample {
public static void main(String[] args) {
String s1 = "Java";
String s2 = "Java";
System.out.println(s1 == s2);
}
}
以上程序的執(zhí)行結(jié)果為:true
,說明變量 s1 和變量 s2 指向的是同一個地址。
在這里我們順便說一下字符串常量池的再不同 JDK 版本的變化。
常量池的內(nèi)存布局
從JDK 1.7 之后把永生代換成的元空間,把字符串常量池從方法區(qū)移到了 Java 堆上。
JDK 1.7 內(nèi)存布局如下圖所示:
JDK 1.8 內(nèi)存布局如下圖所示:
JDK 1.8 與 JDK 1.7 最大的區(qū)別是 JDK 1.8 將永久代取消,并設(shè)立了元空間。官方給的說明是由于永久代內(nèi)存經(jīng)常不夠用或發(fā)生內(nèi)存泄露,會爆出 java.lang.OutOfMemoryError: PermGen 的異常,所以把將永久區(qū)廢棄而改用元空間了,改為了使用本地內(nèi)存空間,官網(wǎng)解釋詳情:http://openjdk.java.net/jeps/122
答案解密
認(rèn)為 new 方式創(chuàng)建了 1 個對象的人認(rèn)為,new String 只是在堆上創(chuàng)建了一個對象,只有在使用 intern()
時才去常量池中查找并創(chuàng)建字符串。
認(rèn)為 new 方式創(chuàng)建了 2 個對象的人認(rèn)為,new String 會在堆上創(chuàng)建一個對象,并且在字符串常量池中也創(chuàng)建一個字符串。
認(rèn)為 new 方式有可能創(chuàng)建 1 個或 2 個對象的人認(rèn)為,new String 會先去常量池中判斷有沒有此字符串,如果有則只在堆上創(chuàng)建一個字符串并且指向常量池中的字符串,如果常量池中沒有此字符串,則會創(chuàng)建 2 個對象,先在常量池中新建此字符串,然后把此引用返回給堆上的對象,如下圖所示:
老王認(rèn)為正確的答案:創(chuàng)建 1 個或者 2 個對象。
技術(shù)論證
解鈴還須系鈴人,回到問題的那個爭議點(diǎn)上,new String 到底會不會在常量池中創(chuàng)建字符呢?我們通過反編譯下面這段代碼就可以得出正確的結(jié)論,代碼如下:
public class StringExample {
public static void main(String[] args) {
String s1 = new String("javaer-wang");
String s2 = "wang-javaer";
String s3 = "wang-javaer";
}
}
首先我們使用 javac StringExample.java
編譯代碼,然后我們再使用 javap -v StringExample
查看編譯的結(jié)果,相關(guān)信息如下:
Classfile /Users/admin/github/blog-example/blog-example/src/main/java/com/example/StringExample.class
Last modified 2020年4月16日; size 401 bytes
SHA-256 checksum 89833a7365ef2930ac1bc3d7b88dcc5162da4b98996eaac397940d8997c94d8e
Compiled from "StringExample.java"
public class com.example.StringExample
minor version: 0
major version: 58
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #16 // com/example/StringExample
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Class #8 // java/lang/String
#8 = Utf8 java/lang/String
#9 = String #10 // javaer-wang
#10 = Utf8 javaer-wang
#11 = Methodref #7.#12 // java/lang/String."<init>":(Ljava/lang/String;)V
#12 = NameAndType #5:#13 // "<init>":(Ljava/lang/String;)V
#13 = Utf8 (Ljava/lang/String;)V
#14 = String #15 // wang-javaer
#15 = Utf8 wang-javaer
#16 = Class #17 // com/example/StringExample
#17 = Utf8 com/example/StringExample
#18 = Utf8 Code
#19 = Utf8 LineNumberTable
#20 = Utf8 main
#21 = Utf8 ([Ljava/lang/String;)V
#22 = Utf8 SourceFile
#23 = Utf8 StringExample.java
{
public com.example.StringExample();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=1
0: new #7 // class java/lang/String
3: dup
4: ldc #9 // String javaer-wang
6: invokespecial #11 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: ldc #14 // String wang-javaer
12: astore_2
13: ldc #14 // String wang-javaer
15: astore_3
16: return
LineNumberTable:
line 5: 0
line 6: 10
line 7: 13
line 8: 16
}
SourceFile: "StringExample.java"
備注:以上代碼的運(yùn)行也編譯環(huán)境為 jdk1.8.0_101。
其中 Constant pool
表示字符串常量池,我們在字符串編譯期的字符串常量池中找到了我們 String s1 = new String("javaer-wang");
定義的“javaer-wang”字符,在信息 #10 = Utf8 javaer-wang
可以看出,也就是在編譯期 new 方式創(chuàng)建的字符串就會被放入到編譯期的字符串常量池中,也就是說 new String 的方式會首先去判斷字符串常量池,如果沒有就會新建字符串那么就會創(chuàng)建 2 個對象,如果已經(jīng)存在就只會在堆中創(chuàng)建一個對象指向字符串常量池中的字符串。
那么問題來了,以下這段代碼的執(zhí)行結(jié)果為 true 還是 false?
String s1 = new String("javaer-wang");
String s2 = new String("javaer-wang");
System.out.println(s1 == s2);
既然 new String 會在常量池中創(chuàng)建字符串,那么執(zhí)行的結(jié)果就應(yīng)該是 true 了。其實并不是,這里對比的變量 s1 和 s2 堆上地址,因為堆上的地址是不同的,所以結(jié)果一定是 false,如下圖所示:
從圖中可以看出 s1 和 s2 的引用一定是相同的,而 s3 和 s4 的引用是不同的,對應(yīng)的程序代碼如下:
public static void main(String[] args) {
String s1 = "Java";
String s2 = "Java";
String s3 = new String("Java");
String s4 = new String("Java");
System.out.println(s1 == s2);
System.out.println(s3 == s4);
}
程序執(zhí)行的結(jié)果也符合預(yù)期:
true
false
擴(kuò)展知識
我們知道 String 是 final 修飾的,也就是說一定被賦值就不能被修改了。但編譯器除了有字符串常量池的優(yōu)化之外,還會對編譯期可以確認(rèn)的字符串進(jìn)行優(yōu)化,例如以下代碼:
public static void main(String[] args) {
String s1 = "abc";
String s2 = "ab" + "c";
String s3 = "a" + "b" + "c";
System.out.println(s1 == s2);
System.out.println(s1 == s3);
}
按照 String 不能被修改的思想來看,s2 應(yīng)該會在字符串常量池創(chuàng)建兩個字符串“ab”和“c”,s3 會創(chuàng)建三個字符串,他們的引用對比結(jié)果也一定是 false,但其實不是,他們的結(jié)果都是 true,這是編譯器優(yōu)化的功勞。
同樣我們使用 javac StringExample.java
先編譯代碼,再使用 javap -c StringExample
命令查看編譯的代碼如下:
警告: 文件 ./StringExample.class 不包含類 StringExample
Compiled from "StringExample.java"
public class com.example.StringExample {
public com.example.StringExample();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #7 // String abc
2: astore_1
3: ldc #7 // String abc
5: astore_2
6: ldc #7 // String abc
8: astore_3
9: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
12: aload_1
13: aload_2
14: if_acmpne 21
17: iconst_1
18: goto 22
21: iconst_0
22: invokevirtual #15 // Method java/io/PrintStream.println:(Z)V
25: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
28: aload_1
29: aload_3
30: if_acmpne 37
33: iconst_1
34: goto 38
37: iconst_0
38: invokevirtual #15 // Method java/io/PrintStream.println:(Z)V
41: return
}
從 Code 3、6 可以看出字符串都被編譯器優(yōu)化成了字符串“abc”了。
總結(jié)
本文我們通過 javap -v XXX
的方式查看編譯的代碼發(fā)現(xiàn) new String 首次會在字符串常量池中創(chuàng)建此字符串,那也就是說,通過 new 創(chuàng)建字符串的方式可能會創(chuàng)建 1 個或 2 個對象,如果常量池中已經(jīng)存在此字符串只會在堆上創(chuàng)建一個變量,并指向字符串常量池中的值,如果字符串常量池中沒有相關(guān)的字符,會先創(chuàng)建字符串在返回此字符串的引用給堆空間的變量。我們還將了字符串常量池在 JDK 1.7 和 JDK 1.8 的變化以及編譯器對確定字符串的優(yōu)化,希望能幫你正在的理解字符串的比較。
最后的話
原創(chuàng)不易,本篇近 3000 的文字描述,以及大量精美的圖片,耗費(fèi)了作者大概 5 個多小時的時間,寫作是一件很酷,并且能幫助他人的事,作者希望一直能堅持下去。如果覺得有用,請隨手點(diǎn)擊一個贊吧,謝謝。
特別推薦一個分享架構(gòu)+算法的優(yōu)質(zhì)內(nèi)容,還沒關(guān)注的小伙伴,可以長按關(guān)注一下:
長按訂閱更多精彩▼
如有收獲,點(diǎn)個在看,誠摯感謝
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺僅提供信息存儲服務(wù)。文章僅代表作者個人觀點(diǎn),不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!