java中的String

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

String相關源碼解析

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

String的不可變性

對于初使用java的小伙伴來說,很容易誤認為String對象是可變的,但是其實String對象是一旦聲明創(chuàng)建好后就不允許改變的,那么接下來我們結合源碼來看看String是如何實現(xiàn)不可變的:

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


    String成員變量

    從源碼可以看出:

    • String是一個final類,保證使用者不能通過繼承來修改String類;
    • 每一個String對象都維護著一個被final關鍵詞修飾的char類型的數(shù)組value,看到這里大家可能有一個疑惑了,數(shù)組其實是一個引用類型,final只能限制value引用不變,但是數(shù)組元素的值是可以改變的啊,那不是可以通過修改數(shù)據(jù)的值來修改String的內容咯?來個簡單例子試下:


      不可變測試

      運行結果:


      運行結果

      從運行結果可以看出:String并沒有被修改。當然咯,你能想到的可以改變的地方Java開發(fā)者肯定也想到了,他們不會給你這個修改的機會的:
      String構造方法

      從該構造方法可以看出,String很雞賊的copy了一份,從而以保證外部數(shù)組的改變完全不會影響到String對象。

  2. 一旦有改變就重新創(chuàng)建一個新的對象
    從外部修改String對象是不可能了,那我們可以通過String提供的一些方法,比如substringreplace來修改么?以substring方法實現(xiàn)為例,我們來看下能不能修改:

    substring實現(xiàn)

    從源碼加紅框部分可以看出:只要剪切后的字符串與原字符串不相等就會創(chuàng)建一個新的String對象,并不能修改原來的String對象。

注:可變的字符串可以用StringBuilder和StringBuffer聲明

==與String.equals()

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

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

Object.equals實現(xiàn)

從源碼可以看出,Object類的equals實現(xiàn)很簡單,就是使用==來匹配。如果對應的類不重寫equals方法,那么equals方法其實也就是比較對象地址。看到這里小伙伴們估計有疑惑了,既然用的都是==,沒有這個方法,其實也是可以使用的,為什么還要讓equals方法存在呢?equals方法存在的意義其實是希望子類重寫這個方法的,對象的比較需要根據(jù)具體的業(yè)務屬性值來做比較,而不是只有兩個對象的地址相同它們才相等。

接下來我們看看String是如何實現(xiàn)equals方法的:


String.equals實現(xiàn)

從源碼可以看出:

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

  2. 如果兩個String對象地址不想同,比較它們的私有屬性:字符數(shù)組value,如果兩個value長度相同并且每一個字符都相等,則兩個字符串相等,否則,不相等。

String的equals比較的是字符串的值是否相等,并不拘泥于內存地址。

+與StringBuilder.append()

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

測試案例

用javap -c反編譯下:
String+反編譯結果

從反編譯結果可以看出, +在做單個變量拼接的時候其實用的是StringBuilder.append()方法, 所以它們的效率并沒有太大的差別。但是,如果把+放在循環(huán)中做字符串循環(huán)拼接時,+的效率就會低很多。來個簡單的例子:
循環(huán)測試案例

同樣用javap -c看下反編譯下:
循環(huán)String+反編譯結果

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

String常量池與String.intern()

JVM開發(fā)者為了提高性能和減少內存的開銷,在實例化字符串時使用字符串常量池,并提供以下使用規(guī)則:

  1. 每一個字符串常量在常量池中全局唯一;

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

  3. 字符串對象可以通過String.intern()方法將其保存到常量池中。

接下來以一個簡單的例子,我們來看看在內存中的關系到底是怎么樣的:

String內存關系測試

從上圖測試代碼可以看出,聲明了三個字符串對象a、b、c,a,b采用雙引號方式聲明,都直接指向JVM字符串常量池,a == b應該返回true,c采用new關鍵字聲明,此時會在堆上創(chuàng)建一個對象,c指向該對象,但是,c的value還是指向JVM常量池中的test字符串,此時,a == c應該返回false。我們實際運行下看下返回結果到底是不是這樣:
運行結果

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

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

String雙引號聲明反編譯

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

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

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

我們來看下ldc指令在HotSpot中是如何實現(xiàn)的:

注:ldc指令在interpreterRuntime.cpp文件中實現(xiàn)

ldc實現(xiàn)

ldc指令會根據(jù)加載的不同的常量進行一些不同的操作,當加載的是字符串常量時,會調用constantPoolOop.string_at方法進行相關處理:
string_at實現(xiàn)

從源碼可以看出,string_at主要干了這兩件事兒:

  1. 獲取當前constantPoolOop實例的句柄;

  2. 調用string_at_impl方法獲取字符串引用。

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

string_at_impl實現(xiàn)

