以下描述均基于 HotSpot 虚拟机

1. 概述

在 JVM 中有一块内存区域被称为字符串常量池,用来存储字符串。

2. 底层实现

2.1 布局内存

在 JDK 6及以前,字符串常量池存储在永久代中在 JDK 7 及以后,字符串常量池存储在堆内存中

网上有很多文章说在 JDK 8 时,字符串常量池被移动到了元空间,其依据是方法区改用元空间实现了,但这其实是错的。首先,方法区是一个逻辑概念,并不是内存中真的有这样一块区域,换句话说,方法区可以是内存中一块连续的区域,也可以是四处分散的内存区域的一个总称。

  • 在 JDK 6 时,方法区使用永久代实现,而永久代是堆内存中的一块区域(其和堆内存中的普通区域并不一样,可以想象成是相互隔离的)

  • 在 JDK 7 时,字符串常量池、静态变量从方法区(永久代)中移动到了堆内存(普通堆内存)中,这样字符串常量池就可以被 GC 管理。此时字符串常量池物理上存储于堆内存中,但其仍然属于方法区,此时方法区便是使用堆内存和永久代组合实现的

  • 在 JDK 8 时,永久代被废弃,转而使用元空间实现方法区。此时字符串常量池、静态变量仍然在堆中。此时方法区更加分裂,其由本地内存中的元空间和堆内存共同实现

关于字符串常量池不在元空间而在堆内存的佐证:

  • JEP 122 中的描述:The proposed implementation will allocate class meta-data in native memory and move interned Strings and class statics to the Java heap.

    参考链接:JEP 122

2.2 内部实现

字符串常量池是使用一个不可扩容的HashTable实现的

这部分我并没有找到相关说明,只是大部分博客都是这样说,暂且这样理解,等以后看一下 HotSpot 的文档再来证明。

3. String.intern()

String.intern() 方法的大致含义是将字符串放入常量池中,这里为什么说是大致含义呢?因为在 JDK 6 及以前JDK 7 及以后的处理方式是不一样的。

3.1 准备

在讲解不同实现之前,我们先来看一下什么时候字符串会被放入常量池,什么时候不会放入常量池。

我们看下面这行代码:

String s1 = new String("桔子");

你知道这行代码创建了几个对象吗?答案是 2 个

  • 首先"桔子"这个字符串字面量会被放入常量池中,这是一个对象。
  • 然后在堆内存中会开辟一块空间,存储"桔子"这个 String 对象,这是第二个对象

这两个对象除了值相等外,没有任何关联,可以想象成第二个对象时第一个对象的拷贝。

我们再看第二行代码:

String s1 = new String("桔子") + new String("你好");

这行代码又创建了几个对象呢?答案是 5 个

我们来分析一下:

  • 首先"桔子""你好"这两个字符串字面量会被放入字符串常量池中,这个我们上面已经分析过了,这里就是两个对象
  • 然后 new String("桔子")new String("你好")会分别在堆内存中分配两个对象,只不过这两个对象时匿名对象
  • 最后,通过+操作符,会在堆内存中生成一个新的对象,其值为"桔子你好",由s1指向它,这是一个对象

所以这行代码一共会生成 5 个对象。

我们再看最后两行代码:

String s1 = new String("桔子");
String s2 = "桔子";

这次我要问的是此时的内存布局是怎样的,我们先逐步来分析一下:

  • 第一行代码

    • 首先会在字符串常量池中放入一个对象:"桔子"
    • 然后在堆内存中开辟一块空间,存储 String 对象:"桔子"
    • 最后在栈上分配一个变量 s1,其指向堆内存的对象
  • 第二行代码

    • 同样, JVM 首先尝试在字符串常量池中方法一个"桔子"对象,但此时会发现常量池中已经有了这个对象,那么就不会重复放入,会直接返回该对象在常量池中的地址
    • 然后在站上分配一个变量 s2,其指向常量池中该字符串的地址

此时对应的内存布局如下( JDK 6 中):

3.2 实现

现在你了解了字符串的分配方式及其在 JVM 中的内存布局,现在让我们再来学习一下 String.intern() 这个方法。上面我们说了字面量会被放入字符串常量池中,但除了这种方法,我们还可以利用 String.intern() 方法手动将字符串放入常量池中。

下面我们看一下 String.intern() 在不同 JDK 版本中的实现方式

3.2.1 JDK 6

在 JDK 6 及以下版本中,String s2 = s1.intern() 这行代码的执行步骤如下:

首先检查字符串常量池中是否有与 s1 相等的字符串(使用 equals() 方法判等)

  • 如果没有,那么将此字符串拷贝一份放入常量池,并返回常量池中此字符串的内存地址(即 s2 将指向常量池中的这个字符串)
  • 如果有,那么就直接返回常量池中此字符串的内存地址(即 s2 将指向常量池中的这个字符串)

3.2.2 JDK 7

在 JDK 7 及以上版本中,String s2 = s1.intern() 这行代码的执行步骤如下:

首先检查字符串常量池中是否有与 s1 相等的字符串(使用 equals() 方法判等)

  • 如果没有,那么在常量池中放置一个变量,其指向堆内存中的这个字符串,并返回堆内存中此字符串的内存地址(即 s2 指向的内存地址和 s1 指向的内存地址相同)
  • 如果有,那么要看常量池中这个字符串变量是个对象还是指针

    • 如果是个对象,那么会返回字符串常量池中这个对象的地址(即 s2 会指向字符串常量池中的这个对象)
    • 如果是个指针,那么会返回这个指针所指向的堆内存的地址(即 s2 会指向堆内存中的字符串对象)

