Java基础-JVM内存管理-常量池与运行时常量池

Java工程师知识树 / Java基础


常量池

JVM的常量池主要有以下几种:

  • class文件常量池
  • 运行时常量池
  • 字符串常量池
  • 基本类型包装类常量池

相关之间的关系为:

图解说明:

  1. 每个class的字节码文件中都有一个常量池,里面是编译后即知的该class会用到的字面量符号引用,这就是class文件常量池。JVM加载class,会将其类信息,包括class文件常量池置于方法区中。
  2. class类信息及其class文件常量池是字节码的二进制流,它代表的是一个类的静态存储结构,JVM加载类时,需要将其转换为方法区中的java.lang.Class类的对象实例;同时,会将class文件常量池中的内容导入运行时常量池
  3. 运行时常量池中的常量对应的内容只是字面量,比如一个"字符串",它还不是String对象;当Java程序在运行时执行到这个"字符串"字面量时,会去字符串常量池里找该字面量的对象引用是否存在,存在则直接返回该引用,不存在则在Java堆里创建该字面量对应的String对象,并将其引用置于字符串常量池中,然后返回该引用。
  4. Java的基本数据类型中,除了两个浮点数类型,其他的基本数据类型都在各自内部实现了常量池,但都在[-128~127]这个范围内。

class文件常量池

测试代码:

public class Test2{
    public static void main(String[] args) {
        int ct = 0;
        for (int i = 0; i < 100; i++) {
            ct++;
        }
        System.out.println("ct:"+ct);
    }
    public void test(){
        String str = "test";
        System.out.println(str);
    }
}

使用反编译命令:javap -verbose Test2.class

public class Test2
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:      // 以下就是class文件常量池 使用#加数字标记每个“常量”。
   #1 = Methodref          #12.#23        // java/lang/Object."":()V
   #2 = Fieldref           #24.#25        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = Class              #26            // java/lang/StringBuilder
   #4 = Methodref          #3.#23         // java/lang/StringBuilder."":()V
   #5 = String             #27            // ct:
   #6 = Methodref          #3.#28         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #7 = Methodref          #3.#29         // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
   #8 = Methodref          #3.#30         // java/lang/StringBuilder.toString:()Ljava/lang/String;
   #9 = Methodref          #31.#32        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #10 = String             #33            // zifuchuan
  #11 = Class              #34            // Test2
  #12 = Class              #35            // java/lang/Object
  #13 = Utf8               
  #14 = Utf8               ()V
  #15 = Utf8               Code
  #16 = Utf8               LineNumberTable
  #17 = Utf8               main
  #18 = Utf8               ([Ljava/lang/String;)V
  #19 = Utf8               StackMapTable
  #20 = Utf8               testT
  #21 = Utf8               SourceFile
  #22 = Utf8               Test2.java
  #23 = NameAndType        #13:#14        // "":()V
  #24 = Class              #36            // java/lang/System
  #25 = NameAndType        #37:#38        // out:Ljava/io/PrintStream;
  #26 = Utf8               java/lang/StringBuilder
  #27 = Utf8               ct:
  #28 = NameAndType        #39:#40        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #29 = NameAndType        #39:#41        // append:(I)Ljava/lang/StringBuilder;
  #30 = NameAndType        #42:#43        // toString:()Ljava/lang/String;
  #31 = Class              #44            // java/io/PrintStream
  #32 = NameAndType        #45:#46        // println:(Ljava/lang/String;)V
  #33 = Utf8               zifuchuan
  #34 = Utf8               Test2
  #35 = Utf8               java/lang/Object
  #36 = Utf8               java/lang/System
  #37 = Utf8               out
  #38 = Utf8               Ljava/io/PrintStream;
  #39 = Utf8               append
  #40 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #41 = Utf8               (I)Ljava/lang/StringBuilder;
  #42 = Utf8               toString
  #43 = Utf8               ()Ljava/lang/String;
  #44 = Utf8               java/io/PrintStream
  #45 = Utf8               println
  #46 = Utf8               (Ljava/lang/String;)V
{...}

class文件常量池存放的是该class编译后即知的,在运行时将会用到的各个“常量”。

