創(chuàng)建幾個對象:常量池與堆機制全解析)
1. 先說結(jié)論這個問題的答案分兩種情況這道題幾乎是Java面試的送分題但也是踩坑率最高的題之一。很多人背過“創(chuàng)建了兩個對象”這個標準答案結(jié)果面試官一追問就翻車。原因很簡單答案不是固定的取決于字符串a(chǎn)bc之前有沒有在常量池里出現(xiàn)過。先給結(jié)論如果JVM常量池中已經(jīng)存在字符串a(chǎn)bc那么new String(abc)只創(chuàng)建一個對象也就是堆中那個新的String實例。如果JVM常量池中不存在abc那么會創(chuàng)建兩個對象一個是常量池中的abc字符串一個是堆中的String對象。為什么會這樣因為new String(abc)這句代碼實際上包含兩層含義第一層是字符串字面量abc本身它會在類加載或執(zhí)行期間被放進字符串常量池第二層是new關鍵字它強制在堆上創(chuàng)建了一個新的String對象。這兩件事是獨立的。很多剛?cè)胄械耐瑢W會疑惑一句代碼怎么可能創(chuàng)建兩個對象其實你把這句話拆開看就清楚了。abc是一個字符串字面量它走的是常量池的機制new String(...)走的是堆內(nèi)存分配機制。兩者各管各的互不干涉。面試官問這道題表面上是考字符串創(chuàng)建機制實際上是考三塊知識JVM運行時數(shù)據(jù)區(qū)、字符串常量池的存儲結(jié)構(gòu)、類加載過程中的常量池解析。任何一塊沒搞透都會在這個問題上露餡。1.1 常量池里已經(jīng)有了abc的情況假設業(yè)務代碼前面已經(jīng)執(zhí)行過String s1 abc;或者類加載時就因為其他地方引用了這個字面量導致它被放進了常量池。此時再執(zhí)行String str new String(abc)JVM先解析字符串字面量abc發(fā)現(xiàn)常量池里已經(jīng)有這個字符串了直接復用不再創(chuàng)建新的常量池對象。new關鍵字在堆上創(chuàng)建一個新的String對象這個對象的內(nèi)部value數(shù)組引用指向常量池中abc的字符數(shù)組JDK 8及以前是char[]JDK 9以后是byte[]。最終堆上多出一個String對象常量池中的abc保持原樣。所以這種情況下只創(chuàng)建一個對象。注意str只是一個棧上的引用變量它不是對象。1.2 常量池里沒有abc的情況如果這是程序第一次出現(xiàn)abc這個字面量情況就不同了類加載階段JVM解析new String(abc)對應的符號引用時發(fā)現(xiàn)常量池中還沒有abc于是在字符串常量池中創(chuàng)建這個字符串。接著執(zhí)行new指令在堆上再創(chuàng)建一個String對象并把常量池中abc的字符內(nèi)容復制一份給堆中的對象要準確說的話是堆中String對象內(nèi)部的value數(shù)組指向了常量池中字符串的字符數(shù)據(jù)具體指向關系在不同JDK版本下稍有區(qū)別后面詳述。一個在常量池一個在堆兩個都是實實在在的對象。這句話就是創(chuàng)建了兩個對象說法的來源。但你要清楚這有個前提之前沒有出現(xiàn)過abc這個字面量。實際開發(fā)中常量池里大概率已經(jīng)存在abc了。因為只要你在任何地方寫過abc這個字面量它就會進常量池。所以嚴格的答案是最多兩個通常一個。如果你張口就說兩個說明你沒搞懂前提條件。2. 深入理解字符串常量池存放位置與存儲內(nèi)容面試官問這道題還有一個用意考察你對JVM內(nèi)存結(jié)構(gòu)的理解是否停留在背書上。很多人知道常量池在方法區(qū)但方法區(qū)在Java 7前后經(jīng)歷了什么變化、常量池里存放的到底是什么、JDK 9之后字符串底層結(jié)構(gòu)改了會不會影響答案這些細節(jié)才是拉開差距的地方。2.1 方法區(qū)和字符串常量池的搬家史Java 7是一個重要的分水嶺。在此之前字符串常量池放在運行時數(shù)據(jù)區(qū)的永久代PermGen中。永久代的大小是固定的你通過-XX:PermSize和-XX:MaxPermSize來調(diào)。這導致一個臭名昭著的坑大量創(chuàng)建字符串很容易觸發(fā)OutOfMemoryError: PermGen space。Java 7開始字符串常量池從永久代移到了堆中確切地說是堆中的一塊獨立區(qū)域。永久代雖然還沒徹底廢除直到Java 8才被元空間Metaspace替代但字符串常量池已經(jīng)不在里面了。為什么這么改因為永久代空間有限而且GC回收效率不高。字符串是業(yè)務系統(tǒng)中最高頻的對象之一很多字符串都是有生命周期可回收的放在堆里就可以用新生代的Minor GC和Full GC統(tǒng)一管理內(nèi)存利用率大幅提升。Java 8的時候永久代被元空間取代字符串常量池依然留在堆中。這個結(jié)論很關鍵面試時如果能主動說出來Java 7之后字符串常量池在堆中而不是在方法區(qū)會讓面試官高看你一眼。2.2 常量池里放的是對象本身還是引用這個問題有點繞但對理解new String(abc)創(chuàng)建幾個對象很重要。在JDK 7之前字符串常量池中存放的是String對象本身也就是完整的字符串對象實例。池中的對象和堆中的對象是兩個獨立的實體。到了JDK 7及以后情況發(fā)生了變化。字符串常量池中存儲的不再是完整的String對象而是引用。這個引用指向堆中真正的String對象。也就是說池中abc對應的String對象的實體存放在堆里常量池只保存指向它的引用。這一點怎么驗證有兩種常見的推斷方式。第一種是intern()方法的返回值比較。同一個字符串多次調(diào)用intern()拿到的引用必須是同一個這是JDK規(guī)范強制要求的。如果常量池存的是引用那多個地方指向同一個堆對象引用自然一致。第二種方式是看堆內(nèi)存的對象數(shù)量。JDK 7之后如果池中保存的是對象實體而不是引用那么邏輯上和堆中的對象會有重復創(chuàng)建的問題內(nèi)存浪費很明顯。改成引用之后一份數(shù)據(jù)處處復用GC壓力也小得多。順帶提一個高頻記憶點new String(abc)創(chuàng)建出來的堆對象它的內(nèi)部字符數(shù)組其實也是引用常量池中abc的字符數(shù)據(jù)。所以在JDK 7之后確實只創(chuàng)建了一個新的String對象殼體數(shù)據(jù)是共享的。這個認識對你的理解深度有質(zhì)的提升。2.3 JDK 9 壓縮字符串后答案變了嗎還有一個新情況。JDK 9引入了字符串壓縮Compact StringsString類的內(nèi)部存儲從char[]改成了byte[]同時增加了一個編碼標志位coder用來區(qū)分LATIN-1單字節(jié)還是UTF-16雙字節(jié)。這會影響上面的答案嗎不影響。無論是char[]還是byte[]字符串對象的本質(zhì)還是一個對象引用加一段字符數(shù)據(jù)。字面量abc仍然會進入常量池new仍然會在堆上創(chuàng)建新的String對象。只是內(nèi)部數(shù)據(jù)存儲格式變了創(chuàng)建對象的數(shù)量計算邏輯沒有任何變化。但這種變化可以作為一個加分項。當面試官問你對JDK 9之后String底層的改動有了解嗎時你能答出這一點說明你在持續(xù)跟進新版本的JVM演進。3. 驗證結(jié)果三組代碼和一把反編譯工具光說不練假把式。下面用真實代碼驗證一下順便把相關的邊界情況也測一遍。建議你直接在自己電腦上跑一遍印象會深很多。3.1 三組最經(jīng)典的對比代碼先看第一組String s1 abc; String s2 abc; System.out.println(s1 s2); // trueabc是字面量s1賦值時JVM把abc放入了常量池假設之前沒有s2賦值時直接在常量池中找到了abc復用了同一個引用。所以兩個引用相等。第二組String s1 new String(abc); String s2 new String(abc); System.out.println(s1 s2); // false兩個new創(chuàng)建了兩個不同的堆對象它們的引用指向不同地址所以不相等。注意這里的常量池中只有一個abc因為字面量只會在第一次解析時創(chuàng)建一次。第三組String s1 abc; String s2 new String(abc); System.out.println(s1 s2); // falses1指向常量池中的字符串s2指向堆中的字符串兩個不同的對象。這個結(jié)果也是混亂的重災區(qū)很多人以為字面量賦值和new之間有些“自動優(yōu)化”的關聯(lián)實際上沒有。再看一個intern()的用法String s1 abc; String s2 new String(abc); String s3 s2.intern(); System.out.println(s1 s2); // false System.out.println(s1 s3); // trues2.intern()做的事情是如果常量池中已經(jīng)有abc返回池中對應字符串的引用這里的引用其實指向的是堆中那個和s1相同的對象如果池中沒有把s2的引用放入池中并返回。所以s3和s1指向同一個字符串相等。JDK 7之后的intern()返回的不一定是“常量池里新創(chuàng)建的”而可能是堆中已有字符串對象的引用這個細節(jié)也值得知道。3.2 用javap反編譯看看到底執(zhí)行了什么javap -v是分析這類問題的利器。寫一段最簡單的代碼public class StringTest { public static void main(String[] args) { String str new String(abc); } }編譯之后執(zhí)行javap -v StringTest.class關鍵字節(jié)碼段長這樣0: new #2 // class java/lang/String 3: dup 4: ldc #3 // String abc 6: invokespecial #4 // Method java/lang/String.init:(Ljava/lang/String;)V逐條解釋new在堆中分配String對象空間對象引用入棧。dup復制引用棧頂?shù)闹狄驗榻酉聛碚{(diào)用構(gòu)造器會消耗一份引用后面保存引用還要一份。ldc從常量池加載字符串a(chǎn)bc的引用壓入操作數(shù)棧。這里就是觸發(fā)常量池字符串創(chuàng)建或復用的關鍵指令。invokespecial調(diào)用構(gòu)造方法把abc作為參數(shù)傳給String的構(gòu)造函數(shù)完成對象初始化。注意ldc只是把常量池中的abc取出來傳參不會創(chuàng)建新的字符串。真正的對象記憶點還是new那一條指令。如果你把代碼改成String str abc;看字節(jié)碼0: ldc #2 // String abc 2: astore_1只有一條加載指令沒有new。這就是為什么字面量賦值不會在堆上創(chuàng)建String對象的原因。在實際面試中如果能手寫出這段字節(jié)碼并解釋ldc、new、invokespecial各自的作用說明你是真懂而不是背答案效果直接拉滿。3.3 連續(xù)new一百次會不會有一百個對象有同學會問如果循環(huán)里執(zhí)行new String(abc)一千次那一千個對象必然有但常量池中的abc始終只有一個。因為ldc指令每次都會去常量池中找abc找到了就直接復用。寫段代碼驗證一下String[] arr new String[1000]; for (int i 0; i 1000; i) { arr[i] new String(abc); } System.out.println(arr[0] arr[1]); // false堆上兩個不同對象用jmap或者VisualVM看堆中的對象數(shù)量你會發(fā)現(xiàn)有1000個String對象但char[]或byte[]數(shù)組大概率遠少于1000個因為字符數(shù)據(jù)是共享的。這一點再次印證了JDK 7之后字符串對象和數(shù)據(jù)存儲分離的設計。順便說一句這種代碼寫多了會產(chǎn)生大量無用對象屬于典型的壞味道。實際業(yè)務中寫new String(abc)基本是多余操作除非你要故意生成一個可變字符串的副本。這也是面試官后面經(jīng)常追問的那你還寫new String干什么3.4 一個容易犯的錯凡字符串都進常量池常量池不是無限的。JDK 7之后雖然在堆中但堆也不是無限的。如果你在代碼里動態(tài)拼接大量字符串比如String s ; for (...) { s other; }這些拼接產(chǎn)生的中間字符串不會全部進常量池它們就是普通的堆對象占用堆內(nèi)存。能進常量池的通常是編譯期可知的字符串字面量以及顯式調(diào)用intern()的字符串。運行時拼接出來的新字符串如果沒有intern()就是純粹的堆對象和常量池沒有關系。寫代碼時別覺得字符串會進常量池很省就肆意拼接常量池滿了照樣OOM。4. 面試官的連環(huán)追問從這一題延伸出來的考點這題很少被單獨問完就放你走。面試官基本都會順著往下連環(huán)追問。我總結(jié)了一串高頻追問每一個都值得認真準備。4.1 追問一String s abc創(chuàng)建了幾個對象答案是最多一個。如果常量池中已經(jīng)有abc直接復用0個新對象如果沒有常量池中創(chuàng)建一個總共一個對象。對比一下這里和new String(abc)的差別在new關鍵字。沒有new就不會在堆上額外創(chuàng)建對象。有人會摳細節(jié)類加載階段會不會提前把abc創(chuàng)建好如果你這個類里其它地方或者其他類已經(jīng)引用了abc那么在你執(zhí)行這一行之前池里就已經(jīng)有它了。這就是為什么說最多一個而不是必然一個。4.2 追問二字符串拼接到底創(chuàng)建了幾個對象這是另外一個經(jīng)典問題??磶追N情況第一種String s a b c;編譯期就會優(yōu)化成abc常量池中只創(chuàng)建一個abc。字節(jié)碼里只有一條ldc指令不會有任何new和拼接操作。第二種String a a; String b b; String s a b;引用了變量編譯期無法確定值走的是StringBuilder拼接。實際執(zhí)行等價于String s new StringBuilder().append(a).append(b).toString();創(chuàng)建了StringBuilder對象和一個新的String對象。如果還要較真的話如果拼接結(jié)果在常量池中不存在toString()產(chǎn)生的字符串不會自動進池abc就可能同時存在池中一個如果之前有字面量出現(xiàn)過、堆中一個。這就是為什么在循環(huán)里做字符串拼接性能極差每次循環(huán)都創(chuàng)建兩個新對象StringBuilder和String簡單粗暴地說是性能災難。4.3 追問三String為什么要設計成不可變判斷你對API設計的理解。核心原因有三條第一字符串常量池能夠復用的前提就是不可變。如果字符串能變那么多個引用指向同一個abc改一個就影響所有引用這會造成嚴重的邏輯錯誤。第二String被廣泛用作HashMap的key和緩存鍵值。HashCode在第一次計算后被緩存起來如果字符串內(nèi)容可變hashCode就失效了HashMap的整個查找機制就崩了。第三線程安全。不可變對象天然線程安全任何線程讀到的都是同一個狀態(tài)不需要加鎖同步。還有網(wǎng)絡安全方面的考慮比如路徑處理、網(wǎng)絡協(xié)議解析中大量使用字符串如果可變中途被篡改會產(chǎn)生安全漏洞。這題的延伸很寬答得好能體現(xiàn)出你有系統(tǒng)設計的全局觀。4.4 追問四說說String、StringBuffer、StringBuilder的區(qū)別順便把這條也備好。三句話解釋String不可變適合字符串常量場景優(yōu)點是線程安全、可復用缺點是拼接效率低。StringBuffer線程安全方法用synchronized修飾適合多線程公共字符串操作的場景缺點是性能稍差。StringBuilder線程不安全方法不加鎖是單線程下的最佳性能選擇日常開發(fā)中使用頻率遠高于StringBuffer。面試官如果追問“StringBuffer轉(zhuǎn)成String怎么做”直接答toString()即可但可以說清楚轉(zhuǎn)換過程會生成一個新的String對象原StringBuffer不受影響。4.5 怎么回答才能拉開差距我的建議是分三層遞進回答。第一層直接給結(jié)論分情況說清楚一個對象和兩個對象的前提條件。這一層就能過大多數(shù)面試。第二層補充說明JDK版本帶來的差異。包括Java 7常量池移入堆、JDK 9之后的byte[]存儲、intern()在JDK 7前后的行為變化。這一層能讓面試官確定你有實戰(zhàn)經(jīng)驗不是背面試題。第三層主動提及字節(jié)碼層面的執(zhí)行過程說清楚ldc、new、invokespecial各干了什么。這一層能體現(xiàn)出你真的讀過Class文件、理解JVM指令集。到了這個深度面試官通常會滿意地點點頭然后換下一個話題。還有一個小技巧回答時可以用一個實際線上問題舉例。比如我們之前排查過一個內(nèi)存泄漏就是因為把大量運行時生成的字符串intern()進了常量池導致堆中無用字符串無法被回收最終Full GC頻繁。這樣的實戰(zhàn)案例比單純背書有說服力得多。5. 操作建議與避坑心得最后聊點實在的。這道題刷三遍不如親手驗證一遍推薦幾個低成本的動手方式第一在本地用JDK 8和JDK 17分別跑一遍上面的測試代碼對比輸出結(jié)果。你會發(fā)現(xiàn)結(jié)論完全一致這印證了字符串創(chuàng)建機制在JDK 8到JDK 17之間是穩(wěn)定的。順便觀察一下兩個版本默認GC的變化你會對JVM演進有更強的體感。第二用javap -c -verbose實際反編譯幾個類不光是String相關的還包括字符串拼接、常量折疊、switch-case字符串匹配這些場景。字節(jié)碼看得多了再遇到類似問題就有畫面感了。第三有條件的話用JFRJava Flight Recorder或者VisualVM抓一下堆轉(zhuǎn)儲看看字符串對象和字符數(shù)組的實際數(shù)量關系。眼見為實比看十篇文章都管用。一些容易踩的坑再強調(diào)一遍不要在循環(huán)中用拼接字符串。性能損耗很大還容易觸發(fā)GC壓力。不要濫用intern()。JDK 7之后intern()會把引用存入堆中的常量池區(qū)域但池不是無限大的大量動態(tài)字符串intern()到池里會導致這塊區(qū)域膨脹且難以回收。不要用比較字符串內(nèi)容除非你能確認兩個引用指向同一個常量池對象。業(yè)務代碼中一律用equals如果你想追求性能至少也要在理解機制的前提下再用而且只限于你能把控的場景。如果做字符串去重優(yōu)化優(yōu)先考慮JVM的G1垃圾收集器自帶的字符串去重特性String Deduplication而不是自己手動intern()前者的安全性和效果在絕大多數(shù)場景下都更好。我自己在帶團隊做code review時幾乎每次都能遇到字符串拼接相關的性能問題。有一次排查線上Full GC頻繁就是因為一個定時任務在循環(huán)里用拼接大字符串每次迭代產(chǎn)生大量中間String對象年輕代直接被撐爆。改成StringBuilder之后GC頻率直線下降。所以這些知識不只是面試用實際排查問題的時候非常管用。還有一次處理一個報表導出的功能里面大量使用String.valueof()和字符串拼接生成CSV內(nèi)存占用居高不下。后來我們把所有已知枚舉值改成常量引用動態(tài)部分用StringBuilder內(nèi)存下降了將近40%。這就是字符串對象創(chuàng)建機制在真實業(yè)務中的價值體現(xiàn)。希望這篇內(nèi)容能幫你在面試時講清楚原理在寫代碼時不再踩字符串的坑。千言萬語匯成一句話理解對象創(chuàng)建的本質(zhì)比背答案重要得多。