前言
String應(yīng)該是Java使用最多的類吧,很少有Java程序沒有使用到String的。在Java中創(chuàng)建對(duì)象是一件挺耗費(fèi)性能的事,而且我們又經(jīng)常使用相同的String對(duì)象,那么創(chuàng)建這些相同的對(duì)象不是白白浪費(fèi)性能嗎。所以就有了StringTable這一特殊的存在,StringTable叫做字符串常量池,用于存放字符串常量,這樣當(dāng)我們使用相同的字符串對(duì)象時(shí),就可以直接從StringTable中獲取而不用重新創(chuàng)建對(duì)象。那么,StringTable都有哪些特性呢?接下來就讓我們好好探討一下StringTable。
String的一些特性
String的不可變性
在講介紹StringTable之前,就不得不提一下String的不可變性,因?yàn)橹挥挟?dāng)String是不可變的才使得StringTable的實(shí)現(xiàn)成為可能。當(dāng)我們定義一個(gè)字符串時(shí):
String s = "hello";
這時(shí)候,“hello”就被存放在StringTable中,而變量s是一個(gè)引用,s指向了StringTable中的“hello”。
當(dāng)我們把s的值改一下,改成”hello world“
String s = "hello";
s = "hello world";
這時(shí)候,并不是原先s指向的”hello“的值改變?yōu)榱恕県ello world“,而是指向了一個(gè)新的字符串。
如何去驗(yàn)證是指向了一個(gè)新的字符串而不是修改其內(nèi)容呢,我們可以打印一下hash值看看。
String s = "hello";
System.out.println(System.identityHashCode(s));
s = "hello world";
System.out.println(s.hashCode());
s = "hello";
System.out.println(System.identityHashCode(s));
可以看到,第一次和第三次的hash值一樣,第二次hash值和其它兩次不同,說明確實(shí)是指向了一個(gè)新的對(duì)象而不是修改了String的值。
那么String是怎么實(shí)現(xiàn)不可變的呢?我們來看一下String類的源碼:
從源碼中我們可以看出,首先String類是final的,說明其不可被繼承,就不會(huì)被子類改變其不可變的特性;其次,String的底層其實(shí)是一個(gè)被final修飾的數(shù)組,說明這個(gè)value在確定值后就不能指向一個(gè)新的數(shù)組。這里我們要明確一點(diǎn),被final修飾的數(shù)組雖然不能指向一個(gè)新的數(shù)組,但卻是可以修改數(shù)組的值的:
既然可以被修改,那String怎么是不可變的呢?因?yàn)镾tring類并沒有提供任何一個(gè)方法去修改數(shù)組的值,所以String的不可變性是由于其底層的實(shí)現(xiàn),而不是一個(gè)final。
那么String為什么要設(shè)計(jì)成不可變的呢?我覺得是因?yàn)槌鲇诎踩缘目剂浚囅胍幌?,在一個(gè)程序中,有多個(gè)地方同時(shí)引用了一個(gè)相同的String對(duì)象,但是你可能只是想在一個(gè)地方修改String的內(nèi)容,要是String是可變的,導(dǎo)致了所有的String的內(nèi)容都改變了,萬(wàn)一這是在一個(gè)重要場(chǎng)景下,比如傳輸密碼什么的,不就出大問題了嗎。所以String就被設(shè)計(jì)成了不可變的。
字符串的拼接
說完了String的不可變性,再來聊一聊字符串的拼接問題,看下面一段程序
public static void main(String[] args) {
String a = "hello";
String b = " world!";
String c = a+b;
}
就是這么簡(jiǎn)單了一段程序,你知道它是怎么實(shí)現(xiàn)的嗎?我們來看一下這段代碼對(duì)應(yīng)的字節(jié)碼指令:
我就不一行行解釋這些字節(jié)碼指令是什么意思了,我們重點(diǎn)看一下用紅色標(biāo)注的幾行代碼,看不懂前面的字節(jié)碼指令沒關(guān)系,可以看后面的注釋??梢钥吹?,字符串拼接其實(shí)就是調(diào)用StringBuilder的append()方法,然后調(diào)用了toString()方法返回一個(gè)新的字符串。
StringTable講解
字符串什么時(shí)候被放入StringTable的
先來簡(jiǎn)單介紹一下StringTable。它的底層數(shù)據(jù)結(jié)構(gòu)是HashTable,每個(gè)元素都是key-value結(jié)構(gòu),采用了數(shù)組+單向鏈表的實(shí)現(xiàn)方式。
再來看下面一段代碼:
public static void main(String[] args) {
-> String a = "hello";
String b = " world!";
String c = "hello world!";
}
在類加載后,“hello”這些字符串僅僅是當(dāng)作符號(hào)被加載進(jìn)了運(yùn)行時(shí)常量池中,還沒有成為字符串對(duì)象,這是因?yàn)镴ava中的字符串采用了延遲加載的機(jī)制,就是程序運(yùn)行到具體某一行的時(shí)候再去加載。比如當(dāng)程序運(yùn)行到箭頭所指向的那一行時(shí),“hello”會(huì)從一個(gè)符號(hào)變成一個(gè)字符串對(duì)象,然后去StringTable中找有沒有相同的字符串對(duì)象,如果有的話就返回對(duì)應(yīng)的地址給變量a,如果沒有的話就把“hello”放入StringTable中,然后再把地址給變量a。我們來看一下是不是這樣:
String s1 = "hello world";
String s2 = "hello world";
String s3 = "hello world";
String s4 = "hello world";
System.out.println(System.identityHashCode(s1));
System.out.println(System.identityHashCode(s2));
System.out.println(System.identityHashCode(s3));
System.out.println(System.identityHashCode(s4));
可以看到,四個(gè)字符串對(duì)象的hash值都一樣,說明如果StringTable中已經(jīng)有了相同的對(duì)象就會(huì)指向同一個(gè)對(duì)象而不是指向新的對(duì)象。
new String()的時(shí)候都干了什么
當(dāng)我們使用new String()去創(chuàng)建一個(gè)字符串對(duì)象時(shí)和直接寫String a = "hello"是不一樣的。前者保存在堆內(nèi)存中,后者保存在StringTable中。
其實(shí)StringTable也是在堆中,我后面會(huì)詳細(xì)說明。我們先來驗(yàn)證一下上面的說法:
String a = "hello";
String b = new String("hello");
System.out.println(a == b);
看一下運(yùn)行結(jié)果:
結(jié)果很顯然肯定是false,說明兩者確實(shí)不是一個(gè)對(duì)象。而且上面提到指向字符串常量時(shí)會(huì)先從StringTable中查找,找到就直接返回找到的字符串,但是new String()的時(shí)候卻不是這樣,每new 一個(gè)String就會(huì)在堆里面創(chuàng)建一個(gè)新的String對(duì)象,即使是相同的內(nèi)容,比如我創(chuàng)建4個(gè)String對(duì)象。
String s1 = new String("hello world");
String s2 = new String("hello world");
String s3 = new String("hello world");
String s4 = new String("hello world");
這時(shí)候在堆里面就會(huì)存在4個(gè)String對(duì)象:
我們?cè)賮泶蛴∫幌耯ash看看是不是4個(gè)對(duì)象:
System.out.println(System.identityHashCode(s1));
System.out.println(System.identityHashCode(s2));
System.out.println(System.identityHashCode(s3));
System.out.println(System.identityHashCode(s4));
從結(jié)果中看出,確實(shí)是4個(gè)不同的對(duì)象。
intern方法是干嗎的
我們先來看一段代碼:
String s1 = new String("hello world");
String s2 = "hello world";
String s3 = s1.intern();
System.out.println(s1 == s2);
System.out.println(s2 == s3);
大家看看能不能分析出結(jié)果是什么,如果你已經(jīng)知道結(jié)果,說明你已經(jīng)掌握了intern方法,如果不知道,就看我下面的講解。

