String类及字符串常量池的学习

1,703 阅读9分钟

String 是Java中常用的基础类之一,用来表示字符串类型,但是相较于其他对象还是比较特殊的,它与字符串常量池(String Pool)密切相关。JVM规范中字符串常量池是在方法区上一个驻留字符串(Interned Strings)的位置,是为了优化而专门供字符串存储的一块区域,这个区域在整个虚拟机中是共享的,而在JDK7及以后的版本被移到了堆空间中。

String 是如何被创建的

String 创建对象有两种方式:

  • 字面量赋值String s = "Hello World";
  • new关键字创建String s = new String("Hello World");

这两种方式表面看起来没有任何区别,对后续的使用也不会有任何影响,但是真实存储还是有所不同的。先看字面量赋值的方式,下面摘取了字节码的重要部分。

Constant pool:
   #1 = Methodref          #4.#23         // java/lang/Object."<init>":()V
   #2 = String             #24            // 你好
......
   #24 = Utf8               你好
......
Code:
      stack=1, locals=2, args_size=1
         0: ldc           #2                  // String 你好
         2: astore_1
......
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  args   [Ljava/lang/String;
            3       3     1    s     Ljava/lang/String;

编译后会在字节码常量池(Constant pool 每个字节码文件都有这一部分内容,存储当前文件中用到的字符串值,后面会详细介绍)中存放当前字面量的值,具体的执行指令也只有两个,ldc将字符串常量从常量池中加载出来,astore_1将字符串赋值给局部变量表中的 s 。当上述字节码被加载到JVM中后,Constant pool 里面的内容会被加载到运行时常量池,但是字符串会被创建到堆中,并且字符串常量池会存放一个相应的引用。直到运行时,会去字符串常量池中查找是否有相同内容对象的引用,否则就在堆中创建一个新的字符串对象,字符串常量池中创建该对象的引用。

通过查看具体堆中对象的情况,按照字符串查找,堆中只有一个符合的对象实例,并且该对象存在唯一一个引用,因此推算是字符串常量池中的引用。 image-20210713172119532.png

再来看通过new关键字创建的方式,单从字节码来看仅多了创建对象和执行初始化函数的指令,也同样生成了常量池字面量。只是堆中生成了具有相同内容的两个对象,第一个对象和上面介绍的一样是在字符串常量池存在一个引用,而第二个在堆中无引用,则表示是new创建出来的对象,因为 s 这个变量是存在于栈中。

Constant pool:
   #1 = Methodref          #6.#24         // java/lang/Object."<init>":()V
   #2 = Class              #25            // java/lang/String
   #3 = String             #26            // 你好
......
   #26 = Utf8              你好
......
Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class java/lang/String
         3: dup
         4: ldc           #3                  // String 你好
         6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
         9: astore_1
......
      LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      13     0  args   [Ljava/lang/String;
               10       3     1    s    Ljava/lang/String;

image-20210713173328460.png

无论使用哪种方式创建String对象,都会首先检查字符串常量池中是否存在相应字符串的引用,存在旧返回该引用,不存在就在堆上创建该一个字符串对象,然后在字符串常量池中创建引用并返回。直接赋值的方式就会直接拿这个引用使用,new创建的方式首先也会经过相同的步骤,但是在运行时会再创建一个新的对象。这也就引出了一个常见的面试题:String 创建了几个对象?下文再通过案例再详细说明。

String 两种创建方式的混合使用

字面量创建和new关键字创建两种方式最大的区别就是堆中生成对象的不同,那么将这两种方式混合一起使用又会产生什么现象呢?下面例子给出了所有的混合方式:

public static void main(String[] args) {
        String s1 = "字符串";
        String s2 = "字符串";
        String s3 = "字符" + "串";
        String s4 = "字符" + new String("串");
        String s5 = new String("字符串");
        String s6 = "字";
        String s7 = "符串";
        String s8 = s6 + s7;
        System.out.println(s1 == s2);
        System.out.println(s1 == s3);
        System.out.println(s1 == s4);
        System.out.println(s1 == s5);
        System.out.println(s1 == s8);
}

s1在创建的时候会在堆中生成字符串对象,再将引用放入字符串常量池,到了s2就能够直接从字符串常量池中找到该引用,所以s1和s2是同一个对象。经检验s1和s3也是相等的,查看字节码发现,编译器直接生成了“字符串”的字面量,这是因为编译器做了优化,字面量直接相加会被合并。再看s4也是相加的方式,但是字节码中却是通过StringBuilderappend()方法实现的,最后再调用toString()生成一个新的String对象,因此s4并不等于s1,s5也是明显的不相等。s8表面来看和s3差不多,但是实际并没有经过像s3那样的优化,和s4一样是通过StringBuilder实现的,因此和s1也不相等。

String 的 intern() 方法

intern()方法算是String比较特殊的一个方法了,几乎很少用到,它是一个native方法,并且和字符串常量池密切相关。方法文档中是这样描述的:如果字符串常量池中存在一个对象和当前字符串内容相同,就直接返回池中对象的引用,否则会将当前字符串添加到字符串常量池中,并返回引用。

该方法的文档已经解释的比较清楚,下面来看几个复杂的使用案例,分析一下其中需要注意的地方:

String s1 = new String("字符串");
s1.intern();
String s2 = "字符串";
System.out.println(s1 == s2); // false
=====================================================
String s3 = new String("字") + new String("符串");
s3.intern();
String s4 = "字符串";
System.out.println(s3 == s4); // true

这两段代码执行结果还是比较出乎意料的,首先第一段代码比较容易理解,s1相当于创建的一个新对象,s1.intern()将字面量放入字符串常量池中,实际上这里并没有产生任何操作,因为在代码第一行是,字符串常量池就已经存在该字符串对象的引用了;第二段代码的结果比较难理解,难道s3没有创建新的对象吗?根据上一小节里的内容可以分析出s3是这样被创建的:

// 以下为根据字节码编写的伪代码,不同于真实执行代码
StringBuilder builder = new StringBuilder();
builder.append(new String("字"));
builder.append(new String("符串"));
return builder.toString();
// StringBuilder源代码
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

看了上面两段代码恍然大悟,原来也是创建了一个新的String对象,只是没有放任何东西到字符串常量池。这样s3.intern()后就把s3对象的引用放入了池中,后面s4也将去池中拿到这个引用,这样当然是相通的了!!!

JVM 中的三个常量池

本文中我们提到了字符串常量池(String Pool),也有存在于字节码中的常量池(Constant Pool),另外还有一种属于JVM运行时数据区的运行时常量池(Runtime Constant Pool),这三种是JVM中最重要的常量池。

1、字符串常量池

相信到目前为止对字符串常量吃已经很熟悉了,字符串是JVM中用的最多的类型,所以为了避免频繁的创建销毁专门开辟了一块地方缓存字符串,此外String是一个不可变对象,全局共享的字符串常量池可以说是一个不错的选择。字符串常量池的逻辑位置根据版本优化不同,也从方法区移到了堆中。

2、字节码常量池

java中的每一个类编译后都会生成.class字节码文件,它仅仅是一种JVM能识别的二进制文件,里面有一块重要的内容就是常量池,主要记录了当前类中的字面量和符号引用,字面量比如文本字符串、声明为final的常量值,符号引用包括了类和接口的全限定名、字段名称和描述符、方法的名称和描述符。常量池中的每一个常量都是一个表,每一类的表结构、大小和表示的类型都有Java虚拟机规范严格要求。

3、运行时常量池

运行时常量池是属于方法区的一部分,字节码常量池中的内容在字节码被加载以后将存放到运行时常量池中,由此可见,每个类都占有一小块位置,解析过程原始的符号引用还会被解析成直接引用存放。

另外运行时常量池相较字节码常量池还具有动态性,运行时也能将新的常量放入池内,上文提到的intern()方法就是这样。

到底创建了几个对象?

String s1 = new String("字符串");

如果只考虑堆中的对象,这行代码创建的对象:1个或2个。首先编译期"字符串"会被创建对象并将引用放入字符串常量池,其次运行期new关键字也会创建一个新对象。然后就是说1个的情况,那就是使用的字符串已经存在于字符串常量池中,有些是上文代码使用过的字符串,也有些是JVM启动自动被加载的字符串,例如:java等等。

以上就是对String类的深入学习,和字符串常量池的学习认识。

参考资料

原文地址 --- String类及字符串常量池的学习