深入剖析JVM常量池
类常量池、运行时常量池和字符串常量池这三种常量池,在Java中扮演着不同但又相互关联的角色。理解它们之间的关系,有助于深入理解Java虚拟机(JVM)的内部工作机制,尤其是在类加载、内存分配和字符串处理方面。
1 类常量池(Class Constant Pool)
每个Java类文件(.class文件)都具有自己的类常量池,它用于存储编译期生成的常量,包括各种字面量(字面量就是指由字母、数字等构成的字符串或者数值)和符号引用。类常量池在编译期间就已经被确定,并被保存在.class文件中。
《深入理解 Java 虚拟机》7.34 节第三版对符号引用和直接引用的解释如下:
Class常量池是用来保存常量的一个中间场所。在JVM真的运行时,需要把类常量池中的常量加载到内存中的运行时常量池中。
2 运行时常量池(Runtime Constant Pool)
运行时常量池是类被加载到JVM时类常量池的内存版本:当Java类被加载到JVM时,各个类文件中的类常量池内容被读取并存入到运行时常量池中,其中字符串的部分直接进到字符串池,其他常量进入到运行时常量池。
根据Java虚拟机规范约定:每一个运行时常量池都在Java虚拟机的方法区中分配,在加载类和接口到虚拟机后,就创建对应的运行时常量池。
规范中规定了运行时常量池属于方法区,但是没规定方法区属于哪。于是虚拟机在各自实现的时候就各显神通了。在不同版本的JDK中,运行时常量池所处的位置也不一样。以HotSpot虚拟机为例:
在JDK 1.7之前,方法区位于永久代,运行时常量池作为方法区的一部分,处于永久代中,字符串常量池位于运行时常量池的一部分也处于永久代中。
因为使用永久代实现方法区可能导致内存泄露问题,所以,从JDK1.7开始,JVM尝试解决这一问题。
在JDK 1.7中,静态变量和运行时常量池中的字符串常量池转移到了堆内存中,其他类型的常量还保留在方法区中。
在JDK 1.8中,彻底移除了永久代,方法区通过元空间的方式实现,元空间是使用本地内存(Native Memory)来存储类的元数据信息的。随之,运行时常量池也在元空间中实现。
运行时常量池中包含了若干种不同的常量,他的来源主要有两种:
-
编译期可知的字面量和符号引用(来自Class常量池)
-
JDK1.7之前,使用intern方法将字符串对象添加到字符串常量池中,即添加到运行时常量池中
3 字符串常量池(String Constant Pool)
字符串常量池是JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建,用于存储所有字面量形式创建的字符串。
3.1 字符串常量池的位置
- 在jdk1.6中运行时常量池位于永久代中,字符串常量池作为运行时常量池的一部分也位于永久代中,由字符串字面量生成的对象存储在字符串常量池中也就是存储在运行时常量池中。
- 从jdk1.7开始,字符串常量池挪到了堆内存中,字符串常量池不再是运行时常量池的一部分了。运行时常量池仅以特殊形式存储着字符串。
为什么从JDK 1.7开始,字符串常量池从永久代中挪到堆(Heap)中?
主要原因是因为永久代的 GC 回收效率太低,只有在FulIGC的时候才会被执行回收。但是Java中往往会有很多字符串的生命周期都很短暂,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
3.2 字符串常量池的常量来源
1、字面量常量
在代码中直接使用双引号括起来的字符串字面值(如 strings="hello”)会被认为是常量,并且会在编译后进入class文件的常量池,并且在运行阶段,进入字符串常量池。这是最常见的字符串常量来源。
程序中的字符串字面量是如何加载到字符串常量池中的?
对于 Hotspot 虚拟机来说,类加载时,字符串字面量作为类常量池的一部分信息被载入运行时常量池中,它们以特殊的形式存储在运行时常量池中,此时它们并未被实例化为Java堆中的String对象。只有当这个字符串字面量被调用时,才会对其进行解析,即检查字符串常量池中是否已经存在相同内容的字符串对象。如果存在,就直接返回指向该对象的引用,如果不存在,虚拟机会在字符串常量池中创建一个对应的String实例,并返回这个新实例的引用。
此方法延迟了String对象的实例化,直到它们真正被需要时才进行实例化,这有助于提高性能并减少内存的无谓占用。
2、intern()方法
String类提供了一个intern方法,用于将字符串对象手动添加到字符串常量池中。关于intern函数可以学习这篇文章,讲解非常通透:深入解析String#intern
jdk7版本在修改了常量池的基础上,也对intern函数做了修改:
在jdk7之前,字符串常量池位于永久代中,使用intern函数,如果字符串常量池中没有该字符串对象,会在字符串常量池中创建一个该对象,但是在jdk7及之后,字符串常量池移到了堆中,使用intern函数,如果字符串常量池中没有该字符串对象,则不会在字符串常量池中创建对象,而是保存堆中该对象的引用。但无论是JDK7之前还是JDK7之后调用intern()方法时,如果字符串常量池中已经存在相同内容的字符串,将会返回常量池中的引用; 比如:
String s = new String("hello") + new String("world");
s.intern();
在第一句代码中,我们创建了一个引用变量s,其指向堆中字符串对象"helloworld",而后调用intern函数,此时字符串常量池中没有"helloworld"字符串对象,如果是在jdk7之前,jvm会在位于永久代的字符串常量池中创建一个"helloworld"字符串对象,但是jdk7之后,字符串常量池位于堆中,不再需要重新创建字符对象,而是在字符串常量池中保存堆中"helloworld"对象的引用。
3.3 字符串常量池中存储的到底是字符串对象还是堆中的引用
HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
关于字符串常量池中保存的到底是字符串对象还是字符串对象的引用,网上争论很多,个人认为是都保存了,要不然,我们通过intern方法添加字符串对象的时候,对于字符串对象已经存在于常量池的情况,怎么返回对于字符串引用呢?具体分析如下:
字面量,在被调用的时候,如果字符串常量池中没有该字符串对象,那么就会在字符串常量池中创建一个该字符串对象,此时,hashtable中的key就是字符串对象,value为该字符串对象的引用。如果是使用intern函数,从JDK7开始,如果字符串常量池中没有该字符串对象,那么就会在字符串常量池中保存堆中该对象的引用,此时,key为一个堆中的字符串对象,value为堆中的引用。
注:字符串常量池是一个固定大小的Hashtable,默认值大小长度是1009,如果放进字符串常量池的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降(因为要一个一个找,以此判断字符串常量在不在字符串常量池中)。在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。在jdk7中,StringTable的长度可以通过一个参数指定:
-XX:StringTableSize=99991
3.4 字符串常见的创建方式
1、使用字符串字面量直接赋值:
String s = "hello";
这种方式创建的字符串对象,只会在常量池中 。
因为存在"hello"这个字面量,创建对象s的时候,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象,若有,直接返回该对象在常量池中的引用,若没有,则会在常量池中创建一个新对象,再返回引用。
2、使用new String
String s = new String("hello");
这种方式会保证字符串常量池和堆中都有这个对象。
步骤大致如下:
因为有"hello"这个字面量,所以会先检查字符串常量池中是否存在字符串"hello",不存在,先在字符串常量池里创建一个字符串对象;再去内存中创建一个字符串对象"hello";存在的话,就直接去堆内存中创建一个字符串对象"hello";最后,将内存中的引用返回。
3、使用intern
String s1 = new String("hello");
String s2 = s1.intern();
该段代码创建1或两个对象
由于存在字符串"hello",首先会判断字符串常量池中是否存在"hello",如果存在则直接在堆内存中创建对象s1,如果不存在,则先在字符串常量池中创建"hello",再在堆内存中创建对象s1。String中的intern方法是一个 native 的方法,当调用 intern方法时,如果池已经包含一个等于此String对象的字符串(用equals(oject)方法确定),则返回池中的字符串引用, 否则,对于jdk7之前将会在字符串常量池中创建该对象,对于jdk7之后将会在字符串常量池中保存堆中s1对象的引用 。由于字符串常量池已经存在"hello",因此直接返回字符串常量池中的引用。
4、字符串拼接:
对于字符串的拼接,纯字面量和字面量的拼接,编译器在编译阶段是知道其值的,因此会将拼接后的字面量存入类常量池中,在将class文件加载到JVM中时会将其加载到字符串常量池中。如果在字符串拼接中,有一个参数是非字面量,而是一个变量的话,整个拼接操作会被编译成StringBuilder.append,这种情况编译器是无法知道其确定值的,因此所对应的字符串是无法加载入字符串常量池中的。
比如这段代码:
string s1 = "Hollis";
string s2 = "Chuang";
string s3 = s1 + s2;
string s4 = "Hollis" + "Chuang";
在经过反编译后得到代码如下:
String s1 = "Hollis";
string s2 = "chuang" ;
string s3 = (new stringBuilder() ).append(s1).append(s2).toString( );
String s4 = "Hollischuang";
参考文章:JVM-深入剖析字符串常量池