Java中的常量池有:class常量池、运行时常量池、String常量池。
为什么要使用常量池?
避免频繁地创建和销毁对象而影响系统性能,实现对象的共享(字符串常量池);对于类共用的元数据信息,使用常量池可以共享使用,而不是不同线程、对象都创建一个副本,节省内存开销(class常量池、运行时常量池)。
一、class常量池
一个Java类被编译后,就会形成一份class文件;class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References),以及其他类的元信息。每个class文件都有一个class常量池。
1、字面量
字面量相当于Java语言层面常量的概念,包括:
- 八种基本类型的值,eg: 1、1.0、true、'a'
- 文本字符串,eg: "hello world"
-
被声明为final的常量
2、符号引用
符号引用则属于编译原理方面的概念,比如代码中定义了一个int a,变量名是a,这就是一个常量。包括:
- 类和接口的全限定名
- 字段名称和描述符
-
方法名称和描述符
以下面的代码为例:
package basic;
public class ConstantsTest {
public String name = "Hello World";
public final int num = 100;
public ConstantsTest(String name) {
this.name = name;
}
public void info() {
System.out.println(name);
System.out.println(num);
}
}
按照上面说的规则,该类的class常量池中包含的常量应该有:
字面量
- 字符串:
“Hello World”
- 被final修饰的基本类型值:
100
符号引用
- 类和接口的全限定名:
basic/ConstantsTest
、Object
- 字段的名称和描述符:
basic/ConstantsTest.name:Ljava/lang/String;
、basic/ConstantsTest.num:I
- 方法名称和描述符:
java/lang/Object."<init>":()V
(构造方法)、info
、java/io/PrintStream.println:(Ljava/lang/String;)V
(第一个print)、java/io/PrintStream.println:(I)V
(第二个print)等
将类编译出class文件,再用 javap -v ConstantsTest
可以看到完整的常量池信息:
二、运行时常量池
当加载一个类时,势必要将其class常量池中的信息加载到内存中,这就是运行时常量池,通常存储类元信息的内存叫方法区,被该类的所有实例对象所共享引用。
JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析阶段,会把符号引用替换为直接引用。
运行时常量池相对于CLass文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。
关于String#intern()
在代码中,字符串字面量会被放入一个字符串常量池中,使用String类的intern方法时,首先在字符串常量池中查找是否存在一份equal相等的字符串如果有的话就返回该字符串的引用,没有的话就将它加入到字符串常量池中,所以存在于class中的常量池并非固定不变的,可以用intern方法加入新的。
三、字符串常量池
1、字符串常量池的实现与本质
在HotSpot VM里是通过 StringTable
类来实现常量池的,它是一个hash表,即通过计算String对象的hashcode,决定要将其存储在表中的哪个位置,,默认大小为1009。StringTable
在JVM中只有一个实例,被所有的类共享。
在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;
在JDK7.0中,StringTable的长度可以通过参数指定:
-XX:StringTableSize=66666
如果在类的定义中使用了字符串的字面量,直接赋值拼接,则对应的字面量会被放到字符串常量池中,如下面的代码所示:
public class StringPool {
public static void main(String[] args) {
String i = "hello";
String j = "World";
String k = "hello" + "World";
String l = new String("hello");
}
}
类的字节码文件内容如下:
从编译器就可以确定值的变量有i、j、k,而l需要调用虚拟方法,所以是运行期决定的,生成的对象不在常量池里,所以程序执行的结果是false。
2、字符串常量池的存储位置
- 在JDK6.0及之前版本中,String Pool里放的都是字符串常量,这些常量都放在Perm Gen区(也就是方法区)中;
- 在JDK7.0中,String Pool里放的实际上是字符串对象的引用,对象的实体存储被转移到堆内存中,这样做是因为方法区存储空间有限,一旦常量池过大会导致OOM。
字符串常量池中的字符串只存在一份
String s1 = "hello,world!";
String s2 = "hello,world!";
执行完第一行代码后,常量池中已存在 “hello,world!”,那么 s2不会在常量池中申请新的空间,而是直接把已存在的字符串内存地址返回给s2。
3、使用案例和坑
String a = "hello";
String b = new String("hello");
System.out.println(a == b);
上面的代码,执行结果为 false
,因为a是常量池中的一个常量,而b是一个普通的位于堆内存中的对象,如下图所示(JDK6.0标准):
使用
new String
创建的对象都是存储在堆内存中的,而a作为字面量,一开始就存储在class文件中,之后运行期,转存至方法区中,所以a和b指向的对象不一样。
String s1 = "Hello";
String s2 = "Hello";
String s3 = "Hel" + "lo";
String s4 = "Hel" + new String("lo");
String s5 = new String("Hello");
String s6 = s5.intern();
// 拼接是动态调用,所以拼接后的String对象存在堆内存中
String s7 = "H";
String s8 = "ello";
String s9 = s7 + s8;
System.out.println("s1 == s2? " + (s1 == s2));
System.out.println("s1 == s3? " + (s1 == s3));
System.out.println("s1 == s4? " + (s1 == s4));
System.out.println("s1 == s5? " + (s1 == s5));
System.out.println("s4 == s5? " + (s4 == s5));
System.out.println("s1 == s6? " + (s1 == s6));
System.out.println("s5 == s6? " + (s5 == s6));
System.out.println("s1 == s9? " + (s1 == s9));
System.out.println("s5 == s9? " + (s5 == s9));
上面代码执行结果如下:
分析:
-
s1 == s2
:都指向常量池中的字符串,所以true。 -
s1 == s3
:虽然用了+号做字符串连接,但是这个操作对编译器来说是可预测的,所以会进行优化,自动生成Hello赋值给s3,s3同样指向常量池中的字符串,所以true。 -
s1 == s4
:s4是分别用了常量池中的字符串和存放对象的堆中的字符串,做+的时候会进行动态调用,最后生成的仍然是一个String对象存放在堆中,所以false。 -
s1 == s5
:s5使用new创建的对象,会在堆内存中分配一个新的内存空间,所以false。 -
s4 == s5
:每次使用new创建的对象都是新分配内存空间,不会相等,所以false。 -
s1 == s6
:s5是使用String#intern()
生成的,方法首先在常量池中查找是否存在一份equal相等的字符串如果有的话就返回该字符串的引用,没有的话就将它加入到字符串常量池中,所以存在于class中的常量池并非固定不变的,可以用intern方法加入新的,这里很明显在常量池中找到equal的字符串,所以为true。 -
s5 == s6
:不管常量池中是否存在跟s5字符串值equal的常量,s6最终都是指向常量池中的常量,所以结果肯定是false。 -
s1 == s9
:虽然s7、s8都是指向常量池中的常量,但是s9的生成用的是动态调用,所以返回的是一个新的String对象,所以结果是false。 -
s5 == s9
:false,分析同上。
除此之外,还有一些特例:
(1)常量拼接
public static void main(String[] args) {
final String a = "hello";
final String b = "world";
String c = a + b;
String d = "helloworld";
System.out.println(c == d);
}
a、b、c类似于上面的s7、s8、s9,但是a、b被final修饰,表示在编译时就可以确定它的值,将其拼接起来的值c也是可以确定的,所以c指向常量池中的字符串常量,执行结果为true。
(2)static静态代码块
public static final String a;
public static final String b;
static {
a = "hello";
b = "world";
}
public static void main(String[] args) {
String c = "helloworld";
String d = a + b;
System.out.println(c == d);
}
虽然a、b用final修饰,也是常量,但是拼接成的d却不是常量,因为在编译器初始化a、b的static代码块是不执行的,因此是未知的,初始化属于类加载的一部分,属于运行期,从反编译的字节码来看,d是先通过 StringBuilder
拼接,再调用其 toString()
方法生成的。
看看StringBuilder的源码,toString()方法调用了
new String()
,所以会在堆内存创建一个新的对象。