java中的String

weixin_34337265發表於2018-02-23

談起String,大家肯定一定都不陌生,肯定也都使用過,出去面試的時候也有碰到過問相關原理的。今天就結合String相關原始碼對其相關原理做一個簡要的分析。

String相關原始碼解析

注:考慮到String原始碼比較簡單,本文將針對一些比較容易造成誤解的地方為切入點做相關分析,另外,本文原始碼的jdk版本為:jdk1.7.0_79

String的不可變性

對於初使用java的小夥伴來說,很容易誤認為String物件是可變的,但是其實String物件是一旦宣告建立好後就不允許改變的,那麼接下來我們結合原始碼來看看String是如何實現不可變的:

  1. 使用final關鍵字來保證不可變:


    3994601-91697f4c121b4e55.png
    String成員變數

    從原始碼可以看出:

    • String是一個final類,保證使用者不能通過繼承來修改String類;
    • 每一個String物件都維護著一個被final關鍵詞修飾的char型別的陣列value,看到這裡大家可能有一個疑惑了,陣列其實是一個引用型別,final只能限制value引用不變,但是陣列元素的值是可以改變的啊,那不是可以通過修改資料的值來修改String的內容咯?來個簡單例子試下:


      3994601-fdd926243b15a646.png
      不可變測試

      執行結果:


      3994601-53fa1e1d1594d624.png
      執行結果

      從執行結果可以看出:String並沒有被修改。當然咯,你能想到的可以改變的地方Java開發者肯定也想到了,他們不會給你這個修改的機會的:
      3994601-0ce7311d239666a2.png
      String構造方法

      從該構造方法可以看出,String很雞賊的copy了一份,從而以保證外部陣列的改變完全不會影響到String物件。

  2. 一旦有改變就重新建立一個新的物件
    從外部修改String物件是不可能了,那我們可以通過String提供的一些方法,比如substringreplace來修改麼?以substring方法實現為例,我們來看下能不能修改:

    3994601-72daff3688fee954.png
    substring實現

    從原始碼加紅框部分可以看出:只要剪下後的字串與原字串不相等就會建立一個新的String物件,並不能修改原來的String物件。

注:可變的字串可以用StringBuilder和StringBuffer宣告

==與String.equals()

在Java中,==是對比兩個記憶體單元的內容是否一樣,如果是原始型別,直接比較它們的值是否相同,如果是引用型別,比較的就是引用的值,換言之就是比較兩個物件的地址是否一樣。

equals()方法則是Object類定義的:

3994601-faafe0151bcc0990.png
Object.equals實現

從原始碼可以看出,Object類的equals實現很簡單,就是使用==來匹配。如果對應的類不重寫equals方法,那麼equals方法其實也就是比較物件地址。看到這裡小夥伴們估計有疑惑了,既然用的都是==,沒有這個方法,其實也是可以使用的,為什麼還要讓equals方法存在呢?equals方法存在的意義其實是希望子類重寫這個方法的,物件的比較需要根據具體的業務屬性值來做比較,而不是隻有兩個物件的地址相同它們才相等。

接下來我們看看String是如何實現equals方法的:


3994601-a03d1bad24c97d70.png
String.equals實現

從原始碼可以看出:

  1. 如果兩個String物件地址相同,它們兩個肯定相等,直接返回true;

  2. 如果兩個String物件地址不想同,比較它們的私有屬性:字元陣列value,如果兩個value長度相同並且每一個字元都相等,則兩個字串相等,否則,不相等。

String的equals比較的是字串的值是否相等,並不拘泥於記憶體地址。

+與StringBuilder.append()

看了好多好多部落格都說String的+運算效率要比StringBuilder.append()的效率低很多很多,但是我跟他們的看法並不相同,來個簡單的例子驗證下我的看法:

3994601-33bd8dd57846658c.png
測試案例

用javap -c反編譯下:
3994601-65ed7243307f0512.png
String+反編譯結果

從反編譯結果可以看出, +在做單個變數拼接的時候其實用的是StringBuilder.append()方法, 所以它們的效率並沒有太大的差別。但是,如果把+放在迴圈中做字串迴圈拼接時,+的效率就會低很多。來個簡單的例子:
3994601-7ab651495405a97f.png
迴圈測試案例

同樣用javap -c看下反編譯下:
3994601-5aeeb8f0cf3e32b4.png
迴圈String+反編譯結果

從反編譯結果可以看出,每一次的迴圈都會產生一個新的StringBuilder物件,通過StringBuilder的append方法完成字串+操作。在迴圈的過程中,result長度越來越長,佔用的空間也就會越來越大,在使用String.append()做拼接的時候比較會容易出現OOM,同時,StringBuilder.toString()也會copy一個新的字串,在分配空間的時候也比較容易出現OOM。總結來說,為什麼說迴圈的拼接+的效能查主要是因為大量迴圈中的大量記憶體使用使記憶體開銷變大,這會導致頻繁的GC,而且更多的是full gc,所以效率才會急劇下降。

