关于Java中String类和字符串常量池的理解,以及通过char数组构造String时实例存放的问题

        最近在看周志明的《深入理解Java虚拟机》,第二章有一段关于运行时常量池的代码,我先贴在下面

class RuntimeConstantPoolOOM {
    public void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);        
        
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}

书中给了这段代码的解释,在JDK7以前,会输出

false
false

而在JDK7及之后,会输出

true
false

        在此之前我只知道字符串在Java中的保存和常量池有关,intern()方法大致是一个返回常量池中字符串所在地址的方法。我明白这肯定是一个很浅显的解释,于是我继续看书:

        这段代码在JDK6中运行,会得到两个false,而在JDK7中运行,会得到一个true和一个false。产生差异的原因是,在JDK6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。

        而JDK7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为"java"(在sun.misc.Version中被加载)这个字符串在执行StringBuilder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

        看完这两段话我真的是脑袋一头雾水了,完全没办法搞明白上面的代码究竟为什么会输出那样的结果。于是我开始漫长的网络搜索,看了各种各样的帖子,但是这些帖子基本上都是在讨论下面几个事情:

1.String s = ""hello"; 和 String s = new String("hello"); 有什么区别?

2.String s1 = new String("xyz"); 这行代码创建了几个String对象?

3.对intern()方法的一些片面解释 。。。。等等

有些从内存角度讲解的帖子也是各相矛盾,不知道应该相信谁。

        如果你也搜索了很多相关的帖子,应该大致也知道上面这些问题的“答案”,可是一旦你希望再了解的深入一点,你会发现很多帖子的说法在某些点上是互相矛盾的,最为典型的就是,我非常想搞清楚究竟字符串的实例对象能不能只创建在堆中,无数的帖子都没有给我一个满意的答案。

        直到我看到RednaxelaFX大神的这一篇文章:

请别再拿“String s = new String("xyz");创建了多少个String实例”来面试了吧-pudn.com

这篇文章中提到:

        问题:

String s = new String("xyz");  创建了几个String Object?

        
运行时的类加载过程与实际执行某个代码片段,两者必须分开讨论才有那么点意义。

       
 为了执行问题中的代码片段,其所在的类必然要先被加载,而且同一个类最多只会被加载一次(要注意对JVM来说“同一个类”并不是类的全限定名相同就足够了,而是<类全限定名, 定义类加载器>一对都相同才行)。

       
 根据上文引用的规范的内容,符合规范的JVM实现应该在类加载的过程中创建并驻留一个String实例作为常量来对应"xyz"字面量;具体是在类加载的resolve阶段进行的。这个常量是全局共享的,只在先前尚未有内容相同的字符串驻留过的前提下才需要创建新的String实例。

等到真正执行原问题中的代码片段时,JVM需要执行的字节码类似这样:

0: new	#2; //class java/lang/String
3: dup
4: ldc	#3; //String xyz
6: invokespecial	#4; //Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1

       
 这之中出现过多少次new java/lang/String就是创建了多少个String对象。也就是说原问题中的代码在每执行一次只会新创建一个String实例。
        这里,ldc指令只是把先前在类加载过程中已经创建好的一个String对象("xyz")的一个引用压到操作数栈顶而已,并不新创建String对象。

        

        这篇文章中提到了,在讨论关于String s = new String("xyz");这个问题时,只有分成类加载阶段和代码执行阶段才是有意义的,在类加载的过程中JVM会创建并驻留一个String实例作为常量来对应"xyz"字面量,也就是说字符串常量池中的部分String实例在类加载时就被确定了

        这篇文章的其他内容我不再赘述,很推荐大家自己去看一下,对我可以说是受益匪浅。

        如果你看到这里,可能会想,这和开头那段代码究竟有什么关系,确实,这篇文章的内容和我寻求的答案是没有直接关系的,但是却让我有了一个问题的分析思路。上文中标红的部分“部分String实例”,为什么是部分呢?因为在程序运行过程中字符串常量池也会新增String实例,也只有这样才能触发“首次遇到”原则,我再重贴一遍书中描述,并将部分文字加粗,逐个分析。

这段代码在JDK6中运行,会得到两个false,而在JDK7中运行,会得到一个true和一个false。产生差异的原因是,在JDK6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。

        而JDK7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为"java"(在sun.misc.Version中被加载)这个字符串在执行StringBuilder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

“关于首次遇到”原则
        
在JDK6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用。而在JDK7中,字符串常量池已经被移动到堆中,所以不用进行复制,只需要在常量池里记录一下首次出现的实例引用即可。
        上面这句话透露了一个信息,执行intern()方法时是完全有可能在字符串常量池中找不到这个字符串的,这也就是为什么我要标红“部分String实例”的原因,因为有些情况下字符串对象实例不是存在于字符串常量池中的,而是在堆中,只有对该字符串实例执行了intern()方法,才会触发“首次遇到”原则,由此在字符串常量池中创建出该对象(JDK6)或者记录堆中字符串实例的引用(JDK7)。
        那么 “字符串的实例对象能不能只创建在堆中?”这个问题实际上就解决了,显然根据分析是可以的。

