本文將為你簡單介紹一下Java 8 update 20中引入的字串去重的特性。
從平均情況來看,應用程式中的String物件會消耗大量的記憶體。這裡面有一部分是冗餘的——同樣的字串會存在多個不同的例項(a != b, 但a.equals(b))。在實踐中,有許多字串會出於不同的原因造成冗餘。
最初JDK提供了一個String.intern()方法來解決字串冗餘的問題。這個方法的缺點在於你必須得去找出哪些字串需要進行駐留(interned)。這通常都需要一個具備冗餘字串查詢功能的堆分析的工具才行,比如Youkit profiler。如果使用得當的話,字串駐留會是一個非常有效的節省記憶體的工具——它讓你可以重用整個字串物件(每個字串物件在底層char[]的基礎上會增加24位元組的額外開銷)。
從Java 7 update 6開始,每個String物件都有一個自己專屬的私有char[] 。這樣JVM才可以自動進行優化——既然底層的char[]沒有暴露給外部的客戶端的話,那麼JVM就能去判斷兩個字串的內容是否是一致的,進而將一個字串底層的char[]替換成另一個字串的底層char[]陣列。
字串去重這個特性就是用來做這個的,它在Java 8 update 20中被引入。下面是它的工作原理:
- 你得使用G1垃圾回收器並啟用這一特性:-XX:+UseG1GC -XX:+UseStringDeduplication。這一特性作為G1垃圾回收器的一個可選的步驟來實現的,如果你用的是別的回收器是無法使用這一特性的。
- 這個特性會在G1回收器的minor GC階段中執行。根據我的觀察來看,它是否會執行取決於有多少空閒的CPU週期。因此,你不要指望它會在一個處理本地資料的資料分析器中會被執行。也就是說,WEB伺服器中倒是很可能會執行這個優化。
- 字串去重會去查詢那些未被處理的字串,計算它們的hash值(如果它沒在應用的程式碼中被計算過的話),然後再看是否有別的字串的hash值和底層的char[]都是一樣的。如果找到的話——它會用一個新字串的char[]來替換掉現有的這個char[]。
- 字串去重只會去處理那些歷經數次GC仍然存活的那些字串。這樣能確保大多數的那些短生命週期的字串不會被處理。字串的這個最小的存活年齡可以通過-XX:StringDeduplicationAgeThreshold=3的JVM引數來指定(3是這個引數的預設值)。
下面是這個實現的一些重要的結論:
- 沒錯,如果你想享受字串去重特性的這份免費午餐的話,你得使用G1回收器。使用parellel GC的話是無法使用它的,而對那些對吞吐量要求比延遲時期高的應用而言,parellel GC應該是個更好的選擇。
- 字串去重是無法在一個已載入完的系統中執行的。要想知道它是否被執行了,可以通過-XX:+PrintStringDeduplicationStatistics引數來執行JVM,並檢視控制檯的輸出。
- 如果你希望節省記憶體的話,你可以在應用程式中將字串進行駐留(interned)——那麼放手去做吧,不要依賴於字串去重的功能。你需要時刻注意的是字串去重是要處理你所有的字串的(至少是大部分吧)——也就是說盡管你知道某個指定的字串的內容是唯一的(比如說GUID),但JVM並不知道這些,它還是會嘗試將這個字串和其它的字串進行匹配。這樣的結果就是,字串去重所產生的CPU開銷既取決於堆中字串的數量(將新的字串和別的字串進行比較),也取決於你在字串去重的間隔中所建立的字串的數量(這些字串會和堆中的字串進行比較)。在一個擁有好幾個G的堆的JVM上,可以通過-XX:+PrintStringDeduplicationStatistics選項來看下這個特性所產生的影響究竟有多大。
- 另一方面,它基本是以一種非阻塞的方式來完成的,如果你的伺服器有足夠多的空閒CPU的話,那為什麼不用呢?
- 最後,請記住,String.intern可以讓你只針對你的應用程式中指定的某一部分已知會產生冗餘的字串。通常來說,它只需要比較一個較小的駐留字串的池就可以了,也就是說你可以更高效地使用你的CPU。不僅如此,你還可以將整個字串物件進行駐留,這樣每個字串你還多節省了24個位元組。
這裡是我用來試驗這一特性的一個測試類。這三個測試都會一直執行到JVM丟擲OOM為止,因此你得分別去單獨地執行它們。
第一個測試會建立內容一樣的字串,如果你想知道當堆中字串很多的時候,字串去重會花掉多少時間的話,這個測試就變得非常有用了。儘量給第一個測試分配儘可能多的記憶體——它建立的字串越多,優化的效果就越好。
第二三個測試會比較去重(第二個測試)及駐留(interning, 第三個測試)間的差別。你得用一個相同的Xmx設定來執行它們。在程式中我把這個常量設定成了Xmx256M,但是當然了,你可以分配得多點。然而,你會發現,和interning測試相比,去重測試會更早地掛掉。這是為什麼?因為我們在這組測試中只有100個不同的字串,因此對它們進行駐留就意味著你用到的記憶體就只是儲存這些字串所需要的空間。而字串去重的話,會產生不同的字串物件,它僅會共享底層的char[]陣列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
/** * String deduplication vs interning test */ public class StringDedupTest { private static final int MAX_EXPECTED_ITERS = 300; private static final int FULL_ITER_SIZE = 100 * 1000; //30M entries = 120M RAM (for 300 iters) private static List<String> LIST = new ArrayList<>( MAX_EXPECTED_ITERS * FULL_ITER_SIZE ); public static void main(String[] args) throws InterruptedException { //24+24 bytes per String (24 String shallow, 24 char[]) //136M left for Strings //Unique, dedup //136M / 2.9M strings = 48 bytes (exactly String size) //Non unique, dedup //4.9M Strings, 100 char[] //136M / 4.9M strings = 27.75 bytes (close to 24 bytes per String + small overhead //Non unique, intern //We use 120M (+small overhead for 100 strings) until very late, but can't extend ArrayList 3 times - we don't have 360M /* Run it with: -XX:+UseG1GC -XX:+UseStringDeduplication -XX:+PrintStringDeduplicationStatistics Give as much Xmx as you can on your box. This test will show you how long does it take to run a single deduplication and if it is run at all. To test when deduplication is run, try changing a parameter of Thread.sleep or comment it out. You may want to print garbage collection information using -XX:+PrintGCDetails -XX:+PrintGCTimestamps */ //Xmx256M - 29 iterations fillUnique(); /* This couple of tests compare string deduplication (first test) with string interning. Both tests should be run with the identical Xmx setting. I have tuned the constants in the program for Xmx256M, but any higher value is also good enough. The point of this tests is to show that string deduplication still leaves you with distinct String objects, each of those requiring 24 bytes. Interning, on the other hand, return you existing String objects, so the only memory you spend is for the LIST object. */ //Xmx256M - 49 iterations (100 unique strings) //fillNonUnique( false ); //Xmx256M - 299 iterations (100 unique strings) //fillNonUnique( true ); } private static void fillUnique() throws InterruptedException { int iters = 0; final UniqueStringGenerator gen = new UniqueStringGenerator(); while ( true ) { for ( int i = 0; i < FULL_ITER_SIZE; ++i ) LIST.add( gen.nextUnique() ); Thread.sleep( 300 ); System.out.println( "Iteration " + (iters++) + " finished" ); } } private static void fillNonUnique( final boolean intern ) throws InterruptedException { int iters = 0; final UniqueStringGenerator gen = new UniqueStringGenerator(); while ( true ) { for ( int i = 0; i < FULL_ITER_SIZE; ++i ) LIST.add( intern ? gen.nextNonUnique().intern() : gen.nextNonUnique() ); Thread.sleep( 300 ); System.out.println( "Iteration " + (iters++) + " finished" ); } } private static class UniqueStringGenerator { private char upper = 0; private char lower = 0; public String nextUnique() { final String res = String.valueOf( upper ) + lower; if ( lower < Character.MAX_VALUE ) lower++; else { upper++; lower = 0; } return res; } public String nextNonUnique() { final String res = "a" + lower; if ( lower < 100 ) lower++; else lower = 0; return res; } } } |
總結
Java 8 update 20中新增了字串去重的特性。它是G1垃圾回收器的一部分,因此你必須使用G1回收器才能啟用它:-XX:+UseG1GC -XX:+UseStringDeduplication。
- 字串去重是G1的一個可選的階段。它取決於當前的系統負載。
- 字串去重會查詢內容相同的那些字串,並將它們底層儲存字元的char[]陣列進行統一。使用這一特性你不需要寫任何程式碼,不過這意味著最後你得到的是不同的字串物件,每個物件會佔用24個位元組。有的時候顯式地呼叫String.intern進行駐留還是有必要的。
- 字串去重不會對年輕的字串進行處理。字串處理的最小年齡是通過-XX:StringDeduplicationAgeThreshold=3的JVM引數來進行管理的(3是這個引數的預設值)。