虚拟机会将我们平时编写的 Java 文件编译成字节码格式的 .class
文件。
class文件中的信息是一项一项排列的, 每项数据都有它的固定长度, 有的占一个字节, 有的占两个字节, 还有的占四个字节或8个字节, 数据项的不同长度分别用u1, u2, u4, u8表示, 分别表示一种数据项在class文件中占据一个字节, 两个字节, 4个字节和8个字节。 可以把u1, u2, u3, u4看做class文件数据项的类型 。
一个典型的class文件分为:MagicNumber,Version,Constant_pool,Access_flag,This_class,Super_class,Interfaces,Fields,Methods 和Attributes这十个部分,用一个数据结构可以表示如下:
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
在class文件开头的四个字节, 存放着class文件的魔数, 这个魔数是class文件的标志,他是一个固定的值: 0XCAFEBABE
。 也就是说他是判断一个文件是不是class格式的文件的标准, 如果开头四个字节不是0XCAFEBABE
, 那么就说明它不是class文件, 不能被JVM识别
接下来四个字节是class文件的此版本号和主版本号(分别占两个字节)。
同版本的javac编译器编译的class文件, 版本号可能不同, 而不同版本的JVM能识别的class文件的版本号也可能不同, 一般情况下, 高版本的JVM能识别低版本的javac编译器编译的class文件, 而低版本的JVM不能识别高版本的javac编译器编译的class文件。
版本号之后就是常量池相关的数据项,常量池中几乎包含类中的所有信息的描述。class文件中的很多其他部分都是对常量池中的数据项的引用,常量池中各个项也会相互引用。
常量池中存放了文字字符串,常量值,当前类的类名,字段名,方法名, 各个字段和方法的描述符, 对当前类的字段和方法的引用信息, 当前类中对其他类的引用信息等等。
常量池中各个数据项通过索引来访问, 有点类似与数组, 只不过常量池中的第一项的索引为1, 而不为0。常量池中的每一种数据项也有自己的类型。 常量池中的数据项的类型如下表:
常量池中数据项 | 类型标志 | 类型描述 |
---|---|---|
CONSTANT_Utf8 | 1 | utf8 编码的字符串 |
CONSTANT_Integer | 3 | int类型字面量 |
CONSTANT_Float | 4 | float类型字面量 |
CONSTANT_Long | 5 | long类型字面量 |
CONSTANT_Double | 6 | double类型字面量 |
CONSTANT_Class | 7 | 对一个类或接口的符号引用 |
CONSTANT_String | 8 | string类型字面量 |
CONSTANT_Fieldref | 9 | 对一个字段的符号引用 |
CONSTANT_Methodref | 10 | 对一个类中声明方法的符号引用 |
CONSTANT_InterfacMethodref | 11 | 对一个接口中声明方法的符号引 |
CONSTANT_NameAndType | 12 | 对一个字段或方法的部分符号引用 |
其中每个数据项叫做一个XXX_info项,比如,一个常量池中一个CONSTANT_Utf8类型的项,就是一个CONSTANT_Utf8_info。除此之外, 每个info项中都有一个标志值(tag),这个标志值表明了这个常量池中的info项的类型是什么, 从上面的表格中可以看出,一个CONSTANT_Utf8_info中的tag值为1,而一个CONSTANT_Fieldref_info中的tag值为9 。
保存了当前类的访问权限
保存了当前类的全局限定名在常量池里的索引
保存了当前类的父类的全局限定名在常量池里的索引
存了当前类实现的接口列表,包含两部分内容:
保存了当前类的成员列表,包含两部分的内容:
包含两部分的内容:
包含了当前类的attributes列表,包含两部分内容
java中常量池分为三种类型:
上面已经写过 Class Constant Pool 的结构。这里再总结一下,class常量池主要存储两大常量: 字面量和符号引用。
字面量有点接近于 java 语言层面的概念,主要包括:
文本字符串: 比如我们经常声明的:public String s = "abc";
中的"abc"
final 修饰的成员变量,包括静态变量、实例变量和局部变量
简单来说就是用双引号引起来的字符串字面量。
符号引用主要指的是:
java/lang/String
这里引申另一个概念:直接引用。符号引用是字面量描述符,用文本形式来表示引用关系。那么直接引用就类似于直接指针,JVM能直接定位到具体位置。
jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。
而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,运行时常量池也是每个类都有一个。
class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。
经过解析(resolve)
之后把符号引用替换为直接引用,解析的过程会去查询全局字符串池(String Table),以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
运行时常量池再JDK7之前位于永久代,JDK8移入元空间(Metaspace)。
HotSpot VM里,记录interned string的一个全局表叫做StringTable
,它本质上就是个HashSet<String>
。这是个纯运行时的结构,而且是惰性(lazy)维护的。注意它只存储对java.lang.String实例的引用,而不存储String对象的内容。 注意,它只存了引用,根据这个引用可以得到具体的String对象。
一般我们说一个字符串进入了全局的字符串常量池其实是说在这个StringTable中保存了对它的引用,反之,如果说没有在其中就是中没有对它的引用。
前面说到在类的解析(resolve)的过程中,会去查询 String Table
保证运行时常量池所引用的字符串字面量与 String Table 中一致。其实这个表述不是很准确,总的来说应该是这样的:
lazy resolution
动作:
StringTable
已经有了内容匹配的 String 的引用,则直接返回这个引用;StringTable
里尚未有内容匹配的 String 实例的引用,则会在Java堆里创建一个对应内容的String对象,然后在StringTable记录下这个引用,并返回这个引用出去一个字符串字面量经过 reslove
后, 就在 StringTable
中创建了引用, 并在 Heap
中创建了字符串对象的实例。当主线程开始创建字符串变量的时候,虚拟机就会到 StringTable
中找到对应的 String 变量, 如果找到了就在栈区的当前栈帧中创建一个String变量,并把 StringTable
中的对象引用复制给创建的 String 变量
对于拼接的参数只有字面量或常量,则会直接返回 String Poll 中的引用:
String s1 = "hello";
String s2 = "hel" + "lo";
System.out.println(s1 == s2) // true
这个在解析的时候, s2是直接返回的拼接后的 “hello” 的在 String Table 中的引用。
如果是堆中两个不同地方创建的对象,实质上是通过 StringBuilder.append
拼接出来的:
String s3 = "hello";
String s4 = "hel" + new String("lo");
System.out.println(s3 == s4) // false
这个时候 s4 实际上是通过 StringBuilder.append
拼接出来,并且最终调用StringBuilder.toString
返回的,StringBuilder.toString
方法如下:
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
可以看到是 new 了一个新的 String 对象, 最终 s4 指向的是另一个对象, 这里需要注意的是并没有把 hello 对象放入字符串常量池
String#intern()这个方法的作用是:
这样一段代码:
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
}
打印的结果:
false false
false true
原因:
String.intern
会把字符串实例复制到字符串常量池种,所以返回的是永久代中字符串实例的引用,而new String
返回的是堆中实例的引用,两者完全不一样new String(11)
的对象, 通过String#intern
将引用放入了 String Table 中,所以 s4 直接在 String Table 中找到了对应的引用, s3 == s4
。而 String s = new String("1")
时,已经创建了两个对象。常量池中的“1” 和 JAVA Heap 中的字符串对象。s.intern(); 这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。在s2 = 1
这行代码中返回的是常量池中的”1”对象的引用。