從面試題說起
String s = new String("xyz"); 創建了幾個實例?
這是一道很經典的面試題,在一本所謂的Java寶典上,我看到的“標准答案”是這樣的:
兩個,一個堆區的“xyz”,一個棧區指向“xyz”的s。
這個所謂的“標准答案”槽點太多,后面我們慢慢分析。
但是我覺得這個問題本身不具有什么意義,因為他沒有既定義“創建”的具體含義,又沒有指定“創建”的時間,是運行時嗎?包不包括類加載的時候?有沒有上下文代碼語境?也沒有定義實例是指什么實例,是指Java實例嗎?還是單指String實例?包不包括JVM中的C++實例?
顯然,這是一個“有問題的問題”。也是一個“有問題的答案”。
String結構
在分析之前,為了方便后面畫內存圖,我們需要對Java中的String結構有一個大致了解:

從上圖可以看出,String類有三個屬性:
value:char數組,用於用於存儲字符。
hash:緩存字符串的哈希碼,默認為0(String的hash值在真正調用hashCode
方法的時候才會去計算)。
serialVersionUID:序列化用的。
正常的問題與合理的解釋
在上面的題干上加上一些限定詞,可以得到一個新的問題:
String s = new String("xyz");創建幾個String實例?
對於這個問題,在網上能找到一些比較高贊的答案:
兩個,一個是字符串字面量"xyz"所對應的、存在於全局共享的常量池中的實例,另一個是通過new String(String)創建並初始化的、內容(字符)與"xyz"相同的實例。考慮到如果常量池中如果有這個字符串,就只會創建一個。同時在棧區還會有一個對new出來的String實例的s。
能提到常量池,我認為這已經達到大部分面試官對這個題目答案的期許了,或許這也是面試官考察的點。
但這個答案也僅是比較合理,並不完全正確。為什么呢?
我認為這個答案並不嚴謹,甚至是有一些錯誤理解在其中的。
首先,我不理解的是為什么很多答主總是用“常量池”來代替“字符串常量池”,在Java體系中,其實是有三個常量池的,三個常量池的概念和用處都不相同,我認為是不應該混淆的。
其次,就算答主說的“常量池”就是“字符串常量池”,可“字符串常量池”中存的是String實例的引用,而不是字符串,這是有很大區別的。
而且這個答案是沒有考慮代碼執行的環境。
這些我們后面都會一一分析。
分清變量和實例
首先我們要分清變量和實例的區別。
先回到開頭的問題與“標准答案” 。
問題:String s = new String("xyz"); 創建了幾個實例?
答案:兩個,一個堆區的“xyz”,一個棧區指向“xyz”的s
很明顯給答案的人是沒有把變量和實例分清楚。Java里變量就是變量,類型的變量只是對某個對象實例或者null的,不是實例本身。聲明變量的個數跟創建實例的個數沒有必然關系。
舉個例子:
String s1 = "xyz";
String s2 = s1.concat("");
String s3 = null;
new String(s1);
這段代碼會涉及3個String類型的變量:
- s1,指向下面String實例的1
- s2,指向與s1相同
- s3,值為null,不指向任何實例
以及3個String實例:
- "xyz"字面量對應的駐留的字符串常量的String實例
- ""字面量對應的駐留的字符串常量的String實例 (String.concat()是個有趣的方法,當發現傳入的參數是空字符串時會返回this,所以這里不會額外創建新的String實例)
- 通過new String(String)創建的新String實例,沒有任何變量指向它。
類加載
對於String s = new String("xyz");創建幾個String實例?這個問題。
似乎網上的所有答案都把類加載過程和實際執行過程合在一起分析的。
看起來好像是沒有什么問題的,因為想要執行某個代碼片段,其所在的類必然要被加載,而且對於同一個類加載器,最多加載一次。
但是我們看一下這段代碼的字節碼:

似乎只出現了一次new java/lang/String
,也就是只創建了一個String實例。也就是說原問題中的代碼在每執行一次只會新創建一個String實例。 這里的ldc指令只是把先前在類加載過程中已經創建好的一個String實例("xyz")的一個引用壓到操作數棧頂而已,並沒有創建新的String實例。
不是應該有兩個實例嗎?還有一個String實例是在什么時候創建的呢?
我們都知道類加載的解析階段是Java虛擬機將常量池內的符號引用替換為直接引用的過程,根據JVM規范,符合規范的JVM實現應該在類加載的過程中創建並駐留一個String實例作為常量來對應"xyz"字面量,具體是在類加載的解析階段進行的。這個常量是全局共享的,只在先前尚未有內容相同的字符串駐留過的前提下才需要創建新的String實例。
所以你可以理解成,在類加載的解析階段,其實已經創建了一個String實例,執行代碼的時候,又new了一個String實例。當然,你把兩者放在一起討論並不會有什么問題。
JVM優化
以上討論都只是針對規范所定義的Java語言與Java虛擬機而言。概念上是如此,但實際的JVM實現可以做得更優化,原問題中的代碼片段有可能在實際執行的時候一個String實例也不會完整創建(沒有分配空間)。
不結合上下文代碼來看就直接說是“標准答案”就是耍流氓。
我們看下這段代碼:

運行這段代碼,會不斷的創建String對象吃內存,然后頻繁的造成GC。
對於這個結論相信大家都沒有意見,我們加上-XX:+PrintGC -XX:-DoEscapeAnalysis
打印日志,關閉逃逸分析(JDK8默認開啟此優化,我們先關閉)運行一下看看。


