字符串在任何應用中都占用了大量的內存。尤其數包含獨立UTF-16字符的char[]數組對JVM內存的消耗貢獻最多——因為每個字符占用2位。
內存的30%被字符串消耗其實是很常見的,不僅是因為字符串是與我們互動的最好的格式,而且是由于流行的HTTP API使用了大量的字符串。使用Java 8 Update 20,我們現在可以接觸到一個新特性,叫做字符串去重,該特性需要G1垃圾回收器,該垃圾回收器默認是被關閉的。
字符串去重利用了字符串內部實際是char數組,并且是final的特性,所以JVM可以任意的操縱他們。
對于字符串去重,開發者考慮了大量的策略,但最終的實現采用了下面的方式:
無論何時垃圾回收器訪問了String對象,它會對char數組進行一個標記。它獲取char數組的hash value并把它和一個對數組的弱引用存在一起。只要垃圾回收器發現另一個字符串,而這個字符串和char數組具有相同的hash code,那么就會對兩者進行一個字符一個字符的比對。
如果他們恰好匹配,那么一個字符串就會被修改,指向第二個字符串的char數組。第一個char數組就不再被引用,也就可以被回收了。
這整個過程當然帶來了一些開銷,但是被很緊實的上限控制了。例如,如果一個字符未發現有重復,那么一段時間之內,它會不再被檢查。
那么該特性實際上是怎么工作的呢?首先,你需要剛剛發布的Java 8 Update 20,然后按照這個配置: -Xmx256m -XX:+UseG1GC 去運行下列的代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
public class LotsOfStrings { private static final LinkedList<String> LOTS_OF_STRINGS = new LinkedList<>(); public static void main(String[] args) throws Exception { int iteration = 0 ; while ( true ) { for ( int i = 0 ; i < 100 ; i++) { for ( int j = 0 ; j < 1000 ; j++) { LOTS_OF_STRINGS.add( new String( "String " + j)); } } iteration++; System.out.println( "Survived Iteration: " + iteration); Thread.sleep( 100 ); } } } |
這段代碼會執行30個迭代之后報OutOfMemoryError。
現在,開啟字符串去重,使用如下配置去跑上述代碼:
1
|
-Xmx256m -XX:+UseG1GC -XX:+UseStringDeduplication -XX:+PrintStringDeduplicationStatistics |
此時它已經可以運行更長的時間,而且在50個迭代之后才終止。
JVM現在同樣打印出了它做了什么,讓我們一起看一下:
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
|
[GC concurrent-string-deduplication, 4658 .2K-> 0 .0B( 4658 .2K), avg 99.6 %, 0.0165023 secs] [Last Exec: 0.0165023 secs, Idle: 0.0953764 secs, Blocked: 0 / 0.0000000 secs] [Inspected: 119538 ] [Skipped: 0 ( 0.0 %)] [Hashed: 119538 ( 100.0 %)] [Known: 0 ( 0.0 %)] [New: 119538 ( 100.0 %) 4658 .2K] [Deduplicated: 119538 ( 100.0 %) 4658 .2K( 100.0 %)] [Young: 372 ( 0.3 %) 14 .5K( 0.3 %)] [Old: 119166 ( 99.7 %) 4643 .8K( 99.7 %)] [Total Exec: 4 / 0.0802259 secs, Idle: 4 / 0.6491928 secs, Blocked: 0 / 0.0000000 secs] [Inspected: 557503 ] [Skipped: 0 ( 0.0 %)] [Hashed: 556191 ( 99.8 %)] [Known: 903 ( 0.2 %)] [New: 556600 ( 99.8 %) 21 .2M] [Deduplicated: 554727 ( 99.7 %) 21 .1M( 99.6 %)] [Young: 1101 ( 0.2 %) 43 .0K( 0.2 %)] [Old: 553626 ( 99.8 %) 21 .1M( 99.8 %)] [Table] [Memory Usage: 81 .1K] [Size: 2048 , Min: 1024 , Max: 16777216 ] [Entries: 2776 , Load: 135.5 %, Cached: 0 , Added: 2776 , Removed: 0 ] [Resize Count: 1 , Shrink Threshold: 1365 ( 66.7 %), Grow Threshold: 4096 ( 200.0 %)] [Rehash Count: 0 , Rehash Threshold: 120 , Hash Seed: 0x0 ] [Age Threshold: 3 ] [Queue] [Dropped: 0 ] |
為了方便,我們不需要自己去計算所有數據的加和,使用方便的總計就可以了。
上面的代碼段規定執行了字符串去重,花了16ms的時間,查看了約 120 k 字符串。
上面的特性是剛推出的,意味著可能并沒有被全面的審視。具體的數據在實際的應用中可能看起來有差別,尤其是那些應用中字符串被多次使用和傳遞,因此一些字符串可能被跳過或者早就有了hashcode(正如你可能知道的那樣,一個String的hash code是被懶加載的)。
在上述的案例中,所有的字符串都被去重了,在內存中移除了4.5MB的數據。
[Table]部分給出了有關內部跟蹤表的統計信息,[Queue]則列出了有多少對去重的請求由于負載被丟棄,這也是開銷減少機制中的一部分。
那么,字符串去重和字符串駐留相比又有什么差別呢?事實上,字符串去重和駐留看起來差不多,除了暫留的機制重用了整個字符串實例,而不僅僅是字符數組。
JDK Enhancement Proposal 192的創造者的爭論點在于開發者們常常不知道將駐留字符串放在哪里合適,或者是合適的地方被框架所隱藏.就像我寫的那樣,當碰到復制字符串(像國家名字)的時候,你需要一些常識.字符串去重,對于在同一個JVM中的應用程序的字符串復制也有好處,同樣包括像XML Schemas,urls以及jar名字等一般認為不會出現多次的字符串.
當字符串駐留發生在應用程序線程中的時候,垃圾回收異步并發處理時,字符串去重也不會增加運行時的消耗.這也解釋了,為什么我們會在上面的代碼中發現Thread.sleep().如果沒有sleep會給GC增加太多的壓力,這樣字符串去重根本就不會發生.但是,這只是示例代碼才會出現的問題.實際的應用程序,常常會在運行字符串去重的時候使用幾毫秒的時間.