Class常量池、运行时常量池、字符串常量池的一些思考

时间:2023-03-09 05:24:32
Class常量池、运行时常量池、字符串常量池的一些思考

Class常量池、运行时常量池、字符串常量池

class常量池

java代码经过编译之后都成了xxx.class文件,这是java引以为傲的可移植性的基石。class文件中,在CAFEBABE、主次版本号之后就是常量池入口了,入口是一个u2类型的数据,也就是占据2个字节,用来给常量池的容量计数,假设这个u2的数字为0x0016,那么对应十进制为22,那么常量池中右21个常量,1-21,其中第0个用于表达“不引用任何一个常量”。在这两个字节之后就是编译器为我们生成的常量了,这些常量包含了两大类:字面量符号引用,通过一个例子看一下:

public class ThreePoolDemo {
int a=1;
}

javap反编译结果如下:

Classfile
Constant pool:
#1 = Class #2 // com/hustdj/jdkStudy/threePool/ThreePoolDemo
#2 = Utf8 com/hustdj/jdkStudy/threePool/ThreePoolDemo
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 a
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Methodref #3.#11 // java/lang/Object."<init>":()V
#11 = NameAndType #7:#8 // "<init>":()V
#12 = Fieldref #1.#13 // com/hustdj/jdkStudy/threePool/ThreePoolDemo.a:I
#13 = NameAndType #5:#6 // a:I
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 Lcom/hustdj/jdkStudy/threePool/ThreePoolDemo;
#18 = Utf8 SourceFile
#19 = Utf8 ThreePoolDemo.java
{
int a;
descriptor: I
flags: (0x0000) public com.hustdj.jdkStudy.threePool.ThreePoolDemo();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #10 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_1
6: putfield #12 // Field a:I
9: return
LineNumberTable:
line 3: 0
line 4: 4
line 3: 9
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this Lcom/hustdj/jdkStudy/threePool/ThreePoolDemo;
}
SourceFile: "ThreePoolDemo.java"

通过反编译我们一睹Constant Pool真容,密密麻麻一大段,我们不妨就关注关注我们定义的成员变量a

//在<init>方法的第六行
6: putfield #12
//可以看到进行了putfield,给成员变量赋值,虽然后面的注释提醒了我们是变量a
//但是不妨跟着去看看常量池中的#12
#12 = Fieldref #1.#13
//这是一个Fieldref它又指向了#,#13,继续追踪
//#1代表是哪一个类,它又指向了一个UTF8的常量,这个常量就保存了完整的类名
#1 = Class #2
#2 = Utf8 com/hustdj/jdkStudy/threePool/ThreePoolDemo
//#13告诉了你这个变量的name和type
#13 = NameAndType #5:#6
//name是a,type是int
#5 = Utf8 a
#6 = Utf8 I

可以看到,在方法给成员变量a赋值是怎么赋值的,通过Constant Pool来确定我们要给com/hustdj/jdkStudy/threePool/ThreePoolDemo对象的name为a类型为int的这么一个变量赋值,相当于一个通讯录,我要找一个人,你就告诉我这个人住在那里,姓甚名谁。但是此刻它们都是符号引用,也就是说还仅仅是一串UTF8的字符串,通过Constant Pool确定了一串字符串,对应要找的哪个字段、方法、对象,而这些符号引用需要等到类加载的解析阶段变成直接引用,也就是直接指向对应的内存指针、偏移量等

运行时常量池

在《Java虚拟机规范8》中是这样描述的,运行时常量池(Runtime constant pool)是class文件中每一个类或者接口的常量池表(constant pool)的运行时表示形式,它包含了若干常量,从编译期可知的数值字面量到必须在运行期解析之后才能获得的方法、字段引用。也就是说class常量池=运行时常量池,只不过是不同的表现形式而已,一个是静态的,一个是动态的,其中静态的符号引用也都在运行时被解析成了动态的直接引用。