結果確實如我們所料,不斷的創建String對象吃內存導致頻繁GC。
我們現在將-XX:-DoEscapeAnalysis
改成-XX:+DoEscapeAnalysis
,重新跑一下這段代碼:

神奇的事情發生了,繼續跑下去也沒有再打出GC日志了。難道新創建String對象都不吃內存了么?
實際情況是:經過HotSpot VM的的優化后,newString()方法不會新創建String實例了。這樣自然不吃內存,也就不再觸發GC了。
現在再來看開篇的那個問題,不結合具體情況,還能簡單的說String s = new String("xyz");會創建兩個String實例嗎?
我只是舉了一個逃逸分析的例子,HotSpot VM還有很多像這樣的優化,比如方法內聯、標量替換和無用代碼削除。
klass-oop
如果題干上沒有加上“Java”實例的定語,那JVM中的oop實例我們也不應該忽略。
為了后面能更好的說清楚這一點,需要補充一下klass-opp模型的知識。
為了保持嚴謹,先做一個約定,全文只要涉及JVM具體實現的內容都是基於Jdk8中HotSpot VM展開的。
HotSpot VM是基於C++實現,而C++是一門面向對象的語言,本身是具備面向對象基本特征的,所以Java中的對象表示,最簡單的做法是為每個Java類生成一個C++類與之對應。但HotSpot VM並沒有這么做,而是設計了一套klass-oop模型。
klass,它是Java類的元信息在JVM中的存在形式。一個Java類被JVM類加載器加載之后,就是以klass的形式存在於JVM之中。

oop,它是Java對象在JVM中的存在形式。每創建一個新的對象,在JVM內部就會相應地創建一個對應類型的OOP對象。
其中instanceOopDesc表示非數組對象,arrayOopDesc表示數組對象;
而objArrayOopDesc表示引用類型數組對象,typeArrayOopDesc表示基本類型數組對象。
舉個例子:Java中String類的一個實例,在JVM中會有一個對應的instanceOopDesc實例。

字符串常量池
在Java體系中,有三種常量池:
- class字節碼中的常量池:存在於硬盤上。主要存放兩大類常量:字面量、符號引用。
- 運行時常量池:方法區的一部分。我們常說的常量池,就是指這一塊區域:方法區中的運行時常量池。
- 字符串常量池:存在於堆區。這個常量池在JVM層面就是一個StringTable,只存儲對java.lang.String實例的引用,而不存儲String對象的內容。一般我們說一個字符串進入了字符串常量池其實是說在這個StringTable中保存了對它的引用,反之,如果說沒有在其中就是說StringTable中沒有對它的引用。
今天,我們重點說的是字符串常量池,即String Pool,在JVM中對應的類是StringTable,底層實現是一個Hashtable。也是利用的哈希思想。

下面這段代碼,是往字符串常量池添加字符串方法。雖然是C++代碼,但我相信學過Java的人都能看懂,至少也能明白這段代碼干了什么事情。會通過String的內容+長度生成的hash值定位下標index,然后將Java的String類的實例對應的instanceOopDesc封裝成HashtableEntry作為存儲結構存儲到常量池。

補充完字符串常量池的知識之后,我們再回到文章開頭的那一題:
String s = new String("xyz");創建了幾個實例?
我們畫一個內存圖,圖中省略了兩個String對應的instanceOopDesc實例。

不難得出答案,如果包括JVM中的C++實例的話,有兩個Java的String實例,兩個String實例對應的instanceOopDesc實例,還有一個char[]數組對應的typeArrayOopDesc實例。加一起一共是5個,也可以說2個String實例加上3個oop實例。
總結
String s = new String("xyz"); 創建了幾個實例?
通過以上的分析,我們會發現,每在這道題目的題干上每加一個定語,這道題目就會有不同的答案。
是否考慮類加載過程,是否考慮JVM優化,是否包括對應的oop實例等等等等,每個點都值得聊一聊的。
下次有人問你,你不妨把這篇的文章分享給他。
寫在最后
為了寫這一篇文章,我翻看了很多@RednaxelaFX前輩和周志明前輩的博客,過程中收益良多。在這里感謝前輩們為國內JVM的科普與發展做出的貢獻!
還有一個很有趣的故事,我在查找“如何通過HSDB來了解String”相關資料的時候,看到一篇寫的很好的文章,驚呼國內還有這么多低調的大神,后來添加了文章旁邊的公眾號,發現這個大神原來是PerfMa的創始人“寒泉子”李嘉鵬前輩,冒犯了冒犯了!
最后的最后
本着對每一篇發出去的文章負責的原則,文中涉及知識理論,我都會盡量在官方文檔和權威書籍找到並加以驗證。但即使這樣,我也不能保證文中每個點都是正確的,如果你發現錯誤之處,歡迎指出,我會對其修正。
創作不易,你的正反饋對我來說非常重要!點個贊,點個再看,點個關注甚至評論區發送一條666都是對我最大的支持!
我是CoderW,一個普通的程序員。
謝謝你的閱讀,我們下期再見
文中涉及代碼:https://github.com/xiaoyingzhi/blog
JVM Spec Java SE 8Edition:https://docs.oracle.com/javase/specs/jvms/se8/jvms8.pdf
參考文章:http://isfeasible.cn/posts/view/5b84b6ab3957bb300a5bca94