likes
comments
collection
share

String、常量池、intern方法

作者站长头像
站长
· 阅读数 13

「本文针对HotSpot VM进行讨论,版本是jdk1.8」

常量池

首先,什么是常量?对这个概念进行解析一下 <注意,定义常量要用大写😶 如Integer.MAX_VALUE>

常量指 程序中固定不变的值,是不会改变的数据,在Java中,比较常见的是用 final 修饰的成员变量表示常量,值一旦给定就无法改变。

final可以修饰的变量有三种:静态变量、实例变量和局部变量

而在Java的内存分配中,有三种常量池:

  • Class文件常量池

  • 运行时常量池

  • 字符串常量池

Class文件常量池

引入

java程序会通过javac进行编译成.class字节码文件,再送到解释器编译成机器码,进行代码执行。其中,class文件中,除了包含类的版本、字段、方法、接口等描述信息,还包括一项: 常量池(constant pool table),用来存放javac编译器生成的两大类常量:

  1. 字面量: 文本字符串**<用双引号引起来的字符串字面量>、被final声明的常量值**
  2. 符号引用,包含三类常量:
    • 类和接口的全限定名 如,java.lang.String 可以使用类的全限定名来引用该类或接口
    • 字段的名称和描述符
    • 方法的名称和描述符

运行时常量池

运行时常量池是方法区的一部分,当Java文件被编译成class文件之后,也就会生成class常量池。接下的步骤请继续看:

Class 文件需要加载到虚拟机中之后才能运行和使用,JVM在加载该类的时候,会经过加载-连接-初始化,其中,连接又分为验证-准备-解析三个阶段。

当加载类到内存中(方法区)后(生成一个Class对象作为类元数据<如类的方法名、变量名等>的访问接口,注意:Class对象还是生成在堆上),JVM就会把class文件常量池的内容存放到运行时常量池中。class常量池中存的是字面量和符号应用,也就是说存放的并不是对象的实例,而是对象的符号引用值。在类加载过程中,经过resolve解析后,会将类的符号引用(例如类名、字段名、方法名)解析为直接引用(具体的内存地址或偏移量)

引申到JVM 使用OOP-Klass模型来表示Java对象,具体请移至对象创建过程查看

简单来说,JVM加载一个类的时候创建一个instanceKlass来表示这个类的元数据,元数据存放在方法区;在new 一个对象时,JVM创建instanceOopDesc,用来表示这个对象,存放在堆区,其引用,在栈区。

平时说的对象实例,指的就是instanceOopDesc,HotSpot并没有把instanceKlass直接暴露给Java,而会另外创建对应的instanceOopDesc来表示java.lang.Class对象,instanceKlass持有指向instanceOopDesc的引用

在解析过程中,如果遇到字符串常量,需要查询全局字符串池(StringTable)来确保运行时常量池所引用中的字符串与全局字符串池中的字符串引用是一致的。通过查询全局字符串池,可以避免重复创建相同内容的字符串,节省内存空间,并确保运行时常量池中的字符串引用的一致性。

字符串常量池 ------ 只存储对String对象的引用,通过引用可以得到具体的String对象

一个定论: 使用双引号声明出来的字符串在常量池中生成,如hello1;new 出来的String 对象则是在堆上生成对象hello1 + 一个常量池引用在堆上的对象hello2

String Pool里面存的是java.lang.String实例的引用,而不存储String对象的内容。根据存储的引用,可以得到具体的String对象。底层实现string pool功能的全局表叫StringTable,本质是一个HashSet<String> ,是个纯运行时的结构,而且是惰性(lazy)维护的

不同版本下的表现

public static void main(String\[\] args) {
String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);

    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);

}

打印结果是

  • jdk6 下false false

  • jdk7 下false true

