问题
String.intern()的工作原理?我们应该如何使用它?
基础知识
字符串池(String Pool)
String类在我们日常编程工作中是使用频率非常高的一种对象类型。JVM为了提升性能和减少内存开销,避免字符串的重复创建,其维护了一块特殊的内存空间,即字符串池(String Pool)。字符串池由String类私有的维护。
字符串驻留(String Interning)
VM(Java虚拟机)在运行时将字符串常量或字符串对象存储在一个特殊的池(String Pool)中,以便重用相同的字符串对象,而不是为每个相同的字符串分配新的内存空间。这样可以减少内存的使用,提高效率,特别是在处理大量字符串时。
自动驻留(String Literal Pooling)
当你使用双引号直接定义字符串常量时,JVM会自动将这个字符串加入到字符串池中。如果池中已经存在相同的字符串,JVM会返回池中已存在的那个引用,而不是创建一个新的字符串对象。
手动驻留(String.intern() 方法)
如果你有一个字符串对象,但希望它被加入到字符串池中(即使它不是通过字面量直接创建的),你可以使用String类的intern()方法。
intern()
public String intern()
当调用 intern 方法时,如果池中已经包含一个由 equals(Object) 方法确定的与此 String 对象相等的字符串,则返回池中的字符串。否则,将此 String 对象添加到池中并返回对此 String 对象的引用。— JDK Javadoc
这样设计本质就是为了优化内存的使用,然而这样做却也有缺点:在 OpenJDK 中, String.intern()是本地方法的,它实际上调用 JVM,将 String 驻留在本地 JVM String 池中。这是因为当本地 VM 和 JDK 代码都必须就特定 String 对象的身份达成一致时,String 驻留是 JDK-VM 接口的一部分。那这么做有什么意义呢?其意义如下:
- 每次intern()时都需要跨 JDK-JVM 接口,这会浪费周期。
- 性能取决于本地的HashTable 实现,这可能会落后于高性能 Java 领域中的实现,尤其是在并发访问的情况下。
- 由于 Java 字符串是来自本地 VM 结构的引用,因此它们成为 GC 根集的一部分。在许多情况下,这需要在 GC 暂停期间进行额外的工作来处理。
实验
大量字符串场景下的吞吐量。
源码-字符串驻留用例
@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));
}
}
}
运行结果
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
由上述时间占比可知,在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来设置StringTable的默认大小,比如设置为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
通过上述运行结果的对比,可发现当将StringTableSize设置为10M时,性能有一定程度的提升,而这种处理方式需要依据实际的预估字符串的规模,机器的内存等资源情况,进行合理的规划和设计,并且盲目地将 StringTableSize 表大小设置为大值,并且不使用它,你将浪费内存。即使你充分利用了大型 StringTable,本地调用成本仍然会消耗周期。
垃圾回收停顿
原生 StringTable最显著的特点是它是 GC 根的一部分,它应该由垃圾收集器专门扫描/更新。在 OpenJDK 中,这意味着在暂停期间要进行艰苦的工作。事实上,对于Shenandoah来说,暂停主要取决于 GC 根集大小,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)
如上述的String Table Roots 所示,由于我们决定在根集中放入更多内容,因此每次暂停就会增加 +13 毫秒左右。这促使一些 GC 实现仅在执行繁重工作时才执行StringTable清理。例如,从 JVM 的角度来看,如果类未卸载,则清理StringTable毫无意义 — — 因为已加载的类是驻留字符串的主要来源。因此,这种工作负载至少在 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();
}
}
}
运行结果
$ 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
那如果禁用类卸载会发生什么呢?其实在禁用类卸载时,也会禁用常规 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
有上述执行结果可知,发生Stop The World GC。而对于CMS而言,有 ExplicitGCInvokesConcurrentAndUnloadsClasses 可以缓解这种情况,假设用户有时会调用System.gc() 。
总结
String.intern()的使用需要综合考虑吞吐量、内存占用、暂停时间问题,这些问题会直接影响JVM运行的性能,其实手动重复数据删除器/interner可能更能帮助我们获得理想的效果。