画图理解 Java String#intern 的内存分布

908 阅读7分钟


文章大纲

  • String#intern 内存分布图
    • 案例1
    • 案例2
    • 案例3
  • String#intern 的存在意义
  • String#intern 的弊端
    • 案例4 垃圾回收
    • OOM
  • Guava Interners


接着上一篇文章 画图理解Java String的内存分布


本文的讲解以jdk1.7为准。

1 String#intern

我们知道 String#intern 就是把首次遇到的字符串加载到字符串常量池中。

用上了 String#intern 后,String 的内存分布图的难度再次升级!


案例1

下面先看第一个单测试案例, 一起了解下 String#intern 。


 @Test
public void demo3() {
    String test = new String("ab"); // @1
    String test1 = test.intern(); // @2
    String test2 = "ab"; // @3
    Assert.assertSame(test1, test2); // 通过
}


经过上篇文章的铺垫, 我们知道 @1 这行代码执行后, 内存分布图如下所示, 此时常量池和堆中都有字符串 "ab" 存在。


来到 @2 这行, 字符串 "ab" 调用了 intern 方法后, 编译器会先去字符串常量池中查找是否有 "ab", 如果存在, 则返回常量池字符串的地址值;
所以此时的内存分布图如下:


来到 @3 这行,双引号创建字符串 "ab", 编译器同样会先去字符串常量池中查找是否有 "ab",

如果存在,则返回常量池字符串的地址值;
如果不存在,那么会直接在常量池生成一个字符串 “ab”, 然后返回常量池字符串的地址值。

所以此时,局部变量 test1 和 test2 都是指向常量池中的字符串 “ab” 。


案例2

下面看第二个单测试案例:


@Test
public void demo3_2() {
    String test = new String("ab") + new String("cd"); // @1
    String test1 = test.intern(); // @2
    String test2 = "abcd"; // @3
    Assert.assertSame(test1, test2);// 通过
    Assert.assertSame(test, test1);// 通过
    Assert.assertSame(test, test2);// 通过
}


同样的, 经过上篇文章的铺垫, 我们知道 @1 这行代码执行后, 字符串常量池没有"abcd", 如下图所示:


来到 @2 这行,字符串 "abcd" 调用了 intern 方法后,编译器会先去字符串常量池中查找是否有 "abcd",很明显不存在,那么:


在 jdk1.6,就将堆内存中的 "abcd" 添加到常量池中; 在 jdk1.7,那么就将指向堆内存中 "abcd" 的地址值保存到常量池中。

本文的讲解以 jdk1.7 为准。


所以此时局部变量 test1 引用的是 new String("abcd") 的地址。


来到 @3 这行, 双引号创建字符串 "abcd", 编译器同样会先去字符串常量池中查找是否有 "abcd", 此时存在,则返回常量池字符串的地址值。

所以此时,局部变量 test, test1 和 test2 都是堆内存中的 new String("abcd")。


案例3

下面看第三个单测试案例:

@Test
public void demo3_3() {
    String test = new String("ab") + new String("cd"); // @1
    String test2 = "abcd"; // @2
    String test1 = test.intern(); // @3
    Assert.assertSame(test1, test2); // 通过
    Assert.assertNotSame(test, test1); // 通过, test!=test1
    Assert.assertNotSame(test, test2); // 通过, test!=test2
}


@1 这行代码执行后的内存分布图如下,此时字符串常量池同样没有 "abcd" :


来到 @2 这行,双引号创建字符串 "abcd", 此时会直接在常量池生成一个字符串 “abcd”, 然后返回常量池字符串的地址值。


来到 @3 这行,字符串 "abcd" 调用了 intern 方法后,因为常量池已经存在该字符串,直接返回地址值。所以此时的内存分布图如下,局部变量 test1 和 test2 都指向常量池中的 “abcd” 。

可以看到,案例 3 和案例 2 仅仅是调换了 intern 方法的执行顺序,内存的分布就已经天差地别!

说了这么多案例,除了被绕晕,还是不知道 String#intern 有什么用,为什么要整出个这么复杂的东西出来?

2 String#intern 存在的意义