那么运行时常量池是和类绑定的,每个类、接口有自己的运行时常量池,每一个运行时常量池的内存是在方法区进行分配的,这只是概念上的方法区,每个虚拟机有自己的实现,同一个虚拟机不同的版本也有不同的实现,以常用的Hotspot虚拟机为例

  • 在1.6运行时常量池以及字符串常量池存放在方法区,此时Hotspot对于方法区的实现为永久代(关于是否属于堆内存https://www.zhihu.com/question/49044988)永久代属于GC heap的一部分
  • 在1.7字符串常量池被从方法区拿到了堆,运行时常量池还留在方法区中
  • 在1.8中hotspot移除了永久代用元空间取代它,字符串常量池还在堆中,而运行时常量池依然在方法区也就是元空间(堆外内存)

字符串常量池

为了减少频繁创建相同字符串的开销,JVM弄了一个String Pool,它是全局共享的,整个JVM独一份,与之对应的有一个StringTable,,简单来说它就是一个Hash Map,key--字符串字面量,value--指向真正的字符串对象的指针。任何通过字面量创建字符串的方式都需要先通过HashMap检查,如果有这个字面量,则直接返回value,如果没有则创建一个。示例如下:

public class StringPoolDemo {
public static void main(String[] args) {
String a="123";
String b="123";
System.out.println(a==b);
}
}
//输出为true

它的过程如下:

Class常量池、运行时常量池、字符串常量池的一些思考

如果这样呢?

public class StringPoolDemo {
public static void main(String[] args) {
String a = new String("123");
String b="123";
System.out.println(a==b);
}
}
//输出false

它的过程如下:

Class常量池、运行时常量池、字符串常量池的一些思考

如果这样呢?

public class StringPoolDemo {
public static void main(String[] args) {
String a = new String("123");
String b=a.intern();
System.out.println(a==b);
}
}

过程如下:

Class常量池、运行时常量池、字符串常量池的一些思考

String s = new String(new char[]{'1', '2', '3'});
String s1=s.intern();
String s2 = "123";
System.out.println(s1==s);
System.out.println(s1==s2);
System.out.println(s==s2);

它的过程如下:

Class常量池、运行时常量池、字符串常量池的一些思考

  1. 通过new创建了一个String对象,此时String Table并没有记录
  2. s.intern(),查看String Table发现,并没有这样的一个字符串,那么新增记录并且返回对应的地址,即s1指向snew出来的string对象
  3. s="123",同样想去string table里面查看,发现已经有这样的字符串了,直接返回地址即可

所以s=s1=s2,三者指向了相同的对象

总结一下:

  • 直接根据字面量创建字符串对象,首先检查string table有没有这个字符串字面量,有的话直接返回对应的对象地址,没有则创建一个string对象,并且string table记录字符串字面量->对象地址的映射
  • new必定会在heap中创建一个对象
  • intern执行的思路与通过字面量创建的思路一致,先检查string table有没有这样的字符串,有的话直接返回对象地址,没有则入池,创建映射

再加入一些编译期优化呢?以下代码摘自Java语言规范8

package com.hustdj.jdkStudy.threePool;

public class StringPoolDemo {
public static void main(String[] args) {
String hello="Hello",lo="lo";
System.out.println(hello=="Hello");
System.out.println(Other.hello==hello);
System.out.println(com.hustdj.jdkStudy.other.Other.hello==hello);
System.out.println(hello=="Hel"+"lo");
System.out.println(hello=="Hel"+lo);
System.out.println(hello==("Hel"+lo).intern());
}
} class Other{
public static String hello="Hello";
} package com.hustdj.jdkStudy.other; public class Other {
public static String hello="Hello";
}

输出结果如下:

true
true
true
true
false
true

解释如下:

//字符串池是JVM层面的,与类、包无关
System.out.println(hello=="Hello");
System.out.println(Other.hello==hello);
System.out.println(com.hustdj.jdkStudy.other.Other.hello==hello);
//编译期优化自动转换成:hello=="Hello"
System.out.println(hello=="Hel"+"lo");
//通过StringBuilder.toString等于:new String("Hello");
System.out.println(hello=="Hel"+lo);
//intern操作时,string pool已经有"Hello"对象了,直接返回相同的引用,可以理解为入池失败
System.out.println(hello==("Hel"+lo).intern());

可见JVM为了减少相同String对象的重复创建还是做了不少努力呀

Integer缓存

同样是减少重复对象的创建,Integer同样做出了努力,示例代码如下:

public class UnboxingTest {
public static void main(String[] args) {
Integer a=1;
Integer b=1;
System.out.println(a==b);
}
}
//输出结果为true

Integer和String难道说采用了同样的策略,Integer池?当然不是,遇事不决先看看字节码

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: iconst_1
1: invokestatic #16 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
4: astore_1
5: iconst_1
6: invokestatic #16 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
9: astore_2
10: getstatic #22 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload_1
14: aload_2
15: if_acmpne 22
18: iconst_1
19: goto 23
22: iconst_0
23: invokevirtual #28 // Method java/io/PrintStream.println:(Z)V
26: return

可以看到Integer a= 1实际的指令应该是Integer a =Integer.valueOf(1)

那么我们来看看Integer的源码:

public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
} 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() {}
}

