写点什么

JVM- 技术专题 - 方法区中常量池分析

发布于: 2021 年 04 月 14 日
JVM-技术专题-方法区中常量池分析

前言

常量池既不属于堆,也不属于栈内存 ,那么常量池可能就和方法区有所关系,为此阅读《深入浅出 JVM》一书,了解常量池和方法区的关联,同时对于常量池的分类也有了一定的认识。本文所有代码都是基于 JDK1.8 进行的。

正文

在探讨常量池的类型之前需要明白什么是常量。

  • final 修饰的成员变量表示常量,值一旦给定就无法改变!

  • final 修饰的变量有三种:静态变量、实例变量和局部变量,分别表示三种类型的常量。


在 Java 的内存分配中,总共 3 种常量池:

全局字符串池

string pool 也有叫做 string literal pool

字符串常量池位置分布

  • JDK6 及之前版本,字符串常量池是放在 Perm Gen 区(也是方法区)中,此时常量池中存储的是对象。

  • JDK7 版本,字符串常量池被移到了堆中了。此时常量池存储的就是引用了。

  • JDK8 版本,永久代(方法区)被元空间取代了,此时常量池存储仍引用,字符串对象仍然在堆中。

字符串常量池是什么

HotSpot VM 里实现的 string pool 功能的是一个 StringTable 类,它是一个 Hash 表,默认值大小长度是 1009;


里面存的是驻留字符串的引用(而不是驻留字符串实例自身)也就是说某些普通的字符串实例被这个 StringTable 引用之后就等同被赋予了“驻留字符串”的身份这个 StringTable 在每个 HotSpot VM 的实例里只有一份,被所有的类共享。


StringTable 本质就是 HashSet<String>。这是个纯运行时的结构,而且是惰性(lazy)维护的。


注意:它只存储对 java.lang.String 实例的引用,而不存储 String 对象的内容。 注意,它只存了引用,根据这个引用可以得到具体的 String 对象。


  • 在 JDK6 中,StringTable 的长度是固定的,长度就是 1009,因此如果放入 String Pool 中的 String 非常多,就会造成 hash 冲突,导致链表过长,当调用 String#intern() 时会需要到链表上一个一个找,从而导致性能大幅度下降;

  • 在 JDK7 中,StringTable 的长度可以通过参数指定: -XX:StringTableSize=66666


class 文件常量池


class constant pool

每个 class 文件都有一个常量池,class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。 字面量比较接近java语言层面常量的概念,如文本字符串、被声明为final的常量值等符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:


  • 类和接口的全限定名

  • 字段的名称和描述符

  • 方法的名称和描述符


常量池的每一项常量都是一个表,一共有如下表所示的 11 种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位(取值 1-12),代表当前这个常量属于哪种常量类型,符号引用也是从 class 中的 constant_pool 中构建的。

  • class 和 interface 的符号引用来自于 CONSTANT_Class_info。

  • class 和 interface 中字段的引用来自于 CONSTANT_Fieldref_info。

  • class 中方法的引用来自于 CONSTANT_Methodref_info。

  • interface 中方法的引用来自于 CONSTANT_InterfaceMethodref_info。

  • 对方法句柄的引用来自于 CONSTANT_MethodHandle_info。

  • 对方法类型的引用来自于 CONSTANT_MethodType_info。

  • 对动态计算常量的符号引用来自于 CONSTANT_MethodType_info。

  • 对动态计算的 call site 的引用来自于 CONSTANT_InvokeDynamic_info。

下面盗图一张给大家看看

不同类型的常量类型具有不同的结构,本文着重区分这三个常量池的概念。但是只有 class 文件中的常量池肯定是不够的,因为我们需要在 JVM 中运行起来。这时候就需要一个运行时常量池,为 JVM 的运行服务。运行时常量池和 class 文件的常量池是一一对应的,它就是 class 文件的常量池来构建的。

运行时常量池

运行时常量池中有两种类型,分别是 symbolic references 符号引用和 static constants 静态常量。

其中静态常量不需要后续解析,而符号引用需要进一步进行解析处理。什么是静态常量,什么是符号引用呢? 我们举个直观的例子。

String site="www.flydean.com"
复制代码

上面的字符串”www.flydean.com”可以看做是一个静态常量,因为它是不会变化的,是什么样的就展示什么样的。而上面的字符串的名字“site”就是符号引用,需要在运行期间进行解析,为什么呢?因为 site 的值是可以变化的,我们不能在第一时间确定其真正的值,需要在动态运行中进行解析。