那么哪种情况下字符串的实例对象是只创建在堆中的呢?
       看书中的粗体描述:“而由StringBuilder创建的字符串实例在Java堆上”,在分析到这里之前,我对这句话都是持怀疑态度的,因为网上的说法真的无法统一,我有理由怀疑书中这句话可能是错的。但是现在我能确定了这句话一定是对的,由StringBuilder创建的字符串实例就是在Java堆上的。可是为什么呢?

        首先我们知道,下面的代码中

String str1 = "hello";
String str2 = new String("hello");
String str3 = new StringBuilder("hel").append("lo").toString();

        第一种声明方式str1直接指向字符串常量池中的"hello"字符串实例(这个实例在类加载时就已经创建完成了)的地址;第二种声明方式str2则是指向堆中的一个String对象,该对象通过某种方式引用了字符串常量池中的"hello"实例(同样,也是类加载时就已经创建完成)的内容。(为什么是某种方式呢,因为我也不太确定是如何做到引用的, 但其实debug的时候可以看到str1和str2的value是同一个对象,所以我猜测str2只是引用了str1的value对象,这样就能保证str1和str2所包含的字符串内容相同但又不是同一个对象了,当然我没有做更多的测试无法下定论)

        可是第三种方式,却是创建了一个在堆中的String实例,字符串常量池中并没有字串"hello"的对应实例(类加载时没有创建该实例,因为代码中根本就没有"hello"这个字符串)。我们知道str3是最终由StringBuilder的toString()方法创建出来的,那就看看这个toString()是如何创建出这个字符串实例的:

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }
    public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= value.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.
        if (offset > value.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }

        上面的JDK源码可以看到,StringBuilder的toString()方法本质上是靠String类的String(char value[], int offset, int count)构造器new了一个String实例,这个字符串实例是只存在于堆中的。相比于第二种new方式,他们的区别就在于,一个是靠字符串作为参数构造字符串,一个是靠char[]数组构造字符串。最后导致了两个字符串实例存放的地方不同。

        要验证我上面这段话,必须要再解释一遍intern()方法:

        在JDK6及之前的版本中,字符串常量池还在方法区(永久代)中,当我对一个字符串调用intern()方法,JVM会去字符串常量池找有没有这个字符串,如果有就返回这个字符串在方法区中的实例引用,如果没有,就会拷贝该字符串在方法区中创建一个实例并返回其引用。

        在JDK7及之后的版本中,字符串常量池被移至堆中,如果intern()方法没有找到该字符串,不会再进行拷贝,而是直接记录该字符串首次出现的实例引用,并返回该该记录。

        看下面的代码:

public class NoFoot_Test {
    public static void main(String[] args) {
        Test.test();
        char[] ch1 = {'h','e','l','l','o'};
        String str1 = new String(ch1);
        String str11 = str1.intern();

        char[] ch2 = {'m','i','k','e'};
        String str2 = new String(ch2);
        String str22 = str2.intern();

        System.out.println(str1 == str11);
        System.out.println(str2 == str22);
    }
}

class Test {
    String s1 = "hello";
    static String s2 = "mike";

    static public void test() {

    }
}

输出: 
true   
false      

        上述代码中,Test类中有两个属性s1和s2,s1是普通成员,而s2是静态成员。主程序对Test类调用其静态的test()方法完成对Test类的类加载。所以当整个类加载完成后,字符串常量池内将会有一个"mike"字符串对象实例,但不会有"hello"。

        回到主程序,str1和str2都由ch[]数组构造而来,所以二者的字符串实例存放在堆中,当对该字符串实例调用intern()方法时,JVM就开始在字符串常量池里找对应的字符串是否存在对应实例。

        显然str1对应的"hello"肯定是找不到的,因为类加载时根本就没有"hello"这个字符串被加载进去,于是就会触发首次遇到”原则字符串常量池中新增一个对str1实例的引用,并返回该引用给str11,这时str11所指向的地址就应该是str1实例的地址,因此第一个输出为true。

        而str2对应的"mike"字符串是能够在池中被找到的,不会触发首次遇到”原则JVM会直接返回池中早已经创建好的"mike"字符串实例的引用给str22,此时str22就应该指向池中的"mike"实例,而str2是被创建在堆中的,因此第二个返回false。
        

         上图是debug的效果,可以看到,str1和str11对应的value字段都是同一个地址char[5]@485,而str2和str22中的value字段则不相同(char[5]@487和char[5]@486)。同样印证了上文的说法。

       我认为上面的例子应该可以基本解决你的疑问了,其实只要能够分析出某个字符串有没有在类加载时被创建到字符串常量池中,这些问题分析起来都会变得很简单。但接下来我还要讲一个例子,这个例子可能不重要(如果你比较懂类加载机制的话可能会觉得下面这个例子在说废话),但当时还是把我给搞迷糊了。看下面的代码:

public class NoFoot_Test {
    public static void main(String[] args) {
        String str3 = "hello";
        char[] chs = {'h', 'e', 'l', 'l', 'o'};
        String str1 = new String(chs);
        String str2 = str1.intern();

        System.out.println(str1 == str2);
        System.out.println(str1 == str3);
        System.out.println(str2 == str3);
    }
}

输出:
false
false
true

       

public class NoFoot_Test {
    public static void main(String[] args) {
        char[] chs = {'h', 'e', 'l', 'l', 'o'};
        String str1 = new String(chs);
        String str2 = str1.intern();
        String str3 = "hello";

        System.out.println(str1 == str2);
        System.out.println(str1 == str3);
        System.out.println(str2 == str3);
    }
}
输出:
true
true
true

        上面这两段代码都是在主程序中运行,唯一的不同点是对str3的声明位置不一样,可是却输出了完全不同的结果。代码中都直接出现了"hello"字符串,难道不应该都在类加载时在字符串常量池中创建好实例吗?显然不是这样,不然也不会输出不一样的结果了。

        其实这个很好理解,在JVM内部会自己创建一个主类的实例,然后调用其main()方法让程序运行起来,main()方法是一个静态方法,类加载的时候肯定会加载这个方法,但是并不会加载里面的内容(实际上对于所有在main()方法开始运行之后才会被调用的方法都是这样的,毕竟方法都是存放在方法区中的),静态方法里的内容只有在运行里面的代码片段时才会真的执行(也就是调用这个静态方法)。也就是说,main()里的内容和类加载是没关系的,类加载完成后字符串常量池中自然也不会创建main()方法中的字符串实例,只有当JVM真的执行到关于字符串的具体语句时才会再执行对应的操作。

        因此上面的第一段代码。先声明的str3的话JVM就会先在字符串常量池里创建"hello"字符串实例了,str1依然在堆中,str2在池里找也能找到所以直接指向str3指向的池中地址。而第二段先生成在堆中的str1,然后对其调用intern()方法触发首次遇到”原则在池中记录一个指向堆中"hello"的引用返回给str2,并不会创造新实例,最后str3肯定也是直接拿到池中的那个记录的引用,所以三个变量最终都指向一个实例,所有都输出true了。

        接下来回到这篇文章开头那段书中的代码

class RuntimeConstantPoolOOM {
    public void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);        
        
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}

JDK6输出:
false
false

JDK7输出:
true
false

        有了上面的分析,这段代码就很容易理解了

        类加载时,"java"字符串已经在sun.misc.Version中出现了,所以字符串常量池中是可以找到"java"对应的String实例的,而"计算机软件"则没有被加载,是找不到的。上述代码中无论是str1("计算机软件")还是str2("java")本质都是依靠char数组构建的,也就是说都是指向存放在堆中的String实例。

       所以对str1调用intern()方法会触发首次遇到”原则在JDK6中,JVM将"计算机软件"拷贝到字符串常量池(在方法区(永久代)中)以此创建一个新的实例并返回其引用,所以str1.intern()是指向字符串常量池(在方法区(永久代)中)中的一个新的String实例,那自然和str1本身指向的堆中String实例不同,所以输出false;而到了JDK7,字符串常量池被移至堆中,触发首次遇到”原则也不需要拷贝再创建了,只要在池中记录该字符串首次出现的实例引用并返回即可,所以str1.intern()和str1指向的是同一个在堆中的String实例,因此输出变成了true。

        而对于str2,在类加载完成后程序运行时,字符串常量池中是可以找到"java"对应的String实例的,所以无论时JDK6还是JDK7,对"java"字符串调用intern()方法都是返回的字符串常量池中的String实例,和依靠char数组构建的堆中实例自然不同,因此都是返回false。

        最后还要说一点,就是字符串做+运算时的一些问题,比如下面这段代码:

        String a = "a";
        String b = "b";
        String ab1 = "a" + "b";
        String ab2 = a + b;
        String ab3 = a + "b";

        System.out.println(ab1 == ab2);
        System.out.println(ab1 == ab3);
        System.out.println(ab2 == ab3);

        System.out.println(ab1 == ab2.intern());
        System.out.println(ab2.intern() == ab3.intern());

输出:
false
false
false
true
true

        很多帖子都解释过字符串在做+运算时的细节,

        形似ab1的运算javac会自动对代码优化,将 "a" + "b"合并为"ab"

        而形似ab2和ab3的运算在底层则是调用StringBuilder重新构造出一个字符串。

        如果我这篇文章写的足够清晰能够让你理解其意思,那你应该可以很快分析得到正确的输出。


版权声明:本文为qq_19539359原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
THE END
< <上一篇
下一篇>>