String常量池與String.intern()

JVM開發者為了提高效能和減少記憶體的開銷,在例項化字串時使用字串常量池,並提供以下使用規則:

  1. 每一個字串常量在常量池中全域性唯一;

  2. 通過String ss = "test"雙引號宣告的字串會直接儲存在常量池中;

  3. 字串物件可以通過String.intern()方法將其儲存到常量池中。

接下來以一個簡單的例子,我們來看看在記憶體中的關係到底是怎麼樣的:

3994601-9e17eac71bce2dbc.png
String記憶體關係測試

從上圖測試程式碼可以看出,宣告瞭三個字串物件a、b、c,a,b採用雙引號方式宣告,都直接指向JVM字串常量池,a == b應該返回true,c採用new關鍵字宣告,此時會在堆上建立一個物件,c指向該物件,但是,c的value還是指向JVM常量池中的test字串,此時,a == c應該返回false。我們實際執行下看下返回結果到底是不是這樣:
3994601-f7919579dbc1a782.png
執行結果

從執行結果可以清晰的看到,上面的分析是正確的。

接下來,我們來看下,在用雙引號方式宣告字串時,HotSpot是如何實現直接將其放在常量池中的。我們就上面的字串測試案例,javap -c反編譯下:

3994601-385e00142a1ee667.png
String雙引號宣告反編譯

從反編譯結果可以看出,String a = "test"對應兩條JVM指令:

  1. ldc #2
    載入常量池中的指定項的引用到棧中,這裡#2表示載入第二項("test")到棧中;

  2. astore_<n>
    將引用賦值給第n個區域性變數,astore_1表示將1中的引用賦值給第一個區域性變數,即String a = "test"

我們來看下ldc指令在HotSpot中是如何實現的:

注:ldc指令在interpreterRuntime.cpp檔案中實現

3994601-a99ffe753311e696.png
ldc實現

ldc指令會根據載入的不同的常量進行一些不同的操作,當載入的是字串常量時,會呼叫constantPoolOop.string_at方法進行相關處理:
3994601-ce1ef8cbf32be55b.png
string_at實現

從原始碼可以看出,string_at主要乾了這兩件事兒:

  1. 獲取當前constantPoolOop例項的控制程式碼;

  2. 呼叫string_at_impl方法獲取字串引用。

接下來我們看看string_at_impl是如何獲取字串引用的:

3994601-74243134aa73b992.png
string_at_impl實現

從原始碼可以看出,字串物件最終其實是呼叫StringTable::intern來方法生成的,生成後會把該字串物件引用更新到常量池中,下一次如果再通過ldc指令宣告相同字串時就直接返回該字串的引用。這就是String記憶體關係測試a == b為什麼返回true,因為它們其實都指向常量池中的同一個引用。

String.intern()

3994601-9b20025f24f57840.png
String.intern實現

從原始碼可以看出,String.intern()是一個native的方法,在使用intern方法時:

  • 如果常量池中已經存在當前字串,就直接返回當前字串;

  • 如果常量池中不存在當前字串,將該字串新增到常量池中,然後返回該字串的引用。

既然是native的方法,那HotSpot中它到底是如何實現的呢?

HotSpot1.7中的intern

注:intern方法在String.c檔案中實現

3994601-660c66936f70e1ea.png
HotSpot的intern實現.png

從原始碼可以看出,intern方法實現的核心在於JVM_InternString方法:

注:JVM_InternString方法在jvm.cpp檔案中實現

3994601-8c1242d785e05386.png
JVM_InternString實現

跟ldc一樣,intern最終也呼叫了StringTable::intern方法生成字串的,接下來重點就是分析StringTable的相關實現了。

StringTable
StringTable實現很簡單,跟Java中的HashMap類似,接下來我們就來看看StringTable相關宣告:

3994601-b06b6a7eac44d62d.png
StringTable宣告

StringTable的宣告在symbolTable.hpp檔案中,從原始碼可以看出:StringTable繼承了Hashtable,它的構造引數指定了StringTable的大小為StringTableSize,預設值為1009。

注:StringTableSize相關宣告在globals.hpp檔案中:

3994601-6a6d457eab45f1f5.png
StringTableSize宣告

StringTable初始化
在建立StringTable時,通過其建構函式就完成了它的初始化,接下來我們就來看看StringTable初始化到底幹了些什麼。由於StringTable繼承了Hashtable,我們就先來看看Hashtable相關實現:

3994601-d227499245e92f2d.png
Hashtable宣告

Hashtable的宣告在hashtable.hpp中,從原始碼可以看出,Hashtable是一個模板類,繼承了基類BasicHashtable,初始化相關也在基類BasicHashtable中實現:
3994601-3221e5ad7cdd12df.png
BasicHashTable構造方法

