字串在任何應用中都佔用了大量的記憶體。尤其數包含獨立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 去執行下列的程式碼:
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。
現在,開啟字串去重,使用如下配置去跑上述程式碼:
-Xmx256m -XX:+UseG1GC -XX:+UseStringDeduplication -XX:+PrintStringDedupliliStatistics🀎而且在50次迭代之後才終止。
JVM現在同樣打印出了它做了什麼,讓我們一起看一下:
[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]則列出了有多少對去重的請求由於負載被丟棄,這也是開銷減少機制中的一部分。
那麼,字串去重和字串駐留相比又有什麼差別呢?我部落格上有一篇文章,名叫how great String Interning is for memory efficiency 。事實上,字串去重和駐留看起來差不多,除了暫留的機制重用了整個字串實例,而不僅僅是字元陣列。
JDK Enhancement Proposal 192的創造者的爭論點在於開發者們常常不知道將駐留字符串放在哪裡合適,或者是合適的地方被框架所隱藏.就像我寫的那樣,當碰到復制字串(像國家名字)的時候,你需要一些常識.字串去重,對於在同一個JVM中的應用程式的字串複製也有好處,同樣包括像XML Schemas,urls以及jar名字等一般認為不會出現多次的字串.
當字串駐留發生在應用程式執行緒中的時候,垃圾回收非同步並發處理時,字串去重也不會增加運行時的消耗.這也解釋了,為什麼我們會在上面的程式碼中發現Thread.sleep().如果沒有sleep會給GC增加太多的壓力,這樣字串去重根本就不會發生.但是,這只是範例程式碼才會出現的問題.實際的應用程式,常常會在運行字串去重的時候使用幾毫秒的時間.