Class 常量池与运行时常量池

Class常量池可以理解为是Class文件中的资源仓库。Class文件中除了包含类的版本、字段吧、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)

通过javap指令对class文件生成更可读的JVM字节码指令文件:

#执行指令
javap -v TestClass.class

#输出如下,Constant pool就是Class的常量池
  Last modified 2020-8-1; size 559 bytes
  MD5 checksum fd3a963f815f3a84a94a1874bb2777de
  Compiled from "TestClass.java"
public class cn.pencilso.study.studyarthas.TestClass
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#24         // java/lang/Object."<init>":()V
   #2 = Class              #25            // cn/pencilso/study/studyarthas/TestClass
   #3 = Class              #26            // java/lang/Object
   #4 = Utf8               tempA
   #5 = Utf8               Ljava/lang/String;
   #6 = Utf8               ConstantValue
   #7 = String             #27            // a
   #8 = Utf8               tempB
   #9 = String             #28            // b
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               LocalVariableTable
  #15 = Utf8               this
  #16 = Utf8               Lcn/pencilso/study/studyarthas/TestClass;
  #17 = Utf8               main
  #18 = Utf8               ([Ljava/lang/String;)V
  #19 = Utf8               args
  #20 = Utf8               [Ljava/lang/String;
  #21 = Utf8               MethodParameters
  #22 = Utf8               SourceFile
  #23 = Utf8               TestClass.java
  #24 = NameAndType        #10:#11        // "<init>":()V
  #25 = Utf8               cn/pencilso/study/studyarthas/TestClass
  #26 = Utf8               java/lang/Object
  #27 = Utf8               a
  #28 = Utf8               b

Constant pool 主要存放两大类常量:字面量和符号引用

字面量

字面量就是指由字母、数字等构成的字符串或者数值常量

字面量只可以右值出现,指等号右边的值,如下所示:

int a = 1; // 1 是字面量
String b = "2";// "2" 是字面量

符号引用

符号引用是编译原理中的概念,主要包括了以下三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符
int a = 1;

上面的 int a就是字段名称,是一种符号引用。

还有TestClass类常量池里的Lcn/pencilso/study/studyarthas/TestClass; 是类的全限定名,

main是方法名称,()是一种UTF8格式的描述符,这些都是符号引用。

这些常量池现在是静态信息,只有运行时被加载到内存后,这些符号才有对应的内存地址信息,这些常量池一旦被装载入内存,就会变成运行时常量池,对应的符号引用在程序加载或运行时会被转变为被加载到内存区域的代码的直接引用,也就是我们说的动态链接了

字符串常量池

字符串常量池的设计思想

  • 字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度的影响程序的性能
  • JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化
    • 为字符串开辟一个字符串常量池,类似于缓存区
    • 创建字符串常量时,首先查询字符串常量池是否存在于该字符串
    • 存在该字符串,返回引用实例,不存在:实例化该字符串并放入池中

三种字符串操作(JDK1.7及以上版本)

1、直接赋值字符串
String str = "test_str"; //str 指向常量池中的引用

这种方式创建的字符串对象,只会在常量池中。

因为有"test_str"这个字面量,创建对象str的时候,JVM会先去常量池中通过equals(key)方法,判断是否有相同的对象

  • 如果常量池中存在:则直接返回该对象在常量池中的引用
  • 如果没有:则会在常量池中创建一个新对象,再返回引用
2、new String
String str1 = new String("test_str"); //str1 指向常量池外的内存对中的对象引用

这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用。

步骤大概如下:

  • 因为有"test_str"这个字面量,所以会先检查字符串常量池中是否存在字符串“test_str”
    • 不存在:先在字符串常量池中创建一个字符串对象,再去字符串常量池之外的堆内存中创建一个字符串对象“test_str”
    • 存在:直接在字符串常量池之外的堆内存中创建一个字符串对象"test_str"
  • 最后将内存中的引用返回
3、intern方法
String str1 = new String("test_str");
String str2 = str1.intern();

System.out.println(str1 == str2);

String中的intern方法是一个native的方法,当调用intern方法时,如果池已经包含一个等于此String对象的字符串(用equals(object) 方法确定),则返回池中的字符串。

否则,将intern返回的引用指向当前字符串 str1 (jdk1.6版本需要将str1复制到字符串常量池里)

字符串常量池位置

jdk1.7:有永久代,但已经逐步"去永久代",字符串常量池从永久代里的运行时常量池分离到堆里

jdk1.8 + :无永久代,运行时常量池在元空间,字符串常量池仍然在堆内存中

字符串常量池问题的几个例子

示例1
    String s0 = "hello";
    String s1 = "hello";
    String s2 = "he" + "llo";
    System.out.println(s0 == s1);//true
    System.out.println(s0 == s2);//true

分析:定义s0的时候,字符串常量池没有这个"hello",那么会把"hello",放到字符串常量池中,并且把对象引用赋值给s0,而定义s1的时候,这个"hello"已经存在于字符串常量池了,所以直接从字符串常量池拿过来对象引用,赋值给s1。所以s0、s1 实际上它们两个的内存地址是一样的。

至于s2,"he" + "llo" 都是字符串常量,当一个字符串由多个字符串常量连接而成时,它自己肯定也是字符串常量,所以s2在编译期就被优化为一个字符串常量"hello",所以s2也是字符串常量池中"hello"的一个引用。

所以我们得出结果 s0==s1==s2

