小小String对象大大奥秘

什么是字符串?

字符串是由引号所括起来的一系列字符序列。例如"String","Hello"就为一个字符串

String 的不可变性

"String对象一旦被创建就是固定不变的了,对String对象的任何改变都不影响到原对象,相关的任何操作都会生成新的对象“。

  1. 固定不变 - 从String 对象的源码中可以看出,String 类声明为 final,且它的属性和方法都被 final 所修饰
  2. 任何操作都会生成新对象 - String:: subString(),String::concat() 等方法都会生成一个新的String对象,不会在原对象上进行操作
    从下面String源码部分中很容易得到上面的结论:
/** String 类源码 */
public final class String 
implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0
    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

    /**
     * Class String is special cased within the Serialization Stream Protocol.
     *
     * A String instance is written into an ObjectOutputStream according to
     * <a href="{@docRoot}/../platform/serialization/spec/output.html">
     * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
     */
    private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];
……
}

接下来使用一段代码来揭示这个过程:

public class ImmutableStrings
{
    public static void main(String[] args)
    {
        String start = "Hello"; // 1
        String end = start.concat(" World!"); // 2
//String end = start + " World!"
        System.out.println(end); // 3
System.out.println(start); // 4
    }
}

// Output
Hello World!
World

在这段代码中,没有改变任何对象。首先在第一个代码中,会在堆内存中创建一个新的String 对象,并把它的引用赋值给 start,接着在第二个调用String:: concat()方法对字符串进行拼接,此时会创建一个新的String 对象,该对象是"Hello" 和 “World” 的串联。就如String:: concat() 源码所示,第三个/四个代码的输出结果分别为:“Hello World!”, “World”。并且操作符 " + "完成了和String:: concat() 类似的事 - > 操作符 “+” 算是一个语法糖,查看编译之后的字节码可以知道最终会调用StringBuilder:: append() 来完成字符串的拼接。

/** concat() 源码 */
public String concat(String str) {
        int otherLen = str.length(); // 拼接的字符串参数长度为0, 返回本身
        if (otherLen == 0) {
            return this;
        }
        int len = value.length;
        char buf[] = Arrays.copyOf(value, len + otherLen);
        str.getChars(buf, len);
        return new String(buf, true); // 创建一个新String对象来存储拼接之后的字符串
}

不可变性设计的初衷

  1. 字符串常量池的需要。String对象的不可变性为字符串常量池的实现提供了基础,使得常量池便于管理和优化。
  2. 多线程安全。同一个字符串对象可以被多个线程共享。
  3. 安全性考虑。字符串应用场景众多,设计成不可变性可以有效防止字符串被有意篡改。
  4. 由于String对象的不可变性,可以对其HashCode进行缓存,可以作为HashMap,HashTable等集合的key 值。

字符串常量池

很多文章都提及到字符串常量池是String对象的集合,这种说法很接近了,但是更准确来说,它是 String 对象引用的集合 (网上关于这个众说纷纭,我更加倾向于存储的是引用的集合~ 若有错误了请指出! 谢谢~ ps: 又看了一遍书,发现在JDK 6以前,永久代中的字符串常量池是存放String 对象实例的,但是JDK 7之后,字符串常量池移到了堆中,String 对象实例也是在堆中的,那字符串常量池只需要保存String 对象的引用就行啦~ 详见《深入理解Java虚拟机》 P63)。 虽说String 是不变的,但是它还是和Java中的其他对象一样,是分配在堆中的,所以说 String 对象存在于堆中,字符串常量池存放了它们的引用。因为 String 对象是不可变的,所以多个引用 “共享” 同一个String 对象是安全的,这种安全性就是 字符串常量池所带来的。

字面量的形式创建字符串

public class ImmutableStrings{
    public static void main(String[] args)
    {
        String one = "someString"; // 1
        String two = "someString"; // 2

        System.out.println(one.equals(two)); // String 对象是否相同内容
        System.out.println(one == two); // String 对象是否相同的引用
    }
}

// Output
true
true

执行完上面的第一句代码之后,会在堆上创建一个String 对象,并把String 对象的引用存放到字符串常量池中,并把引用返回给 one,那当第二句代码执行时,字符串常量池已经有对应内容的引用了,直接返回对象引用给 two。one.equals(two) / one == two 都为true。 图形化如下所示:

new 创建字符串

public class ImmutableStrings
{
    public static void main(String[] args)
    {
        String one = "someString";
        String two = new String("someString");
        
        System.out.println(one.equals(two));
        System.out.println(one == two);
    }
}

