关于常量池

在面试的时候经常会被问到,下面代码输出的是什么?

String a = "abc";
String b = new String("abc");
// 输出什么
System.out.println(a == b);

或者

// 创建了几个实例
String a = new String("abc");

一般这样问的目的是为了知道你有没有常量池的概念。
然而网上关于常量池的说法众说纷纭,各种都有。所以这里按自己的理解,整理一份资料,供自己学习(英文不好,看不懂官方文档),有错的地方请指出,谢谢

常量池

首先先给常量池分个类,其实常量池有3种:

  1. Class文件常量池
  2. 运行时常量池
  3. 字符串常量池

Class文件常量池(class constant pool)

我们都知道,java的代码是需要先编译成 .class的二进制文件的。 .class二进制文件中包含了类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)
可以通过 javap -v XXXX.class > XXXX.txt ,JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。如下:

Classfile /C:/TEST/StringPoolTest.class
  Last modified 2019-4-18; size 808 bytes
  MD5 checksum d59e238977e53fcd0934878aef20b0c4
  Compiled from "StringPoolTest.java"
public class StringPoolTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #15.#28        // java/lang/Object."<init>":()V
   #2 = Class              #29            // java/lang/StringBuilder
   #3 = Methodref          #2.#28         // java/lang/StringBuilder."<init>":()V
   #4 = Class              #30            // java/lang/String
   #5 = String             #31            // abcd
   #6 = Methodref          #4.#32         // java/lang/String."<init>":(Ljava/lang/String;)V
   #7 = Methodref          #2.#33         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #8 = String             #34            // efg
   #9 = Methodref          #2.#35         // java/lang/StringBuilder.toString:()Ljava/lang/String;
  #10 = Methodref          #4.#36         // java/lang/String.intern:()Ljava/lang/String;
  #11 = String             #37            // abcdefg
  #12 = Fieldref           #38.#39        // java/lang/System.out:Ljava/io/PrintStream;
  #13 = Methodref          #40.#41        // java/io/PrintStream.println:(Z)V
  #14 = Class              #42            // StringPoolTest
  #15 = Class              #43            // java/lang/Object
  #16 = Utf8               <init>
  #17 = Utf8               ()V
  #18 = Utf8               Code
  #19 = Utf8               LineNumberTable
  ...

其中 Constant pool 就是.class文件的常量池,也可以称为“静态常量池”。是在编译时生成的,也是一切的开始。(.class文件中详细的结构这里不做说明,可以自行查阅资料)

运行时常量池(runtime constant pool)

jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中。
在这里插入图片描述 相较于Class文件常量池,运行时常量池更具动态性,在运行期间也可以将新的变量放入常量池中,而不是一定要在编译时确定的常量才能放入。最主要的运用便是String类的intern()方法,这在后面会详细说明。

字符串常量池(string pool / string literal pool)

String是非常特殊的,作为最基础、使用最多的引用数据类型,Java 设计者为 String 提供了字符串常量池以提高其性能。总是为池中每个唯一的字符串对象维护一个引用,这就意味着它们一直引用着字符串常量池中的对象,所以,在常量池中的这些字符串不会被垃圾收集器回收(划个重点,引用)
String pool其实就是一个 hashtable,key是“字面量”,val是内存引用。其位置是在哪里呢?如下图:

在这里插入图片描述

上面的图结构都是根据 jdk1.6 所画。在 jdk1.7 时,运行时常量池+字符串常量池 都被移动到了java堆中

String.intern()

该方法简单来说,就是返回字符串常量池中的引用,但是当字符串常量池中没有该字符串时(通过equals比较字面量),则会将该字符串加入到字符串常量池中,并返回引用。
上面有提到,jdk1.7时,常量池被移到了java堆中,所以该方法的操作有了变化:

  1. jdk1.6:当字符串常量池中没有该字符串时,则会将堆中的字符串实例复制一份,放到永久代中,然后再将永久代中的字符串实例的引用给放到字符串常量池中,作为驻留字符串。
  2. jdk1.7:当字符串常量池中没有该字符串时,则会将堆中的字符串实例的引用,直接放到字符串常量池中,作为驻留字符串。因为常量池同样是在堆中,所以不做额外创建对象,减少开销。
    在这里插入图片描述

问题一

让我们回到最初的问题

// 创建了几个实例
String a = new String("abc");// 2个

为什么是2个,哪2个:

  1. 字符串常量池所引用的 “abc” 实例
  2. new String(“abc”) 在堆中创建的实例

题外话:注意,这里说的是“实例”,不是“对象”,对象是 a,放在 栈中的,并且有一个堆中 new String(“abc”)的 “引用”。

那这两个实例是怎么来的呢?让我们从头开始一步步来:

