原文地址:JVM Anatomy Park #10: String.intern()
问题
String.intern()
究竟是如何工作的?我应该避免使用它吗?
理论
如果你曾经研究过 String
的文档,那么你肯定留意过一个有趣的方法:
public String intern()
返回字符串对象的规范化表示形式。一个初始为空的字符串池,它由类
String
私有地维护。
当调用 intern 方法时,如果池已经包含一个等于此String
对象的字符串(用equals(Object)
方法确定),则返回池中的字符串。否则,将此String
对象添加到池中,并返回此String
对象的引用。— JDK Javadoc java.lang.String
这段文档的意思好像是: String 提供了字符串池的用户访问入口,我们可以利用这个机制来优化内存使用,是这样么?然而这伴随着一个缺点:在 OpenJDK 中 String.intern()
是本地方法,它实际上会调用到 JVM,最终会驻留本地 JVM 字符串池中的 String。由于字符串驻留是 JDK-VM 接口的一部分,所以 VM 本地代码和 JDK 代码需要对 String 对象的引用达成共识。
这种实现将会产生下述影响:
- 每次执行
intern()
都需要跨越 JDK-JVM 接口,这将浪费不少时间。 - 性能受制于本地哈希表的实现,这部分的开发还是比较滞后的,尤其是在并发访问的情况下。
- 因为字符串是来自本地 VM 结构的引用,所以它们是 GC 根集合的一部分。在很多场景下,这需要在 GC 停顿过程中处理不少附加工作。
这影响大么?
实验:吞吐量
我们又构建了一个简单的实验。使用 HashMap
和 ConcurrentHashMap
可以实现去重和驻留逻辑,因此可以这样构建 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 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 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));
}
}
}
这个测试用例尝试驻留一些字符串,实际上只有在第一次循环的时候保存字符串,接下来的循环仅仅是通过现有的映射检查字符串。参数 size
控制驻留的字符串数量,因此限制了我们需要处理的字符串表的大小。这也是驻留器的通常用法。
使用 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()
要慢得多!慢的原因在本地实现中(乡亲们,“本地”并不意味着“更快”),通过 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 ; <---- !!!!!!
在链接哈希表中每个桶中有16个元素就已经超负荷了。更糟糕的是,字符串表不是可变大小的 —— 虽然曾经尝试过改为大小可变的,但是因为“某些原因”放弃了。你可以通过 -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
但是这仅仅是一个有待商榷的测试,因为你必须预先知道字符串数量。如果你盲目的将字符串表设置的很大,那么将会浪费内存。即使字符串表的大小与实际使用情况匹配,然而本地调用的成本仍然会耗费很多时间。
实验:GC 停顿
但是本地字符串表可能会导致灾难性后果的原因是:它是 GC 根的一部分!这意味着它需要被垃圾收集器扫描和更新。在 OpenJDK 中,这意味着在停顿过程中做繁重的工作。对于 Shenandoah 来说,停顿时间的长短主要取决于 GC根集合的大小。这是字符串表有 1M 条记录情况下的 GC 情况:
$ ... 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)
每次停顿都超过 13ms,这仅仅因为我们增加了更多内容到根集合中。
这也提示某些 GC 实现仅仅在某些情况下做字符串表清理即可。例如,从 JVM 角度来看,如果类没有被卸载,那么清理字符串表的意义不大,因为加载的类是驻留字符串的主要来源。所以至少在 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
到目前为止一切顺利。遍历超载的字符串表耗费相当长的时间。但是更要命的情况是通过 -XX:-ClassUnloading
关闭类卸载。这有效地 关闭了正常 GC 周期中的字符串表清理!你可能已经猜到了接下来的事情:
$ 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
Full STW GC,我的老朋友。对于 CMS 来说,这个 ExplicitGCInvokesConcurrentAndUnloadsClasses
参数可以稍微缓解一下问题,假设用户将会时而调用一下 System.gc()
。
观察
注:我们仅仅讨论可以实现驻留和去重的方式,并且假设这或者是改善内存使用的需要,或者是底层
==
优化的需要,或者别的隐蔽的需要。这些需求可以接受,也可以拒绝。关于更多 Java 字符串的细节,可以看我另外一个演讲:“java.lang.String 问答集”
对于 OpenJDK 来说,String.intern()
方法是通向本地 JVM 字符串表的通道,同时也伴随着警告:吞吐量、内存使用、停顿时间的问题将会阻挡用户。这些警告的影响很容易被低估。手工的去重器和驻留器更可靠,因为它们工作在 Java 层面,仅仅是通常的 Java 对象,可以更好的实现大小调整,当不再需要时可以完全丢弃。GC 协助的字符串去重可以更好的解决问题。
在大部分我们处理过的项目中,从热路径中移除 String.intern()
,或者替换为手工的去重器,是非常有效的性能优化方案。在深思熟虑之前请不要使用 String.intern()
,好吗?