在jdk7或jdk8中執行如下代碼(執行結果見對應的註釋行):
public static void main(String[] args) {
System.out.println("第一組對比:");
System.out.println("======");
String s1 = "1" + new String("2");
String s2 = "12";
String s3 = s1.intern();
System.out.println(s1 == s3);//jdk6、jdk7、jdk8都爲false
System.out.println(s2 == s3);//true
System.out.println("======");
String s4 = "4" + "5";
String s5 = "45";
String s6 = s4.intern();
System.out.println(s4 == s6);//true
System.out.println(s5 == s6);//true
System.out.println("======");
System.out.println();
System.out.println("第二組對比:");
System.out.println("======");
String s7 = new String("7") + new String("8");
String s8 = "78";
String s9 = s7.intern();
System.out.println(s7 == s9);//false
System.out.println(s8 == s9);//true
System.out.println("======");
String s10 = new String("9") + new String("0");
String s11 = s10.intern();
String s12 = "90";
System.out.println(s10 == s12);//true
System.out.println(s11 == s12);//true
System.out.println("======");
}
上面的測試代碼進行了兩組對比,如果你完全能理解執行的結果,那麼恭喜你,這篇博客你沒必要看了;反之,這篇博客接下來的內容就是你的菜。不過,即使你能答對執行結果,也建議你閱讀下本文對常量池及字符串創建的分析,因爲關於這塊解讀的博客確實非常多,但可惜的是,大多說法都是錯誤的!!!
字符串常量池
在分析這兩組對比結果之前,我們需要先了解Java字符串常量池。我們知道,這jdk6及以前,字符串常量池位於持久帶(方法區)中,到了jdk7,儘管持久帶還在,但字符串常量池已經從持久帶移到了堆區,到jdk8,整個持久帶都沒了,換成了Metasapce區。
瞭解了字符串常量池,我們再來回答一個問題:字符串常量池裏存的到底是對象還是引用?在 JDK6.0 及之前版本,字符串常量池是放在 Perm Gen 區(也就是方法區)中,此時字符串常量池中存儲的是對象;在 JDK7.0版本,字符串常量池被移到了堆中了,因而字符串常量池中存儲的就只是引用了;在 JDK8.0 版本,永久代(方法區)被元空間取代了,字符串常量池仍然在堆中,因而字符串常量池中存儲的也是引用。正是以上的差異,導致上述代碼在jdk6中和jdk7和jdk8中的執行結果是不同的,下面具體分析。
字符串的三種生成方式
我們接着來介紹字符串的生成方式。很多博客說Java字符串生成有兩種方式,但本博客總結爲三種:
- 以雙引號的方式:
String str1 = "test1";
該方式在jdk6、jdk7和jdk8中都是在類加載階段(包括加載、驗證、準備、解析和初始化)生成一個值爲“test1”的對象,但不同的是,在jdk6中這個對象位於字符串常量池中,str1指向該對象;在jdk7和jdk8中這個對象都位於堆中,str1指向該對象,且該對象同時會被常量池所引用。因而在代碼執行階段,以雙引號的方式創建會直接返回字符串常量池中引用的對象。
- 以new String()的方式:
String str2 = new String("test2");
該方式在jdk6、jdk7和jdk8中最終都會生成兩個相互獨立、值都爲“test1”的對象,且都是一個在類加載階段創建,另一個在上述代碼片段的執行階段創建。但不同的是,在jdk6中這兩個對象分別位於堆中和常量池中,其中str1指向堆中的對象;在jdk7和jdk8中這兩個對象都位於堆中,str1指向其中一個,另一個被常量池引用。
- 以運算符(+)創建:
String str3 = "te3" + "st3";//方式一 String str4 = new String("te4") + new String("st4");//方式二 String str5 = "te5" + new String("st5");//方式三 String str6 = "te6"; String str7 = "st7"; String str8 = str6 + str7;//方式四
這種方式可以認爲是前面兩種方式和運算符“+”的組合方式。經過反編譯後,上述代碼變成了如下等價代碼:
String str3 = "te3st3";//方式一 String str4 = (new StringBuilder()).append("te4").append("st4").toString();//方式二 String str5 = (new StringBuilder()).append("te5").append("st5").toString();//方式三 String str6 = "te6"; String str7 = "st7"; String str8 = (new StringBuilder()).append(str6).append(str7").toString();;//方式四
由此可知,方式一等價於前面的雙引號方式,即只會在類加載階段創建一個值爲“te3st3”的對象,這裏需要注意的是,在方式一中字符串常量池中不會包含“te3”或“st3”對象或對象引用,其實由編譯完成之後的代碼來看這是顯然的;而方式二、三、四實際上都是通過StringBuilder來實現的。以方式二爲例,在類加載階段會創建值爲“te4”和“st4”的對象各一個,這兩個對象在jdk6中是位於常量池中,而在jdk7和jdk8中則是位於堆中(常量池中保存了其引用)。然後在代碼執行階段會在堆中創建一個值爲“te4st4”的對象,並將其引用賦值給str4,即字符串常量池中不會有值爲“te4st4”的引用。
String#intern()方法
由前面的介紹可知,在有些場景下,字符串在編譯結束後其值是無法確定的, 因而無法在類加載階段將這樣的字符串添加到字符串常量池中。intern方法就是爲了解決這樣的問題而誕生的,即該方法可以在運行過程中手動將字符串添加進字符串常量池。
在該方法的源碼中有這樣一段註釋:
When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
實際上,這段話對jdk6來說是合適的,但到了jdk7和jdk8就容易引起誤會了。具體來說就是,在jdk6中,如果常量池中不包含值相等的字符串常量,則在常量池中重新創建一個該值的對象,該對象和堆中的對象沒有任何關聯;對於jdk7和jdk8時,如果常量池中不包含值相等的字符串常量時,是直接將堆中該對象的引用直接添加到常量池中,因而此時從常量池中獲取到的對象就是堆中的對象。
測試代碼分析 分別分析jdk6和jdk7,8
String s1 = "1" + new String("2");
String s2 = "12";
String s3 = s1.intern();
System.out.println(s1 == s3);//false
System.out.println(s2 == s3);//true
由前面的介紹可知,s1在代碼執行階段指向的是堆中值爲“12”的對象,而此時字符串常量池中是沒有該對象引用的;第二行代碼在類加載階段會在堆中創建一個值爲“12”的對象,並將其引用添加到字符串常量池中,而s2指向的就是類加載階段創建的對象;第三行代碼執行intern()方法時,發現常量池中已經有值爲“12”的對象引用了,於是返回的是字符串常量池中對象的引用,即s2和s3指向的是同一個對象,因而有s2==s3爲true,而s1指向的是堆中另一個對象,因而s1==s3爲false。
String s4 = "4" + "5";
String s5 = "45";
String s6 = s4.intern();
System.out.println(s4 == s6);//true
System.out.println(s5 == s6);//true
類似地,s4指向的對象是在類加載階段創建的值爲“45”的對象,且此時字符串常量池中也引用了該對象;第二句,s5獲取到的是字符串常量池中引用的對象,即s4和s5指向了同一個對象。第三句執行intern方法時發現常量池已經有該對象了,返回的還是常量池的對象,因而纔會有s4==s5==s6都爲true。
String s7 = new String("7") + new String("8");
String s8 = "78";
String s9 = s7.intern();
System.out.println(s7 == s9);//false
System.out.println(s8 == s9);//true
類似地,s7指向的是位於堆中值爲“78”的對象,此時常量池中還沒有值爲“78”的對象;執行第二句時,s8獲取到的是字符串常量池中引用的對象;執行intern方法時,返回了常量池中的對象引用,因而s7==s9爲false,而s8==s9爲true。
String s10 = new String("9") + new String("0");
String s11 = s10.intern();
String s12 = "90";
System.out.println(s10 == s12);//true
System.out.println(s11 == s12);//true
類似地,s10指向的是位於堆中值爲“90”的對象,此時常量池中還沒有值爲“90”的對象;此時執行intern方法,發現常量池中還沒有值爲“90”的對象,於是將對中值爲“90”的對象引用添加(add)到字符串常量池中,並返回該對象的引用(即s11和s10指向的都是堆中的對象);執行第三句時,發現常量池中已經有該對象的引用了,則直接返回該引用,因而纔會有s10==s11==s12都爲true。
String#intern()方法使用分析
美團技術團隊給了一個正確使用intern方法的示例:
static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];
public static void main(String[] args) throws Exception {
Integer[] DB_DATA = new Integer[10];
Random random = new Random(10 * 10000);
for (int i = 0; i < DB_DATA.length; i++) {
DB_DATA[i] = random.nextInt();
}
long t = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
//arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));//不使用intern方法
arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();//使用intern方法
}
System.out.println((System.currentTimeMillis() - t) + "ms");
System.gc();
}
在該示例中,實際上只使用了10個值不同的字符串,但通過分別註釋掉不使用intern方法和使用intern方法這兩句代碼發現,不使用 intern 的代碼生成了1000w 個字符串,佔用了大約640m 空間,而使用了 intern 的代碼生成了1345個字符串,佔用總空間 133k 左右。
看到這裏不知道你會不會和博主有同樣的困惑:怎麼可能呢?雖然使用了intern方法,可是不還是通過new String()的方式創建的對象麼?new String().intern()方法肯定是先在堆中創建個對象,然後再嘗試將該字符串引用添加到常量池中呀,那爲什麼會和不使用intern方法有這麼大差異呢?難道美團技術團隊寫錯了?
對於這個困惑,博主也是百思不得其解(甚至開始懷疑人生)了好幾天,後來突然靈光乍現:是GC,肯定是GC!!什麼意思呢?沒錯,上面的示例中,不論是否使用intern方法,兩者都是先使用new String()的方式來創建的字符串對象,因而如果沒有GC的化,兩種方式創建字符串對象肯定是一樣多的。而有了GC之後,兩者的情形就大不一樣了。對於不使用intern方法時,new String()返回的對象是被arr[i]所引用的,因而在GC時是不會被回收的;相反,使用intern方法後,由於上述代碼實際上只使用了10個值不同的字符串,因而在絕大多數情況下,attr[i]中保存的都是同一個對象的引用,而此時new String().intern()語句產生的對象本身成了匿名對象,在GC過程中就被當成垃圾給回收了。因而纔會出現這麼大的差距。
其實由這個示例可知,intern方法適合在字符串變化不頻繁的場景下使用;對於大量值不同的字符串,如果使用intern方法則會導致常量池hashTable中存儲過多字符串引用,從而導致YGC變長等問題,具體參見參考博客。
總結
本文深入分析了jdk8字符串的存儲機制及String#intern方法的功能,其目的是爲了梳理虛擬機對字符串的處理機制,爲我們在使用字符串時具體選擇哪種方式來產生字符串提供依據。此外,對於intern方法的適用場景可以總結爲:適合於變化不頻繁的字符串。
參考博客:
1、https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html 深入解析String#intern
2、https://blog.csdn.net/goldenfish1919/article/details/81216560 JVM的方法區和永久帶是什麼關係
3、https://blog.csdn.net/liupeifeng3514/article/details/81067150 源碼學習 | String 之 new String()和 intern()方法深入分析
4、https://blog.csdn.net/Herishwater/article/details/100924191 Java 中方法區與常量池
5、https://zhuanlan.zhihu.com/p/107781993 Java 基礎:String——常量池與 intern
6、https://cloud.tencent.com/developer/article/1450501 徹底弄懂java中的常量池
7、http://lovestblog.cn/blog/2016/11/06/string-intern/ JVM源碼分析之String.intern()導致的YGC不斷變長