JVM探寻之旅:常量池

前言

在《JVM探究之旅:运行时数据区》提到过在方法区中有一块区域叫做运行时常量池,而运行时常量池只是所有常量池里面的一部分。

正文

常量池通常可以分为两类:静态常量池(class常量池)和运行时常量池

一、Class常量池

Class常量池也就是class文件中的常量池(Constant Pool)。Java文件被编译后会生成Class文件,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Class文件是一组以8个字节(一个字节两位16进制数)为基础单位的二进制流,最开始的4个字节用于存储魔数(Magic Number,判断一个文件是否能被JVM解读,Java的Class文件的魔数为0xCAFEBABE);接着4个字节存储class版本号(前2个字节为次版本号,后2个字节存储主版本号);接下来就是常量池入口(放置一项u2类型数据,用于表示常量池容量,从1开始计数)

用反汇编命令可以看到常量池信息:javap -v xxx.class
在这里插入图片描述


常量池主要存放字面量和符号引用
在这里插入图片描述

字面量

字面量(Literal)相当于Java层面的常量,如文本字符串"acb"、被声明为final的常量等。

符号引用

符号引用(Symbolic References)属于编译原理方面的概念,主要包括下面的常量:

  • 类和接口的全限定名(如:java.lang.String)
  • 字段的名称和描述符
  • 方法的名称和描述符
  • 被模块导出或者开放的包
  • 方法句柄和方法类型
  • 动态调用点和动态常量

什么是符号引用?
假设有一个Java类,在编译期间,由于还没有被JVM加载,所以编译器并不知道引用该类的实际内存地址(还没有分配),这个时候就需要用符号来代替实际引用。当JVM加载被编译后的class文件时,因为已经为对象分配了内存地址,这个时候就可以用实际地址代替之前的符号引用,也即直接引用。

二、运行时常量池

运行时常量池(Run-time Constant Pool)是类文件中常量池表(Class文件常量池)的每个类或每个接口的运行时表示。运行时常量池存储在方法区中,每个类和接口的运行时常量池都是JVM在创建类或接口时同步构造的。它包含几种常量:从编译时已知数字字面值(字面量)到必须在运行时解析方法字段引用。运行时常量池的功能类似于传统编程语言的符号表,尽管它包含的数据范围比典型的符号表更广。

方法区中Class文件常量池和运行时常量池分布
在这里插入图片描述

运行时常量池就是Class文件常量池运行时的表现形式,当Class文件被加载后,它存储的符号引用会转存到运行时常量池,当类被解析后,由符号引用翻译出来的直接引用也会保存到运行时常量池中。

运行时常量池相对Class文件常量池还有一个特性就是动态性,在运行期间可以将常量存放到运行时常量池,例如String类的intern()方法。

HotSpot虚拟机在JDK7以后就将运行时常量池从永久代移到了堆空间中(物理上在堆,逻辑上仍旧属于方法区)

字符串常量池

字符串常量池也叫做全局字符串池,《Java虚拟机规范》并没有字符串常量池的概念,我把它归类到运行时常量池中特殊的一种。因为String类在程序中使用的特别频繁,JVM为了减少内存开销和提升性能,避免字符串重复创建,就维护了一块特殊的内存空间,就是字符串常量池。要了解字符串常量池,就得先来了解String类的特性。

String类其实是对char数组的封装,它只有两个成员变量value[]和hash。仔细观察可以发现,不管是String类名还是value[]数组都被final修饰,由此可以看出String对象的不可变性,即String对象一旦创建成功则无法被更改。这样做的好处是什么?
第一, 保证String 对象的安全性。假设String 对象是可变的,那么String 对象将可能被恶意修改。
第二, 保证hash 属性值不会频繁变更,确保了唯一性,使得类似HashMap 容器才能实现相应的key-value 缓存功能。
第三, 可以实现字符串常量池。
在这里插入图片描述

在Java 中,通常有两种创建字符串对象的方式:
1.通过字符串常量的方式创建,如String str=“abc”
2.字符串变量通过new关键字创建,如String str = new String(“abc”)

JVM的字符串对象优先会去字符串池通过equals()方法查找有没有相等的字符串,如果有则返回该字符串的引用,如果没有,就在字符串池创建该对象并返回对象的引用


我们先来玩一个小游戏,String对象到底创建了几个?(以下代码全都基于JDK1.8,不同版本结果会有不同)

  1. 代码一