不难发现,在Integer类初始完成之后就已经存在了-128<=value<=127的所有Integer对象,valueOf传入的参数如果在这之间的话直接返回相应的对象即可,并且上限是可以修改的。

此外,Short、Character、Long、Byte、Boolean都是有缓存处理的,而Float、Double没有,它们的valueOf如下

public static Short valueOf(short s) {
final int offset = 128;
int sAsInt = s;
if (sAsInt >= -128 && sAsInt <= 127) { // must cache
return ShortCache.cache[sAsInt + offset];
}
return new Short(s);
} public static Character valueOf(char c) {
if (c <= 127) { // must cache
return CharacterCache.cache[(int)c];
}
return new Character(c);
} public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
} public static Byte valueOf(byte b) {
final int offset = 128;
return ByteCache.cache[(int)b + offset];
} public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
} public static Float valueOf(float f) {
return new Float(f);
} public static Double valueOf(double d) {
return new Double(d);
}

总结

  • String Pool是JVM层面实现的,Integer这些是Java层面通过静态代码块在类加载的初始化阶段完成的
  • Integer的默认缓存范围为[-128,127],其它详见代码,Float、Double并不提供缓存
  • Integer的缓存上限可扩大,最大为Integer.MAX_VALUE - (-low) -1

常量池的内存分布问题

前面关于常量池的内存分布已经做了介绍,这里再补充一些。详见关于问题方法区的Class信息,又称为永久代,是否属于Java堆?的知乎讨论https://www.zhihu.com/question/49044988

总结如下:

  • 永久代/方法区也属于GC Heap的一部分

  • SymbolTable / StringTable,这俩table一直在native memory里面

  • JDK6的以永久代(PermGen)作为方法区的实现,除了JIT编译的代码存在native memory中以外,其他的方法区的数据都存在永久代中(此时的String Pool中的字符串示例都是在永久代中的)

  • JDK7还是以永久代作为方法区的实现

    • 把Symbol的存储从PermGen移动到了native memory
    • 把静态变量从instanceKlass末尾(位于PermGen内)移动到了java.lang.Class对象的末尾(位于普通Java heap内)
    • StringTable引用的java.lang.String实例则从PermGen移动到了普通Java heap
  • JDK8中永久代彻底被移除,用元空间作为方法区的实现

为什么需要移来移去呢?

在PermGen中元数据可能会随着每一次Full GC发生而进行移动。HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理PermGen中的元数据,分离出来以后可以简化Full GC以及对以后的并发隔离类元数据等方面进行优化。

参考文献

https://www.zhihu.com/question/49044988

https://segmentfault.com/a/1190000012577387