浅谈String的intern

先来看一道题,谁能猜出它的输出结果?
  1. publicclassStringTest{
  2.    publicstaticvoid main(String[] args){
  3.        String s1 =newStringBuilder().append("String").append("Test").toString();
  4.        System.out.println(s1.intern()== s1);
  5.        String s2 =newStringBuilder().append("ja").append("va").toString();
  6.        System.out.println(s2.intern()== s2);
  7.    }
  8. }
答案:JDK6下输出false false,而JDK6以后输出true false。
what?为什么这种结果?我第一次看就被吓了一跳。要说原因还得从常量池的内存结构以及intern方法来说起。
我们先来说说intern方法,这个方法很有意思,我们先看看jdk对它的描述
  1. /**
  2. * 返回字符串对象的规范表示形式。
  3. * 字符串常量池,初始为空,由String类来维护
  4. * 当intern方法被调用时,如果池中已经有一个字符串和传入的字符串相等(equals),
  5. * 返回池中的字符串,否则,将这个String对象添加到池中并返回这个String对象的引用。
  6. *
  7. * 因此,对于任意两个字符串s和t,如果str1.equals(str2)则有str1.intern() == str2.intern()。
  8. */
  9. publicnativeString 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中也可以存放放于堆内的字符串对象的引用。
需要说明的是:字符串常量池中的字符串只存在一份!
  1. String s1 ="hello,world!";
  2. 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-verboseStringTest):
  1. publicclassStringTest{
  2.    publicstaticvoid main(String[] args){
  3.        String s ="hello world";
  4.    }
  5. }
下面是输出的常量池内容:
  1. Constant pool:
  2.   #1=Methodref          #4.#13         // java/lang/Object."<init>":()V
  3.   #2=String             #14            // hello world
  4.   #3=Class              #15            // com/github/StringTest
  5.   #4=Class              #16            // java/lang/Object
  6.   #5=Utf8               <init>
  7.   #6=Utf8               ()V
  8.   #7=Utf8               Code
  9.   #8=Utf8               LineNumberTable
  10.   #9=Utf8               main
  11.  #10=Utf8               ([Ljava/lang/String😉V
  12.  #11=Utf8               SourceFile
  13.  #12=Utf8               StringTest.java
  14.  #13=NameAndType        #5:#6          // "<init>":()V
  15.  #14=Utf8               hello world
  16.  #15=Utf8               com/github/StringTest
  17.  #16=Utf8               java/lang/Object
我们可以看到字符串 hello world 在#14 常量池中的定义。
在main方法的字节码指令中, Strings="hello world"; 由两部分组成:
  1. publicstaticvoid main(java.lang.String[]);
  2.    descriptor:([Ljava/lang/String😉V
  3.    flags: ACC_PUBLIC, ACC_STATIC
  4.    Code:
  5.      stack=1, locals=2, args_size=1
  6.         0: ldc           #2                  // String hello world
  7.         2: astore_1
  8.         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大就分析过了,请自行百度。

3、活学活用

偶然在知乎上发现一个问题,正好验证一下上面学的是否牢固。
在jdk7中,有以下代码:
  1. publicstaticvoid pushPool(){
  2.    String a ="a";
  3.    String param ="b"+ a;
  4.    //这里的"ba"为字面量不应该在类加载后就进入常量池了吗
  5.    //(查看字节码也可以看到它被放到了constant pool),那么param应该不会放到pool中啊
  6.    System.out.println(param.intern()=="ba");
  7.    System.out.println(param =="ba");
  8. }
这里的返回是两个true
  1. publicstaticvoid pushPool(){
  2.    String a ="a";
  3.    String param ="b"+ a;
  4.    System.out.println("ba"== param.intern());
  5.    System.out.println(param =="ba");
  6. }
这里的返回一个true一个false
注意:下面有一个重要的字节码指令ldc,ldc字节码在下面的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找该index对应的项,如果该项尚未resolve则resolve之,并返回resolve后的内容。
分析过程如下。
下面是第一个方法的字节码。
  1. publicstaticvoid main(java.lang.String[]);
  2.    descriptor:([Ljava/lang/String😉V
  3.    flags: ACC_PUBLIC, ACC_STATIC
  4.    Code:
  5.      stack=3, locals=3, args_size=1
  6.         0: ldc           #2                  // String a
  7.         2: astore_1
  8.         3:new           #3                  // class java/lang/StringBuilder
  9.         6: dup
  10.         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
  11.        10: ldc           #5                  // String b
  12.        12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  13.        15: aload_1
  14.        16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  15.        19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  16.        22: astore_2
  17.        23: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
  18.        26: aload_2
  19.        27: invokevirtual #9                  // Method java/lang/String.intern:()Ljava/lang/String;
  20.        30: ldc           #10                 // String ba
  21.        32: if_acmpne     39
  22.        35: iconst_1
  23.        36:goto          40
  24.        39: iconst_0
  25.        40: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
  26.        43: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
  27.        46: aload_2
  28.        47: ldc           #10                 // String ba
  29.        49: if_acmpne     56
  30.        52: iconst_1
  31.        53:goto          57
  32.        56: iconst_0
  33.        57: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
  34.        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。
我们再来看看第二个方法的字节码。
  1. publicstaticvoid main(java.lang.String[]);
  2.    descriptor:([Ljava/lang/String😉V
  3.    flags: ACC_PUBLIC, ACC_STATIC
  4.    Code:
  5.      stack=3, locals=3, args_size=1
  6.         0: ldc           #2                  // String a
  7.         2: astore_1
  8.         3:new           #3                  // class java/lang/StringBuilder
  9.         6: dup
  10.         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
  11.        10: ldc           #5                  // String b
  12.        12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  13.        15: aload_1
  14.        16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  15.        19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  16.        22: astore_2
  17.        23: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
  18.        26: ldc           #9                  // String ba
  19.        28: aload_2
  20.        29: invokevirtual #10                 // Method java/lang/String.intern:()Ljava/lang/String;
  21.        32: if_acmpne     39
  22.        35: iconst_1
  23.        36:goto          40
  24.        39: iconst_0
  25.        40: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
  26.        43: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
  27.        46: aload_2
  28.        47: ldc           #9                  // String ba
  29.        49: if_acmpne     56
  30.        52: iconst_1
  31.        53:goto          57
  32.        56: iconst_0
  33.        57: invokevirtual #11                 // Method java/io/PrintStream.println:(Z)V
  34.        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。
本站所有文章均由网友分享,仅用于参考学习用,请勿直接转载,如有侵权,请联系网站客服删除相关文章。若由于商用引起版权纠纷,一切责任均由使用者承担
极客文库 » 浅谈String的intern

欢迎加入「极客文库」,成为原创作者从这里开始!

立即加入 了解更多