注意这个常量不是编程中所说的final修饰的变量,而是字面量符号引用,如下图所示:

字面量

字面量大约相当于Java代码中的双引号字符串和常量的实际的值,包括:

1.文本字符串,即代码中用双引号包裹的字符串部分的值。

例如刚刚的例子中,有三个字符串:"zifuchuan""ct:",它们在class文件常量池中分别对应:

  #33 = Utf8               zifuchuan
  #27 = Utf8               ct:

这里的#49就是"张三"的字面量,它不是一个String对象,只是一个使用utf8编码的文本字符串而已。

2.用final修饰的成员变量,例如,private static final int entranceAge = 18;这条语句定义了一个final常量entranceAge,它的值是18,对应在class文件常量池中就会有:#25 = Integer 18

#25 = Integer            18

注意,只有final修饰的成员变量如entranceAge,才会在常量池中存在对应的字面量。而非final的成员变量scores,以及局部变量base(即使使用final修饰了),它们的字面量都不会在常量池中定义。

符号引用

符号引用包括

1.类和接口的全限定名,例如:

   #3 = Class              #26            // java/lang/StringBuilder
  #26 = Utf8               java/lang/StringBuilder

2.方法的名称和描述符,例如:

#20 = Utf8               testT

以及这种对其他类的方法的引用:

  #9 = Methodref          #31.#32        // java/io/PrintStream.println:(Ljava/lang/String;)V

  #31 = Class              #44            // java/io/PrintStream
  #32 = NameAndType        #45:#46        // println:(Ljava/lang/String;)V

  #44 = Utf8               java/io/PrintStream
  #45 = Utf8               println
  #46 = Utf8               (Ljava/lang/String;)V

3.字段的名称和描述符,例如:

  #2 = Fieldref           #24.#25        // java/lang/System.out:Ljava/io/PrintStream;

  #24 = Class              #36            // java/lang/System
  #25 = NameAndType        #37:#38        // out:Ljava/io/PrintStream;

  #36 = Utf8               java/lang/System
  #37 = Utf8               out
  #38 = Utf8               Ljava/io/PrintStream;

以及这种局部变量:

  #33 = Utf8               zifuchuan

运行时常量池

运行时常量池包括导入class文件常量池的内容和符号引用对应的直接引用(实际内存地址)。

JVM在加载某个class的时候,需要完成以下任务:

  1. 通过该class的全限定名来获取它的二进制字节流,即读取其字节码文件。
  2. 将读入的字节流从静态存储结构转换为方法区中的运行时的数据结构。
  3. 在Java堆中生成该class对应的类对象,代表该class原信息。这个类对象的类型是java.lang.Class,它与普通对象不同的地方在于,普通对象一般都是在new之后创建的,而类对象是在类加载的时候创建的,且是单例。

而上述过程的第二步,就包含了将class文件常量池内容导入运行时常量池。class文件常量池是一个class文件对应一个常量池,而运行时常量池只有一个,多个class文件常量池中的相同字符串只会对应运行时常量池中的一个字符串。

运行时常量池除了导入class文件常量池的内容,还会保存符号引用对应的直接引用(实际内存地址)。这些直接引用是JVM在类加载之后的链接(验证、准备、解析)阶段从符号引用翻译过来的。

此外,运行时常量池具有动态性的特征,它的内容并不是全部来源与编译后的class文件,在运行时也可以通过代码生成常量并放入运行时常量池。

要注意的是,运行时常量池中保存的“常量”依然是字面量符号引用。比如字符串,这里放的仍然是单纯的文本字符串,而不是String对象。

字符串常量池

字符串常量池由来

在日常开发过程中,字符串的创建是比较频繁的,而字符串的分配和其他对象的分配是类似的,需要耗费大量的时间和空间,从而影响程序的运行性能,所以作为最基础最常用的引用数据类型,Java设计者在JVM层面提供了字符串常量池。

实现前提

  1. 实现这种设计的一个很重要的因素是:String类型是不可变的,实例化后,不可变,就不会存在多个同样的字符串实例化后有数据冲突;
  2. 运行时,实例创建的全局字符串常量池中会有一张表,记录着长相持中每个唯一的字符串对象维护一个引用,当垃圾回收时,发现该字符串被引用时,就不会被回收。

