蹲厕所的熊

benjaminwhx

浅谈String的intern

2018-05-27 作者: 吴海旭


  1. 1、什么是常量池
    1. 1.1、字符串常量池
      1. 字符串常量池在Java内存区域的哪个位置?
      2. 字符串常量池是什么?
      3. 字符串常量池放的是什么?
    2. 1.2、Class常量池
    3. 1.3、运行时常量池
  2. 2、原理分析
  3. 3、活学活用
  4. 4、参考

先来看一道题,谁能猜出它的输出结果?

public class StringTest {

    public static void main(String[] args) {
        String s1 = new StringBuilder().append("String").append("Test").toString();
        System.out.println(s1.intern() == s1);

        String s2 = new StringBuilder().append("ja").append("va").toString();
        System.out.println(s2.intern() == s2);
    }
}

答案:JDK6下输出false false,而JDK6以后输出true false。

what?为什么这种结果?我第一次看就被吓了一跳。要说原因还得从常量池的内存结构以及intern方法来说起。

我们先来说说intern方法,这个方法很有意思,我们先看看jdk对它的描述

/**
 * 返回字符串对象的规范表示形式。
 * 字符串常量池,初始为空,由String类来维护
 * 当intern方法被调用时,如果池中已经有一个字符串和传入的字符串相等(equals),
 * 返回池中的字符串,否则,将这个String对象添加到池中并返回这个String对象的引用。
 *
 * 因此,对于任意两个字符串s和t,如果str1.equals(str2)则有str1.intern() == str2.intern()。
 */
public native String intern();

jdk的描述很清晰,但是可能有人不知道字符串常量池是什么,我们先来看看什么是常量池。

1、什么是常量池

在Java的内存分配中,总共3种常量池:

  1. 字符串常量池
  2. Class常量池
  3. 运行时常量池

1.1、字符串常量池

字符串常量池在Java内存区域的哪个位置?

  • 在JDK6及之前的版本,字符串常量池是放在Perm Gen(也就是方法区)中。
  • 在JDK7版本,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。

字符串常量池是什么?

  • 在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。
  • 在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;
  • 在JDK7.0中,StringTable的长度可以通过参数指定:-XX:StringTableSize=66666

字符串常量池放的是什么?

  • 在JDK6.0及之前版本中,String Pool里放的都是字符串常量;
  • 在JDK7.0中,由于String的intern()发生了改变,因此String Pool中也可以存放放于堆内的字符串对象的引用。

需要说明的是:字符串常量池中的字符串只存在一份!

String s1 = "hello,world!";
String s2 = "hello,world!";

上面的代码执行完第一行代码后,常量池中已存在 “hello,world!”,那么 s2不会在常量池中申请新的空间,而是直接把已存在的字符串内存地址返回给s2。

1.2、Class常量池

  • 我们写的每一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References);
  • 每个class文件都有一个class常量池。

这里有人可能要问了,什么是字面量和符号引用?

字面量包括:

  1. 文本字符串
  2. 八种基本类型的值
  3. 被声明为final的常量等

符号引用包括:

  1. 类和方法的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

我们可以很容易的看到Class常量池的内容(执行javap -verbose StringTest):

public class StringTest {
    public static void main(String[] args) {
        String s = "hello world";
    }
}

下面是输出的常量池内容:

Constant pool:
   #1 = Methodref          #4.#13         // java/lang/Object."<init>":()V
   #2 = String             #14            // hello world
   #3 = Class              #15            // com/github/StringTest
   #4 = Class              #16            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               main
  #10 = Utf8               ([Ljava/lang/String;)V
  #11 = Utf8               SourceFile
  #12 = Utf8               StringTest.java
  #13 = NameAndType        #5:#6          // "<init>":()V
  #14 = Utf8               hello world
  #15 = Utf8               com/github/StringTest
  #16 = Utf8               java/lang/Object

我们可以看到字符串hello world 在#14 常量池中的定义。

在main方法的字节码指令中,String s = "hello world"; 由两部分组成:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: ldc           #2                  // String hello world
         2: astore_1
         3: return
  1. 当StringTest类加载到虚拟机时,”hello world”字符串在Constant pool中使用符号引用symbol表示,当调用 ldc #2 指令时,如果Constant pool中索引 #2 的symbol还未解析,则调用C++底层的 StringTable::intern 方法生成char数组,并将引用保存在StringTable和常量池中,当下次调用 ldc #2 时,可以直接从Constant pool根据索引 #2获取 “test” 字符串的引用,避免再次到StringTable中查找。(这个在上面字符串常量池中已经说过了)
  2. astore_1指令将”hello world”字符串的引用保存在局部变量表中。

1.3、运行时常量池

