先說一些話題外話。html
上篇文章 Core Java 52 問(含答案) 閱讀量意外的高,總算沒白費我整理了一個清明假期。其實也挺出乎個人意料的,由於涉及的內容大多數是 Java 基礎。可是基礎可能也正是不少人所欠缺的,正如我一直在寫的 走進 JDK
系列,也算是從 JDK 源碼的角度,從 JVM 的角度來梳理 Java 基礎。萬丈高樓平地起,對於一個程序員來講,拋去如今紛繁複雜,學也學不完的各類框架,計算機、操做系統、網絡、語言基礎等基礎知識,這些東西是更重要的,後續的文章也會朝着這個方向,爭取作一個 "基礎型"
程序員。你們也能夠多多關注個人公衆號 秉心說
, 持續輸出 Java、Android 原創知識分享,每週也會帶來一篇閱讀分享。java
PS : 以前好像忘記說了,整個
走進 JDK
專欄都是基於java 1.8
源碼進行分析的。關於其餘版本的差別,可能會提到,可是不會細說。全部添加註釋的代碼都上傳到個人 Github 了,傳送門git
好了,進入今天的正文吧!在 走進 JDK 之 String 中,結合源代碼分析了 String
的不可變性和它的一些經常使用方法。那麼,你以爲你瞭解 String
了嗎?來考考你吧,看看下面這題:程序員
String str1 = new String("j") + new String("ava");
str1.intern();
String str2 = "java";
System.out.println(str1 == str2);
String str3 = new String("ja") + new String("va2");
String str4 = "java2";
str3.intern();
System.out.println(str3 == str4);
複製代碼
你能快速準確的給出答案嗎?我先劇透一下,打印結果是 :github
true
false
複製代碼
若是你答對了而且能準確的在腦海裏回想一遍編譯期以及運行期每一行代碼都發生了什麼,那麼就沒有往下看的必要了。若是不行,且聽我慢慢道來。面試
在說 String
以前,先說一些基本概念,否則後面的內容很容易看的雲裏霧裏。緩存
我在以前的一篇文章 Class 文件格式詳解 中也說到過 Class 常量池
,這裏再總結一下。bash
常量池中主要存放兩大類常量:字面量(Literal)
和 符號引用(Symbolic Reference)
,字面量比較接近於 Java 語言層面的常量概念,如文本字符串 、聲明爲 final 的常量值等。而符號引用則屬於編譯原理方面的概念,包括了下面三類常量:微信
經過 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
。
對照下圖理解一下:
再來講說開頭的題目中出現的 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
系列寫了兩篇了,
最後一篇計劃寫一下關於字符串拼接的知識,回想一下你在代碼中使用過哪些拼接字符串的方式,以及它們的區別,敬請期待。
文章首發於微信公衆號:
秉心說
, 專一 Java 、 Android 原創知識分享,LeetCode 題解,歡迎關注!