public static void main(String[] args) {
	String s = "a" + "b";
}

代码解析:1
下面是上述代码对应的class文件,从编译结果来看,编译器将纯字符串"+"操作优化了,只会在字符串池中创建一个"ab"对象
在这里插入图片描述

  1. 代码二
public static void main(String[] args) {
	String s = new String("abc");
}

将上述代码反汇编:
在这里插入图片描述
代码解析:2
通过反汇编结果可知,首先会在Java堆中通过new String()方法创建一个String对象。然后在字符串池创建"ab”对象,所以是两个

  1. 代码三
public static void main(String[] args) {
	String s = new StringBuilder("ab").append("c").toString();
}

代码解析:3
由StringBuilder创建的字符串对象实例在Java堆上,所以Java堆有一个对象。
然后在字符串池中会依次创建"ab"和"c"两个字符串对象
new StringBuilder(“ab”).append(“c”).toString()会被编译器优化成"ab"+“c”(但在JVM中仍旧还是通过StringBuilder的方式创建对象),再将结果"abc"返回到StringBuilder对象中
所以字符串池中有"ab"和"c"两个字符串对象,Java堆中有一个"abc"对象,总共3个对象

  1. 代码四
public static void main(String[] args) {
	String s = new String("ab") + "c";
}

将上述代码反汇编:在这里插入图片描述
代码解析:4
从反汇编结果来看,当字符串"+“无法被编译器优化时,在JVM中其实是通过StringBuilder的方式创建对象的,这样可以提高效率,上述代码等同于:String s = new StringBuilder(new String(“ab”)).append(“c”).toString(),两个new分别在堆创建两个对象,然后在字符串池创建两个对象"ab"和"c”,所以是4个

  1. 代码五
public static void main(String[] args) {
	String s = "ab";
  	for (int i=0; i<5; i++) {
   		s += i;
  	}
}

将上述代码反汇编:在这里插入图片描述
代码解析:6
在编写该段代码的时候,IDEA会提示"Convert variable “s” from String to StringBuilder",而在反汇编结果来看,JVM也是将"+“优化成了StringBuilder,所以上述代码也可以写成:
在这里插入图片描述
首先字符串池中会有一个"ab"对象;
然后循环遍历5次,每一次都会在堆中创建一个StringBuilder对象,总共5个,分别是"ab0”、“ab01”、“ab012”、“ab0123”、“ab01234”;
所以加起来一共是6个对象


了解了String对象在内存中的创建形式,下面就来实战演练巩固一下吧,看看输出结果是什么

  1. 示例1
public static void main(String[] args) {
	String s1 = new String("abc");
	String s2 = "abc";
   	System.out.println(s1 == s2);
}

代码解析:false
s1指向堆中创建的String对象,s2指向字符串池中"abc”,引用地址不同结果自然为false

  1. 示例二
public static void main(String[] args) {
	String s1 = "abc";
	String s2 = "ab" + "c";
   	System.out.println(s1 == s2);
}

代码解析:true
s1是字符串池中"abc"的引用地址
s2在编译时会进行自动优化成"abc",结果也是指向字符串池中的"abc"
s1和s2都指向字符串池中的"abc",所以结果为true

  1. 示例三
public static void main(String[] args) {
	String s1 = "ab" + "c";
	String s2 = new StringBuilder("ab").append("c").toString();
   	System.out.println(s1 == s2);
}

代码解析:false
s1指向字符串池中的"abc",s2指向堆中的StringBuilder对象,引用不同结果为false

  1. 示例四
public static void main(String[] args) {
	String s1 = "abc";
	String s2 = new String("abc").intern();
   	System.out.println(s1 == s2);
}

首先我们来看看java中对intern解释原文:
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
在调用intern方法时,如果字符串池中已经包含了由equals()方法确定的与该字符串对象相等的字符串,则返回字符串池中的字符串。否则,该字符串对象将被添加到字符串池中,并返回对该字符串对象的引用。

注:
在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上。
JDK 7(以及部分其他虚拟机,例如JRockit)由于字符串常量池已经移到Java堆中,intern()方法实现只需要在常量池里记录一下首次出现的实例引用即可

代码解析:true
s1指向字符串池中的"abc",s2的intern()方法会优先查找字符串池,发现已经有了"abc"则返回它的引用,s1和s2都指向同一个地址,所以结果为true

  1. 示例五
