Java 中方法区与常量池

hresh 446 0

Java 中方法区与常量池

前言

Java 的 JVM 的内存可分为 3 个区:堆内存(heap)、栈内存(stack)和方法区(method)也叫静态存储区。

在学习的过程中经常还会听到常量池这一术语,在上节关于数据做 = = 比较时,提到了字符串常量池,经查询得知常量池既不属于堆,也不属于栈内存 ,那么常量池可能就和方法区有所关系,为此阅读《深入浅出JVM》一书,了解常量池和方法区的关联,同时对于常量池的分类也有了一定的认识。

本文所有代码都是基于 JDK1.8 进行的。

正文

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

  • 用 final 修饰的成员变量表示常量,值一旦给定就无法改变!
  • final 修饰的变量有三种:静态变量、实例变量和局部变量,分别表示三种类型的常量。

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

全局字符串池(string pool也有叫做string literal pool)

字符串常量池在 Java 内存区域的哪个位置

  • 在 JDK6.0 及之前版本,字符串常量池是放在 Perm Gen 区(也就是方法区)中,此时常量池中存储的是对象。
  • 在 JDK7.0 版本,字符串常量池被移到了堆中了。此时常量池存储的就是引用了。在 JDK8.0 中,永久代(方法区)被元空间取代了。

字符串常量池是什么?

在 HotSpot VM 里实现的 string pool 功能的是一个 StringTable 类,它是一个 Hash 表,默认值大小长度是1009;里面存的是驻留字符串的引用(而不是驻留字符串实例自身)。也就是说某些普通的字符串实例被这个 StringTable 引用之后就等同被赋予了“驻留字符串”的身份。这个 StringTable 在每个 HotSpot VM 的实例里只有一份,被所有的类共享。

StringTable 本质上就是个 HashSet<String>。这是个纯运行时的结构,而且是惰性(lazy)维护的。注意它只存储对java.lang.String 实例的引用,而不存储 String 对象的内容。 注意,它只存了引用,根据这个引用可以得到具体的 String 对象。

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

在 JDK7.0 中,StringTable 的长度可以通过参数指定:

-XX:StringTableSize=66666

class 文件常量池(class constant pool)

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

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

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

常量池表

每种不同类型的常量类型具有不同的结构,具体的结构本文就先不叙述了,本文着重区分这三个常量池的概念(读者若想深入了解每种常量类型的数据结构可以查看《深入理解java虚拟机》第六章的内容 )。

运行时常量池(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 类型的常量,这里才真正持有字符串的内容。

在HotSpot VM中,运行时常量池里,

CONSTANT_Utf8 -> Symbol*(一个指针,指向一个Symbol类型的C++对象,内容是跟Class文件同样格式的UTF-8编码的字符串)
CONSTANT_String -> java.lang.String(一个实际的Java对象的引用,C++类型是oop)

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

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

关于 lazy resolution 需要在这里了解一下 ldc 指令

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

以下面代码为例:

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

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

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 文件如下:

class编译结果

用图解的方式展示:

字符串常量池

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

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

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编译结果

通过 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 都有的,在编译阶段,存放各种字面量和符号引用。

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

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

参考链接

发表评论 取消回复
表情 图片 链接 代码

分享