解析请前往查看 tech.meituan.com/2014/03/06/…

  • jdk6及之前,存放在方法区(永久代)中,此时存储的是对象。此时,StringTable的长度是固定的,长度为1009,如果存入的String非常多,会造成hash冲突,导致链表过长,导致性能下降

  • jdk7及之后,字符串串常量池移入堆,此时存储的是String对象的引用。StringTable的长度可以通过参数指定-XX:StringTableSize=66666

在1.7之后,一般我们说一个字符串进入全局的字符串常量池,是在说该StringTable中保存该字符串对象的引用

为什么1.7字符串常量池要移入堆?

从垃圾回收的角度回答,方法区的GC效率太低,只有在Full GC才会执行,而字符串创建和回收频率在实际应用中是非常频繁的,所以将其移入堆中,能够更加高效的进行回收字符串内存。

为什么要设计字符串常量池?

可以从资源高效利用的角度回答。事实上,常量池的设计可以理解成是缓存,包括其他包装类型的常量池(除了Double、Float和Boolean,默认范围是-128~127),避免同一对象的重复创建和销毁,用空间换时间。

三种常量池之间的关系

  1. 类加载完后,JVM会将class文件常量池里的类元数据、常量池信息存放到运行时常量池
  2. 在类加载过程的解析阶段中,class文件常量池中的字面量会进入字符串常量池中,这些常量全局共享,同时JVM 规范里明确指定 resolve 阶段可以是懒解析的,需要用到才会解析(lazy-resolve)。

JVM规范里Class文件的常量池项的类型,有两种东西:CONSTANT_Utf8 和CONSTANT_String

后者是String常量的类型,但它并不直接持有String常量的内容,而是只持有一个index,这个index必须是指向CONSTANT_Utf8类型的常量,CONSTANT_String指向的另一个CONSTANT_Utf8常量才真正持有字符串的内容。

第一个CONSTANT_Utf8 会在类加载的过程中就全部创建出来,而 CONSTANT_String 则是 lazy resolve 的,例如说在第一次引用该项的 ldc 指令被第一次执行到的时候才会 resolve。那么在尚未 resolve 的时候,HotSpot VM 把它的类型叫做JVM_CONSTANT_UnresolvedString,内容跟 Class 文件里一样只是一个 index;等到 resolve 过后这个项的常量类型就会变成最终的 JVM_CONSTANT_String,而指向的内容**<另一个CONSTANT_Utf8常量>**则变成实际的那个字符串对象。

就 HotSpot VM 的实现来说,加载类的时候,那些字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池(即在 StringTable 中并没有相应的引用,在堆中也没有对应的对象产生)。所以上面提到的,经过 resolve 时,会去查询全局字符串池,最后把符号引用替换为直接引用。(即字面量和符号引用虽然在类加载的时候就存入到运行时常量池,但是对于 lazy resolve 的字面量,具体操作还是会在 resolve 之后进行的。)

简单地说,ldc指令用于将 String 型常量值从常量池中推送至栈顶

执行 ldc 指令就是触发 lazy resolution 动作的条件

ldc字节码在这里的执行语义是:到当前类的运行时常量池去查找该index对应的项,没resolve过,则进行resolve,并返回resolve的内容

在遇到String类型常量时,resolve的过程如果发现StringTable已经有了内容匹配的java.lang.String的引用,则直接返回这个引用,反之,如果StringTable里尚未有内容匹配的String实例的引用,则会在Java堆里创建一个对应内容的String对象,然后在StringTable记录下这个引用,并返回这个引用出去。

可见,ldc指令是否需要创建新的String实例,全看在第一次执行这一条ldc指令时,StringTable是否已经记录了一个对应内容的String的引用。

说人话就是

用双引号是直接在字符串常量池中创建对象引用,使用new创建对象会先在字符串常量池查看有没有引用,没有的话在堆上创建对象1,并在字符串常量池中创建该对象的引用<对象2>;如果存在引用的话,则直接返回该引用<对象2>

String 与 intern方法

上面有提到三种常量池以及三者之间的联系,就有提到intern方法,可以将字符串对象的引用加入到字符串常量池中