运行时常量池存在于内存中,也就是class常量池被加载到内存之后的版本,不同之处是:它的字面量可以动态的添加(String#intern()),符号引用可以被解析为直接引用。

JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

2、原理分析

介绍完了常量池的概念以及不同版本的内存结构,我们再去看一开始的例子就能理解为什么JDK6下输出false false,而JDK6以后输出true false了吧。

在JDK6中,常量池在永久代分配内存,永久代和Java堆的内存是物理隔离的,执行intern方法时,如果常量池不存在该字符串,虚拟机会在常量池中复制该字符串,并返回引用,所以需要谨慎使用intern方法,避免常量池中字符串过多,导致性能变慢,甚至发生PermGen内存溢出。

在JDK7中,常量池已经在Java堆上分配内存,执行intern方法时,如果常量池已经存在该字符串,则直接返回字符串引用,否则复制该字符串对象的引用到常量池中并返回,所以在JDK7中,可以重新考虑使用intern方法,减少String对象所占的内存空间。

对于变量s1,常量池中没有 “StringTest” 字符串,s1.intern() 和 s1都是指向Java对象上的String对象。
对于变量s2,常量池中一开始就已经存在 “java” 字符串,所以 s2.intern() 返回常量池中 “java” 字符串的引用。(因为像”java”这样出现率高的字符串,在虚拟机启动的时候,肯定已经使用过了)

对于这个问题的原理,早在很多年前R大就分析过了,这是原地址:主题:发现String#intern的API描述有问题

3、活学活用

偶然在知乎上发现一个问题,正好验证一下上面学的是否牢固。

在jdk7中,有以下代码:

public static void pushPool() {
    String a = "a";
    String param = "b" + a;
    //这里的"ba"为字面量不应该在类加载后就进入常量池了吗
    //(查看字节码也可以看到它被放到了constant pool),那么param应该不会放到pool中啊
    System.out.println(param.intern() == "ba"); 
    System.out.println(param == "ba");
}

这里的返回是两个true

public static void pushPool() {
    String a = "a";
    String param = "b" + a;
    System.out.println("ba" == param.intern()); 
    System.out.println(param == "ba");
}

这里的返回一个true一个false

注意:下面有一个重要的字节码指令ldc,ldc字节码在下面的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找该index对应的项,如果该项尚未resolve则resolve之,并返回resolve后的内容。

分析过程如下。

下面是第一个方法的字节码。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: new           #3                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
        10: ldc           #5                  // String b
        12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        15: aload_1
        16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        22: astore_2
        23: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        26: aload_2
        27: invokevirtual #9                  // Method java/lang/String.intern:()Ljava/lang/String;
        30: ldc           #10                 // String ba
        32: if_acmpne     39
        35: iconst_1
        36: goto          40
        39: iconst_0
        40: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
        43: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        46: aload_2
        47: ldc           #10                 // String ba
        49: if_acmpne     56
        52: iconst_1
        53: goto          57
        56: iconst_0
        57: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
        60: return
  1. 【0-2】先用ldc把”a”送到栈顶,换句话说,会创建对象,并且会保存引用到字符串常量池中。
  2. 【3-22】new一个StringBuilder对象把“b”和“a”进行拼接,接着用ldc把“b”送到栈顶,创建”b”的对象,并把引用保存到字符串常量池中。接着一路append,最后调用StringBuilder对象的toString方法得到一个String对象(内容是ba,注意这个toString方法会new一个String对象),并把它赋值给param。注意,这里没有把ba的引用放入字符串常量池。
  3. 【23-40】接着调用intern去字符串常量池找有没有“ba”的引用,发现没有就会把“ba”对象的引用保存到字符串常量池,接着ldc去字符串常量池获取到刚刚保存完的地址,所以这个判断肯定是true。
  4. 【43-57】接着获取到param的引用,ldc去字符串常量池找“ba”的引用,也就是上面param指向的地址,所以这个判断肯定也是true。

我们再来看看第二个方法的字节码。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: new           #3                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
        10: ldc           #5                  // String b
        12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        15: aload_1
        16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        22: astore_2
        23: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        26: ldc           #9                  // String ba
        28: aload_2
        29: invokevirtual #10                 // Method java/lang/String.intern:()Ljava/lang/String;
        32: if_acmpne     39
        35: iconst_1
        36: goto          40
        39: iconst_0
        40: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
        43: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        46: aload_2
        47: ldc           #9                  // String ba
        49: if_acmpne     56
        52: iconst_1
        53: goto          57
        56: iconst_0
        57: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
        60: return
  1. 【0-2】同上。
  2. 【3-22】同上。
  3. 【23-40】这个时候和上面的不同了。它会用ldc把”ba”送到栈顶,在堆中创建一个对象,并且会保存引用到字符串常量池中。接着param的intern发现字符串常量池中已经有“ba”的引用了,就直接返回已存在的引用,但是这个引用和param指向的地址是不同的。不过这里比较的是“ba”和param.intern(),所以返回true。
  4. 【43-57】上面说了“ba”的引用和param指向的地址是不同的,所以这里返回false。

4、参考

java用这样的方式生成字符串:String str = “Hello”,到底有没有在堆中创建对象?

Java 中new String(“字面量”) 中 “字面量” 是何时进入字符串常量池的?

浅谈Java String.intern()

浅谈Java String内幕



坚持原创技术分享,您的支持将鼓励我继续创作!



分享

评论