实现原理

为了提高性能并减少内存的开销,JVM在实例化字符串常量时进行了一系列的优化操作:

  1. 在JVM层面为字符串提供字符串常量池,可以理解为是一个缓存区;
  2. 创建字符串常量时,JVM会检查字符串常量池中是否存在这个字符串;
  3. 若字符串常量池中存在该字符串,则直接返回引用实例;若不存在,先实例化该字符串,并且,将该字符串放入字符串常量池中,以便于下次使用时,直接取用,达到缓存快速使用的效果。

字符串常量池位置变化

方法区

提到字符串常量池,还得先从方法区说起。方法区和Java堆一样(但是方法区是非堆),是各个线程共享的内存区域,是用于存储已经被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

很多人会把方法区称为永久代,其实本质上是不等价的,只不过HotSpot虚拟机设计团队是选择把GC分代收集扩展到了方法区,使用永久代来代替实现方法区。其实,在方法区中的垃圾收集行为还是比较少的,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,但是这个区域的回收总是不尽如人意的,如果该区域回收不完全就会出现内存泄露。当然,对于JDK1.8时,HostSpot VM对JVM模型进行了改造,将元数据放到本地内存,将常量池和静态变量放到了Java堆里。

元空间

JDK 1.8, HotSpot JVM将永久代移除了,使用本地内存来存储类的元数据信息,即为元空间(Metaspace)

所以,字符串常量池的具体位置是在哪里?当然这个我们后面需要区分jdk的版本,jdk1.7之前,jdk1.7,以及jdk1.8,因为这些版本中,字符串常量池因为方法区的改变而做了一些变化。

JDK1.7之前

在jdk1.7之前,常量池是存放在方法区中的。

JDK1.7

在jdk1.7中,字符串常量池移到了堆中,运行时常量池还在方法区中。

JDK1.8

jdk1.8删除了永久代,方法区这个概念还是保留的,但是方法区的实现变成了元空间,常量池沿用jdk1.7,还是放在了堆中。这样的效果就变成了:常量池与静态变量存储到了堆中,类的元数据及运行时常量池存储到元空间中。

为啥要把方法区从JVM内存(永久代)移到直接内存(元空间)?

主要有两个原因:

  1. 直接内存属于本地系统的IO操作,具有更高的一个IO操作性能,而JVM的堆内存这种,如果有IO操作,也是先复制到直接内存,然后再去进行本地IO操作。经过了一系列的中间流程,性能就会差一些。非直接内存操作:本地IO操作——>直接内存操作——>非直接内存操作——>直接内存操作——>本地IO操作,而直接内存操作:本地IO操作——>直接内存操作——>本地IO操作
  2. 永久代有一个无法调整更改的JVM固定大小上限,回收不完全时,会出现OutOfMemoryError问题;而直接内存(元空间)是受到本地机器内存的限制,不会有这种问题。

总结:

  1. 在JDK1.7前,运行时常量池+字符串常量池是存放在方法区中,HotSpot VM对方法区的实现称为永久代。
  2. 在JDK1.7中,字符串常量池从方法区移到堆中,运行时常量池保留在方法区中。
  3. 在JDK1.8中,HotSpot移除永久代,使用元空间代替,此时字符串常量池保留在堆中,运行时常量池保留在方法区中,只是实现不一样了,JVM内存变成了直接内存。

基本类型包装类常量池

除了字符串常量池,Java的基本类型的封装类大部分也都实现了常量池。包括Byte,Short,Integer,Long,Character,Boolean,注意,浮点数据类型Float,Double是没有常量池的。

封装类的常量池是在各自内部类中实现的,比如IntegerCache(Integer的内部类),自然也位于堆区。

要注意的是,这些常量池是有范围的:

  • Byte,Short,Integer,Long : [-128~127]
  • Character : [0~127]
  • Boolean : [True, False]

你可能感兴趣的:(Java基础-JVM内存管理-常量池与运行时常量池)