談起String,大家肯定一定都不陌生,肯定也都使用過,出去面試的時候也有碰到過問相關原理的。今天就結合String相關源碼對其相關原理做一個簡要的分析。
String相關源碼解析
注:考慮到String源碼比較簡單,本文將針對一些比較容易造成誤解的地方為切入點做相關分析,另外,本文源碼的jdk版本為:
jdk1.7.0_79
String的不可變性
對于初使用java的小伙伴來說,很容易誤認為String對象是可變的,但是其實String對象是一旦聲明創(chuàng)建好后就不允許改變的,那么接下來我們結合源碼來看看String是如何實現(xiàn)不可變的:
-
使用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對象。
-
一旦有改變就重新創(chuàng)建一個新的對象
從外部修改String對象是不可能了,那我們可以通過String提供的一些方法,比如substring
,replace
來修改么?以substring
方法實現(xiàn)為例,我們來看下能不能修改:
substring實現(xiàn)
從源碼加紅框部分可以看出:只要剪切后的字符串與原字符串不相等就會創(chuàng)建一個新的String對象,并不能修改原來的String對象。
注:可變的字符串可以用StringBuilder和StringBuffer聲明
==與String.equals()
在Java中,==
是對比兩個內存單元的內容是否一樣,如果是原始類型,直接比較它們的值是否相同,如果是引用類型,比較的就是引用的值,換言之就是比較兩個對象的地址是否一樣。
equals()
方法則是Object類定義的:
從源碼可以看出,Object類的equals實現(xiàn)很簡單,就是使用
==
來匹配。如果對應的類不重寫equals方法,那么equals方法其實也就是比較對象地址。看到這里小伙伴們估計有疑惑了,既然用的都是==
,沒有這個方法,其實也是可以使用的,為什么還要讓equals方法存在呢?equals方法存在的意義其實是希望子類重寫這個方法的,對象的比較需要根據(jù)具體的業(yè)務屬性值來做比較,而不是只有兩個對象的地址相同它們才相等。
接下來我們看看String是如何實現(xiàn)equals方法的:
從源碼可以看出:
如果兩個String對象地址相同,它們兩個肯定相等,直接返回true;
如果兩個String對象地址不想同,比較它們的私有屬性:字符數(shù)組value,如果兩個value長度相同并且每一個字符都相等,則兩個字符串相等,否則,不相等。
String的equals比較的是字符串的值是否相等,并不拘泥于內存地址。
+與StringBuilder.append()
看了好多好多博客都說String的+
運算效率要比StringBuilder.append()的效率低很多很多,但是我跟他們的看法并不相同,來個簡單的例子驗證下我的看法:
用javap -c反編譯下:
從反編譯結果可以看出,
+
在做單個變量拼接的時候其實用的是StringBuilder.append()
方法, 所以它們的效率并沒有太大的差別。但是,如果把+
放在循環(huán)中做字符串循環(huán)拼接時,+
的效率就會低很多。來個簡單的例子:同樣用javap -c看下反編譯下:
從反編譯結果可以看出,每一次的循環(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ī)則:
每一個字符串常量在常量池中全局唯一;
通過
String ss = "test"
雙引號聲明的字符串會直接存儲在常量池中;字符串對象可以通過String.intern()方法將其保存到常量池中。
接下來以一個簡單的例子,我們來看看在內存中的關系到底是怎么樣的:
從上圖測試代碼可以看出,聲明了三個字符串對象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 a = "test"
對應兩條JVM指令:
ldc #2
加載常量池中的指定項的引用到棧中,這里#2表示加載第二項("test")到棧中;astore_<n>
將引用賦值給第n個局部變量,astore_1
表示將1中的引用賦值給第一個局部變量,即String a = "test"
。
我們來看下ldc指令在HotSpot中是如何實現(xiàn)的:
注:ldc指令在
interpreterRuntime.cpp
文件中實現(xiàn)
ldc指令會根據(jù)加載的不同的常量進行一些不同的操作,當加載的是字符串常量時,會調用
constantPoolOop.string_at
方法進行相關處理:從源碼可以看出,string_at主要干了這兩件事兒:
獲取當前constantPoolOop實例的句柄;
調用string_at_impl方法獲取字符串引用。
接下來我們看看string_at_impl是如何獲取字符串引用的:
從源碼可以看出,字符串對象最終其實是調用
StringTable::intern
來方法生成的,生成后會把該字符串對象引用更新到常量池中,下一次如果再通過ldc指令聲明相同字符串時就直接返回該字符串的引用。這就是String內存關系測試中a == b
為什么返回true,因為它們其實都指向常量池中的同一個引用。
String.intern()
從源碼可以看出,
String.intern()
是一個native的方法,在使用intern方法時:
如果常量池中已經(jīng)存在當前字符串,就直接返回當前字符串;
如果常量池中不存在當前字符串,將該字符串添加到常量池中,然后返回該字符串的引用。
既然是native的方法,那HotSpot中它到底是如何實現(xiàn)的呢?
HotSpot1.7中的intern
注:intern方法在String.c文件中實現(xiàn)
從源碼可以看出,intern方法實現(xiàn)的核心在于
JVM_InternString
方法:
注:JVM_InternString方法在jvm.cpp文件中實現(xiàn)
跟ldc一樣,intern最終也調用了
StringTable::intern
方法生成字符串的,接下來重點就是分析StringTable的相關實現(xiàn)了。
StringTable
StringTable實現(xiàn)很簡單,跟Java中的HashMap類似,接下來我們就來看看StringTable相關聲明:
StringTable的聲明在symbolTable.hpp文件中,從源碼可以看出:StringTable繼承了Hashtable,它的構造參數(shù)指定了StringTable的大小為StringTableSize,默認值為1009。
注:StringTableSize相關聲明在globals.hpp文件中:
StringTable初始化
在創(chuàng)建StringTable時,通過其構造函數(shù)就完成了它的初始化,接下來我們就來看看StringTable初始化到底干了些什么。由于StringTable繼承了Hashtable,我們就先來看看Hashtable相關實現(xiàn):
Hashtable的聲明在hashtable.hpp中,從源碼可以看出,Hashtable是一個模板類,繼承了基類BasicHashtable,初始化相關也在基類BasicHashtable中實現(xiàn):
在BasicHashtable的初始化中,主要干了以下三件事:
調用
initialize
方法初始化BasicHashtable相關基本值;調用
NEW_C_HEAP_ARRAY
方法在堆上為其分配桶節(jié)點空間;清空桶節(jié)點中的數(shù)據(jù)。
看完StringTable相關初始化之后,我們就該來進入正題,看看StringTable::intern
方法的相關實現(xiàn)了。
StringTable::intern實現(xiàn)
從源碼可以看出:
調用
java_lang_String::hash_string
方法根據(jù)String對象中字符數(shù)組的拷貝name和字符數(shù)組長度len計算字符串的hash值;-
調用
hash_to_index
方法根據(jù)該字符串的hash值計算出字符串在StringTable中桶的位置index:
hash_to_index實現(xiàn) -
調用
lookup
方法在StringTable查找該字符串:
lookup實現(xiàn)
遍歷桶節(jié)點下的HashtableEntry鏈表,如果在鏈表中可以找到對應的hash值,并且字符串的值也相同,那么該字符串在StringTable中已經(jīng)存在,返回該字符串的引用,否則,返回NULL
。 -
如果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)原理,接下來就來個小例子實踐下:
我們分別在jdk6和jdk7下運行下,結果竟然是:
jdk6:
false false
jdk7:
true false
吼吼,還能出現(xiàn)這個操作,相同的代碼輸出結果竟然還是不一樣的~接下來就來解釋下為什么輸出是不一樣的。
jdk6中的intern
jdk6中StringTable是放在Perm區(qū)的,它和heap有內存隔離,在執(zhí)行intern方法時,如果StringTable中不存在該字符串,JVM就會在StringTable中復制該字符串并且返回引用,針對上述案例:
變量a分配在heap上,
a.intern()
指向的是Perm區(qū)StringTable中的引用,跟a指向的不是同一個引用,在做==
判斷時返回false;同理,對于變量b也是一樣的,
b.intern()
和b指向的也不是同一個引用,在做==
判斷當然也返回false。
jdk7中的intern
由于Perm區(qū)是一個靜態(tài)區(qū)域,主要存儲一些加載類的信息,方法片段等內容,默認的大小也很小,一旦大量使用intern很容易就出現(xiàn)Perm區(qū)的oom。所以在jdk7中,StringTable從Perm區(qū)遷移到和heap。針對上述案例:
對于變量a,在做intern操作時,此時StringTable不存在"miaomiao test String",JVM會復制變量a的引用到StringTable中,
a.intern()
和a其實指向相同的引用,在做==
判斷時返回true
;對于變量b,StringTable一開始就存在字符串
java
,b.intern()
返回的是StringTable中的引用,跟b指向的不是同一個引用,所以在做==
判斷時返回false
。
后記
涉及到HotSpot源碼分析起來總是比較費勁,如果小伙伴們有C/C++基礎我相信看起來應該不會很費勁,看完這個,面試再問到String相關問題一定不會卡殼。如果有問題可以留言啊,一起討論。