静态常量详解

  运行时常量池中的静态常量是从class文件中的constant_pool构建的。可以分为两部分:String常量和数字常量。
复制代码

String 常量

String 常量是对 String 对象的引用,是从 class 中的 CONSTANT_String_info 结构体构建的:

CONSTANT_String_info {    u1 tag;    u2 string_index;}
复制代码

tag 就是结构体的标记,string_index 是 string 在 class 常量池的 index,string_index 对应的 class 常量

池的内容是一个 CONSTANT_Utf8_info 结构体。

CONSTANT_Utf8_info {    u1 tag;    u2 length;    u1 bytes[length];}
复制代码

CONSTANT_Utf8_info 是啥呢?它就是要创建的 String 对象的变种 UTF-8 编码。我们知道 unicode 的范围是从 0x0000 至 0x10FFFF。变种 UTF-8 就是将 unicode 进行编码的方式。那是怎么编码呢?

从上图可以看到,不同的 unicode 范围使用的是不同的编码方式。

注意,如果一个字符占用多个字节,那么在 class 文件中使用的是 big-endian 大端优先的排列方式。

如果字符范围在 FFFF 之后,那么使用的是 2 个 3 字节的格式的组合。

讲完 class 文件中 CONSTANT_String_info 的结构之后,我们再来看看从 CONSTANT_String_info 创建运行时 String 常量的规则:

  1. 规则一:如果 String.intern 之前被调用过,并且返回的结果和 CONSTANT_String_info 中保存的编码是一致的话,表示他们指向的是同一个 String 的实例。

  2. 规则二:如果不同的话,那么会创建一个新的 String 实例,并将运行时 String 常量指向该 String 的实例。最后会在这个 String 实例上调用 String 的 intern 方法。调用 intern 方法主要是将这个 String 实例加入字符串常量池。

数字常量

数字常量是从 class 文件中的 CONSTANT_Integer_info, CONSTANT_Float_info, CONSTANT_Long_info 和 CONSTANT_Double_info 构建的。

String Pool 字符串常量池

我们在讲到运行时常量池的时候,有提到 String 常量是对 String 对象的引用。那么这些创建的 String 对象是放在什么地方呢?

没错,就是 String Pool 字符串常量池。

这个 String Pool 在每个 JVM 中都只会维护一份。是所有的类共享的。

String Pool 是在 1.6 之前是存放在方法区的。在 1.8 之后被放到了 java heap 中。

注意,String Pool 中存放的是字符串的实例,也就是用双引号引起来的字符串。

那么问题来了?

String name = new String("www.flydean.com");
复制代码

到底创建了多少个对象呢?


运行时常量池(runtime constant pool)是方法区的一部分。

Java 文件被编译成 class 文件之后,也就是会生成上面所说的 class 常量池,那么运行时常量池又是什么时候产生的呢?

  • JVM 在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析(resolve)三个阶段。而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。

  • class 常量池中存的是字面量和符号引用也就是说它们存的并不是对象的实例,而是对象的符号引用值经过 resolve 之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是上面所说的 StringTable,保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

  • 三种常量池之间的关联关于 JVM 执行的时候,还涉及到了字符串常量池。


类加载阶段, JVM 会在堆中创建对应这些 class 文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用。具体在 resolve 阶段执行。这些常量全局共享,没错,是 resolve 阶段,但是并不是大家想的那样,立即就创建对象并且在字符串常量池中驻留了引用。JVM 规范里明确指定 resolve 阶段可以是 lazy 的。


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

前者是 UTF-8 编码的字符串类型,后者是 String 常量的类型,但它并不直接持有 String 常量的内容,而是只持有一个 index,这个 index 所指定的另一个常量池项必须是一个 CONSTANT_Utf8 类型的常量,这里才真正持有字符串的内容。


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

  2. CONSTANT_String -> java.lang.String(一个实际的 Java 对象的引用,C++类型是 oop)


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


3.关于 lazy resolution 需要在这里了解一下 ldc 指令,它用于将 String 型常量值从常量池中推送至栈顶。

以下面代码为例:

public static void main(String[] args) {    String s = "abc";}
复制代码

比如说该代码文件为 Test.java,首先在文件目录下打开 Dos 窗口,执行 javac Test.java 进行编译,然后输入 javap -verbose Test 看其编译后的 class 文件如下:

使用 ldc 指令将"abc"加载到操作数栈顶,然后用 astore_1 把它赋值给我们定义的局部变量 s,然后 return。


