提高 Java 程式碼效能的各種技巧

位元組技術發表於2015-05-06

Java 6,7,8 中的 String.intern – 字串池

這篇文章將要討論 Java 6 中是如何實現 String.intern 方法的,以及這個方法在 Java 7 以及 Java 8 中做了哪些調整。

字串池

字串池(有名字串標準化)是通過使用唯一的共享 String 物件來使用相同的值不同的地址表示字串的過程。你可以使用自己定義的 Map<String, String> (根據需要使用 weak 引用或者 soft 引用)並使用 map 中的值作為標準值來實現這個目標,或者你也可以使用 JDK 提供的 String.intern()

很多標準禁止在 Java 6 中使用 String.intern() 因為如果頻繁使用池會市區控制,有很大的機率觸發 OutOfMemoryException。Oracle Java 7 對字串池做了很多改進,你可以通過以下地址進行了解 http://bugs.sun.com/view_bug.do?bug_id=6962931以及 http://bugs.sun.com/view_bug.do?bug_id=6962930

Java 6 中的 String.intern()

在美好的過去所有共享的 String 物件都儲存在 PermGen 中 — 堆中固定大小的部分主要用於儲存載入的類物件和字串池。除了明確的共享字串,PermGen 字串池還包含所有程式中使用過的字串(這裡要注意是使用過的字串,如果類或者方法從未載入或者被條用,在其中定義的任何常量都不會被載入)

Java 6 中字串池的最大問題是它的位置 — PermGen。PermGen 的大小是固定的並且在執行時是無法擴充套件的。你可以使用 -XX:MaxPermSize=N 配置來調整它的大小。據我瞭解,對於不同的平臺預設的 PermGen 大小在 32M 到 96M 之間。你可以擴充套件它的大小,不過大小使用都是固定的。這個限制需要你在使用 String.intern 時需要非常小心 — 你最好不要使用這個方法 intern 任何無法控制的使用者輸入。這是為什麼在 JAVA6 中大部分使用手動管理 Map 來實現字串池

Java 7 中的 String.intern()

Java 7 中 Oracle 的工程師對字串池的邏輯做了很大的改變 — 字串池的位置被調整到 heap 中了。這意味著你再也不會被固定的記憶體空間限制了。所有的字串都儲存在堆(heap)中同其他普通物件一樣,這使得你在調優應用時僅需要調整堆大小。這 個改動使得我們有足夠的理由讓我們重新考慮在 Java 7 中使用 String.intern()。

字串池中的資料會被垃圾收集

沒錯,在 JVM 字串池中的所有字串會被垃圾收集,如果這些值在應用中沒有任何引用。這是用於所有版本的 Java,這意味著如果 interned 的字串在作用域外並且沒有任何引用 — 它將會從 JVM 的字串池中被垃圾收集掉。

因為被重新定位到堆中以及會被垃圾收集,JVM 的字串池看上去是存放字串的合適位置,是嗎?理論上是 — 違背使用的字串會從池中收集掉,當外部輸入一個字元傳且池中存在時可以節省記憶體。看起來是一個完美的節省記憶體的策略?在你回答這個之前,可以肯定的是你 需要知道字串池是如何實現的。

在 Java 6,7,8 中 JVM 字串池的實現

字串池是使用一個擁有固定容量的 HashMap 每個元素包含具有相同 hash 值的字串列表。一些實現的細節可以從 Java bug 報告中獲得 http://bugs.sun.com/view_bug.do?bug_id=6962930

預設的池大小是 1009 (出現在上面提及的 bug 報告的原始碼中,在 Java7u40 中增加了)。在 JAVA 6 早期版本中是一個常量,在隨後的 java6u30 至 java6u41 中調整為可配置的。而在java 7中一開始就是可以配置的(至少在java7u02中是可以配置的)。你需要指定引數 -XX:StringTableSize=N,  N 是字串池 Map 的大小。確保它是為效能調優而預先準備的大小。

在 Java 6 中這個引數沒有太多幫助,因為你仍任被限制在固定的 PermGen 記憶體大小中。後續的討論將直接忽略 Java 6

