你並不瞭解 String

先說一些話題外話。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 文件格式詳解 中也說到過 Class 常量池,這裏再總結一下。bash

常量池中主要存放兩大類常量:字面量(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 對象,而後利用 dupldc 向操做數棧壓入構造函數所需的兩個參數,默認參數 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() 方法會直接返回已經駐留的引用,因此這裏 str1str3 是等價的。

圖片會更加直觀一點:

基本概念都捋清楚以後,回頭再來看開頭的第一道題目,你會發現其實很簡單。

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 行代碼。通過編譯,javajava 進入 Class 常量池 中。 類加載階段並不會建立實例,駐留字符串常量池。到運行期,第一行代碼中會建立 java 實例並駐留常量池,+ 會被 JVM 自動優化爲 StringBuilder ,拼接出 java 字符串,將 str1 指向該字符串實例。須要注意的是,這裏不會將 java 駐留到常量池。第二行代碼調用了 intern(),因爲此時常量池中沒有 java,因此將 str1 的引用存入了常量池。第三行代碼,ldc 指令發現常量池中就有 java,直接返回常量池中其對應的引用,並賦給 str2。因此 str1str2 是相等的。

再看第二部分的 4 行代碼,和第一部分相比,僅僅只是把 intern() 方法的調用往下挪了一行,就形成了最後結果的不一樣。通過編譯,java2java2 進入 Class 常量池 中。第一行代碼的執行和上一塊同樣,執行完成後字符串常量池中並無駐留 java2 的引用,str3 指向堆中實例。第二行代碼,ldc 指令發現常量池中沒有 java2,就建立一個 java2 實例並將其駐留到常量池,str4 指向該實例。第三行代碼,str3.intern(),常量池中已經保存了 java2 的引用,直接返回該引用。只是咱們並無去接收返回值。因此,str3str4 指向的是不一樣的內存地址。

上面的全部圖示中把堆內存和字符串常量池分開畫了,其實只是爲了看起來清晰一些,實際上字符串常量池就是在堆中的。固然,前提條件是 Java 1.6 以後。在 Java 1.6,常量池是在永久代中的,和 Java 堆是徹底分開來的區域,這也會致使上述代碼執行結果不同,有興趣的能夠試一下,我這裏就再也不展開分析了。

總結

關於 String,展開來細說的話,涉及的內容十分之廣。不可變類的實現,類加載的過程,解析階段的延遲執行,全局字符串常量池的使用,Java 內存區域 ...... 理解了這些知識點,才能真正的去了解 String,面對那些刁鑽的面試題才能夠遊刃有餘,捋清每一步流程。

最後推薦兩篇經典文章,一篇是 R 大請別再拿「String s = new String("xyz");建立了多少個String實例」來面試了吧。另外一篇是美團技術團隊的 深刻理解 String.intern()

String 系列寫了兩篇了,

走進 JDK 之 String

你並不瞭解 String

最後一篇計劃寫一下關於字符串拼接的知識,回想一下你在代碼中使用過哪些拼接字符串的方式,以及它們的區別,敬請期待。

文章首發於微信公衆號: 秉心說 , 專一 Java 、 Android 原創知識分享,LeetCode 題解,歡迎關注!

相關文章
相關標籤/搜索