string会传null吗_JVM 解剖公园(10): String.intern()
(給ImportNew加星標,提高Java技能)
編譯:ImportNew/唐尤華
shipilev.net/jvm/anatomy-quarks/10-string-intern/
1. 寫在前面
“JVM 解剖公園”是一個持續(xù)更新的系列迷你博客,閱讀每篇文章一般需要5到10分鐘。限于篇幅,僅對某個主題按照問題、測試、基準程序、觀察結(jié)果深入講解。因此,這里的數(shù)據(jù)和討論可以當軼事看,不做寫作風格、句法和語義錯誤、重復或一致性檢查。如果選擇采信文中內(nèi)容,風險自負。
Aleksey Shipilёv,JVM 性能極客
推特 @shipilev
問題、評論、建議發(fā)送到 aleksey@shipilev.net"">aleksey@shipilev.net
2. 問題
String.intern() 的工作機制究竟是怎樣的?要不要避免使用 String.intern()?
3. 理論
如果仔細讀過 String Javadoc,你應該會注意到 public API 中有一個非常有意思的方法:
public String intern()
返回字符串對象的規(guī)范表示。String 類維護一個內(nèi)部字符串池,初始為空。
調(diào)用 intern(),如果池中有字符串與調(diào)用的字符串 equals(Object) 結(jié)果相等,直接返回池中的字符串;否則,加入字符串池并返回對象引用。
— JDK Javadoc
java.lang.String
看起來 String 提供的接口可以操作內(nèi)部字符串池進而優(yōu)化內(nèi)存,對嗎?然而,這里有一個缺點:OpenJDK 的 String.intern() 是本地(native)實現(xiàn),執(zhí)行時會調(diào)用 JVM 把 String 存入本地 JVM 字符串池。由于 intern 是一個 JDK 與 VM 之間的接口,所以 VM 本地代碼和 JDK 代碼都需要處理字符串對象。
這種實現(xiàn)會帶來下列影響:
每次調(diào)用 intern() 都要在 JDK 與 JVM 之間的接口,浪費時間。
intern() 性能取決于 HashTable 本地實現(xiàn),落后于高性能 Java 實現(xiàn),在并發(fā)訪問情況下尤其如此。
由于 Java 字符串是本地 VM 結(jié)構(gòu)的引用,它們成為 GC root set 一部分。許多情況下,需要在 GC 暫停時進行額外處理。
這些影響重要嗎?
4. 吞吐量實驗
下面是我們設計的一個簡單的實驗,用 HashMap 和 ConcurrentHashMap 實現(xiàn)去重與 intern 操作。JMH 運行得到的結(jié)果很好。
@State(Scope.Benchmark)public class StringIntern {
@Param({"1", "100", "10000", "1000000"})
private int size;
private StringInterner str;
private CHMInterner chm;
private HMInterner hm;
@Setuppublic void setup() {
str = new StringInterner();
chm = new CHMInterner();
hm = new HMInterner();
}
public static class StringInterner {
public String intern(String s) {
return s.intern();
}
}
@Benchmarkpublic void intern(Blackhole bh) {
for (int c = 0; c < size; c++) {
bh.consume(str.intern("String" + c));
}
}
public static class CHMInterner {
private final Mapmap;public CHMInterner() {map = new ConcurrentHashMap<>();
}public String intern(String s) {
String exist = map.putIfAbsent(s, s);return (exist == null) ? s : exist;
}
}
@Benchmarkpublic void chm(Blackhole bh) {for (int c = 0; c < size; c++) {
bh.consume(chm.intern("String" + c));
}
}public static class HMInterner {private final Mapmap;public HMInterner() {map = new HashMap<>();
}public String intern(String s) {
String exist = map.putIfAbsent(s, s);return (exist == null) ? s : exist;
}
}
@Benchmarkpublic void hm(Blackhole bh) {for (int c = 0; c < size; c++) {
bh.consume(hm.intern("String" + c));
}
}
}
上面的測試對大量字符串執(zhí)行 intern 操作,但實際上只有第一次循環(huán)會發(fā)生 intern,其他循環(huán)都從已有 map 中檢查。size 參數(shù)控制執(zhí)行 intern 字符串數(shù)量以及 StringTable 的大小。
使用 JDK 8u131 運行,結(jié)果如下:
Benchmark (size) Mode Cnt Score Error UnitsStringIntern.chm 1 avgt 25 0.038 ± 0.001 us/op
StringIntern.chm 100 avgt 25 4.030 ± 0.013 us/op
StringIntern.chm 10000 avgt 25 516.483 ± 3.638 us/op
StringIntern.chm 1000000 avgt 25 93588.623 ± 4838.265 us/op
StringIntern.hm 1 avgt 25 0.028 ± 0.001 us/op
StringIntern.hm 100 avgt 25 2.982 ± 0.073 us/op
StringIntern.hm 10000 avgt 25 422.782 ± 1.960 us/op
StringIntern.hm 1000000 avgt 25 81194.779 ± 4905.934 us/op
StringIntern.intern 1 avgt 25 0.089 ± 0.001 us/op
StringIntern.intern 100 avgt 25 9.324 ± 0.096 us/op
StringIntern.intern 10000 avgt 25 1196.700 ± 141.915 us/op
StringIntern.intern 1000000 avgt 25 650243.474 ± 36680.057 us/op
為什么會產(chǎn)生這樣的結(jié)果?很明顯 String.intern() 執(zhí)行的速度更慢!答案是 intern 采用本地實現(xiàn)(“本地 native”并不等于“更好”)。使用 perf record -g 可以清晰地看到:
- 6.63% 0.00% java [unknown] [k] 0x00000006f8000041- 0x6f8000041
- 6.41% 0x7faedd1ee354
- 6.41% 0x7faedd170426
- JVM_InternString
- 5.82% StringTable::intern
- 4.85% StringTable::intern
0.39% java_lang_String::equals
0.19% Monitor::lock
+ 0.00% StringTable::basic_add
- 0.97% java_lang_String::as_unicode_string
resource_allocate_bytes
0.19% JNIHandleBlock::allocate_handle
0.19% JNIHandles::make_local
雖然 JNI 轉(zhuǎn)換本身開銷很大,但似乎在 StringTable 上也花費了很多時間。通過 -XX:+PrintStringTableStatistics 可以了解到關聯(lián)信息:
StringTable statistics:Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 1002714 = 24065136 bytes, avg 24.000
Number of literals : 1002714 = 64192616 bytes, avg 64.019
Total footprint : = 88737856 bytes
Average bucket size : 16.708 ;
HashTable 內(nèi)部每個 bucket 包含 16 個元素,采用鏈式組合,在上面的結(jié)果中都報告“超載”。更糟糕的是 StringTable 不支持調(diào)整大小,盡管有些實驗性工作可以支持調(diào)整大小,但處于“某些原因”被否決了。通過 -XX:StringTableSize 參數(shù)可以讓 -XX:StringTableSize 變大,比如設為10M:
Benchmark (size) Mode Cnt Score Error Units# Default, copied from above
StringIntern.chm 1 avgt 25 0.038 ± 0.001 us/op
StringIntern.chm 100 avgt 25 4.030 ± 0.013 us/op
StringIntern.chm 10000 avgt 25 516.483 ± 3.638 us/op
StringIntern.chm 1000000 avgt 25 93588.623 ± 4838.265 us/op
# Default, copied from above
StringIntern.intern 1 avgt 25 0.089 ± 0.001 us/op
StringIntern.intern 100 avgt 25 9.324 ± 0.096 us/op
StringIntern.intern 10000 avgt 25 1196.700 ± 141.915 us/op
StringIntern.intern 1000000 avgt 25 650243.474 ± 36680.057 us/op
# StringTableSize = 10M
StringIntern.intern 1 avgt 5 0.097 ± 0.041 us/op
StringIntern.intern 100 avgt 5 10.174 ± 5.026 us/op
StringIntern.intern 10000 avgt 5 1152.387 ± 558.044 us/op
StringIntern.intern 1000000 avgt 5 130862.190 ± 61200.783 us/op
但這只是一種權宜之計,你必須事先計劃好。如果盲目地增大 StringTable 會造成浪費。即使充分使用了增大后的 StringTable,本地調(diào)用還會同樣增加開銷。
5. GC 暫停實驗
本地 StringTable 最大的問題在于,它是 GC root 的一部分。也就是說,需要由垃圾收集器專門對其進行掃描和更新。在 OpenJDK 中,這意味著需要在 GC 暫停期間完成繁雜的工作。實際上,對于 Shenandoah,GC 暫停的時長主要取決于 root set 大小。StringTable 包含1M記錄時,執(zhí)行結(jié)果如下:
$ ... StringIntern -p size=1000000 --jvmArgs "-XX:+UseShenandoahGC -Xlog:gc+stats -Xmx1g -Xms1g"...
Initial Mark Pauses (G) = 0.03 s (a = 15667 us) (n = 2) (lvls, us = 15039, 15039, 15039, 15039, 16260)
Initial Mark Pauses (N) = 0.03 s (a = 15516 us) (n = 2) (lvls, us = 14844, 14844, 14844, 14844, 16088)
Scan Roots = 0.03 s (a = 15448 us) (n = 2) (lvls, us = 14844, 14844, 14844, 14844, 16018)
S: Thread Roots = 0.00 s (a = 64 us) (n = 2) (lvls, us = 41, 41, 41, 41, 87)
S: String Table Roots = 0.03 s (a = 13210 us) (n = 2) (lvls, us = 12695, 12695, 12695, 12695, 13544)
S: Universe Roots = 0.00 s (a = 2 us) (n = 2) (lvls, us = 2, 2, 2, 2, 2)
S: JNI Roots = 0.00 s (a = 3 us) (n = 2) (lvls, us = 2, 2, 2, 2, 4)
S: JNI Weak Roots = 0.00 s (a = 35 us) (n = 2) (lvls, us = 29, 29, 29, 29, 42)
S: Synchronizer Roots = 0.00 s (a = 0 us) (n = 2) (lvls, us = 0, 0, 0, 0, 0)
S: Flat Profiler Roots = 0.00 s (a = 0 us) (n = 2) (lvls, us = 0, 0, 0, 0, 0)
S: Management Roots = 0.00 s (a = 1 us) (n = 2) (lvls, us = 1, 1, 1, 1, 1)
S: System Dict Roots = 0.00 s (a = 9 us) (n = 2) (lvls, us = 8, 8, 8, 8, 11)
S: CLDG Roots = 0.00 s (a = 75 us) (n = 2) (lvls, us = 68, 68, 68, 68, 81)
S: JVMTI Roots = 0.00 s (a = 0 us) (n = 2) (lvls, us = 0, 0, 0, 0, 1)
從上面的結(jié)果可以看到,由于 root set 中加入了更多數(shù)據(jù),每次暫停增加了額外13ms。
這表明,一些 GC 實現(xiàn)只有在重負載情況下才會清理 StringTable。例如,從 JVM 的角度來看,如果類沒有卸載(unloaded),清理 StringTable 是沒有意義的。只有已經(jīng)加載的類是 intern 字符串的主要來源。以 G1 和 CMS 為例,上面的測試負載會產(chǎn)生有趣的結(jié)果:
public class InternMuch {public static void main(String... args) {
for (int c = 0; c < 1_000_000_000; c++) {
String s = "" + c + "root";
s.intern();
}
}
}
使用 CMS 運行:
$ java -XX:+UseConcMarkSweepGC -Xmx2g -Xms2g -verbose:gc -XX:StringTableSize=6661443 InternMuchGC(7) Pause Young (Allocation Failure) 349M->349M(989M) 357.485ms
GC(8) Pause Initial Mark 354M->354M(989M) 3.605ms
GC(8) Concurrent Mark
GC(8) Concurrent Mark 1.711ms
GC(8) Concurrent Preclean
GC(8) Concurrent Preclean 0.523ms
GC(8) Concurrent Abortable Preclean
GC(8) Concurrent Abortable Preclean 935.176ms
GC(8) Pause Remark 512M->512M(989M) 512.290ms
GC(8) Concurrent Sweep
GC(8) Concurrent Sweep 310.167ms
GC(8) Concurrent Reset
GC(8) Concurrent Reset 0.404ms
GC(9) Pause Young (Allocation Failure) 349M->349M(989M) 369.925ms
目前為止運行結(jié)果還算不錯,遍歷過載的 StringTable 需要耗費一段時間。但如果使用 -XX:-ClassUnloading 屏蔽類卸載,運行結(jié)果會變得糟糕。這實際上在常規(guī) GC 循環(huán)中禁用 StringTable 清理!可以預測接下來的運行結(jié)果:
$ java -XX:+UseConcMarkSweepGC -Xmx2g -Xms2g -verbose:gc -XX:-ClassUnloading -XX:StringTableSize=6661443 InternMuchGC(11) Pause Young (Allocation Failure) 273M->308M(989M) 338.999ms
GC(12) Pause Initial Mark 314M->314M(989M) 66.586ms
GC(12) Concurrent Mark
GC(12) Concurrent Mark 175.625ms
GC(12) Concurrent Preclean
GC(12) Concurrent Preclean 0.539ms
GC(12) Concurrent Abortable Preclean
GC(12) Concurrent Abortable Preclean 2549.523ms
GC(12) Pause Remark 696M->696M(989M) 133.920ms
GC(12) Concurrent Sweep
GC(12) Concurrent Sweep 175.949ms
GC(12) Concurrent Reset
GC(12) Concurrent Reset 0.463ms
GC(14) Pause Full (Allocation Failure) 859M->0M(989M) 1541.465ms GC(13) Pause Young (Allocation Failure) 859M->0M(989M) 1541.515ms
看到了完整的 STW(Stop The World 萬物靜止)GC。CMS 中包含了 ExplicitGCInvokesConcurrentAndUnloadsClasses,假設用戶不時調(diào)用 System.gc() 能夠有效緩解這個問題。
6. 觀察
這里我們只討論?intern?或去重方法的實現(xiàn),滿足改進內(nèi)存占用、底層優(yōu)化或其他模糊的需求。這些需求可以另行討論,挑戰(zhàn)或接納。更多有關 Java String 的討論,推薦我的演講?“java.lang.String 問答”。
String.intern() 為 OpenJDK 提供了訪問本地 JVM StringTable 方法。使用 intern 時需要關注吞吐量、內(nèi)存占用和暫停時間,這些都有可能讓用戶等待。人們很容易低估這些警告帶來的影響。手工實現(xiàn)去重或 intern 方法運行更加可靠。因為它們工作在 Java 端,只是普通的 Java 對象,可以更好地設置和重新調(diào)整大小。而且在不再需要時也可以完全丟棄。GC 輔助的字符串去重的確更好地減輕了負擔。
在實際項目中,從性能開銷的熱點路徑上去除 String.intern() 或者采用手工實現(xiàn)去重方法有助于性能優(yōu)化。請不要沒有深思熟慮就使用 String.intern(),好嗎?
推薦閱讀
(點擊標題可跳轉(zhuǎn)閱讀)
深入理解 Java String.intern() 內(nèi)存模型
String 常量池和 String#intern()
深入解析 String.intern
看完本文有收獲?請轉(zhuǎn)發(fā)分享給更多人
關注「ImportNew」,提升Java技能
好文章,我在看??
總結(jié)
以上是生活随笔為你收集整理的string会传null吗_JVM 解剖公园(10): String.intern()的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 谷歌AI聊天机器人给出错误答案 市值抹去
- 下一篇: python编辑程序模型_Python编