结合上文所讲,在 resolve 阶段( constant pool resolution ),字符串字面量被创建对象并在字符串常量池中驻留其引用,但是这个 resolve 是 lazy 的。换句话说并没有真正的对象,字符串常量池里自然也没有,那么 ldc 指令还怎么把值推送至栈顶并进行了赋值操作?或者换一个角度想,既然 resolve 阶段是 lazy 的,那总有一个时候它要真正的执行吧,是什么时候?


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


ldc 字节码在这里的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM 里是 ConstantPool + ConstantPoolCache)去查找该 index 对应的项,如果该项尚未 resolve 则 resolve 之,并返回 resolve 后的内容。


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


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

用以下代码做分析展示:

 public static void main(String[] args) {        String s1 = "abc";          String s2 = "abc";        String s3 = "xxx";    }
复制代码

查看其编译后的 class 文件如下:


用图解的方式展示:


  1. String s1 = "abc";resolve 过程在字符串常量池中发现没有”abc“的引用,便在堆中新建一个”abc“的对象,并将该对象的引用存入到字符串常量池中,然后把这个引用返回给 s1。

  2. String s2 = "abc"; resolve 过程会发现 StringTable 中已经有了”abc“对象的引用,则直接返回该引用给 s2,并不会创建任何对象。

  3. String s3 = "xxx"; 同第一行代码一样,在堆中创建对象,并将该对象的引用存入到 StringTable,最后返回引用给 s3。


常量池与 intern 方法

 public static void main(String[] args) {        String s1 = "ab";//#1        String s2 = new String(s1+"d");//#2        s2.intern();//#3        String s4 = "xxx";//#4        String s3 = "abd";//#5        System.out.println(s2 == s3);//true    }
复制代码

查看其编译后的 class 文件如下:

通过 class 文件信息可知,“ab”、“d”、“xxx”,“abd”进入到了 class 文件常量池,由于类在 resolve 阶段是 lazy 的,所以是不会创建实例对象,更不会驻留字符串常量池。

图解如下:

进入 main 方法,对每行代码进行解读。

  • 1,ldc 指令会把“ab”加载到栈顶,换句话说,在堆中创建“ab”对象,并把该对象的引用保存到字符串常量池中。

  • 2,ldc 指令会把“d”加载到栈顶,然后有个拼接操作,内部是创建了一个 StringBuilder 对象,一路 append,最后调用 StringBuilder 对象的 toString 方法得到一个 String 对象(内容是 abd,注意 toString 方法会 new 一个 String 对象),并把它赋值给 s2(赋值给 s2 的依然是对象的引用而已)。

  • 注意此时没有把“abd”对象的引用放入字符串常量池。

  • 3,intern 方法首先会去字符串常量池中查找是否有“abd”对象的引用,如果没有,则把堆中“abd”对象的引用保存到字符串常量池中,并返回该引用,但是我们并没有使用变量去接收它。

  • 4,无意义,只是为了说明 class 文件中的“abd”字面量是 #5 时得到的。

  • 5,字符串常量池中已经有“abd”对象的引用,因此直接将该引用返回给 s3。

总结

1、全局字符串常量池在每个 VM 中只有一份,存放的是字符串常量的引用值

2、class 常量池是在编译的时候每个 class 都有的,在编译阶段,存放各种字面量和符号引用包含:字符串常量,类和接口名字,字段名,和其他一些在 class 中引用的常量。每个 class 都有一份。

3、运行时常量池是在类加载完成之后,将每个 class 常量池中的符号引用值转存到运行时常量池中,也就是说,每个 class 都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

4、class 文件常量池中的字符串字面量在类加载时进入到运行时常量池在真正在 resolve 阶段(即执行 ldc 指令时)时将该字符串的引用存入到字符串常量池中,另外运行时常量池相对于 class 文件常量池具备动态性,有些常量不一定在编译期产生,也就是并非预置入 class 文件常量池的内容才能进入到方法区运行时常量池,运行期间通过 intern 方法,将字符串常量存入到字符串常量池中和运行时常量池

5、运行时常量池保存的是从 class 文件常量池构建的静态常量引用和符号引用。每个 class 都有一份。

6、字符串常量池保存的是“字符”的实例直接引用,供运行时常量池使用。

7、运行时常量池是和 class 或者 interface 一对应的,那么如果一个 class 生成了两个实例对象,这两个实例对象是共享一个运行时常量池。(当然属于不同的类加载器,常量池的数据可能会产生多份)


用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论

发布
暂无评论
JVM-技术专题-方法区中常量池分析