在BasicHashtable的初始化中,主要乾了以下三件事:

  • 呼叫initialize方法初始化BasicHashtable相關基本值;

  • 呼叫NEW_C_HEAP_ARRAY方法在堆上為其分配桶節點空間;

  • 清空桶節點中的資料。

看完StringTable相關初始化之後,我們就該來進入正題,看看StringTable::intern方法的相關實現了。

StringTable::intern實現

3994601-6c083b517a486006.png
StringTable::intern實現

從原始碼可以看出:

  1. 呼叫java_lang_String::hash_string方法根據String物件中字元陣列的拷貝name和字元陣列長度len計算字串的hash值;

  2. 呼叫hash_to_index方法根據該字串的hash值計算出字串在StringTable中桶的位置index:

    3994601-e29b5ca9ab0b1123.png
    hash_to_index實現

  3. 呼叫lookup方法在StringTable查詢該字串:

    3994601-9433b84a70ec8e8c.png
    lookup實現

    遍歷桶節點下的HashtableEntry連結串列,如果在連結串列中可以找到對應的hash值,並且字串的值也相同,那麼該字串在StringTable中已經存在,返回該字串的引用,否則,返回NULL

  4. 如果StringTable中存在該字串,返回字串引用,否則,呼叫basic_add方法新增字串引用到StringTable中:

    3994601-45bc833d46fd4da0.png
    basic_add實現

    需要注意的,並不會每一個字串都進行復制操作,只要滿足!string_or_null.is_null() && (!JavaObjectsInPerm || string_or_null()->is_perm())條件就不會進行字串複製,HashtableEntry其實封裝的就是原字串的hash值和控制程式碼。

    注:


    3994601-f56a7f048da575d8.png
    JavaObjectsInPerm宣告.png

    JavaObjectsInPerm的預設值為false

    另外,其實整個新增字串引用到StringTable的操作是呼叫add_entry方法完成的:

    3994601-d5ae743e39048453.png
    add_entry實現

    add_entry並沒有複雜的自動擴容之類,操作簡單粗暴,每次就是直接在對應桶節點下的HashtableEntry連結串列裡做插入。那麼,當StringTable中的字串達到一定規模的時候,hash衝突會灰常嚴重,從而導致某一個桶節點下的連結串列會非常非常長,效能也就會急劇下降,很可能查詢的時間複雜度就從期望的o(1)降到o(n)了,所以大家在使用的時候也要視情況而定,不要亂用!

    注:jdk6的StringTable的大小是固定不可變的,就是預設的1009,在jdk7中,JVM提供了引數-XX:StringTableSize可以用於修改StringTable的長度。

綜上所述,在HotSpot1.7中,在執行intern方法時,如果StringTable已經存在相等的字串,返回StringTable中的字串引用,如果不存在,複製字串的引用到常量池中,然後返回。

jdk6和jdk7中的intern

上面的大篇幅文章介紹了HotSpot1.7中的intern實現原理,接下來就來個小例子實踐下:


3994601-e6d81a4f61b9bcdc.png
String.intern()測試

我們分別在jdk6和jdk7下執行下,結果竟然是:

  1. jdk6:false false

  2. jdk7:true false

吼吼,還能出現這個操作,相同的程式碼輸出結果竟然還是不一樣的~接下來就來解釋下為什麼輸出是不一樣的。

jdk6中的intern
jdk6中StringTable是放在Perm區的,它和heap有記憶體隔離,在執行intern方法時,如果StringTable中不存在該字串,JVM就會在StringTable中複製該字串並且返回引用,針對上述案例:

  1. 變數a分配在heap上,a.intern()指向的是Perm區StringTable中的引用,跟a指向的不是同一個引用,在做==判斷時返回false;

  2. 同理,對於變數b也是一樣的,b.intern()和b指向的也不是同一個引用,在做==判斷當然也返回false。

jdk7中的intern
由於Perm區是一個靜態區域,主要儲存一些載入類的資訊,方法片段等內容,預設的大小也很小,一旦大量使用intern很容易就出現Perm區的oom。所以在jdk7中,StringTable從Perm區遷移到和heap。針對上述案例:

  1. 對於變數a,在做intern操作時,此時StringTable不存在"miaomiao test String",JVM會複製變數a的引用到StringTable中,a.intern()和a其實指向相同的引用,在做==判斷時返回true

  2. 對於變數b,StringTable一開始就存在字串javab.intern()返回的是StringTable中的引用,跟b指向的不是同一個引用,所以在做==判斷時返回false

後記

涉及到HotSpot原始碼分析起來總是比較費勁,如果小夥伴們有C/C++基礎我相信看起來應該不會很費勁,看完這個,面試再問到String相關問題一定不會卡殼。如果有問題可以留言啊,一起討論。

相關文章