注意此处 JDK 6 和 JDK 7 的区别在于,JDK 7 第一步存放时,可能会放入一个地址,也可能会放入一个对象。也就会导致后续第二步的处理有相应的不同。

至于为什么 JDK 7 时可以存放字符串引用,我猜测是因为此时字符串常量池被移动到普通的堆内存中了,并不像在 JDK 6 时与堆内存相互隔离,所以此时可以直接存一份引用指向堆内存。

至此,我们还能得出一个结论:

  • 在 JDK 6 及以下时,String.intern() 返回的地址一定指向字符串常量池
  • 在 JDK 7 及以上时,String.intern() 返回的地址可能指向字符串常量池,也可能指向堆内存

4. 代码分析

4.1 JDK 6

本小节讲解基于 JDK 6 及以下版本

首先我们看下面这样一段代码:

String s1 = new String("桔子");
String s2 = s1.intern();
String s3 = "桔子";
System.out.println(s1 == s2); // ===> false
System.out.println(s1 == s3); // ===> false
System.out.println(s2 == s3); // ===> true

可能这个结果你看得有点懵,我们逐行代码来讲解一下:

  • 第一行代码

    • "1"放入字符串常量池中
    • 在堆上分配一个对象,其值为"1",与字符串常量池中的"1"无任何关联
    • 在栈上分配一个变量,其指向第二部在堆上分配的对象
  • 第二行代码

    • 根据上一节所讲的 String.intern() 的规则,此时 s2 会指向字符串常量池中的"1"对象
  • 第三行代码

    • 这是个字面量字符串,在分配时先检查字符串常量池,发现已经存在了,所以 s3 直接指向字符串常量池中的对象

此时的内存布局如下:

这应该就能很容易理解上面的结果了。

下面我们再看第二段代码:

String s1 = new String("桔子") + new String("你好");
String s2 = s1.intern();
String s3 = "桔子你好";
System.out.println(s1 == s2); // ===> false
System.out.println(s1 == s3); // ===> false
System.out.println(s2 == s3); // ===> true

同样逐行分析一下:

  • 第一行

    • "桔子""你好"两个字符串被放入常量池中
    • 堆上有两个匿名对象new String("桔子")new String("你好")
    • 堆上还有一个用+操作符生成的对象,其值为"桔子你好"
    • 栈上分配 s1,其指向上一个+操作符生成的对象
  • 第二行

    • 根据上一节所讲的规则,"桔子你好"被放入字符串常量池
    • s2 指向字符串常量池中的"桔子你好"
  • 第三行

    • 检查到字符串常量池中已经存在了"桔子你好"
    • 栈上分配 s3,指向常量池中的"桔子你好"

此时的内存布局如下:

3.2 JDK 7

由于 JDK 7 中 String.intern() 方法的改变,造成上述代码的结果有了差异

首先我们看第一段代码:

String s1 = new String("桔子");
String s2 = s1.intern();
String s3 = "桔子";
System.out.println(s1 == s2); // ===> false
System.out.println(s1 == s3); // ===> false
System.out.println(s2 == s3); // ===> true

这段代码和 JDK 6 的情况下并没有什么差别,我这里就不做分析了,留给你自己去分析一下。

我们着看看一下第二段代码,这是体现 JDK 7 与 JDK 6 差别的精髓所在:

String s1 = new String("桔子") + new String("你好");
String s2 = s1.intern();
String s3 = "桔子你好";
System.out.println(s1 == s2); // ===> true
System.out.println(s1 == s3); // ===> true
System.out.println(s2 == s3); // ===> true

我们还是逐行分析一下:

  • 第一行

    • "桔子""你好"两个字符串被放入常量池中
    • 堆上有两个匿名对象new String("桔子")new String("你好")
    • 堆上还有一个用+操作符生成的对象,其值为"桔子你好"
    • 栈上分配 s1,其指向上一个+操作符生成的对象
  • 第二行

    • 按照前面讲解的规则,JVM 检测到字符串常量池中没有s1 "桔子你好"这个字符串,那么此时会在字符串常量池中放一个指针,其指向堆内存中的"桔子你好",并将堆内存的地址返回(即 s2 会指向堆内存中的"桔子你好",与 s1 一致)
  • 第三行

    • 这是一个字面量,JVM 同样先去检测字符串常量池,此时第二行代码已经放了"桔子你好"这个字符串的引用到常量池里面(虽然是放的一个引用,但仍然能检测到),那么此时就直接将字符串常量池中这个字符串引用指向的堆内存地址返回(即 s3 同样指向堆内存中的"桔子你好",与 s1 和 s2 一致)

此时的内存布局如下:

5. 总结

这篇文章,我们详细的分析了一下字符串常量池在 JVM 中的存储区域、底层实现,以及 String.intern() 方法在不同 JDK 版本下的实现区别。希望借助这边文章,能让你对字符串常量池有一个清晰的认识,在遇到类似字符串的面试题时,能根据这些实现理论快速推出答案。

Last modification:August 26th, 2020 at 11:21 am