示例2
    String s0 = "hello";
    String s1 = new String("hello");
    String s2 = "he" + new String("llo");
    System.out.println(s0 == s1);//false
    System.out.println(s0 == s2);//false
    System.out.println(s1 == s2);//false

分析:

  • 用 new String() 创建的字符串不是常量,而是对象,不能在编译期就确定,所以new String()创建的字符串不放入常量池中,它们有自己的地址空间。
  • S0 还是字符串常量池中"hello"的引用,而s1因为无法在编译期确定,所以是运行时创建的新对象"hello"的引用,s2因为有后半部分new String("llo") 所以也无法在编译期确定,所以也是一个新创建对象"hello"的引用
示例3
    String a = "a1";
    String b = "a" + 1;
    System.out.println(a == b);//true

    String a = "atrue";
    String b = "a" + true;
    System.out.println(a == b);//true

    String a = "a3.4";
    String b = "a" + 3.4;
    System.out.println(a == b);//true

分析:

  • JVM对于字符串常量的"+"连接符,将在程序编译期,JVM就将常量字符串的 "+"连接符优化为连接后的值。
  • 如 String b = "a" + 1; 经编译器优化后再class中就已经是b = "a1"。在编译期其字符串常量的值就确定下来,故上面程序最终的结果都为true
示例4
    String a = "ab";
    String bb = "b";

    String b = "a" + bb;

    System.out.println(a==b);//false

分析:JVM对于字符串引用,由于在字符串的"+"连接中,有字符串引用存在,而引用的值在程序编译器是无法确定的,即"a"+bb无法被编译器优化,因为编译器不能确定bb这个变量到底引用的是什么值。

只有在程序运行期动态分配并连接后的新对象赋给b,所以上面程序的结果也就为false。

实例5
String a = "ab";
final String bb = "b";

String b = "a" + bb;

System.out.println(a == b);//true

分析:和示例4中唯一不同的就是给bb这个变量增加了final修饰,对于final修饰的变量,它在编译时会被解析为常量值的一个本地拷贝存储到自己的常量池中,或嵌入到它的字节码流中。

所以此时,编译器能确定bb这个变量的值是什么,而且不可变,所以会对String b = "a" + bb; 进行编译优化,此时的"a"+bb 和 a+"b"效果是一样的。

故上面的程序结果为true。

示例6
String a = "ab";
final String bb = getBB();

String b = "a" + bb;

System.out.println(a == b);//false

private static String getBB() {
    return "b";
}

分析:JVM对于字符串引用bb,它的值再编译器无法确定,只有在程序运行期调用了getBB这个方法后,将方法的返回值和"a"来动态链接并分配给地址为b,故上面程序的结果为false。

八种基本数据类型的包装类和对象池

Java中基本类型的包装类,大部分都实现了常量池技术,(严格来说应该叫对象池,在堆内存上)。

这些类是Byte、Short、Integer、Long、Character、Boolean,另外两种浮点型包装类没有实现(Double、Float)

另外Byte、Short、Integer、Long、Character这五种整形的包装类也只是在对应值小于127,大于-128时才可使用对象池。也就是说对象池不负责创建和管理超过这个范围之外的对象。因为一般这种比较小的数用到的概率相对较大。

Integer

查看Integer的valueof源码

    /**
     * Returns an {@code Integer} instance representing the specified
     * {@code int} value.  If a new {@code Integer} instance is not
     * required, this method should generally be used in preference to
     * the constructor {@link #Integer(int)}, as this method is likely
     * to yield significantly better space and time performance by
     * caching frequently requested values.
     *
     * This method will always cache values in the range -128 to 127,
     * inclusive, and may cache other values outside of this range.
     *
     * @param  i an {@code int} value.
     * @return an {@code Integer} instance representing {@code i}.
     * @since  1.5
     */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

可以看到有一个判断语句 大于小于IntegerCache.low 并且小于等于 IntegerCache.high,则直接从缓存IntegerCache.cache 数组中获取,并返回。

而IntegerCache.low = -128、IntegerCache.high=127

看几个示例

Integer i1 = 127;//这种写法其实地城会调用Integer.valueof,所以里面会用到IntegerCache对象池
Integer i2 = 127;

System.out.println(i1 ==i2);//true

//值大于127的时候,则会创建新的Integer对象
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 ==i4);//false

//用new关键字新生成的对象不会使用对象池
Integer i5 = new Integer(127);
Integer i6 = new Integer(127);
System.out.println(i5 == i6);//false

Sting为什么被final修饰?

大家都知道,String是被final所修饰的,不能被继承,不能被修改,那么JVM为什么要这么做呢,而且还有其他的基本数据类型的包装类也被final所修饰。

我个人的见解是,出于对JVM性能和内存节省的考虑。

字符串常量池的作用在于,数据的复用,一个字符串"java"存到了字符串常量池后,任意线程去定义"java"这个字符串的时候,都会引用字符串常量池的对象,而不是去重新创建一个对象。

那么如果说字符串,也就是String没有被final,我们把"java"这个内容给改成其他的内容,那么就会产生并发安全性问题。我们知道,多线程读是不会产生问题的,但是如果多线程写的话,就会产生数据的安全性问题。

而String进行final修饰,就是为了防止对象内容被修改,从而配合字符串常量池,提升程序的性能、节省内存空间。

其他的基本数据类型也有对象缓存区,我认为都是出于这方面的考虑。