想知道 String#intern 存在的意义, 我们来看看 String 类的 equals 方法:
public boolean equals(Object anObject) {
    if (this == anObject) { // @1
        return true;
    }
    if (anObject instanceof String) { // @2
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}


可以明显地看到,在使用 equals 比较两个字符串变量的时候,如果他们指向的地址是同一个字符串,那么将会立刻返回 true;否者的话,就要通过 while 循环变量字符串中所有的字符!因此 @1 和 @2 的性能可以说是差异巨大。


在要求高性能的场景下,肯定要竭力避免进入 @2 的条件分支,这就是 String#intern 存在的意义!


3 String#intern 的弊端

虽然 String#intern 很好,但是在某些场景下还是要多加留意的,比如垃圾回收。下面我们来一起看看案例4,在垃圾回收后内存分布会出现什么问题。


案例4 垃圾回收

@Test
public void test_afterGC() throws InterruptedException {
    String canonical = new String("5") + new String("5"); // @1
    String not = new String("5") + new String("5"); // @2
    assertSame(canonical, canonical.intern()); // @3
    
    WeakReference<String> signal = new WeakReference<String>(canonical);
    canonical = null;  // Hint to the JIT that canonical is unreachable
    System.gc(); // @4
    GcFinalization.awaitClear(signal); // @5
    
    assertSame(not, not.intern()); // @ 6
}


在执行完 @3 这行代码后,内存分布图如下所示:




在没有垃圾回收的情况下,也就是没有执行 @4 和 @5 的时候,
字符串 “55” 调用 intern 后返回的常量池中的地址是 0x0001,这和变量 not 引用的地址 0x0002 明显不是同一个对象。

现在来垃圾回收之后 intern 的引用情况。

在 @4 这里我显式地调用了垃圾回收, 把 canonical 占用的对内存给回收了。

在 @5 这里会一直阻塞等待 canonical 被成功回收掉, 再执行后面的代码。


怎么知道 canonical 是否已经被成功回收掉? 这里我引入了 Guava 的测试包:
<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava-testlib</artifactId>
  <version>29.0-jre</version>
  <scope>test</scope>
</dependency>


GcFinalization 这个类正是来自 Guava 测试工具包, GcFinalization#awaitClear 默认会通过 CountDownLatch 阻塞等到 canonical 变量被回收掉。


但是如果超过 10 s 变量 canonical 还没被垃圾回收则会直接抛异常,所以 @4 那里显式地调用垃圾回收就显得有必要了。

垃圾回收之后,内存分布如下所示:


现在终于到了 @6 这行,字符串 “55” 调用 intern 后发现常量池中没有 “55”,于是把堆内存中 “55” 的地址保存到常量池并返回 0x0002,所以这个时候 inten 返回的地址跟变量 not 引用的地址 0x0002 都是同一个对象。

String#intern 遇上垃圾回收之后,一切都变得不可捉摸了!



OOM

除了上面存在的问题,其实 String#intern 还是有缺陷的,字符串常量池空间是有限的, 数据多了之后, 就很可能会出现OOM。

4 解决方案 Interners

针对上面的缺陷, Guava 给出了新的方案 Interners , 把字符串常量池的内容存储到了堆内存里。
Interners内部基于ConcurrentHashMap实现,而且可以设置强引用类型, 防止实例被垃圾回收。

Interners 的简单用法如下,和 String#intern 的效果一致:
private static final Interner<String> pool = Interners.newWeakInterner();
pool.intern("Order_" + orderId)

咱们下一篇再讲讲 Interners 的使用场景。

X References

字符串常量池、class常量池和运行时常量池
http://tangxman.github.io/2015/07/27/the-difference-of-java-string-pool/

来看看String类和常量池内存分析以及8种基本类型和常量池例子
https://blog.csdn.net/aodubi0638/article/details/102144579

来自公众号 Java3y


往期回顾

画图理解Java Integer的“值传递”

画图理解Java String的内存分布

Kafka Topic为什么要分区

一张图理解Kafka时间轮(TimingWheel)

Java队列是怎么支撑起多人运动的?

画个花瓶理解Java线程池


用d3动画讲解各种有趣的编程知识。