JVM笔记:Java虚拟机的常量池

这篇文章主要是做一个总结,将查找到相关的资料自己做一个整理,最后会列出查找过的相关资料,感兴趣的可以去翻一翻。

常量池

  • class文件常量池(class constant pool)

    常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,包含了类也是占用Class文件中第一个出现的表类型数据项目。

    常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包含了下面三类常量:

    • 类和接口的全限定名(Full Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符

    类和接口的全限定名,例如:com/example/demo/Demo.class

    字段的名称和描述符,例如:Field a:[Ljava/lang/String

    方法的名称和描述符,例如:Method java/lang/String."<init>":(Ljava/lang/String;)V

    后两个是字节码指令,不懂得可以查阅下相关资料(TODO)
    +
    可以通过查看字节码的形式来查看Class的常量池的内容,因为是在编译时产生的,也可以称为静态常量池

public class Main {
   private int a=1;
   private int b=1;
   private Aload c=new Aload();
   private String [] d =new String[10];
   public static void main(String[] args) {

   }
}
字节码:
public class com.verzqli.snake.Main
 minor version: 0
 major version: 51
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool: //这里就是class文件的常量池
  #1 = Methodref          #10.#30        // java/lang/Object."<init>":()V
  #2 = Fieldref           #9.#31         // com/verzqli/snake/Main.a:I
  #3 = Fieldref           #9.#32         // com/verzqli/snake/Main.b:I
  #4 = Class              #33            // com/verzqli/snake/Aload
  #5 = Methodref          #4.#30         // com/verzqli/snake/Aload."<init>":()V
  #6 = Fieldref           #9.#34         // com/verzqli/snake/Main.c:Lcom/verzqli/snake/Aload;
  #7 = Class              #35            // java/lang/String
  #8 = Fieldref           #9.#36         // com/verzqli/snake/Main.d:[Ljava/lang/String;
  #9 = Class              #37            // com/verzqli/snake/Main
 #10 = Class              #38            // java/lang/Object
 #11 = Utf8               a
 #12 = Utf8               I
 #13 = Utf8               b
 #14 = Utf8               c
 #15 = Utf8               Lcom/verzqli/snake/Aload;
 #16 = Utf8               d
 #17 = Utf8               [Ljava/lang/String;
 #18 = Utf8               <init>
 #19 = Utf8               ()V
 #20 = Utf8               Code
 #21 = Utf8               LineNumberTable
 #22 = Utf8               LocalVariableTable
 #23 = Utf8               this
 #24 = Utf8               Lcom/verzqli/snake/Main;
 #25 = Utf8               main
 #26 = Utf8               ([Ljava/lang/String;)V
 #27 = Utf8               args
 #28 = Utf8               SourceFile
 #29 = Utf8               Main.java
 #30 = NameAndType        #18:#19        // "<init>":()V
 #31 = NameAndType        #11:#12        // a:I
 #32 = NameAndType        #13:#12        // b:I
 #33 = Utf8               com/verzqli/snake/Aload
 #34 = NameAndType        #14:#15        // c:Lcom/verzqli/snake/Aload;
 #35 = Utf8               java/lang/String
 #36 = NameAndType        #16:#17        // d:[Ljava/lang/String;
 #37 = Utf8               com/verzqli/snake/Main
 #38 = Utf8               java/lang/Object
  • 运行时常量池

    当java文件被编译成class文件之后,就会生成上面的常量池,在Class文件中描述的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。
    类从被加载到虚拟机内存中开始,到卸载出内存位置,他的生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initalization)、使用(Using)和卸载(Unloading),其中验证、准备、解析三个部分统称Wie连接(Linking)。

    而当类加载到内存中后,JVM就会将Class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在解析过程中需要将常量池中所有的符号引用(classes、interfaces、fields、methods referenced in the constant pool)转为直接引用(得到类或者字段、方法在内存中的指针或者偏移量,以便直接调用该方法)。直接引用可以是内存中,直接指向目标的指、相对偏移量,或是一个能间接定位到目标的句柄,解析的这个阶段其实就是将符号引用转换为可以直接定位对象等在内存中的位置的直接引用。

    运行时常量池位于JVM规范的方法区中,在Java8以前,位于永生代;Java8之后位于元空间。

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

    全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中。在HotSpot中具体实现string pool这一功能的是StringTable类,它是一个哈希表,里面存的是key(字面量“abc”, 即驻留字符串)-value(字符串"abc"实例对象在堆中的引用)键值对,StringTable本身存在本地内存(native memory)中。

    StringTable在每个HotSpot VM的实例只有一份,被所有的类共享(享元模式)。在Java7的时候将字符串常量池移到了堆里,同时里面也不在存放对象(Java7以前被intern的String对象存放于永生代,所以很容易造成OOM),而是存放堆上String实例对象的引用。

    那么字符串常量池中引用的String对象是在什么时候创建的呢?在JVM规范里明确指定resolve阶段可以是lazy的,即在需要进行该符号引用的解析时才去解析它,这样的话,可能该类都已经初始化完成了,如果其他的类链接到该类中的符号引用,需要进行解析,这个时候才会去解析。

    这时候就需要ldc这个字节码指令,其作用是将int、float或String型常量值从常量池中推送至栈顶,如下面这个例子。

public class Main {
    public static void main(String[] args) {
      String a="B";
    }
}
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: ldc           #2                  // String B
         2: astore_1
         3: return
      LineNumberTable:
        line 14: 0
        line 15: 3
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  args   [Ljava/lang/String;
            3       1     1     a   Ljava/lang/String;
}

在main方法的字节码中使用ldc将字符串“B”推到栈顶,然后赋值给局部变量a,最后退出。

根据上面说的,在类加载阶段,这个 resolve 阶段( constant pool resolution )是lazy的。换句话说并没有真正的对象,字符串常量池里自然也没有,那么ldc指令还怎么把人推送至栈顶?或者换一个角度想,既然resolve 阶段是lazy的,那总有一个时候它要真正的执行吧,是什么时候?执行ldc指令就是触发这个lazy resolution动作的条件

ldc字节码在这里的执行语义是:到当前类的运行时常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找该index对应的项,如果该项尚未resolve则resolve之,并返回resolve后的内容。

在遇到String类型常量时,resolve的过程如果发现StringTable已经有了内容匹配的java.lang.String的引用,则直接返回这个引用,反之,如果StringTable里尚未有内容匹配的String实例的引用,则会在Java堆里创建一个对应内容的String对象,然后在StringTable记录下这个引用,并返回这个引用出去。可见,ldc指令是否需要创建新的String实例,全看在第一次执行这一条ldc指令时,StringTable是否已经记录了一个对应内容的String的引用。

public class Main {
    String a="b";
    public static void main(String[] args) {
    }
}

public com.verzqli.snake.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: ldc           #2                  // String b
         7: putfield      #3                  // Field a:Ljava/lang/String;
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/verzqli/snake/Main;

上面例子执行完main方法后,“b”就不会进入字符串常量池。因为String a = "b"是Main类的成员变量,成员变量只有在执行到构造方法的时候才会初始化。

往细讲,只有执行了ldc指令的字符串才会进入字符串常量池

至于ldc指令的工作原理可以看这篇文章

String.intern()

当一个字符串对象调用这个intern方法时,如果该字符串常量池中不包含该对象引用,也即StringTable不包含该对象字面量和引用时,将该字符串对象引用存入字符串常量中 ,同时返回该地址。这样做的目的是为了提升性能,降低开销,后续如果定义相同字面量的字符串即可返回该引用(内存地址),不必再在堆上创建字符串实例。

  • 实例(以下实例环境为JDK7以后)

          String a="c";
          String b = new String("c");
          System.out.println("a==b.intern()="+(a==b.intern()));
          System.out.println("b==b.intern()="+(b==b.intern()));
          
          结果:
          a==b.intern()=true
          b==b.intern()=false
    

    类加载阶段,什么都没干。

    然后运行main方法,创建“c”对象 ,假设其地址为0xeee,将其加入字符串常量池。随后在堆上创建了String对象b,假设其地址为0xfff

    这里b.intern()检测到了字符串常量池中包含“c”这个字符串引用,所以其返回的是0xeee,而b指向的依旧是0xfff,所以第一个为true,第二个为false

         String a = new String("hellow") + new String("orld");
         String b = new String("hello") + new String("world");
         System.out.println("a==a.intern()="+(a==a.intern()));
         System.out.println("a==b.intern()="+(a==b.intern()));
         System.out.println("b==b.intern()="+(b==b.intern()));
    
       结果:
      a==b.intern()=true
      a==b.intern()=true
      b==b.intern()=false
    

    类加载阶段,什么都没干。

    然后运行main方法,创建“hellow”,"orld"对象,并放入字符串常量池。然后会创建一个"helloworld"对象,没有放入字符串常量池,a指向这个"helloworld"对象(0xeee)。

    接着创建“hello”,"world"对象,同样也创建一个"helloworld"对象,也没有放入字符串常量池,b指向这个"helloworld"对象地址(0xfff)。

    这时候第一个判断,字符串常量池没有“helloworld”这个字符串对象引用,所以将a的引用(0xeee)放入字符串常量池,也就是说池子中的引用和a的引用(0xeee)是一样的,所以a==a.intern()

    b.intern()时因为上一部字符串常量池中已经有了这个“helloworld”的引用,所以他返回回去的引用(0xeee)就是a的引用,所以a==b.intern()

    从上面可以清楚的知道b.intern()返回的是0xfff,而b引用地址为0xfff,所以b!=b.intern()

        //        String a1="helloworld";
        String a = new String("hello")+new String("world");
        System.out.println("a==a=" + (a == a.intern()));
    

    这里的结果如果a1没有被注释则为false,注释了则为true,原理同上,可以自己脑补一下。

  • JVM对字符串的优化

          String a = "hello";
        String b = a+"world";
        String c = "helloworld";
        String d = "hello"+"world";
        System.out.println(b==c); false
        System.out.println(d==c); true
        System.out.println(b==d); false
        
            Code:
      stack=3, locals=5, args_size=1
         0: ldc           #4                  // String hello //ldc指令创建字符串对象“hello”
         2: astore_1                          // 将a从放入局部变量表(第一个局部变量,第0个是this)
         3: new           #5                  // class java/lang/StringBuilder //创建StringBuilder对象
         6: dup                               // 复制栈顶数据(创建StringBuilder对象)压入栈中
         7: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V 
        10: aload_1                           // 从局部变量中载入a到栈中
        11: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; //可以看出字符串相加在字节码里就是StringBuilder的append
        14: ldc           #8                  // String world /ldc指令创建字符串对象“world”
        16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;//继续append
        19: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String; //相加完毕,隐形的调用toString生成String对象返回
        22: astore_2                          // 将b放入局部变量表(第二个局部变量)   
        23: ldc           #10                 // String helloworld  //ldc指令创建字符串对象“helloworld”
        25: astore_3                          // 将c放入局部变量表(第三个局部变量) 
        26: ldc           #10                 // String helloworld  //这里字符串常量池中已经包含了helloworld,就不会再创建,直接引用,而且这个helloworld是"hello"+"world"拼接的,这就是JVM对字符串的优化
        28: astore        4                   // 将d放入局部变量表(第四个局部变量) 
        30: getstatic     #11                 // Field java/lang/System.out:Ljava/io/PrintStream; //调用静态方法打印
        33: aload_2                           // 从局部变量表加载b入栈
        34: aload_3                           // 从局部变量表加载c入栈
        35: if_acmpne     42                  // 比较两个对象的引用类型 下面四行就是一个if else 语句,如果相等就直接doto打印结果,
        38: iconst_1                          // 获得两个引用是否相等的结果(true为1,false为0),将1入栈
        39: goto          43                  // 跳转到43行 直接打印出结果
        42: iconst_0                          // 两引用不相等,将0入栈 
        43: invokevirtual #12                 // Method java/io/PrintStream.println:(Z)V
        46: getstatic     #11                 // Field java/lang/System.out:Ljava/io/PrintStream;
        后续都是相同的意思,这里就不注释了。
        49: aload         4
        51: aload_3
        52: if_acmpne     59
        55: iconst_1
        56: goto          60
        59: iconst_0
        60: invokevirtual #12                 // Method java/io/PrintStream.println:(Z)V
        63: getstatic     #11                 // Field java/lang/System.out:Ljava/io/PrintStream;
        66: aload_2
        67: aload         4
        69: if_acmpne     76
        72: iconst_1
        73: goto          77
        76: iconst_0
        77: invokevirtual #12                 // Method java/io/PrintStream.println:(Z)V
        80: return
    

    从上面的字节码可以看出字符串的相加其实是new了一个StringBuilder来进行append,a和b不相等就是因为这已经是两个不同的对象了,引用也不相等。后续c和d相等是因为JVM对纯字符串想加做了调优,会在字节码中把他们直接相加后的值赋给局部变量,所以c和d指向的是同一个字符串。

    
        String a= "a";
        for (int i = 0; i < 3; i++) {
            a+="b";
        }
        
        Code:
      stack=2, locals=3, args_size=1
         0: ldc           #4                  // String a
         2: astore_1
         3: iconst_0
         4: istore_2
         5: iload_2
         6: iconst_3
         7: if_icmpge     36
        10: new           #5                  // class java/lang/StringBuilder
        13: dup
        14: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
        17: aload_1
        18: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        21: ldc           #2                  // String b
        23: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        26: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        29: astore_1
        30: iinc          2, 1
        33: goto          5
        36: return
    

    对于for循环中的字符串相加(3到33行就是for循环的内容),JVM就没有优化了,每次相加都是重新创建了StringBuilder,开销就是一个StringBuilder的几何倍数那么大,因而在循环中使用StringBuilder的append来替代直接相加。

  • 总结

    除了日常的如果觉得文章有错误,欢迎指出并交流。这里问一个问题,后续如果知道了再删除:字符串常量池和StringTable是一个东西吗,两者都是存的字符串引用,但是R大说过StringTable是存于本地内存(native memory),但是看过的文章都说的是字符串常量池位于java堆中,希望有知道的大佬可以告知一下。

  • 引用:

    彻底搞懂string常量池和intern

    JVM 常量池中存储的是对象还是引用呢?

    Java String实例的创建和常量池的关系及intern方法

    Java 中new String(“字面量”) 中 “字面量” 是何时进入字符串常量池的?

上一篇:有关Java字符串的一些知识


下一篇:Oracle和MySQL数据库的备份与恢复