// Output
true
false

在使用 new关键字时的情况会有稍微不同,关于这两个字符串的引用任然会存放字符串常量池中,但是关键字 new使得虚拟机在运行时会创建一个新的String对象,而不是使用字符串常量池中已经存在的引用,此时 two 指向 堆中这个新创建的对象,而one 是常量池中的引用。 one.equals(two) 为 true,而 one == two 都为false。

如果想要one,two都引用同一个对象,则可以使用 String:: intern()方法 - 当调用intern()方法时,如果字符串常量池中已经有了这个字符串,那么直接返回字符串常量池中它的引用,如果没有,那就将它的引用保存一份到字符串常量池中,然后直接返回这个引用。这个方法是有返回值的,是返回引用。

String one = "someString";
String two = new String("someString"); // 仍指向堆中new 出的新对象
String three = two.intern();
System.out.println(one.equals(two)); // true
System.out.println(one == two); // false
System.out.println(one == three); // true
System.out.println(two == three); // false

垃圾收集

当一个对象没有引用指向时,垃圾收集器便会对它进行收集操作。看下面的一个事例:

public class ImmutableStrings
{
    public static void main(String[] args)
    {
        String one = "someString";
        String two = new String("someString");
        
        one = two = null;
    }
}

当 one = two = null时,只有一个对象会被回收,String 对象总是有来自字符串常量池的引用,所以不会被回收

String 对象的创建和字符串常量池的放入

上面嘀咕了那么久,那到底什么时候会创建String 对象?什么时候引用放入到字符串常量池中呢?先需要提出三个常量池的概念:

  1. 静态常量池:常量池表(Constant Pool table,存放在Class文件中),也可称作为静态常量池,里面存放编译器生成的各种字面量和符号引用。其中有两个重要的常量类型为CONSTANT_String_info和CONSTANT_Utf8_info类型(具体描述可以看看《深入理解Java虚拟机》的p 219 啦~)
  2. 运行时常量池:运行时常量池属于方法区的一部分,常量池表中的内容会在类加载时存放在方法区的运行时常量池,运行时常量池相比于Class文件常量池一个重要特征是 动态性,运行期间也可以将新的常量放入到 运行时常量池中
  3. 字符串常量池:在HotSpot 虚拟机中,使用StringTable来存储 String 对象的引用,即来实现字符串常量池,StringTable 本质上是HashSet,所以里面的内容是不可以重复的。一般来说,说一个字符串存储到了字符串常量池也就是说在StringTable中保存了对这个String 对象的引用

执行过程

有了上面的概念之后,便可来描述下述过程了
首先给出结论,“在类的解析阶段,虚拟机便会在创建String 对象,并把String对象的引用存储到字符串常量池中”。

  1. 当*.java 文件 编译为*.class 文件时,字符串会像其他常量一样存储到class 文件中的常量池表中,对应于CONSTANT_String_info和CONSTANT_Utf8_info类型;
  2. 类加载时,会把静态常量池中的内容存放到方法区中的运行时常量池中,其中CONSTANT_Utf8_info类型在类加载的时候就会全部被创建出来,即说明了加载类的时候,那些字符串字面量会进入到当前类的运行时常量池,但是此时StringTable(字符串常量池)并没有相应的引用,在堆中也没有相应的对象产生;
  3. 遇到ldc字节码指令(该指令将int、float或String型常量值从常量池中推送至栈顶)之前会触发解析阶段,进入到解析阶段,若在解析的过程中发现StringTable已经有与CONSTANT_String_info一样的引用,则返回该引用,若没有,则在堆中创建一个对应内容的String对象,并在StringTable中保存创建的对象的引用,然后返回;

具体示例

下面给出几个具体实例,来说下这个过程:

  • 字面量的形式创建字符串
public class test{
public static void main(String[] args){
    String name = "HB";
String name2 = "HB";
}
}

通过javap 反编译后的字节码代码如下所示

#2 = String  #14 
#14 = utf8 HB
……
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=3, args_size=1
         0: ldc           #2  // String HB
         2: astore_1
         3: ldc           #2  // String HB
         5: astore_2
         6: return
……

