JVM中字符串的秘密


简介

字符数组的存储方式

字符串常量池

字符串在java程序中被大量使用,为了避免每次都创建相同的字符串对象及内存分配,JVM内部对字符串对象的创建做了一定的优化,在Permanent Generation中专门有一块区域用来存储字符串常量池(一组指针指向Heap中的String对象的内存地址)。

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

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

字符串Hashcode

不通方式创建字符串在JVM存储的形式

  • ### 双引号方式

双引号引起来的字符串,首先从常量池中查找是否存在此字符串。如果不存在,则在常量池中添加此字符串。在堆中创建字符串对象,因String底层是通过char数组形式存储的,所以同时会在堆中生成一个TypeArrayOopDesc用来存储char数组对象。如果存在,则直接引用此字符串对象。

测试代码1:

public static  void test1(){
        String s1="11";
        String s2="11";

        System.out.println(s1==s2);
    }

测试结果:

原因分析:

s1代码执行后,常量池中添加了“11”这个常量,在堆中也创建了String对象并引用此常量的。当s2代码执行时,先在常量池中查找是否存在“11”这个常量,发现常量池中存在这个值,就找到引用此常量的字符串对象,将s2的引用指向找到的字符串对象。因为s1和s2指向同一个地址,所以比较结果为true。

  • ### new String

1、首先从常量池中查找是否存在括号内的常量,如果不存在,则在常量池中添加此字符串。在堆中创建字符串对象,因String底层是通过char数组形式存储的,所以同时会在堆中生成一个TypeArrayOopDesc用来存储char数组对象。如果存在,则直接引用堆中存在的字符串对象。

2、通过new方式创建的String对象,每次都会在Heap上创建一个新的实例。并将此新实例中char数组对象,指向第一步堆中的已经存在的TypeArrayOopDesc。

测试代码:

public static void test2() {
        String s1 = new String("11");
        String s2 = new String("11");

        System.out.println(s1 == s2);
    }

测试结果:

原因分析:

通过new方式创建的String对象,每次都会在Heap上创建一个新的实例。所以s1和s2的分别指向了不同的实例,引用地址不同。

测试代码:

public static void test3() {
        String s1 = new String("11");
        String s2 = "11";

        System.out.println(s1 == s2);
    }

测试结果:

原因分析:

当执行s1时,首先会将括号内的字面量常量“11”添加到常量池中,并且在堆中生成字符串实例及char数组实例TypeArrayOopDesc。再通过new方式创建的String对象,会在Heap上新创建一个实例,此新实例中char数组不需要新的实例,指向堆中的已存在的TypeArrayOopDesc。

当执行s2时,在常量池中发现常量已存在,则直接将虚拟机栈的指向堆中代表此常量的字符串实例。

因此s1和s2的分别指向了不同的实例,引用地址不同。

【缺图】

字符串在JVM中是如何拼接的

测试代码:

public static void test4(){
        String s2="1"+"1";
        String s1="11";


        System.out.println(s1==s2);
    }

测试结果:

原因分析:

文件在编译期成字节码时,编译器将“1”+“1”变成了“11”,编译后,相当于s2="11"。就与上面的测试代码1相同了,具体原因见测试代码1的原因分析。

测试代码:

public static void test5(){
        String s1="1";
        String s2="1";
        String s3=s1+s2;
        String s4="11";

        System.out.println(s3==s4);
    }

测试结果:

原因分析:

编译器在编译时无法确定s3的值,是在运行时才能确定,保存在jvm的堆里面,在拼接的时候,先在常量池里面生成是s1、s2的字符串,在执行加号的时候,会从常量池中取出s1、s2常量,在堆中生成两个字符串对象,然后再生成第三个字符串对象来保存两个对象拼接后的值。

测试代码:

public static void test6() {
        final String s1 = "1";
        final String s2 = "1";
        String s3 = s1 + s2;
        String s4 = "11";

        System.out.println(s3 == s4);
    }