結(jié)果是false和true,intern方法是干嗎的呢?
intern方法的作用就是嘗試將一個(gè)字符串放入StringTable中,如果不存在就放入StringTable并返回StringTable中的地址,如果存在的話就直接返回StringTable中的地址。這是jdk1.8版本中intern方法的作用,jdk1.6版本中有些不同,1.6中intern嘗試將字符串對(duì)象放入StringTable,如果有則并不會(huì)放入,如果沒有會(huì)把此對(duì)象復(fù)制一份,放入StringTable, 再把StringTable中的對(duì)象返回。不過我們?cè)谶@里不討論1.6版本。
解釋一下上面的代碼:首先我們?cè)诙阎袆?chuàng)建了一個(gè)"hello world"字符串對(duì)象,s1指向了這個(gè)堆中的對(duì)象;然后在StringTable中創(chuàng)建了一個(gè)值為"hello world"的字符串常量對(duì)象,s2指向了這個(gè)StringTable中的對(duì)象;最后我們嘗試將s1指向的堆中對(duì)象放入StringTable中,發(fā)現(xiàn)已經(jīng)有了,所以就返回了StringTable中的字符串對(duì)象的地址給了s3。所以s1和s2指向了同一個(gè)對(duì)象,s2和s3是一個(gè)對(duì)象。就像下圖這樣:
要是把代碼稍微改一下呢:
String s1 = new String("hello world").intern();
String s2 = "hello world";
System.out.println(s1 == s2);
這時(shí)候結(jié)果就是true了。我們來分析一下:首先使用了new String()在堆中創(chuàng)建了字符串對(duì)象,然后調(diào)用了其intern()方法,所以就從StringTable中查找有沒有同樣的字符串,發(fā)現(xiàn)沒有,就將字符串放入StringTable中,然后將StringTable中的對(duì)象的地址給了s1;到第二行的時(shí)候,因?yàn)闆]有用new String(),所以就直接從StringTable中查找,發(fā)現(xiàn)有,就將StringTable中的對(duì)象的地址給了s2;所以s1、s2指向了同一個(gè)對(duì)象。
StringTable的位置
前面已經(jīng)提到了StringTable在堆中,現(xiàn)在來驗(yàn)證一下。驗(yàn)證的方式很簡(jiǎn)單,我們放入大量的字符串導(dǎo)致內(nèi)存溢出,看看是哪個(gè)部分內(nèi)存溢出就知道StringTable在哪兒了。
ArrayList list = new ArrayList();
String str = "hello";
for(int i = 0;i < Integer.MAX_VALUE;i++) {
String s = str + i;
str = s;
list.add(s.intern());
}
我們先是調(diào)用了intern方法將字符串放入StringTable,再用一個(gè)ArrayList去存放字符串,目的是為了避免垃圾回收,因?yàn)檫@樣的話每個(gè)字符串都會(huì)被強(qiáng)引用,就不會(huì)被垃圾回收了,垃圾回收了就不會(huì)看到我們想要的結(jié)果。來看一下結(jié)果:
很明顯,是堆內(nèi)存發(fā)生了內(nèi)存溢出,這樣就可以確定StringTable是存放在堆中的。不過這是從1.7版本開始的,1.7之前保存在永久代中。
StringTable的垃圾回收
既然前面提到了垃圾回收,我們就來驗(yàn)證一下StringTable會(huì)不會(huì)發(fā)生垃圾回收。還是上面的代碼,只不過稍微修改一下:
String str = "hello";
for(int i = 0;i < 10000;i++) {
String s = str + i;
s.intern();
}
這里沒有再將字符串放入ArrayList了,要不然就算是發(fā)生了內(nèi)存溢出也不會(huì)垃圾回收。為了看到垃圾回收的過程,所以添加幾個(gè)虛擬機(jī)參數(shù),先不指定堆大?。?/p>
運(yùn)行程序,看看打印情況:
因?yàn)槎褍?nèi)存足夠大,所以沒有發(fā)生垃圾回收,我們現(xiàn)在將堆內(nèi)存設(shè)置的小一點(diǎn),,來個(gè)1m:
-Xmx1m
再來運(yùn)行下程序:
這回因?yàn)槎褍?nèi)存不夠,發(fā)生了多次垃圾回收,所以說,StringTable也會(huì)因?yàn)閮?nèi)存不足導(dǎo)致垃圾回收。
StringTable底層實(shí)現(xiàn)以及性能調(diào)優(yōu)
在介紹性能調(diào)優(yōu)之前不得不說一說StringTable的底層實(shí)現(xiàn),前面已經(jīng)提到了StringTable底層是一個(gè)HashTable,HashTable長(zhǎng)什么樣呢?其實(shí)就是數(shù)組+鏈表,每個(gè)元素是一個(gè)key-value。當(dāng)存入一個(gè)元素的時(shí)候,就會(huì)將其key通過hash函數(shù)計(jì)算得出數(shù)組的下標(biāo)并存放在對(duì)應(yīng)的位置。
比如現(xiàn)在有一個(gè)key-value,這個(gè)key通過hash函數(shù)計(jì)算結(jié)果為2,那么就把value存放在數(shù)組下標(biāo)為2的位置。但是如果現(xiàn)在又有一個(gè)key通過hash函數(shù)計(jì)算出了相同的結(jié)果,比如也是2,但2的位置已經(jīng)有值了,這種現(xiàn)象就叫做哈希沖突,怎么解決呢?這里采用了鏈表法:
鏈表法就是將下標(biāo)一樣的元素通過鏈表的形式串起來,如果數(shù)組容量很小但是元素很多,那么發(fā)生哈希沖突的概率就會(huì)提高。大家都知道,鏈表的效率遠(yuǎn)沒有數(shù)組那么高,哈希沖突過多會(huì)影響性能。所以為了減少哈希沖突的概率,所以可以適當(dāng)?shù)脑黾訑?shù)組的大小。數(shù)組的每一格在StringTable中叫做bucket,我們可以增加bucket的數(shù)量來提高性能,默認(rèn)的數(shù)量為60013個(gè),來看一個(gè)對(duì)比:
long startTime = System.nanoTime();
String str = "hello";
for(int i = 0;i < 500000;i++) {
String s = str + i;
s.intern();
}
long endTime = System.nanoTime();
System.out.println("花費(fèi)的時(shí)間為:"+(endTime-startTime)/1000000 + "毫秒");
先通過一個(gè)虛擬機(jī)參數(shù)將bucket指定的小一點(diǎn),來個(gè)2000吧:
-XX:StringTableSize=2000
運(yùn)行一下:
一共花費(fèi)了1.2秒。再來將bucket的數(shù)量增加一點(diǎn),來個(gè)20000個(gè):
-XX:StringTableSize=20000
運(yùn)行一下:
可以看到,這次只花了0.19秒,性能有了明顯的提升,說明這樣確實(shí)可以優(yōu)化StringTable。這里只介紹了一種提升性能的方法,篇幅有限,就不再多說了,我以后可能會(huì)專門寫一篇文章來專門講講StringTable性能優(yōu)化的問題。
作者:Robod
鏈接:https://juejin.im/post/6854573212886368270
來源:掘金