JVM 解剖公园(10): String.intern()
(给ImportNew加星标,提高Java技能)
编译:ImportNew/唐尤华
shipilev.net/jvm/anatomy-quarks/10-string-intern/
1. 写在前面
“JVM 解剖公园”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。
Aleksey Shipilёv,JVM 性能极客
推特 @shipilev
问题、评论、建议发送到 aleksey@shipilev.net"">aleksey@shipilev.net
2. 问题
String.intern() 的工作机制究竟是怎样的?要不要避免使用 String.intern()?
3. 理论
如果仔细读过 String Javadoc,你应该会注意到 public API 中有一个非常有意思的方法:
public String intern()
返回字符串对象的规范表示。String 类维护一个内部字符串池,初始为空。
调用 intern(),如果池中有字符串与调用的字符串 equals(Object) 结果相等,直接返回池中的字符串;否则,加入字符串池并返回对象引用。
— JDK Javadoc
java.lang.String
看起来 String 提供的接口可以操作内部字符串池进而优化内存,对吗?然而,这里有一个缺点:OpenJDK 的 String.intern() 是本地(native)实现,执行时会调用 JVM 把 String 存入本地 JVM 字符串池。由于 intern 是一个 JDK 与 VM 之间的接口,所以 VM 本地代码和 JDK 代码都需要处理字符串对象。
这种实现会带来下列影响:
每次调用 intern() 都要在 JDK 与 JVM 之间的接口,浪费时间。
intern() 性能取决于 HashTable 本地实现,落后于高性能 Java 实现,在并发访问情况下尤其如此。
由于 Java 字符串是本地 VM 结构的引用,它们成为 GC root set 一部分。许多情况下,需要在 GC 暂停时进行额外处理。
这些影响重要吗?
4. 吞吐量实验
下面是我们设计的一个简单的实验,用 HashMap 和 ConcurrentHashMap 实现去重与 intern 操作。JMH 运行得到的结果很好。
@State(Scope.Benchmark)
public class StringIntern {
@Param({"1", "100", "10000", "1000000"})
private int size;
private StringInterner str;
private CHMInterner chm;
private HMInterner hm;
@Setup
public void setup() {
str = new StringInterner();
chm = new CHMInterner();
hm = new HMInterner();
}
public static class StringInterner {
public String intern(String s) {
return s.intern();
}
}
@Benchmark
public void intern(Blackhole bh) {
for (int c = 0; c < size; c++) {
bh.consume(str.intern("String" + c));
}
}
public static class CHMInterner {
private final Map<String, String> map;
public CHMInterner() {
map = new ConcurrentHashMap<>();
}
public String intern(String s) {
String exist = map.putIfAbsent(s, s);
return (exist == null) ? s : exist;
}
}
@Benchmark
public void chm(Blackhole bh) {
for (int c = 0; c < size; c++) {
bh.consume(chm.intern("String" + c));
}
}
public static class HMInterner {
private final Map<String, String> map;
public HMInterner() {
map = new HashMap<>();
}
public String intern(String s) {
String exist = map.putIfAbsent(s, s);
return (exist == null) ? s : exist;
}
}
@Benchmark
public void hm(Blackhole bh) {
for (int c = 0; c < size; c++) {
bh.consume(hm.intern("String" + c));
}
}
}
上面的测试对大量字符串执行 intern 操作,但实际上只有第一次循环会发生 intern,其他循环都从已有 map 中检查。size 参数控制执行 intern 字符串数量以及 StringTable 的大小。
使用 JDK 8u131 运行,结果如下:
Benchmark (size) Mode Cnt Score Error Units
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
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
为什么会产生这样的结果?很明显 String.intern() 执行的速度更慢!答案是 intern 采用本地实现(“本地 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 转换本身开销很大,但似乎在 StringTable 上也花费了很多时间。通过 -XX:+PrintStringTableStatistics 可以了解到关联信息:
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 内部每个 bucket 包含 16 个元素,采用链式组合,在上面的结果中都报告“超载”。更糟糕的是 StringTable 不支持调整大小,尽管有些实验性工作可以支持调整大小,但处于“某些原因”被否决了。通过 -XX:StringTableSize 参数可以让 -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,本地调用还会同样增加开销。
5. GC 暂停实验
本地 StringTable 最大的问题在于,它是 GC root 的一部分。也就是说,需要由垃圾收集器专门对其进行扫描和更新。在 OpenJDK 中,这意味着需要在 GC 暂停期间完成繁杂的工作。实际上,对于 Shenandoah,GC 暂停的时长主要取决于 root set 大小。StringTable 包含1M记录时,执行结果如下:
$ ... 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)
从上面的结果可以看到,由于 root set 中加入了更多数据,每次暂停增加了额外13ms。
这表明,一些 GC 实现只有在重负载情况下才会清理 StringTable。例如,从 JVM 的角度来看,如果类没有卸载(unloaded),清理 StringTable 是没有意义的。只有已经加载的类是 intern 字符串的主要来源。以 G1 和 CMS 为例,上面的测试负载会产生有趣的结果:
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 InternMuch
GC(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
目前为止运行结果还算不错,遍历过载的 StringTable 需要耗费一段时间。但如果使用 -XX:-ClassUnloading 屏蔽类卸载,运行结果会变得糟糕。这实际上在常规 GC 循环中禁用 StringTable 清理!可以预测接下来的运行结果:
$ java -XX:+UseConcMarkSweepGC -Xmx2g -Xms2g -verbose:gc -XX:-ClassUnloading -XX:StringTableSize=6661443 InternMuch
GC(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,假设用户不时调用 System.gc() 能够有效缓解这个问题。
6. 观察
这里我们只讨论 intern 或去重方法的实现,满足改进内存占用、底层优化或其他模糊的需求。这些需求可以另行讨论,挑战或接纳。更多有关 Java String 的讨论,推荐我的演讲 “java.lang.String 问答”。
String.intern() 为 OpenJDK 提供了访问本地 JVM StringTable 方法。使用 intern 时需要关注吞吐量、内存占用和暂停时间,这些都有可能让用户等待。人们很容易低估这些警告带来的影响。手工实现去重或 intern 方法运行更加可靠。因为它们工作在 Java 端,只是普通的 Java 对象,可以更好地设置和重新调整大小。而且在不再需要时也可以完全丢弃。GC 辅助的字符串去重的确更好地减轻了负担。
在实际项目中,从性能开销的热点路径上去除 String.intern() 或者采用手工实现去重方法有助于性能优化。请不要没有深思熟虑就使用 String.intern(),好吗?
推荐阅读
(点击标题可跳转阅读)
深入理解 Java String.intern() 内存模型
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
好文章,我在看❤️