Java 7 (直至 Java7u40)

在 Java7 中,換句話說,你被限制在一個更大的堆記憶體中。這意味著你可以預先設定好 String 池的大小(這個值取決於你的應用程式需求)。通常說來,一旦程式開始記憶體消耗,記憶體都是成百兆的增長,在這種情況下,給一個擁有 100 萬字串物件的字串池分配 8-16M 的記憶體看起來是比較適合的(不要使用1,000,000 作為 -XX:StringTaleSize 的值 – 它不是質數;使用 1,000,003代替)

你可能期待關於 String 在 Map 中的分配 — 可以閱讀我之前關於 HashCode 方法調優的經驗。

你必須設定一個更大的 -XX:StringTalbeSize 值(相比較預設的 1009 ),如果你希望更多的使用 String.intern() — 否則這個方法將很快遞減到 0 (池大小)。

我沒有注意到在 intern 小於 100 字元的字串時的依賴情況(我認為在一個包含 50 個重複字元的字串與現實資料並不相似,因此 100 個字元看上去是一個很好的測試限制)

下面是預設池大小的應用程式日誌:第一列是已經 intern 的字串數量,第二列 intern 10,000 個字串所有的時間(秒)

0; time = 0.0 sec
50000; time = 0.03 sec
100000; time = 0.073 sec
150000; time = 0.13 sec
200000; time = 0.196 sec
250000; time = 0.279 sec
300000; time = 0.376 sec
350000; time = 0.471 sec
400000; time = 0.574 sec
450000; time = 0.666 sec
500000; time = 0.755 sec
550000; time = 0.854 sec
600000; time = 0.916 sec
650000; time = 1.006 sec
700000; time = 1.095 sec
750000; time = 1.273 sec
800000; time = 1.248 sec
850000; time = 1.446 sec
900000; time = 1.585 sec
950000; time = 1.635 sec
1000000; time = 1.913 sec

測試是在 Core i5-3317U@1.7Ghz CPU 裝置上進行的。你可以看到,它成線性增長,並且在 JVM 字串池包含一百萬個字串時,我仍然可以近似每秒 intern 5000 個字串,這對於在記憶體中處理大量資料的應用程式來說太慢了。

現在,調整 -XX:StringTableSize=100003 引數來重新執行測試:

50000; time = 0.017 sec
100000; time = 0.009 sec
150000; time = 0.01 sec
200000; time = 0.009 sec
250000; time = 0.007 sec
300000; time = 0.008 sec
350000; time = 0.009 sec
400000; time = 0.009 sec
450000; time = 0.01 sec
500000; time = 0.013 sec
550000; time = 0.011 sec
600000; time = 0.012 sec
650000; time = 0.015 sec
700000; time = 0.015 sec
750000; time = 0.01 sec
800000; time = 0.01 sec
850000; time = 0.011 sec
900000; time = 0.011 sec
950000; time = 0.012 sec
1000000; time = 0.012 sec

可以看到,這時插入字串的時間近似於常量(在 Map 的字串列表中平均字串個數不超過 10 個),下面是相同設定的結果,不過這次我們將向池中插入 1000 萬個字串(這意味著 Map 中的字串列表平均包含 100 個字串)

2000000; time = 0.024 sec
3000000; time = 0.028 sec
4000000; time = 0.053 sec
5000000; time = 0.051 sec
6000000; time = 0.034 sec
7000000; time = 0.041 sec
8000000; time = 0.089 sec
9000000; time = 0.111 sec
10000000; time = 0.123 sec

現在讓我們將吃的大小增加到 100 萬(精確的說是 1,000,003)

1000000; time = 0.005 sec
2000000; time = 0.005 sec
3000000; time = 0.005 sec
4000000; time = 0.004 sec
5000000; time = 0.004 sec
6000000; time = 0.009 sec
7000000; time = 0.01 sec
8000000; time = 0.009 sec
9000000; time = 0.009 sec
10000000; time = 0.009 sec

如你所看到的,時間非常平均,並且與 “0 到 100萬” 的表沒有太大差別。甚至在池大小足夠大的情況下,我的筆記本也能每秒新增1,000,000個字元物件。

