Java 性能優化手冊:提高 Java 代碼性能的各種技巧

jopen 9年前發布 | 14K 次閱讀 Java Java開發

原文出處: 字節技術

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; }</pre>

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

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:+PrintFlagsFinalJVM 參數獲得這個值。

我嘗試在原始發布的 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:gcJVM 參數來運行這個測試,這樣可以查看垃圾收集是何時以及如何發生的。另外最好使用-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() ); } }</pre>

      總結

      • 由于 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:+PrintStringTableStatisticsJVM 參數,當你的應用掛掉時它告訴你字符串池的使用量信息。
 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!