JDK字符串存儲機制及String#intern方法深入研究

在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字符串生成有兩種方式,但本博客總結爲三種:

  1. 以雙引號的方式:
    String str1 = "test1";

    該方式在jdk6、jdk7和jdk8中都是在類加載階段(包括加載、驗證、準備、解析和初始化)生成一個值爲“test1”的對象,但不同的是,在jdk6中這個對象位於字符串常量池中,str1指向該對象;在jdk7和jdk8中這個對象都位於堆中,str1指向該對象,且該對象同時會被常量池所引用。因而在代碼執行階段,以雙引號的方式創建會直接返回字符串常量池中引用的對象。

  2. 以new String()的方式:
    String str2 = new String("test2");

    該方式在jdk6、jdk7和jdk8中最終都會生成兩個相互獨立、值都爲“test1”的對象,且都是一個在類加載階段創建,另一個在上述代碼片段的執行階段創建。但不同的是,在jdk6中這兩個對象分別位於堆中和常量池中,其中str1指向堆中的對象;在jdk7和jdk8中這兩個對象都位於堆中,str1指向其中一個,另一個被常量池引用。

  3. 以運算符(+)創建:
    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不斷變長

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章