编写java代码:

public class Test_1{	
	public static void main(String[] args){
	    String a = new String("abc");
	}
}

然后javac编译.class文件,通过javap -v Test_1.class > Test_1.txt 命令将class文件反编译:本机使用的是jdk1.8.0_181

Classfile /C:/TEST/csdnTest/Test_1.class
  Last modified 2019-4-19; size 336 bytes
  MD5 checksum b2acc1d46ab5c1b54e1e1ab90501f0c6
  Compiled from "Test_1.java"
public class Test_1
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Class              #16            // java/lang/String
   #3 = String             #17            // abc
   #4 = Methodref          #2.#18         // java/lang/String."<init>":(Ljava/lang/String;)V
   #5 = Class              #19            // Test_1
   #6 = Class              #20            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               Test_1.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Utf8               java/lang/String
  #17 = Utf8               abc
  #18 = NameAndType        #7:#21         // "<init>":(Ljava/lang/String;)V
  #19 = Utf8               Test_1
  #20 = Utf8               java/lang/Object
  #21 = Utf8               (Ljava/lang/String;)V
{
  public Test_1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class java/lang/String
         3: dup
         4: ldc           #3                  // String abc
         6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
         9: astore_1
        10: return
      LineNumberTable:
        line 4: 0
        line 5: 10
}
SourceFile: "Test_1.java"

ok,现在让我们来看看是个什么情况

首先,我们来看下 class文件的常量池,也就是 Constant pool,常量池的每一项都有对应的数据结构,分2大类,字面量和符号引用,其中 Utf8 是字面量,String 是引用,分别对应类型 “CONSTANT_Utf8_info”、“CONSTANT_String_info”。
其他类型的在这我们就不关注了,感兴趣可以自行查资料。

提取我们关心的数据:

  ...
  #3 = String             #17            // abc
  ...
  #17 = Utf8               abc
  ...