测试结果:

原因分析:

通过s1、s2增加final修饰符,s1和s2的值赋值后不允许改变,这样编译器在编译时会把s3编译成s3="11",所以在执行时会字符串常量池中添加“11”这个常量,执行s4时会在常量池中找到“11”这个常量, s4会执行堆中已存在的字符串对象。因此s3和s4相等。

intern做了什么

intern()方法:

public String intern()

JDK源代码如下图:

返回字符串对象的规范化表示形式。

一个初始时为空的字符串池,它由类 String 私有地维护。

当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),

则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。

它遵循对于任何两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

所有字面值字符串和字符串赋值表达式都是内部的。

返回:

一个字符串,内容与此字符串相同,但它保证来自字符串池中。

尽管在输出中调用intern方法并没有什么效果,但是实际上后台这个方法会做一系列的动作和操作。

在调用”ab”.intern()方法的时候会返回”ab”,但是这个方法会首先检查字符串池中是否有”ab”这个字符串,

如果存在则返回这个字符串的引用,否则就将这个字符串添加到字符串池中,然会返回这个字符串的引用。

测试代码:

public static void test8_3(){
        String s1="11";
        String s2=new String("11");
        String s3=s2.intern();

        System.out.println(s1==s2);//#1
        System.out.println(s1==s3);//#2
    }

测试结果:

原因分析:

结果 #1:因为s1指向的是字符串中的常量,s2是在堆中生成的对象,所以s1==s2返回false。

结果 #2:s2调用intern方法,会将s2中值(“string”)复制到常量池中,但是常量池中已经存在该字符串(即s1指向的字符串),

所以直接返回该字符串的引用,因此s1==s2返回true。

测试代码:

public static void test8_4(){
        String s1="1";
        final String s2="1";
        String s3="11";
        String s4="1"+"1";
        String s5=s1+"1";
        String s6=s2+"1";
        String s7=new String("11").toString().intern();

        System.out.println(s3==s4);//#1
        System.out.println(s3==s5);//#2
        System.out.println(s3==s6);//#3
        System.out.println(s3==s7);//#4

    }

测试结果:

原因分析:

通过反编译文件,比较容易理解:

在解释上述执行过程之前,先了解两条指令:

ldc:Push item from run-time constant pool,从常量池中加载指定项的引用到栈。

astore_:Store reference into local variable,将引用赋值给第n个局部变量。

现在我们开始解释字节码的执行过程:

0: ldc #8 :加载常量池中的第八项(“1”)到栈中。

2: astore_0 :将1中的引用复制给第零个局部变量,即 String s1="1";

3: ldc #8 :加载常量池中的第八项(“1”)到栈中。

5: astore_1 :将3中的引用赋值给第一个局部变量,即 final String s2="1";

6: ldc #3 :加载常量池中的第三项(“11”)到栈中。

8: astore_2 :将6中的引用赋值给第二个局部变量,即 String s3="11";

9: ldc #3 :加载常量池中的第三项(“11”)到栈中。

11: astore_3 :将9中的引用赋值给第三个局部变量,即 String s4="11";

结果#1:s3==s4 肯定会返回true,因为s3和s4都指向常量池中的同一引用地址。

其实在JAVA 1.6之后,常量字符串的“+”操作,编译阶段直接会合成为一个字符串。

12: new #9:生成StringBuilder的实例。

15: dup :赋值12生成对象的引用并压入栈中。

16: invokespecial #10: 滴啊用常量池中的第十项,即StringBuilder.方法。

以上三条指令的作用是生成一个StringBuilder的对象。

19: aload_0 :加载第零个局部变量的值,即“1”

20: invokevirtual #11 : 调用StringBuilder对象的append方法。

23: ldc #8 :加载常量池中第八项(“1”)到栈中。

25: invokevirtual #11 :调用StringBuilder对象的append方法。

28: invokevirtual #12 :调用StringBuilder对象的toString方法。