String、常量池、intern方法

讲一下运行过程:

  1. ==String s1 = new String("abc")==

运行时创建了两个对象,一个是在堆中的"abc"对象1,一个是在堆中创建的"abc"对象2,并在常量池中保存"abc"对象的引用地址

  1. ==String s2 = s1.intern()==

在常量池中寻找与 s1 变量内容相同的对象引用,发现已经存在内容相同对象"abc"的引用,返回该对象引用地址,赋值给 s2。

  1. ==String s3 = "abc"==

首先在常量池中寻找是否有相同内容的对象引用,发现有,返回对象"abc"的引用地址,赋值给 s3。

  1. ==String s4 = new String("3") + new String("3")==

运行时创建了四个对象,一个是在堆中的"33"对象1,一个是在堆中创建的"3"对象1,并在常量池中保存"3"对象2的引用地址。中间还有2个匿名的 new String("3") ,+号的内部是创建了一个StringBuilder对象,一路append,最后调用StringBuilder对象的toString方法得到一个String对象(内容是hello,注意这个toString方法会new一个String对象,标记为hello1),并把它赋值给s1。注意啊,此时没有把hello的引用放入字符串常量池

  1. ==String s5 = s4.intern()==

在常量池中寻找与 "33"对象内容相同的对象引用,没有发现"33"对象引用,将 s4 对应的"33"对象的地址保存到常量池中,并返回给 s5。

  1. ==String s6 = "33"==

首先在常量池中寻找是否有相同内容的对象引用,发现有,返回对象"33"的引用地址,赋值给 s6。

解析String s1 = new String("abc")

关于对象的创建,用图解的形式展示:

String、常量池、intern方法 从图中我们可以发现对象创建的步骤如下:

  • 执行 new 指令

  • 检查这个指令参数是否能够在class文件常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载,解析和初始化。

  • 如果该类没有被加载则先执行类的加载操作

  • 如果该类已经被加载,则开始给该对象在 jvm 的堆中分配内存。

  • 虚拟机初始化操作,虚拟机对分配的空间初始化为零值。

  • 执行 init 方法,初始化对象的属性,至此对象被创建完成。

  • Java 虚拟机栈中的 Reference 执行我们刚刚创建的对象。

对于类加载的过程中,具体如下:

代码编译后,就会生成 JVM(Java虚拟机)能够识别的二进制字节流文件(*.class)。而 JVM 把 Class 文件中的类描述数据从文件加载到内存,并对数据进行校验、转换解析、初始化,使这些数据最终成为可以被 JVM 直接使用的 Java 类型,这个说来简单但实际复杂的过程叫做 JVM的类加载。

在类加载完成后,字符串字面量会进入到字符串常量池。

那么就引出下面的问题,String s=new String("abc") 涉及到几个对象的创建?

上述代码运行即分为两个阶段:类加载阶段和代码片段自身执行的时候,所以当提问为"String s=new String("xyz") 在运行时涉及到几个对象"时,合理的答案是:

两个,一个是字符串字面量"xyz"2在堆中创建的对象,并将其引用驻留(intern)在全局共享的字符串常量池中,另一个是通过newString(String)在堆中创建并初始化的、内容与"xyz"相同的对象1

"String s=new String("xyz") 在类加载时涉及到几个对象",该问题合理的答案就是一个。

如果问题改为"String s=new String("java") 在运行时涉及到几个对象",答案就不再是两个了,正确答案只有一个,因为java标准库在JVM启动过程加载的类中,可能有引用"java"字符串字面量,这个字面量在第一次运行的时候就被加载到常量池中。

参考:

  1. www.zhihu.com/question/55…

  2. tech.meituan.com/2014/03/06/…

  3. juejin.cn/post/684490…

  4. juejin.cn/post/684490…

  5. blog.csdn.net/weixin_4425…

  6. javaguide.cn/java/jvm/me…