可以看到字面量 “abc”,还有个 String 类型 对于 “abc”(#17)的引用。
其中 #3 、#17 是什么?通俗点说,可以理解为是“占位符”,给什么占位的呢?当然是内存地址啦。在编译class文件的时候,编译器并不知道该字面量 “abc” 在内存中实际放哪里,也不知道 String 引用的是哪个 内存地址,所以在编译的时候,就先拿个“占位符” 占坑。

接下来就是执行的时候了
上面在介绍运行时常量池的时候有说过:
jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。
所以按步骤的大致流程:

  1. 加载:将Test_1.class文件加载到jvm中,并将 class文件中的常量池(Constant pool)放入运行时常量池中
  2. 验证:.class文件结构是否符合规范
  3. 准备:为常量、静态变量等数据分配空间
  4. 解析 resolve:将.class文件中的符号引用,转换成直接引用(内存地址都分配好了,当然能填坑了),并且将String 类型常量放入字符串常量池中,同步 字符串常量池 和 运行时常量池 中的引用,既:将两个常量池中的字符串引用保持一致(该步骤可以lazy)
  5. 初始化:初始化数据

在准备阶段之后,字面量“abc”会在堆(疑惑点:不太清楚 jdk1.6 字面量实例是在堆中生成还是在永久代中生成,为了方便记忆,自认为是在堆中)中创建实例,解析的时候,会将运行时常量池中的符号引用给替换了

所以第一个对象就生成了
在这里插入图片描述等等,上面不是说:第一个对象是:字符串常量池所引用的 “abc” 实例
可这图片上字符串常量池里没有引用啊!!!

没错,当类加载完后,对象是有了,但字符串常量池中没有引用。
为什么呢?
理论上字面量进入字符串常量池的时机,是在类加载的resolve阶段,但是,JVM规范里明确指定resolve阶段可以是lazy的

CONSTANT_Utf8会在类加载的过程中就全部创建出来,而CONSTANT_String则是lazy resolve的,例如说在第一次引用该项的ldc指令被第一次执行到的时候才会resolve

看到这里想必也就明白了, 就HotSpot VM的实现来说,加载类的时候,那些字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池。未resolve的String引用会标记为:JVM_CONSTANT_UnresolvedString,内容跟Class文件里一样只是一个index引用,等到resolve过后这个项的常量类型就会变成最终的JVM_CONSTANT_String。

ldc指令是什么东西?

简单地说,它用于将int、float或String型常量值从常量池中推送至栈顶

ldc字节码的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找该index对应的项,如果该项尚未resolve则resolve之,并返回resolve后的内容。
在遇到String类型常量时,resolve的过程如果发现StringTable已经有了内容匹配的java.lang.String的引用,则直接返回这个引用,反之,如果StringTable里尚未有内容匹配的String实例的引用,则会将resolve的String类型的常量的引用记录在StringTable中,并返回这个引用出去。

了解ldc指令后,我们回到class文件中,找找ldc指令在哪:

 ...
  #3 = String             #17            // abc
  ...
  #17 = Utf8               abc
  ...
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class java/lang/String
         3: dup
         4: ldc           #3                  // String abc
         6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
         9: astore_1
        10: return
      LineNumberTable:
        line 4: 0
        line 5: 10

可以看到是在main方法的执行中,执行了ldc指令,去找 #3 的 String 引用,发现该String 引用状态是:JVM_CONSTANT_UnresolvedString,所以先进行 resolve解析,将 #3 String 指向 #17 abc。
然后去StringTable中查找是否有字面量abc的引用:
有:则直接返回StringTable里的引用,将其推送至栈顶(只做了#3 String 的resolve)
无:则将 #3 String 的实例引用放入StringTable, 并将其返回推送至栈顶(#3 String 的 resolve 并在 StringTable 中驻留)

至此,字符串的引用建立了,并且可以看到main方法第一条就是new // class String, 所以新创建了一个String对象

问题二

例1:

String a = "abc";
String b = new String("abc");
// 输出什么
System.out.println(a == b);// false

第一行:使用ldc指令,在字符串常量池中查找是否有字面量"abc"驻留,没有则将字面量"abc"驻留,并返回引用,赋值给a
第二行:new String(“abc”) ,并将引用赋值给b
a(字面量abc)!= b(堆中new String(abc))

例2:

String a = new String("abc") + new String("def");
String b = "abcdef";
// 输出什么
System.out.println(a == b); // false

第一行:abc,def在字符串常量池中驻留,并在堆中创建 new String(“abcdef”)对象,赋值给a
第二行:ldc指令,将字面量abcdef在字符串常量池中驻留,并将引用赋值给b
a(堆中new String(abcdef))!= b(字面量abcdef)

例3:

String a = new String("abc") + new String("def");
a.intern();
String b = "abcdef";
// 输出什么
System.out.println(a == b); 

第一行:abc,def在字符串常量池中驻留,并在堆中创建 new String(“abcdef”)对象,赋值给a
第二行:到字符串常量池中查找字面量"abcdef",发现没有
jdk1.6 >> new String(abcdef)复制一份存放到永久代中,并将永久代对象的引用驻留,返回引用
jdk1.7 >> 直接将new String(abcdef)的引用驻留,并返回引用
第三行:ldc指令,到字符串常量池中查找"abcdef",发现有驻留,返回引用,并将引用对象推至栈顶,赋值个b
jdk1.6 >> a(new String(abcdef))!= b(永久代abcdef)
jdk1.7 >> a(new String(abcdef))== b(new String(abcdef))

例4:

	public static String z = "abcdef";
	
	public static void main(String[] args){
	    String a = new String("abc")+new String("def");
		a.intern();
		String b = "abcdef";
		
		System.out.println(a == b);// false
	}

和上例代码一样,只是多了个z,并且z是静态变量,会在类初次加载时初始化,可以使用javap查看:

...
  static {};
    flags: ACC_STATIC

    Code:
      stack=1, locals=0, args_size=0
         0: ldc           #11                 // String abcdef
         2: putstatic     #14                 // Field z:Ljava/lang/String;
         5: return        
      LineNumberTable:
        line 3: 0

可以看到在初始化的时候就直接调用ldc指令,将其放入了字符串常量池中

总结

  1. 常量池:Class文件常量池、 运行时常量池、字符串常量池
  2. 类加载时, Class文件常量池 放入 运行时常量池,并在解析阶段,将间接引用 转换 直接引用
  3. 字符串常量池进入的时机,一般在使用ldc指令时,去字符串常量池中查看并驻留

Java的8种包装类(Byte, Short, Integer, Long, Character, Boolean, Float, Double), 除Float和Double以外, 其它六种都实现了常量池, 但是它们只在大于等于-128并且小于等于127时才使用常量池。Boolean则是缓存了true和false。

以Integer为例:
Integer i = 1;
自动装箱变为
Integer i = Integer.valueOf(1);

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

查看代码发现,比较 i 是否在 [-128,127]区间内,在则从 IntegerCache.cache 的缓存中通过下标直接返回
否则 new Integer(i)返回(Integer是不变模式)
在这里插入图片描述IntegerCache在初始化时,通过static初始化了cache 数组,以此来实现常量池

参考:
https://www.zhihu.com/question/55994121/answer/147296098
https://rednaxelafx.iteye.com/blog/774673
https://www.zhihu.com/question/29884421/answer/113785601
https://blog.csdn.net/xiao______xin/article/details/81985654
https://blog.csdn.net/zhangzeyuaaa/article/details/10026357

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值