当编译成字节码文件后,字面量"HB" 会存储到常量类型 CONSTANT_Utf8_info中,类加载时,其也会随之加载到方法区中的运行时常量池中,接下来可以用此来在StringTable查询是否有匹配的String 对象引用(当然只是简化的说法,具体CONSTANT_Utf8_info还指向一个Symbol对象~);遇到第一个ldc字节码指令之前,解析过程中发现StringTable(字符串常量池)还没有与CONSTANT_String_info一样的引用,则在堆中创建一个对应内容的String对象,并在StringTable中保存创建的对象的引用,然后返回;astore_1指令把返回的引用存到本地变量name; 遇到二个ldc字节码指令之前,解析过程中发现StringTable(字符串常量池)已经有与CONSTANT_String_info一样的引用,则直接返回即可,并通过astore_2 指令将其返回的引用保存到本地变量 name2中

  • new 创建字符串
public class test2{
public static void main(String[] args){
String name = new String("HB");
String name2 = new String("HB");
}
}

通过javap 反编译后的字节码代码如下所示

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: new           #2  // class java/lang/String
         3: dup
         4: ldc           #3 // String HB
         6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
         9: astore_1
        10: new           #2 // class java/lang/String
        13: dup
        14: ldc           #3 // String HB
        16: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
        19: astore_2
        20: return

使用了关键字new后,会有稍微不同,new 指令会在堆中创建一个新的String 对象,并将其引用值压入栈顶,通过dup指令 复制栈顶的新对象的引用值并把复制值压入栈顶,本地变量name 所保存的值就为该引用值;接下来在遇到第一个ldc字节码指令之前,解析过程中发现StringTable(字符串常量池)还没有与CONSTANT_String_info一样的引用,则在堆中创建一个对应内容的String对象,并在StringTable中保存创建的对象的引用, 所以在运行时,会创建两个String对象哦~接下来的过程和前面的差不多,就不一一叙述啦!

  • 其他重要值得关注的示例
String s1 = new String("hb");
String s2 = "hb";
System.out.println(s1 == s2); // false
String s3 = s1.intern(); // 从字符串串常量池中得到相应引用
System.out.println(s2 == s3);  // true
        
System.out.println(" ===== 分割线 =====  ");
String s5 = "hb" + "haha";  // 虚拟机会优化进行优化, 当成一个整体 "hbhaha"成立, 而不会用StringBuild::append()处理
String s6 = "hbhaha";
System.out.println(s5 == s6);  // true
        
System.out.println(" ===== 分割线 =====  ");
String temp = "hb";
String s7 = temp + "haha"; // 采用StringBuilder::append()处理
System.out.println(s7 == s6);  // false
String s8 = s7.intern(); // 从字符串串常量池中得到相应引用
System.out.println(s8 == s6); // true
        
System.out.println(" ===== 分割线 =====  ");
String s9 = new String("hb") + new String("haha");  //采用StringBuilder::append()处理
System.out.println(s9 == s6); // false
String s10 = s9.intern(); // 从字符串串常量池中得到相应引用
System.out.println(s10 == s6); // true

System.out.println(" ===== 分割线 =====  ");
String s11 = new StringBuilder("hello").append(" world").toString();
System.out.println(s11 == s11.intern()); // true - 一个很特殊的例子, intern()方法会把s11引用放入到字符串常量池中

System.out.println(" ===== 分割线 =====  ");
String s12 = new StringBuilder("hello").append(" java").toString();
String s13 = "hello java";
System.out.println(s12 == s12.intern()); // false - 一个很特殊的例子, intern()方法返回s13 在常量池中的引用

总结

  1. String 对象存在于堆中,字符串常量池存放了它们的引用
  2. 字符串常量池存储String对象的引用,且是全局共享的,相同的字符串都将指向同一个字符串对象
  3. 运行时创建的字符串(new)关键字 和 “” (字面量形式) 创建的字符串存在不同
  4. 检查字符串是否相同的最好方法是 equal()
  5. 可以通过String:: intern() 方法从常量池中得到String对象的引用,或 将String 对象的引用存入到 字符串常量池中
  6. 上述所有的实验都是在JDK 8 HotSpot虚拟机下进行的,在JDK 7 中HotSpot,字符串常量池移到了堆中哦~,所以不同JDK版本,不同虚拟机下可能存在差异

参考资料

[1] https://javaranch.com/journal/200409/ScjpTipLine-StringsLiterally.html
[2] https://www.iteye.com/blog/rednaxelafx-774673#comments
[3] https://www.zhihu.com/question/55994121/answer/408891707
[4] https://www.cnblogs.com/Kidezyq/p/8040338.html

Author: HB
Link: http://www.huangbin.fun/小小String对象大大奥秘.html
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.