聊聊Java String.intern 背后你不知道的知识
Java的 String类有个有意思的public方法:
public String intern()
返回标准表示的字符串对象。String类维护私有字符串池。
调用此方法时,如果字符串池已经包含等于此字符串对象的字符串(通过equals方法确定),
则返回池中的字符串。 否则,将此String对象添加到池中,并返回对此String对象的引用。
这个功能为String提供了字符串池,我们可以使用它来优化内存。 但是,这有一个缺点:在OpenJDK中,String.intern()是本地方法,它实际上调用了JVM的相关方法来实现该功能。这样实现的原因是,当VM和JDK代码必须就特定String对象的标识达成一致时,String interning就必须是JDK-VM接口的一部分。
这样的实现意味着:
- 您需要在每个intern调用使用JDK-JVM接口,这会浪费CPU。
- 性能受本地HashTable实现的影响,可能落后于高性能Java版本,特别是在并发访问的情况下。
- 由于Java Strings是来自VM的引用,因此它们成为GC rootset的一部分。 在许多情况下,这需要在GC停顿期间执行额外的工作。
吞吐量实验
我们可以构建简单的实验来说明问题。 使用HashMap和ConcurrentHashMap实现intern方法,这为我们提供了一个非常好的JMH基准:
1 @State(Scope.Benchmark) 2 public class StringIntern { 3 4 @Param({"1", "100", "10000", "1000000"}) 5 private int size; 6 7 private StringInterner str; 8 private CHMInterner chm; 9 private HMInterner hm; 10 11 @Setup 12 public void setup() { 13 str = new StringInterner(); 14 chm = new CHMInterner(); 15 hm = new HMInterner(); 16 } 17 18 public static class StringInterner { 19 public String intern(String s) { 20 return s.intern(); 21 } 22 } 23 24 @Benchmark 25 public void intern(Blackhole bh) { 26 for (int c = 0; c < size; c++) { 27 bh.consume(str.intern("String" + c)); 28 } 29 } 30 31 public static class CHMInterner { 32 private final Map<String, String> map; 33 34 public CHMInterner() { 35 map = new ConcurrentHashMap<>(); 36 } 37 38 public String intern(String s) { 39 String exist = map.putIfAbsent(s, s); 40 return (exist == null) ? s : exist; 41 } 42 } 43 44 @Benchmark 45 public void chm(Blackhole bh) { 46 for (int c = 0; c < size; c++) { 47 bh.consume(chm.intern("String" + c)); 48 } 49 } 50 51 public static class HMInterner { 52 private final Map<String, String> map; 53 54 public HMInterner() { 55 map = new HashMap<>(); 56 } 57 58 public String intern(String s) { 59 String exist = map.putIfAbsent(s, s); 60 return (exist == null) ? s : exist; 61 } 62 } 63 64 @Benchmark 65 public void hm(Blackhole bh) { 66 for (int c = 0; c < size; c++) { 67 bh.consume(hm.intern("String" + c)); 68 } 69 } 70 }
该测试试图在很多字符串上执行intern方法,但实际的intern仅在第一次遍历循环时发生,之后只访问map中的字符串。 size参数用于控制我们intern的字符串数量,从而限制我们正在处理的字符串表大小。 对于intern来说,通常都这样使用。
使用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 ; <---- !!!!!!
注意最后一行,平均每个bucket 16个元素表示已经过载。 更糟糕的是,字符串表不可调整大小(虽然有实验工作使它们可以调整大小,但是因为“其他原因”而被移除)。 通过设置更大的-XX:StringTableSize可能会减轻该问题:
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
然而这只能暂时缓解问题,因为你必须提前做好规划。 如果盲目地将String表大小设置为较大值,并且不使用它,则会浪费内存。 即使您使用很大的StringTable,JNI本地调用仍然会消耗CPU。
GC停顿实验
本地字符串表最大问题在于它是GC root的一部分。也就是说,它应该需要垃圾收集器进行特殊扫描/更新。 在OpenJDK中,这意味着在暂停期间额外工作。 实际上,对于Shenandoah(译者注:对于ZGC也如此),暂停主要依赖于GC root set大小,在String表中存在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实现仅在完成重要操作时执行String表清理。 比如,如果不进行卸载类,从JVM角度来看清理String表是没有意义的(因为加载的类是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
看起来结果还可以。 遍历重载的字符串表需要一段时间。 蛋疼的事情会在使用-XX:-ClassUnloading禁用类卸载后发生。你猜猜接下来会发生什么:
$ 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 GC! 对于CMS,假设用户会调用System.gc(),使用ExplicitGCInvokesConcurrentAndUnloadsClasses会缓解这一情况。
意见
在假设改进内存占用空间或低级==优化的情况下,我们讨论了实现intern的方法。
对于OpenJDK,String.intern()是本机JVM字符串表的代理,使用它需要注意:吞吐量,内存占用,暂停时间等问题。 很容易低估这些问题的影响。 手动控制的intern工作更加可靠,因为它们在Java端工作,只是普通Java对象,通常更容易调整大小,并且在不再需要时也可以完全丢弃。 GC辅助字符串去重复数据(http://openjdk.java.net/jeps/192)确实可以减少很多问题。
几乎在在我们进行每个项目中,从热路径中删除String.intern(),或者用手动方式替代它,都有很大的性能提升。 不要无脑使用String.intern(),好吗?