我們還需要手工管理字串池嗎?

現在我們需要對比 JVM 字串池和 WeakHashMap<String, WeakReference<String>> 它可以用來模擬 JVM 字串池。下面的方法用來替換 String.intern

private static final WeakHashMap<String, WeakReference<String>> s_manualCache = 
    new WeakHashMap<String, WeakReference<String>>( 100000 );

private static String manualIntern( final String str )
{
    final WeakReference<String> cached = s_manualCache.get( str );
    if ( cached != null )
    {
        final String value = cached.get();
        if ( value != null )
            return value;
    }
    s_manualCache.put( str, new WeakReference<String>( str ) );
    return str;
}

下面針對手工池的相同測試:

0; manual time = 0.001 sec
50000; manual time = 0.03 sec
100000; manual time = 0.034 sec
150000; manual time = 0.008 sec
200000; manual time = 0.019 sec
250000; manual time = 0.011 sec
300000; manual time = 0.011 sec
350000; manual time = 0.008 sec
400000; manual time = 0.027 sec
450000; manual time = 0.008 sec
500000; manual time = 0.009 sec
550000; manual time = 0.008 sec
600000; manual time = 0.008 sec
650000; manual time = 0.008 sec
700000; manual time = 0.008 sec
750000; manual time = 0.011 sec
800000; manual time = 0.007 sec
850000; manual time = 0.008 sec
900000; manual time = 0.008 sec
950000; manual time = 0.008 sec
1000000; manual time = 0.008 sec

當 JVM 有足夠記憶體時,手工編寫的池提供了良好的效能。不過不幸的是,我的測試(保留 String.valueOf(0 < N < 1,000,000,000))保留非常短的字串,在使用 -Xmx1280M 引數時它允許我保留月為 2.5M 的這類字串。JVM 字串池 (size=1,000,003)從另一方面講在 JVM 記憶體足夠時提供了相同的效能特性,知道 JVM 字串池包含 12.72M 的字串並消耗掉所有記憶體(5倍多)。我認為,這非常值得你在你的應用中去掉所有手工字串池。

在 Java 7u40+ 以及 Java 8 中的 String.intern()

Java7u40 版本擴充套件了字串池的大小(這是組要的效能更新)到 60013.這個值允許你在池中包含大約 30000 個獨立的字串。通常來說,這對於需要儲存的資料來說已經足夠了,你可以通過 -XX:+PrintFlagsFinal JVM 引數獲得這個值。

我嘗試在原始釋出的 Java 8 中執行相同的測試,Java 8 仍然支援 -XX:StringTableSize 引數來相容 Java 7 特性。主要的區別在於 Java 8 中預設的池大小增加到 60013:

50000; time = 0.019 sec
100000; time = 0.009 sec
150000; time = 0.009 sec
200000; time = 0.009 sec
250000; time = 0.009 sec
300000; time = 0.009 sec
350000; time = 0.011 sec
400000; time = 0.012 sec
450000; time = 0.01 sec
500000; time = 0.013 sec
550000; time = 0.013 sec
600000; time = 0.014 sec
650000; time = 0.018 sec
700000; time = 0.015 sec
750000; time = 0.029 sec
800000; time = 0.018 sec
850000; time = 0.02 sec
900000; time = 0.017 sec
950000; time = 0.018 sec
1000000; time = 0.021 sec

測試程式碼

這篇文章的測試程式碼很簡單,一個方法中迴圈建立並保留新字串。你可以測量它保留 10000 個字串所需要的時間。最好配合 -verbose:gc JVM 引數來執行這個測試,這樣可以檢視垃圾收集是何時以及如何發生的。另外最好使用 -Xmx 引數來執行堆的最大值。

這裡有兩個測試:testStringPoolGarbageCollection 將顯示 JVM 字串池被垃圾收集 — 檢查垃圾收集日誌訊息。在 Java 6 的預設 PermGen 大小配置上,這個測試會失敗,因此最好增加這個值,或者更新測試方法,或者使用 Java 7.

