一文搞懂 String str =new String(“abc“) 到底创建多少个对象?

1,006 阅读11分钟

String 基本概念

基本用法

1)通过常量定义

String str= "abc"; 

2)通过 new 创建

String str= new String("abc");

3)使用 + 运算符

String s1= "a";
String s2= "bc";
String str= s1 + s2;

如下的 s1 + s2 的执行细节:

1、StringBuilder str= new StringBuilder(); 

2、str.append("a") 

3、strappend("bc") 

4、str.toString() --> 约等于 new String("abc")

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

在 JDK5 之后使用的是 StringBuilder,在 JDK5 之前使用的是 StringBuffer。

内部原理

String 的不可变性

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
	//...
}

首先,可以看到这里面有个非常重要的属性,即 private final 的 char 数组,数组名字叫 value。它存储着字符串的每一位字符,同时 value 数组是被 final 修饰的,也就是说,这个 value 一旦被赋值,引用就不能修改了;并且在 String 的源码中可以发现,除了构造函数之外,并没有任何其他方法会修改 value 数组里面的内容,而且 value 的权限是 private,外部的类也访问不到,所以最终使得 value 是不可变的。

举例说明: 

String str = "abc";
str = "a";
System.out.println(str);// a

虽然后面 str 被设置成 "a",但是常量池中同时存在 "abc" 和 "a" 这两个对象,说明 str 只是常量池的引用指向发生了变化。

验证:javap -verbose 查看字节码:

为什么 String 要被设计成不可变的?

1)常量池 String 

不可变的第一个好处是可以使用常量池。在 Java 中有常量池的概念,比如两个字符串变量的内容一样,那么就会指向同一个对象,而不需创建第二个同样内容的新对象,例如:

String s1 = "abc";
String s2 = "abc";

在图中可以看到,左边这两个引用都指向常量池中的同一个 "abc",正是因为这样的机制,再加上 String 在程序中的应用是如此广泛,我们就可以节省大量的内存空间。

如果想利用常量池这个特性,这就要求 String 必须具备不可变的性质,否则的话会出问题,我们来看下面这个例子:

String s1 = "abc";
String s2 = "abc";
s1 = "ABC";
System.out.println(s2);

假设 String 对象是可变的,那么把 s1 指向的对象从小写的 "abc" 修改为大写的 "ABC" 之后,s2 理应跟着变化,那么此时打印出来的 s2 也会是大写的 "ABC"。

这就和我们预期不符了,同样也就没办法实现常量池的功能了,因为对象内容可能会不停变化,没办法再实现复用了。假设这个小写的 "abc" 对象已经被许多变量引用了,如果使用其中任何一个引用更改了对象值,那么其他的引用指向的内容是不应该受到影响的。实际上,由于 String 具备不可变的性质,所以上面的程序依然会打印出小写的 "abc",不变性使得不同的字符串之间不会相互影响,符合我们预期。

 2)用作 HashMap 的 key

对于 key 来说,最重要的要求就是它是不可变的,这样我们才能利用它去检索存储在 HashMap 里面的 value。由于 HashMap 的工作原理是 Hash,也就是散列,所以需要对象始终拥有相同的 Hash 值才能正常运行。如果 String 是可变的,这会带来很大的风险,因为一旦 String 对象里面的内容变了,那么 Hash 码自然就应该跟着变了,若再用这个 key 去查找的话,就找不回之前那个 value 了。

3)缓存 hashCode 

 在 Java 中经常会用到字符串的 hashCode,在 String 类中有一个 hash 属性,代码如下:

/** Cache the hash code for the String */
private int hash;

这是一个成员变量,保存的是 String 对象的 hashCode。因为 String 是不可变的,所以对象一旦被创建之后,hashCode 的值也就不可能变化了,我们就可以把 hashCode 缓存起来。这样的话,以后每次想要用到 hashCode 的时候,不需要重新计算,直接返回缓存过的 hash 的值就可以了,因为它不会变,这样可以提高效率,所以这就使得字符串非常适合用作 HashMap 的 key。 

而对于其他的不具备不变性的普通类的对象而言,如果想要去获取它的 hashCode ,就必须每次都重新算一遍,相比之下,效率就低了。

4) 线程安全

String 不可变的第四个好处就是线程安全,因为具备不变性的对象一定是线程安全的,我们不需要对其采取任何额外的措施,就可以天然保证线程安全。 

由于 String 是不可变的,所以它就可以非常安全地被多个线程所共享,这对于多线程编程而言非常重要,避免了很多不必要的同步操作。 

扩展:为什么 JDK9 中将实现从 char[] 数组修改为 byte[] 数组实现?

原文链接:openjdk.java.net/jeps/254

简单总结:大部分场景下能用 1 个字节就能够满足数据存储,用 char 的话会浪费一个字节,如果遇到需要 2 个字节存储的数据,分配 2 个 byte 即可。

String 内存分配

前提:

在 JDK1.7+ 版本举例,因为 JDK1.6 版本常量池是位于永久代,从 JDK1.7 开始是位于堆中。

常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 

Java 虚拟机对于 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用 于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池, 《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现 这个内存区域,不过一般来说,除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。 

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量 一定只有编译期才能产生,也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError 异常。 

举例说明:

