当前位置: 首页 > news >正文

JVM常用概念之String.intern()

问题

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可能更能帮助我们获得理想的效果。

相关文章:

  • Linux(Centos 7.6)命令详解:zip
  • 递归专题刷题
  • linux下ollama离线安装
  • Unity游戏开发中的网格简化与LOD技术(Mesh Simplification LOD)
  • Linux基础--文件权限+软件包管理+管道符
  • mysql中in和exists的区别?
  • 深入解析ECDSA与RSA公钥算法:原理、对比及AWS最佳实践
  • 【AD】5-14 多跟走线设置
  • 16位-32768的补码和原码是什么【补码和原码的转换】
  • spring IOC(实现原理)
  • 如何让一个类作为可调用对象被thread调用?
  • WSL with NVIDIA Container Toolkit
  • 基于单片机的风速报警装置设计
  • 深度学习模型组件之优化器--自适应学习率优化方法(Adadelta、Adam、AdamW)
  • 【Ant Design X Vue】Vue 首个 AI 组件库发布!
  • 前端题目类型
  • 记录小白使用 Cursor 开发第一个微信小程序(一):注册账号及下载工具(250308)
  • manus本地部署方法研究测试
  • 后序线索化二叉树,并找到指定结点前驱,非递归逆序输出
  • 通义万相 2.1 + 蓝耘算力,AI 视频生成的梦幻组合
  • 微站网建站系统/免费推广网站视频
  • 北京建设工程建设交易信息网站/网站源码
  • 政务内网网站群建设方案/平台关键词排名优化
  • 手机网站表单页面制作/企业网站推广策略
  • 电脑自带做网站的软件/营销 推广
  • 幼儿园网站建设实践研究/hao123网址之家官网