(二)常量池的探索
JVM常量池主要包含四个常量池,分别是:Class文件常量池、运行时常量池、全局字符串常量池、以及基本类型包装类对象常量池。
一、class文件常量池
为了更加容易的理解本文所说的,我们先写一个程序来分析:
1 |
|
javac
编译之后,用javap -v
命令打开,可以看到:
1 |
|
(一)class 文件组成
任何一个class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。将任意一个有效的类或接口所应当满足的格式称为“class文件格式”,实际上它并不一定以磁盘文件的形式存在。“class文件”应当是一串二进制的字节流,无论以何种形式存在。接下来的分析都是基于JDK8环境,包括代码和具体的格式。
1、魔幻数
总共4字节,固定为0xCAFEBABE。class文件的第一个u4结构存储的就是魔数,魔数的唯一作用就是供虚拟机辨别是否是可执行的class文件,并不是只有class文件才有魔数。常见的后缀为jpg、jpeg、png、gif、zip、jar等等这些文件都是有魔数的。比如,我们借助 VScode 插件Hex Editor
可以打开字节码文件看看:
它的魔数确实为CA FE BA BE
。我们再看看png
的魔数:
它的魔数为89 50 4E 47
,确实不一样。关于魔幻数,不再赘述。
2、版本号
总共4字节,具体格式为,次版本号(2字节),主版本号(2字节)。本例子中,主版本号为0,次版本号为 60(0x3C)。
3、常量池
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。————《深入理解 Java 虚拟机》
上面揭示了为什么 JVM为什么会需要一个 class 文件以及class常量池的作用。事实上,class 文件是用来保存常量的一个中间媒介场所。在 JVM 运行时,需要把常量池中的常量加载到内存中,即从 class 常量池加载到运行时常量池中,方便在运行期间使用其中的常量和各种信息。
class 文件常量池是静态的,而运行时常量池是动态的,它可以不断地变化。
(1)常量池中存放的内容
常量池的容量计数器与其它不同,该计数器是从1开始真正计数的代表的是各个常量的索引,0不指向任何表,而是代表“不引用任何一个常量池中项目”的含义,下图看下常量池的数量是0x1E,代表十进制的30,则表示常量池中有30-1=29个表结构数据。
我们看看是不是有 29 个:
1 |
|
果然可以清晰看出总共是有29个常量(表)存储在常量池中。
先解释下上面常量池表的结构,第一列是索引号,也是存储的序号,第二列Methodref、String、Fieldref存储的则是表的类型,第三列存储的则是当前表存储的信息,第四列双斜杠后表示当前结构存储的具体的值起到说明的作用相当于注释,从上面的图片我们不仅可以看到常量池中有29项常量表。
常量池中有哪些表结构?
常量池中总共有17种表结构(截止JDK13),用以存储类中的字面量与符号引用,这些信息在虚拟机解析时会根据索引号找到具体值被加载进虚拟机中,这17种表结构涵盖了所有的java信息,所有的表如下所示:
因为每一种表结构的存储结构都不相同,因此我们只需要关键性的记住几种即可。
下面我们来具体分析。
什么是常量?一般的理解就是 : 用final修饰的变量是常量 ,以及在编译时期定义好的字符串。但是这种说法是不严谨的,准确来说 : 常量是final修饰的成员变量(实例变量)和静态变量(静态变量也只能是用static修饰的成员变量)。而用final修饰的局部变量(方法内)并不是常量,而是不可变量,它存储在栈中。
我们只来分析它的常量池部分,既然是常量池,那么其中存放的肯定是常量,那么什么是“常量”呢? class文件常量池主要存放两大常量:字面量和符号引用。
- 字面量: 字面量接近java语言层面的常量概念,主要包括:文本字符串,也就是我们经常申明的: public String s = “abc”;中的”abc”:
1 |
|
- 用final修饰的成员变量,包括静态变量、实例变量和局部变量,就是我们声明的:public final static int b = 10086:
1 |
|
而对于基本类型数据(甚至是方法中的局部变量),也就是上面的private int value = 1;常量池中只保留了他的的字段描述符I和字段的名称value,他们的字面量不会存在于常量池:
1 |
|
- 类和接口的全限定名,也就是java/lang/String;这样,将类名中原来的”.”替换为”/“得到的,主要用于在运行时解析得到类的直接引用,像这样:
1 |
|
- 字段的名称和描述符,字段也就是类或者接口中声明的变量,包括类级别变量和实例级的变量:
1 |
|
- 方法中的名称和描述符,也即参数类型+返回值:
1 |
|
可以看到,在常量池中,并没有找到final int temp = 10010;
。
4、访问标志
长度:2字节
用于识别类或接口的访问信息, 包括是否为类,是否public等:
1 |
|
可以看到各种标志位,比如:
1 |
|
5、类索引、父类索引、接口索引集合
这一项存储的信息主要就是确定当前类的类全限定名、父类全限定名、实现的接口的全限定名。这里的类全限定名、父类全限定名、接口全限定名引用的都是常量池中的信息。类索引、父类索引都是各使用一个u2类型的数据存储,Java支持多实现所以接口索引集合使用多个u2类型的数据进行存储。
6、字段表集合
集合是相同数据结构的无符号数或者表多个汇集在一起,加上前置的容量计数器来构成的。字段表集合结构自然也是这样的是由一个容量计数器加上多个字段表构成的。
1 |
|
7、方法表集合
方法表集由一个容量计数器和多个方法表以及属性表集合构成。一个方法表也是有三个部分(与字段表相同)访问标志、方法名称索引、方法描述符索引三项。与字段表基本一致,不一致的地方是每个方发表都会有自己的属性表集合,因为方法的代码会存储的code这个属性表中(很少有没有代码的方法)。
1 |
|
8、属性表集合
属性表集合是class文件结构里要介绍的最后一项,属性表集合并不会单独存在,会和字段表、方发表配合使用,作为这些表的补充存在。常见的方法编译后的代码也会存在code属性表中的,方法中定义的异常是存在Exceptions表中的。
二、运行时常量池
我们知道,JVM在执行某个类的时候,要经过加载、链接(验证、准备、解析)、初始化,在第一步加载的时候需要完成:
- 获取字节流:通过一个类的全限定名来获取此类的二进制字节流
- 进行转化:将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 创建类对象:在内存中生成一个类对象,代表加载的这个类,这个对象是java.lang.Class,它作为方法区这个类的各种数据访问的入口。
将class字节流代表的静态存储结构转化为方法区的运行时数据结构,其中就包含了class文件常量池进入运行时常量池的过程。这和数据从辅存进入内存有些相似,但又有不同。不同的类共用一个运行时常量池,同时在进入运行时常量池的过程中,多个class文件中常量池相同的字符串,多个class文件中常量池中相同的字符串只会存在一份在运行时常量池,这本质上是一种优化,主要解决内存过大的问题。
运行时常量池的作用是存储java class文件常量池中的符号信息,运行时常量池中保存着一些class文件中描述的符号引用,同时在类的解析阶段还会将这些符号引用翻译出直接引用(直接指向实例对象的指针,内存地址),翻译出来的直接引用也是存储在运行时常量池中。
运行时常量池相对于class文件常量池的另外一个特性是具备动态性,java语言并不要求常量一定只有编译器才产生,也就是并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。
三、全局字符串常量池
(一)Java中创建字符串对象的两种方式
1 |
|
第一种方式声明的字面量”helloworld”是在编译期就已经确定,它会直接进入class文件常量池中;当运行期间在全局字符串常量池中会保存它的一个引用.实际上最终还是要在堆上创建一个”helloworld”对象。
第二种方式方式使用了new String(),调用了String类的构造函数。new指令是创建一个类的实例对象并完成加载初始化的,因此这个字符串对象是在运行期才能确定的,创建的字符串对象是在堆内存上。
因此,s0 和 s1不是一个东西,运行:System.out.println(s0 == s1)
的结果肯定是 false
,它们的地址并不一样。
(二)Java 方法区运行时内存
对于JDK7,字符串常量池和静态变量被从方法区拿到了堆中,运行时常量池以及其它的信息还在方法区, 也就是hotspot中的永久代。
但对于 JDK8,hotspot移除了永久代,用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace),也就是内存中,这大大释放了虚拟机内存的空间,提高了性能。
四、JAVA 基本类型的封装类及对应常量池
Java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。
1 |
|
本文思维导图:
全文完,感谢你的阅读。
参考:
https://cloud.tencent.com/developer/article/1690589
https://juejin.cn/post/6844903732757397517
https://developer.aliyun.com/article/1130700
https://www.jianshu.com/p/d8492e748c57
https://cloud.tencent.com/developer/article/1450501
https://blog.csdn.net/AlbenXie/article/details/88542111