public static void main(String[] args) {
	String s1 = "ab" + "c";
	String s2 = new StringBuilder("ab").append("c").toString();
	String s3 = s2.intern();
   	System.out.println(s1 == s2);
   	System.out.println(s2 == s3);
}

代码解析:false, false
示例三可以看出s1 == s2
s2保存的是堆中StringBuilder对象,值为"abc",s2.intern()优先查找字符串池,判断是否有值为"abc"的对象,显然s1时就已经创建了,所以s3保存的是和s1一样的引用,所以s1 == s3,那么s2 != s3
在这里插入图片描述

  1. 示例六
public static void main(String[] args) {
	String s2 = new StringBuilder("ab").append("c").toString();
	String s3 = s2.intern();
	String s1 = "ab" + "c";
   	System.out.println(s1 == s2);
   	System.out.println(s2 == s3);
}

代码解析:true, true
该示例只是把示例五中String s1 = “ab” + "c"移动到s3下面,可结果却完全不同了
s2.intern()把s2创建在堆中的"abc"对象的引用保存到了字符串池中,再把该引用返回给s3,所以s2 == s3为true
而s1通过查找字符串池发现有"abc"的实例引用,也把这个引用返回给s1
最后s3和s1其实都是指向s2创建在堆中的"abc“对象,所以s1 == s2 == s3
在这里插入图片描述

  1. 示例七
public class Test {
    private static final String a = "abc";
    private static final String b = "def";
    public static void main(String[] args) {
        String c = "abcdef";
        String d = a + b;
        System.out.println(c == d);
    }
}

看一下对应的class文件:
在这里插入图片描述
代码解析:true
a和b是final修饰的常量且已经有了初始值,在编译期间a+b已经被优化且直接替换成了"abcdef",所以c和d都指向字符串池的"abcdef",且整个过程只产生了一个"abcdef"对象

  1. 示例八
public class MyClass {
    private static final String a;
    private static final String b;
    static{
        a = "abc";
        b = "def";
    }

    public static void main(String[] args) {
        String c = "abcdef";
        String d = a + b;
        System.out.println(c == d);
    }
}

将上述代码反汇编:
在这里插入图片描述
代码解析:false
static静态代码块在编译期间是不会执行的,所以a和b的值无法确定,只有在类加载初始化时才会执行。从反汇编结果来看,static静态代码块已经在字符串池创建了"abc"和"def"两个对象,执行c时又创建了"abcdef"对象,执行d时"+"自动优化成StringBuilder对象,所以c和d分别指向字符串池和堆中的对象,c != d,且总共创建了4个对象

Integer包装类

其实,除了String类型外,还有其他的基本数值包装类也实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean,这五种类型只会在 [-128, 127] 范围内使用常量池,一旦超过仍旧会去创建新的对象。两种浮点数类型的包装类Float,Double并没有实现常量池技术。

我们以Integer为例介绍一下如何使用缓存实现常量池技术

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

Interger采用缓存的形式实现常量池技术,缓存最小值固定-128,最大值可以通过参数-XX:AutoBoxCacheMax来调整。

代码示例:

public static void main(String[] args) {
    Integer i1 = 127;
    Integer i2 = 127;
    System.out.println(i1 == i2);
}

代码解析:true
编译的时候代码其实会封装成Integer i1 = Integer.valueOf(127),优先从缓存里面获取值。但是Integer i = new Integer(127)则会在堆中创建对象

我们再来看一个示例:

public static void main(String[] args) {
    Integer i1 = 40;
    Integer i2 = 10;
    System.out.println(i1 == i2 + 30);
}

代码解析:true
i2 + 30:因为Integer是包装类不能进行基本运算,所以这里自动拆箱成10 + 30=40
i1 == 40:i1不能与数值进行比较,同样自动拆箱成40,所以40 == 40数值比较结果就为true

后记

字符串常量池的说法颇有争议,池中保存的是堆中字符串对象的引用还是字符串对象本身或者两者皆有?有说字符串是以char数组的形式保存在堆中,然后字符串池持有的只是这个数组对象的引用。其实JDK1.7以后就把字符串池移动到了堆中,所以字符串对象肯定是在堆中这是毋庸置疑的,那么既然如此,就不用再去纠结对象到底是在池中还是在非池的堆中了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值