1)通过常量定义的 String 对象会直接存储在常量池中,即 "abc" 会在常量池中存储。

String str = "abc"; 

 

2)通过 new 创建的 String 对象,new 会创建一个对象在堆中,"abc" 会创建对象在常量池中。

String str= new String("abc"); 

3)使用 + 运算符创建 String 对象,str 会在堆中创建 "abc" 对象,但是不会在常量池存储。

String s1= "a";
String s2= "bc";
String str= s1 + s2;

原因:因为 s1 和 s2 都是变量,所以编译器不会在常量池中创建对象。 可以通过 javap 命令查看常量池来明确这个结果。

如果你用 final 修饰 s1 和 s2 之后,它们就是常量,就会在常量池创建。如下:

final String s1= "a";
final String s2= "bc";
String str= s1 + s2; // 等价 String str = "a" + "bc"; => 会优化成 String str = "abc";

intern() 方法详解

 /**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section 3.10.5 of the
     * <cite>The Java&trade; Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
    public native String intern();

简单总结:将这个字符串对象放入常量池中。  

  • 当常量池没有的时候,返回的是对象在堆中的引用地址;如果常量池有的时候,返回常量池中的地址。

具体案例

案例一:

String s1 = "a" + "b" + "c";//编译期优化:等同于"abc"
String s2 = "abc"; //"abc"一定是放在常量池中,将此地址赋给s2

System.out.println(s1 == s2); //true,因为都是指向常量池

案例二:

String s1 = "abc";
String s2 = "def";

String s3 = "abcdef";
String s4 = "abc" + "def";//编译期优化
// 如果拼接符号的前后出现了变量,则相当于在堆空间中new String()
String s5 = s1 + "def";
String s6 = "abc" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false
//intern():判断常量池中是否存在abcdef值,如果存在,则返回常量池中abcdef的地址;
//如果常量池中不存在abcdef,则在常量池中加载一份abcdef,并返回对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true

案例三:

String s3 = new String("1") + new String("1");//new String("11")
String s4 = "11";//在常量池中生成对象"11"
String s5 = s3.intern();//常量池已经有“11”,返回常量池中对象地址
System.out.println(s3 == s4);//false
System.out.println(s3 == s5);//false
System.out.println(s4 == s5);//true

案例四:

String s3 = new String("1") + new String("1");//new String("11")
String s5 = s3.intern();//常量池没有“11”,返回常量池中引用地址
System.out.println(s3 == s5);//true

通过案例四和案例五对比,可以验证 intern() 方法的作用,当常量池没有的时候,返回的是对象在堆中的引用地址,和 new String("11") 是一致的;如果常量池有的时候,返回常量池中的地址,和 new String("11") 是不一致的。

案例五:

String s = new String("1");
s.intern();//调用此方法之前,常量池中已经存在了"1",s指向的是堆中对象
String s2 = "1"; //s2指向常量池中的对象
System.out.println(s == s2);//false

String s3 = new String("1") + new String("1");//s3变量记录的地址为:new String("11")
s3.intern();//在常量池中生成"11"
String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址,是对象在堆中的引用地址
System.out.println(s3 == s4);//true

不同 JDK 版本对内存分配的影响

String s1 = new String("he") + new String("llo");
String s2 = s1.intern(); 

System.out.println(s1 == s2);
// 在 JDK 1.6 下输出是 false
// 在 JDK 1.7 及以上的版本输出是 true

为什么输出会有这些变化呢?主要还是字符串池从永久代中脱离、移入堆区的原因, intern() 方法也相应发生了变化:

1)在 JDK 1.6 中,调用 intern() 首先会在字符串池中寻找 equal() 相等的字符串,假如字符串存在就返回该字符串在字符串池中的引用;假如字符串不存在,虚拟机会重新在永久代上创建一个实例,将 StringTable 的一个表项指向这个新创建的实例。 

2)在 JDK 1.7 (及以上版本)中,由于字符串池不在永久代了,intern() 做了一些修改,更方便地利用堆中的对象。字符串存在时和 JDK 1.6一样,但是字符串不存在时不再需要重新创建实例,可以直接指向堆上的实例。

由上面两个图,也不难理解为什么 JDK 1.6 字符串池溢出会抛出 OutOfMemoryError: PermGen space ,而在 JDK 1.7 及以上版本抛出 OutOfMemoryError: Java heap space 。

问题解惑

String str =newString("abc"); 到底创建多少个对象?

如果常量池不存在 "abc" 对象,那么会创建两个对象。一个对象是:new 关键字在堆空间创建的;另一个对象是:常量池中的对象 "abc"。  

如果常量池已经存在 "abc" 对象,那么只会创建一个对象,即 new 关键字在堆空间创建。

扩展:new String("a") + new String("bc") 创建多少个对象?

对象1:new StringBuilder() 

对象2: new String("a") 

对象3: 常量池中的 "a" 

对象4: new String("bc")

对象5: 常量池中的 "bc"

StringBuilder 的 toString():

对象6 :new String("abc"); 强调一下,toString() 的调用,在常量池中,没有生成"abc"。

所以 StringBuilder 的 toString() 中的 new String("abc")和直接在代码中 new String("abc") 是有区别的,并不是完全等价。

    @Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }

参考资料

书籍:深入理解Java虚拟机