你並不瞭解 String
先說一些話題外話。

上篇文章 Core Java 52 問(含答案) 閱讀量意外的高,總算沒白費我整理了一個清明假期。其實也挺出乎我的意料的,因為涉及的內容大多數是 Java 基礎。但是基礎可能也正是很多人所欠缺的,正如我一直在寫的 走進 JDK
系列,也算是從 JDK 原始碼的角度,從 JVM 的角度來梳理 Java 基礎。萬丈高樓平地起,對於一個程式設計師來說,拋去現在紛繁複雜,學也學不完的各種框架,計算機、作業系統、網路、語言基礎等基礎知識,這些東西是更重要的,後續的文章也會朝著這個方向,爭取做一個 "基礎型"
程式設計師。大家也可以多多關注我的公眾號 秉心說
, 持續輸出 Java、Android 原創知識分享,每週也會帶來一篇閱讀分享。
PS : 之前好像忘記說了,整個 走進 JDK
專欄都是基於 java 1.8
原始碼進行分析的。關於其他版本的差異,可能會提到,但是不會細說。所有添加註釋的程式碼都上傳到我的 Github 了, 傳送門
好了,進入今天的正文吧!在走進 JDK 之 String 中,結合原始碼分析了 String
的不可變性和它的一些常用方法。那麼,你覺得你瞭解 String
了嗎?來考考你吧,看看下面這題:
String str1 = new String("he") + new String("llo"); str1.intern(); String str2 = "hello"; System.out.println(str1 == str2); String str3 = new String("h") + new String("ello2"); String str4 = "hello2"; str3.intern(); System.out.println(str3 == str4); 複製程式碼
你能快速準確的給出答案嗎?我先劇透一下,列印結果是 :
true false 複製程式碼
如果你答對了並且能準確的在腦海裡回想一遍編譯期以及執行期每一行程式碼都發生了什麼,那麼就沒有往下看的必要了。如果不行,且聽我慢慢道來。
在說 String
之前,先說一些基本概念,不然後面的內容很容易看的雲裡霧裡。
Class 常量池
我在之前的一篇文章Class 檔案格式詳解 中也說到過 Class 常量池
,這裡再總結一下。
常量池中主要存放兩大類常量: 字面量(Literal)
和 符號引用(Symbolic Reference)
,字面量比較接近於 Java 語言層面的常量概念,如文字字串 、宣告為 final 的常量值等。而符號引用則屬於編譯原理方面的概念,包括了下面三類常量:
- 類和介面的全限定名(Fully Qualified Name)
- 欄位的名稱和描述符(Descriptor)
- 方法的名稱和描述符
通過 javap
命令就可以看到 Class 檔案的常量池部分了。
執行時常量池
執行時常量池(Runtime Constant Pool)是方法區的一部分,它是 Class 檔案中每一個類或介面的常量池表的執行時表示形式。Class 常量池中存放的編譯期生成的各種字面量和符號引用,將在類載入後進入方法區的執行時常量池中存放。
方法區與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態常量、即時編譯器編譯後的程式碼等資料。雖然 Java 虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫 Non-Heap(非堆)
。目的應該是與 Java 堆區分開來。
字串常量池
字串常量池是用來快取字串的。對於需要重複使用的字串,每次都去 new
一個 String
例項,無疑是在浪費資源,降低效率。所以,JVM 一般會維護一個字串常量池,它是全域性共享的,你可是把它看成是一個 HashSet<String>
。需要注意的是,它儲存的是堆中字串例項的引用,並不儲存例項本身。
看完上面這幾個概念的介紹,記住下面幾個重點:
-
Class 常量池
是編譯期生成的 Class 檔案中的常量池 -
執行時常量池
是Class 常量池
在執行時的表示形式 -
字串常量池
是快取字串的,全域性共享,它儲存的是String
例項物件的引用
先不看文章開頭提出的問題,來看一道經典的面試題:
String str = new String("hello"); 複製程式碼
上面的程式碼中建立了幾個物件?
這樣問其實前提還不夠明確,再限定一些條件:
假設這行程式碼就是 main()
方法的第一行程式碼,且字串常量池中原本沒有 hello
的引用
首先經過編譯器編譯, Class 常量池
中儲存了 hello
字串。按照 Java 虛擬機器規範,在類載入過程的解析(reslove)階段,JVM 將 Class 常量池
中的符號引用替換為直接引用放入 執行時常量池
, 並將 Class 常量池
中的字面量在堆中生成對應的 String
例項物件。另外,JVM 順道會把字串快取起來,即把它的引用加入到字串常量池。
那麼,在類載入階段, hello
字串的例項就已經建立,且字串常量池也儲存了其引用,真的是這樣嗎?其實不是的。Java 虛擬機器規範中並沒有規定解析階段發生的具體時間,只要求了在執行 16 個用於操作符號引用的位元組碼指令之前,先對它們所使用的符號引用進行解析。所以一般在類載入階段不會進行解析過程,還是等到一個符號引用將要被使用前才去解析它。也就是說到執行期,才會去建立字串例項並存入字串常量池。
接著通過位元組碼看看 String str = new String("hello")
是如何執行的,通過 javap
檢視如下:
0: new#2// class java/lang/String 3: dup 4: ldc#3// String hello 6: invokespecial #4// Method java/lang/String."<init>":(Ljava/lang/String;)V 9: astore_1 10: return 複製程式碼
new
表示新建了一個 String
物件。
dup
表示複製棧頂數值並將複製值壓入棧頂。這裡壓入的是預設引數 this
。
ldc
是個很關鍵的命令,它表示將 int 、float 或 String 型常量從常量池中推送至棧頂。 ldc
就是之前提到的 16 種位元組碼指令中的一種。經過編譯器和類載入階段, hello
並不存在,那麼此時 ldc
推什麼去棧頂呢?其實, ldc
指令就會除觸發類載入的解析過程。當字串常量池中存在 hello
時則直接返回其引用。若不存在,在堆中建立 hello
例項並將其引用存入字串常量池。
所以上面限定的條件下,會在執行 ldc
命令時,在堆中建立 hello
例項並將其引用存入字串常量池。
invokespecial
執行了 init()
方法,即 String
的建構函式。
astore_1
表示將引用 str
指向剛剛建立的字串物件。
大致說一下流程, new
一個 String
物件,然後利用 dup
和 ldc
向運算元棧壓入建構函式所需的兩個引數,預設引數 this
和字串 hello
,接著呼叫 init
執行建構函式。最後,通過 astore_1
將引用 str
指向字串例項。這樣一看,建立了幾個物件就顯而易見了吧!
趁熱打鐵,再來一題:
String str1 = "java"; String str2 = new String("java"); System.out.println(str1 == str2); 複製程式碼
看一下位元組碼就知道在執行期,第一句程式碼沒有新建物件,即沒有使用 new
指令。而第二行程式碼使用了 new
指令,所以顯然結果是 false
。
對照下圖理解一下:

String.intern()
再來說說開頭的題目中出現的 intern()
方法。說起來簡單,其實也不簡單,它的作用是查詢當前字串常量池是否存在該字串的引用,如果存在直接返回引用;如果不存在,則在堆中建立該字串例項,並返回其引用。結合下面這題來說明一下:
String str1 = "java"; // 1 String str2 = new String("java"); // 2 String str3 = new String("java").intern(); // 3 System.out.println(str1 == str2); System.out.println(str1 == str3); 複製程式碼
s1 == s2
無疑是 false
,前面已經分析過。那麼 s1 == s3
呢?老規矩,來分析一下程式碼,從編譯器到執行期。
編譯後 "java"
字串進入 Class 常量池
,此時並未在堆中建立物件,也未在字串常量池中快取 "java"
。執行期,執行第一行程式碼,建立 "java"
字串例項並存入字串常量池, str1
等同於常量池中的引用。第二行程式碼,會在堆中 new
一個 String 例項,並將 str2
指向它。第三行程式碼,先在堆中 new
一個 String 例項,然後呼叫 intern()
方法,嘗試將其駐留在字串常量池, intern()
方法首先會檢查字串常量池中是否已經駐留過該字串,第一行程式碼中 "java"
字串已經快取到常量池了, intern()
方法會直接返回已經駐留的引用,所以這裡 str1
和 str3
是等價的。
圖片會更加直觀一點:

基本概念都捋清楚之後,回頭再來看開頭的第一道題目,你會發現其實很簡單。
String str1 = new String("j") + new String("ava"); // 1 str1.intern(); // 2 String str2 = "java"; // 3 System.out.println(str1 == str2); // 4 String str3 = new String("ja") + new String("va2"); // 1 String str4 = "java2"; // 2 str3.intern(); // 3 System.out.println(str3 == str4); // 4 複製程式碼
先看第一部分的 4 行程式碼。經過編譯, j
、 ava
和 java
進入 Class 常量池
中。 類載入階段並不會建立例項,駐留字串常量池。到執行期,第一行程式碼中會建立 j
、 ava
例項並駐留常量池, +
會被 JVM 自動優化為 StringBuilder
,拼接出 java
字串,將 str1
指向該字串例項。需要注意的是,這裡不會將 java
駐留到常量池。第二行程式碼呼叫了 intern()
,由於此時常量池中沒有 java
,所以將 str1
的引用存入了常量池。第三行程式碼, ldc
指令發現常量池中就有 java
,直接返回常量池中其對應的引用,並賦給 str2
。所以 str1
和 str2
是相等的。
再看第二部分的 4 行程式碼,和第一部分相比,僅僅只是把 intern()
方法的呼叫往下挪了一行,就造成了最後結果的不同。經過編譯, ja
、 va2
和 java2
進入 Class 常量池
中。第一行程式碼的執行和上一塊一樣,執行完成後字串常量池中並沒有駐留 java2
的引用, str3
指向堆中例項。第二行程式碼, ldc
指令發現常量池中沒有 java2
,就建立一個 java2
例項並將其駐留到常量池, str4
指向該例項。第三行程式碼, str3.intern()
,常量池中已經儲存了 java2
的引用,直接返回該引用。只是我們並沒有去接收返回值。所以, str3
和 str4
指向的是不同的記憶體地址。

上面的所有圖示中把堆記憶體和字串常量池分開畫了,其實只是為了看起來清晰一些,實際上字串常量池就是在堆中的。當然,前提條件是 Java 1.6 之後。在 Java 1.6,常量池是在永久代中的,和 Java 堆是完全分開來的區域,這也會導致上述程式碼執行結果不一樣,有興趣的可以試一下,我這裡就不再展開分析了。
總結
關於 String
,展開來細說的話,涉及的內容十分之廣。不可變類的實現,類載入的過程,解析階段的延遲執行,全域性字串常量池的使用,Java 記憶體區域 ...... 理解了這些知識點,才能真正的去了解 String
,面對那些刁鑽的面試題才可以遊刃有餘,捋清每一步流程。
最後推薦兩篇經典文章,一篇是 R 大
的 請別再拿“String s = new String("xyz");建立了多少個String例項”來面試了吧 。另一篇是美團技術團隊的 深入理解 String.intern() 。
String
系列寫了兩篇了,
你並不瞭解 String
最後一篇計劃寫一下關於字串拼接的知識,回想一下你在程式碼中使用過哪些拼接字串的方式,以及它們的區別,敬請期待。
文章首發於微信公眾號: 秉心說
, 專注 Java 、 Android 原創知識分享,LeetCode 題解,歡迎關注!
