JVM 解剖公园(10): String.intern()

>>2020,微服务装逼指南

编译: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 代码都需要处理字符串对象。

这种实现会带来下列影响:

  1. 每次调用 intern() 都要在 JDK 与 JVM 之间的接口,浪费时间。
  2. intern() 性能取决于 HashTable 本地实现,落后于高性能 Java 实现,在并发访问情况下尤其如此。
  3. 由于 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(),好吗?

 

原文始发于微信公众号(ImportNew):JVM 解剖公园(10): String.intern()