第二個測試顯示記憶體中保留了多少字串。在 Java 6 中執行需要兩個不同的記憶體配置 比如: -Xmx128M 以及 -Xmx1280M (10 倍以上)。你可能發現這個值不會影響放入池中字串的數量。另一方面,在 Java 7 中你能夠在堆中填滿你的字串。

/**
 - Testing String.intern.
 *
 - Run this class at least with -verbose:gc JVM parameter.
 */
public class InternTest {
    public static void main( String[] args ) {
        testStringPoolGarbageCollection();
        testLongLoop();
    }

    /**
     - Use this method to see where interned strings are stored
     - and how many of them can you fit for the given heap size.
     */
    private static void testLongLoop()
    {
        test( 1000 * 1000 * 1000 );
        //uncomment the following line to see the hand-written cache performance
        //testManual( 1000 * 1000 * 1000 );
    }

    /**
     - Use this method to check that not used interned strings are garbage collected.
     */
    private static void testStringPoolGarbageCollection()
    {
        //first method call - use it as a reference
        test( 1000 * 1000 );
        //we are going to clean the cache here.
        System.gc();
        //check the memory consumption and how long does it take to intern strings
        //in the second method call.
        test( 1000 * 1000 );
    }

    private static void test( final int cnt )
    {
        final List<String> lst = new ArrayList<String>( 100 );
        long start = System.currentTimeMillis();
        for ( int i = 0; i < cnt; ++i )
        {
            final String str = "Very long test string, which tells you about something " +
            "very-very important, definitely deserving to be interned #" + i;
//uncomment the following line to test dependency from string length
//            final String str = Integer.toString( i );
            lst.add( str.intern() );
            if ( i % 10000 == 0 )
            {
                System.out.println( i + "; time = " + ( System.currentTimeMillis() - start ) / 1000.0 + " sec" );
                start = System.currentTimeMillis();
            }
        }
        System.out.println( "Total length = " + lst.size() );
    }

    private static final WeakHashMap<String, WeakReference<String>> s_manualCache =
        new WeakHashMap<String, WeakReference<String>>( 100000 );

    private static String manualIntern( final String str )
    {
        final WeakReference<String> cached = s_manualCache.get( str );
        if ( cached != null )
        {
            final String value = cached.get();
            if ( value != null )
                return value;
        }
        s_manualCache.put( str, new WeakReference<String>( str ) );
        return str;
    }

    private static void testManual( final int cnt )
    {
        final List<String> lst = new ArrayList<String>( 100 );
        long start = System.currentTimeMillis();
        for ( int i = 0; i < cnt; ++i )
        {
            final String str = "Very long test string, which tells you about something " +
                "very-very important, definitely deserving to be interned #" + i;
            lst.add( manualIntern( str ) );
            if ( i % 10000 == 0 )
            {
                System.out.println( i + "; manual time = " + ( System.currentTimeMillis() - start ) / 1000.0 + " sec" );
                start = System.currentTimeMillis();
            }
        }
        System.out.println( "Total length = " + lst.size() );
    }
}

總結

  • 由於 Java 6 中使用固定的記憶體大小(PermGen)因此不要使用 String.intern() 方法
  • Java7 和 8 在堆記憶體中實現字串池。這以為這字串池的記憶體限制等於應用程式的記憶體限制。
  • 在 Java 7 和 8 中使用 -XX:StringTableSize 來設定字串池 Map 的大小。它是固定的,因為它使用 HashMap 實現。近似於你應用單獨的字串個數(你希望保留的)並且設定池的大小為最接近的質數並乘以 2 (減少碰撞的可能性)。它是的 String.intern 可以使用相同(固定)的時間並且在每次插入時消耗更小的記憶體(同樣的任務,使用java WeakHashMap將消耗4-5倍的記憶體)。
  • 在 Java 6 和 7(Java7u40以前) 中 -XX:StringTableSize 引數的值是 1009。Java7u40 以後這個值調整為 60013 (Java 8 中使用相同的值)
  • 如果你不確定字串池的用量,參考:-XX:+PrintStringTableStatistics JVM 引數,當你的應用掛掉時它告訴你字串池的使用量資訊。

相關文章