本文记录的内容以JDK8为基础,参考:
1. 常量池在内存中的分布
在JDK8之前,方法区实现方式还是永久代,布局和下图有区别。在JDK8之后,方法区使用元空间实现,存储在本地内存中。JDK8中各常量池布局如下图所示:
![hide](https://img-blog.csdnimg.cn/img_convert/c6dcb1c1f610b9fded8e0fef4a8f8977.png)
2. Class常量池
Class常量池在编译期由编译器生成,用于存放编译器生成的各种字面量和符号引用。Class常量池的结构可以参考《深入理解Java虚拟机》6.3.2小节。
3. 运行时常量池
简单来说,class常量池以及其他类相关的数据存在于class文件中,但是JVM运行时肯定不能直接从class文件拿数据。因此需要将class常量池和相关信息中导入到运行时常量池,供JVM使用。同时class常量池中含有符号引用,导入到运行时常量池中时,需要将符号引用转化为直接引用。
以下为从网上找到的我认为比较好的描述:
运行时常量池就是将编译后的类信息放入方法区中,也就是说运行时常量池是方法区的一部分。
运行时常量池用来动态获取类信息,包括:class文件元信息描述、编译后的代码数据、引用类型数据、类文件常量池等。
运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中。每个class文件都有一个运行时常量池,类在解析之后将符号引用替换成直接引用,与全局常量中的引用值保持一致。
运行时常量池相对于class文件常量池的另外一个特性是具备动态性,java语言并不要求常量一定只有编译器才产生,也就是并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。
4. Class常量池到运行时常量池的转化过程
Class常量池由编译器生成,最开始是存储于Class文件中的,而代码在运行时,如果想要使用其中的变量,需要在运行时常量池中查找,所以JVM需要将Class常量池中的内存经过转化导入到运行时常量池中。
Java类加载过程(参考《深入理解Java虚拟机》 7.3小节)如下图所示:
在加载(仅指上图的Loading阶段,而上图整个过程称为类加载过程,有所区别)过程中,JVM需要完成如下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
其中第二点任务就包括将Class常量池转化为运行时常量池中的数据结构。
5. 字符串常量池
字符串常量池的结构:字符串常量池中,存放的其实是一堆String对象的引用,可以将字符串常量池理解为一个Map结构,key为字符串字面量,value为对应String对象的引用。
5.1 字面量赋值创建String对象
以下面这个简单的例子来说明使用字面量赋值方法创建一个String对象的大致流程:
class Main{
String s = "strvalue";
}
当JVM加载Main类的时候,字符串“strvalue”就已经在类加载的Loading阶段进入运行时常量池;
然后主线程开始运行,第一次执行到这条语句时,JVM会根据运行时常量池的这个字面量去字符串常量池寻找其中是否有该字面量对应的String对象的引用。
如果没有,就会去Java堆中创建一个值为“strvalue”的String对象,并将该对象的引用保存到字符串常量池,然后返回该引用;如果找到了说明之前已经有其他语句通过相同的字面量复制创建了该String对象,直接返回引用即可。
思考一下如下代码的输出结果为什么是这样?
public class TestString {
public static void main(String[] args) {
String s1 = "strvalue";
String s2 = "strvalue";
System.out.println(s1==s2); // 输出true,同一个引用
String s3 = new String("strvalue");
String s4 = new String("strvalue");
System.out.println(s3==s4);// 输出false,不用引用
System.out.println(s3.intern()==s1); // 输出true,同一个引用
}
}
5.2 字符串常量池
字符串常量池,是JVM用来维护字符串实例的一个引用表。在HotSpot虚拟机中,它被实现为一个全局的StringTable
,底层是一个C++的hashtable,它将字符串的字面量作为key,实际堆中创建的String对象的引用作为value。
字符串常量池在逻辑上属于方法区,但JDK1.7开始,就被挪到了堆区。
String的字面量被导入JVM的运行时常量池时,并不会马上试图在字符串常量池加入对应String的引用,而是等到程序实际运行时,要用到这个字面量对应的String对象时,才会去字符串常量池试图获取或者加入String对象的引用。
5.3 new String()与String.intern()
new String()
:创建一个新的对象,返回新对象的引用。
String.intern()
:如果字符串常量池中已经存在相同字面量的字符串,则返回字符串常量池中的引用;否则,将该对象的引用加入到字符串常量池中,并返回该引用。因此,只要字面量相同,该方法返回的一定是同一个String实例的引用。
是什么意思呢?看如下例子:
public class TestString {
public static void main(String[] args) {
String ss = "ss";
String ss1 = "ss";
System.out.println(ss==ss1); // true
String ss2 = new String("ss");
String ss3 = new String("ss");
System.out.println(ss2==ss3); // false
String ss4 = ss2.intern();
String ss5 = ss3.intern();
System.out.println(ss4==ss5); // true
System.out.println(ss==ss4); // true
}
}
//结论:ss = ss1 = ss4 = ss5 != ss2 != ss3
第三行: String ss = “ss”;
:使用字面量赋值创建字符串。首先JVM检查字符串常量池中有没有key为“ss”的条目(字符串常量池本质上是一个C++的hashtable),发现没有,则会创建一个值为”ss”的String对象,将该对象的引用存放到字符串常量池中,并返回该引用。
第四行: String ss1 "ss";
:同样使用字面量创建字符串。JVM还是会先检查字符串常量池中有没有key为“ss”的条目,发现有(第三行代码创建的),则直接返回字符串常量池中的引用,不创建新的String实例。而在第三行中也是返回的相同的引用,因此ss和ss1两个引用指向的是同一个String对象,第五行代码输出true。
第7行:String ss2 = new String("ss")
:首先需要考虑的是这个构造函数,JDK8中该构造函数的源码为:
public class String{
private final char value[];
private int hash; // Default to 0
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
}
/*
* String内部的结构其实是一个char数组,如果调用此构造函数,
* 虽然是两个不同的对象实例,但内部数据仍然指向的同一个char数组,
*/
参数是一个String对象,我们传入的实参为“ss”,JVM对此的操作是,先检查字符串常量池是否有该字面量的引用,如果有则直接返回该引用,没有则先创建再返回。显然,之前已经创建过了,所以这里的实参指向的String实例其实和ss、ss1指向的是同一个String实例。紧接着,JVM参会以传入的String实例为基础,创建一个新的对象,并返回该对象的引用。
第8行:String ss3 = new String("ss");
:逻辑同第7行,也是返回的一个新创建对象的引用,因此第9行输出flase;
第11行:String ss4 = ss2.intern();
:String.intern()
方法会先去字符串常量池查看有没有以该对象对应字面量(这里的字面量为“ss”)
为key的条目,如果有则直接返回,没有则先创建然后再返回。因此,这里返回的引用指向的实例同ss、ss1引用指向的实例。第12行代码同理。
综上,全局一共有三个对象实例,有ss = ss1 = ss4 = ss5 != ss2 != ss3
。所以下面这种情况也好理解了是吧~
public class TestString {
public static void main(String[] args) {
String ss = "ss".intern();
String ss1 = "ss";
System.out.println(ss==ss1); // 输出为true
}
}
最后,总结一下String.intern()
方法的常见用法(参考文章):
- 节省空间:在程序运行期间,需要创建大量的String实例,难免会出现不同String实例代表的字面量是相同的。我们可以将创建语句变成这样:
String s = new String("java").intern()
,这样新创建的对象就可以及时回收,从而节约空间。但是多调用了一次intern()
会消耗更多时间,不过相比于GC消耗的时间,还是划算的吧。当然随意调用该方法,也可能会给字符串常量池造成很大负担,具体参考一下美团技术的文章。 synchronized
同步:synchronized(String.intern()){}
,这样就可以用String实例作为同步锁,很方便~
6. 基本类型包装类常量池
除了字符串常量池,Java基本类型的封装类大部分也都实现了常量池,包括Byte、Short、Integer、Long、Character、Boolean,浮点数据类型Float、Double是没有常量池的。
封装类的常量池实在各自内部实现的,比如IntegerCache(Integer的内部类),存在于堆中。
要注意的是,这些常量池是有范围的:
- Byte、Short、Integer、Long:[-128, 127]
- Character:[0, 127]
- Boolean:[true,false]
以Integer常量池的范围为例进行简单测试:
class Main{
public static String judge(int num){
Integer i1 = num;
Integer i2 = num;
return String.format("%d == %d: %s", i1,i2,i1 == i2);
}
public static void main(String[] args){
System.out.println(judge(-128));
System.out.println(judge(127));
System.out.println(judge(0));
System.out.println(judge(-129));
System.out.println(judge(128));
}
}
运行结果如下图所示:
实验结果告诉我们,在常量池的范围 [-128, 127] 之内,进行Integer = num
的赋值操作,不会新建对象,直接用IntegerCache中缓存的对象,超过范围就会new一个新的Integer。以下为IntegerCache的源码:
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
可以看到,默认缓存范围为 [-128, 127] ,上界是可以通过参数指定的。而数据结构采用的是Integer数组,数组实例肯定也是存放在堆中的。
注意:字符串常量池没有范围限制,只要第一次新建一个字符串,都会将对应的引用加入到字符串常量池中。