從源碼可以看出,字符串對象最終其實是調用StringTable::intern來方法生成的,生成后會把該字符串對象引用更新到常量池中,下一次如果再通過ldc指令聲明相同字符串時就直接返回該字符串的引用。這就是String內存關系測試a == b為什么返回true,因為它們其實都指向常量池中的同一個引用。

String.intern()

String.intern實現(xiàn)

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

  • 如果常量池中已經(jīng)存在當前字符串,就直接返回當前字符串;

  • 如果常量池中不存在當前字符串,將該字符串添加到常量池中,然后返回該字符串的引用。

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

HotSpot1.7中的intern

注:intern方法在String.c文件中實現(xiàn)

HotSpot的intern實現(xiàn).png

從源碼可以看出,intern方法實現(xiàn)的核心在于JVM_InternString方法:

注:JVM_InternString方法在jvm.cpp文件中實現(xiàn)

JVM_InternString實現(xiàn)

跟ldc一樣,intern最終也調用了StringTable::intern方法生成字符串的,接下來重點就是分析StringTable的相關實現(xiàn)了。

StringTable
StringTable實現(xiàn)很簡單,跟Java中的HashMap類似,接下來我們就來看看StringTable相關聲明:

StringTable聲明

StringTable的聲明在symbolTable.hpp文件中,從源碼可以看出:StringTable繼承了Hashtable,它的構造參數(shù)指定了StringTable的大小為StringTableSize,默認值為1009。

注:StringTableSize相關聲明在globals.hpp文件中:

StringTableSize聲明

StringTable初始化
在創(chuàng)建StringTable時,通過其構造函數(shù)就完成了它的初始化,接下來我們就來看看StringTable初始化到底干了些什么。由于StringTable繼承了Hashtable,我們就先來看看Hashtable相關實現(xiàn):

Hashtable聲明

Hashtable的聲明在hashtable.hpp中,從源碼可以看出,Hashtable是一個模板類,繼承了基類BasicHashtable,初始化相關也在基類BasicHashtable中實現(xiàn):
BasicHashTable構造方法

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

  • 調用initialize方法初始化BasicHashtable相關基本值;

  • 調用NEW_C_HEAP_ARRAY方法在堆上為其分配桶節(jié)點空間;

  • 清空桶節(jié)點中的數(shù)據(jù)。

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

StringTable::intern實現(xiàn)

StringTable::intern實現(xiàn)

從源碼可以看出:

  1. 調用java_lang_String::hash_string方法根據(jù)String對象中字符數(shù)組的拷貝name和字符數(shù)組長度len計算字符串的hash值;

  2. 調用hash_to_index方法根據(jù)該字符串的hash值計算出字符串在StringTable中桶的位置index:

    hash_to_index實現(xiàn)

  3. 調用lookup方法在StringTable查找該字符串:

    lookup實現(xiàn)

    遍歷桶節(jié)點下的HashtableEntry鏈表,如果在鏈表中可以找到對應的hash值,并且字符串的值也相同,那么該字符串在StringTable中已經(jīng)存在,返回該字符串的引用,否則,返回NULL

  4. 如果StringTable中存在該字符串,返回字符串引用,否則,調用basic_add方法添加字符串引用到StringTable中:

    basic_add實現(xiàn)

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

    注:


    JavaObjectsInPerm聲明.png

    JavaObjectsInPerm的默認值為false

    另外,其實整個添加字符串引用到StringTable的操作是調用add_entry方法完成的:

    add_entry實現(xiàn)

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

    注:jdk6的StringTable的大小是固定不可變的,就是默認的1009,在jdk7中,JVM提供了參數(shù)-XX:StringTableSize可以用于修改StringTable的長度。

綜上所述,在HotSpot1.7中,在執(zhí)行intern方法時,如果StringTable已經(jīng)存在相等的字符串,返回StringTable中的字符串引用,如果不存在,復制字符串的引用到常量池中,然后返回。

jdk6和jdk7中的intern

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


String.intern()測試

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

  1. jdk6:false false

  2. jdk7:true false

吼吼,還能出現(xiàn)這個操作,相同的代碼輸出結果竟然還是不一樣的~接下來就來解釋下為什么輸出是不一樣的。

jdk6中的intern
jdk6中StringTable是放在Perm區(qū)的,它和heap有內存隔離,在執(zhí)行intern方法時,如果StringTable中不存在該字符串,JVM就會在StringTable中復制該字符串并且返回引用,針對上述案例:

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

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

jdk7中的intern
由于Perm區(qū)是一個靜態(tài)區(qū)域,主要存儲一些加載類的信息,方法片段等內容,默認的大小也很小,一旦大量使用intern很容易就出現(xiàn)Perm區(qū)的oom。所以在jdk7中,StringTable從Perm區(qū)遷移到和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相關問題一定不會卡殼。如果有問題可以留言啊,一起討論。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容