31: astore 4 :将28中的结果引用赋值给第四个局部变量,即对变量s5进行赋值。

结果 #2:因为s5实际上是stringBuilder.append()生成的结果,所以与s3不相等,结果返回false。

33: ldc #3:加载常量池中第三项(“11”) 到栈中。

35: astore 5 :将33中的引用赋值给第五个局部变量,即s6=“11”。

结果 #3 :因为s3和s6指向的都是常量池中相同的引用,所以s3==s6返回true。

这里我们还能发现一个现象,对于加了final属性的字段,编译期直接进行了常量替换,而对于非final字段则是在运行期进行赋值处理的。

37: new #6 :创建String对象。

40: dup :复制引用并压如栈中。

41: ldc #3:加载常量池中的第三项(“11”)到栈中。

43: invokespecial #7 :调用String.””方法,并传42步骤中的引用作为参数传入该方法。

46: invokevirtual #20 :调用String.tostring()方法。

49: invokevirtual #13 :调用String.intern方法。

从37到49的对应的源码就是new String("11").toString().intern();

52: astore 6 :将49步返回的结果赋值给变量6,即s7指向11在常量池中的位置。

结果 #6 :因为s7和str3都指向的都是常量池中的同一个字符串,所以s3==s7返回true。

测试代码:

public static void test8_5_1(){
        String s1=new String("1")+new String("1");
        s1.intern();
        String s2="11";
        System.out.println(s1==s2);//#1
    }

    public static void test8_5_2(){
        String s2="11";
        String s1=new String("1")+new String("1");
        s1.intern();
        System.out.println(s1==s2);//#2
    }

测试结果:

原因分析:

JDK 1.7后,对于第一种情况返回true,但是调换了一下位置返回的结果就变成了false。这个原因主要是从JDK 1.7后,

HotSpot 将常量池从永久代移到了元空间,正因为如此,JDK 1.7 后的intern方法在实现上发生了比较大的改变,

JDK 1.7后,intern方法还是会先去查询常量池中是否有已经存在,如果存在,则返回常量池中的引用,这一点与之前没有区别,区别在于,

如果在常量池找不到对应的字符串,则不会再将字符串拷贝到常量池,而只是在常量池中生成一个对原字符串的引用。所以:

结果 #1:在第一种情况下,因为常量池中没有“11”这个字符串,所以会在常量池中生成一个对堆中的“11”的引用,

而在进行字面量赋值的时候,常量池中已经存在,所以直接返回该引用即可,因此s1和s2都指向堆中的字符串,返回true。

结果 #2:调换位置以后,因为在进行字面量赋值(String s2 = “11″)的时候,常量池中不存在,所以s2指向的常量池中的位置,

而s1指向的是堆中的对象,再进行intern方法时,对s1和s2已经没有影响了,所以返回false。

测试代码:

public static void test8_6_1(){
        String s1=new StringBuilder("1").append("1").toString();
        System.out.println(s1==s1.intern());//#1
    }

    public static void test8_6_2(){
        String s1=new StringBuilder("11").toString();
        System.out.println(s1==s1.intern());//#2
    }

测试结果:

原因分析:

结果#1 :

String s1 = new StringBuilder("1").append("1").toString();
System.out.println(s1==s1.intern());
上面的代码等价于下面的代码
String a = "1";
String b = "1";
String str3 = new StringBuilder(a).append(b).toString();
System.out.println(s1==s1.intern());
很容易分析出:
“nihao” 最先创建在堆中 s1.intern()然后缓存在字符串常连池中 运行结果为true.

结果#2:

String s1 = new StringBuilder("11").toString();
System.out.println(s1==s1.intern());
可以写成下面的形式
String a = "11";
String s1 = new StringBuilder(a).toString();
System.out.println(s1==s1.intern());

很容易分析出:
“nihao” 最先创建在常量池中, 运行结果为false.


原文链接:https://www.cnblogs.com/gengaixue/p/13493522.html