推荐书籍:
java虚拟机规范 这本书告诉我们: JVM是一种规范
深入理解java虚拟机
先有个整体的概念:java程序运行的本质就是方法套方法,我们需要知道的就是我们编写的java文件编译后的字节码被JVM如何保存(即如何把一个类的各种信息存下来),在方法执行的时候如何能找到之前存的信息,还有在执行过程中发生了什么(内存中如何变化)
字节码如何被JVM保存?
被保存在方法区,主要是分成:访问权限和类的属性(access_flags)、类索引父类索引和接口索引集合、常量池、字段表、方法表、和字段和方法结构中的属性表(方法的内容字节码就是存在属性表的code[]属性中)。 可以理解成Class的结构就是一张表,在执行的时候提供需要的信息
在方法执行的时候如何找到这些信息?
在编译的时候所有在本Class文件中用到的字段和调用的方法(只要有方法调用就行例如上面的setAge中的getName方法),符号引用都会被保存在字节码的常量池中(包括其他类的也会存在自己的常量池表中),类加载后JVM会为每一个类都维护一个自己的常量池,在类加载的解析阶段,这些符号引用就被解析成了内存地址直接引用。
这里肯定会有一个疑问那那些没有用到的方法和字段如何查找呢?
可以通过Class对象
一、JVM如何保存字节码文件
1. Java代码编译成的字节码文件:
先看一个类:
public class TestA {
public static String gender;
public int age;
public String name;
public static String getGender() {
return gender;
}
public static void setGender(String gender) {
TestA.gender = gender;
}
public final int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
getName();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
TestA testA = new TestA();
testA.setAge(2);
}
}
再看字节码文件:
Last modified 2022-1-7; size 999 bytes
MD5 checksum 56933a64868cb691926b1cc349d82d08
Compiled from "TestA.java"
public class com.example.fragmentadaptertest.testjava.TestA
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#35 // java/lang/Object."<init>":()V
#2 = Fieldref #6.#36 // com/example/fragmentadaptertest/testjava/TestA.gender:Ljava/lang/String;
#3 = Fieldref #6.#37 // com/example/fragmentadaptertest/testjava/TestA.age:I
#4 = Methodref #6.#38 // com/example/fragmentadaptertest/testjava/TestA.getName:()Ljava/lang/String;
#5 = Fieldref #6.#39 // com/example/fragmentadaptertest/testjava/TestA.name:Ljava/lang/String;
#6 = Class #40 // com/example/fragmentadaptertest/testjava/TestA
#7 = Methodref #6.#35 // com/example/fragmentadaptertest/testjava/TestA."<init>":()V
#8 = Methodref #6.#41 // com/example/fragmentadaptertest/testjava/TestA.setAge:(I)V
#9 = Class #42 // java/lang/Object
#10 = Utf8 gender
#11 = Utf8 Ljava/lang/String;
#12 = Utf8 age
#13 = Utf8 I
#14 = Utf8 name
#15 = Utf8 <init>
#16 = Utf8 ()V
#17 = Utf8 Code
#18 = Utf8 LineNumberTable
#19 = Utf8 LocalVariableTable
#20 = Utf8 this
#21 = Utf8 Lcom/example/fragmentadaptertest/testjava/TestA;
#22 = Utf8 getGender
#23 = Utf8 ()Ljava/lang/String;
#24 = Utf8 setGender
#25 = Utf8 (Ljava/lang/String;)V
#26 = Utf8 getAge
#27 = Utf8 ()I
#28 = Utf8 setAge
#29 = Utf8 (I)V
#30 = Utf8 getName
#31 = Utf8 setName
#32 = Utf8 testA
#33 = Utf8 SourceFile
#34 = Utf8 TestA.java
#35 = NameAndType #15:#16 // "<init>":()V
#36 = NameAndType #10:#11 // gender:Ljava/lang/String;
#37 = NameAndType #12:#13 // age:I
#38 = NameAndType #30:#23 // getName:()Ljava/lang/String;
#39 = NameAndType #14:#11 // name:Ljava/lang/String;
#40 = Utf8 com/example/fragmentadaptertest/testjava/TestA
#41 = NameAndType #28:#29 // setAge:(I)V
#42 = Utf8 java/lang/Object
{
public static java.lang.String gender;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC, ACC_STATIC
public int age;
descriptor: I
flags: ACC_PUBLIC
public java.lang.String name;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC
public com.example.fragmentadaptertest.testjava.TestA();
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 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/fragmentadaptertest/testjava/TestA;
public static java.lang.String getGender();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: getstatic #2 // Field gender:Ljava/lang/String;
3: areturn
LineNumberTable:
line 13: 0
public static void setGender(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: putstatic #2 // Field gender:Ljava/lang/String;
4: return
LineNumberTable:
line 17: 0
line 18: 4
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 gender Ljava/lang/String;
public final int getAge();
descriptor: ()I
flags: ACC_PUBLIC, ACC_FINAL
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #3 // Field age:I
4: ireturn
LineNumberTable:
line 21: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/fragmentadaptertest/testjava/TestA;
public void setAge(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: iload_1
2: putfield #3 // Field age:I
5: aload_0
6: invokevirtual #4 // Method getName:()Ljava/lang/String;
9: pop
10: return
LineNumberTable:
line 25: 0
line 26: 5
line 27: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/example/fragmentadaptertest/testjava/TestA;
0 11 1 age I
public java.lang.String getName();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #5 // Field name:Ljava/lang/String;
4: areturn
LineNumberTable:
line 30: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/fragmentadaptertest/testjava/TestA;
public void setName(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=2
0: aload_0
1: aload_1
2: putfield #5 // Field name:Ljava/lang/String;
5: new #6 // class com/example/fragmentadaptertest/testjava/TestA
8: dup
9: invokespecial #7 // Method "<init>":()V
12: astore_2
13: aload_2
14: iconst_2
15: invokevirtual #8 // Method setAge:(I)V
18: return
LineNumberTable:
line 34: 0
line 35: 5
line 36: 13
line 37: 18
LocalVariableTable:
Start Length Slot Name Signature
0 19 0 this Lcom/example/fragmentadaptertest/testjava/TestA;
0 19 1 name Ljava/lang/String;
13 6 2 testA Lcom/example/fragmentadaptertest/testjava/TestA;
}
SourceFile: "TestA.java"
从这个字节码文件中我有了新的认识。
这个字节码文件已经按照java虚拟机规范中的Class文件格式把信息存好了,例如:
字段表、方法表、属性表、运行时常量池
看过java虚拟机规范后就知道这个结构为啥是这样的,例如
方法的descriptor方法描述符:是描述方法的参数和返回值的
flags是访问权限这些public、static、final等
方法的attribute 属性中的code[]是存放方法的内容字节码的,LineNumberTable是存放上面的字节码对应的java代码的多少行,调试的时候用的等
(1).运行时常量池:
有一篇文章有助于理解常量池:
[java]JVM之运行时常量池里到底有什么 - 简书1. 概念 首先我们来复习一下java内存模型,java运行时数据区大概分为五块,分别是 方法区 虚拟机栈 本地方法栈 堆 程序计数器 而运行时常量池是方法区的一部分,文字解...https://www.jianshu.com/p/614e2b6a0f22常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符 号引用则属于编译原理方面的概念,包括了下面三类常量:
类和接口的全限定名(Fully Qualified Name)
字段的名称和描述符(Descriptor)
方法的名称和描述符
为啥要有这个常量池呢?
a..Java代码在进行
Javac
编译的时候,并不像
C
和
C++
那样有
“
连接
”
这一步骤,而是在虚拟
机加载
Class
文件的时候进行动态连接。
也就是说,在Class文件中不会保存各个方法、字段
的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正
的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的
符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
方法符号引用会被方法表中的那个方法结构的地址所替代,字段符号引用会被字段表中的那个字段结构所替代
b.运行时常量池相对于CLass文件常量池的另外一个重要特征是
具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是
String类的intern()方法。String的intern()方法会查找在常量池中是否存在一份equal相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池
c.因为Java中的“类”是无穷无尽的,无法通过简单的无符号字节来描述一个方法用到了什么类,因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达。
字段表(
field_info
)、方法表(
method_info
)、属性表(
attribute_info
)会引用到常量池里的一些常量,它们会用来描述一些不方便使用“
固定字节
”
进行表达的内容。譬如描述方法的返回值是什么?有几个参数?每个参数的类型是什么?
所以我理解的常量池的作用就是:提供一些常量(字面量),这些表用到这些常量时都引用一个地方,提供本Class文件中用到的字段和方法的符号引用(在jvm进行类加载的解析阶段这些符号引用会变成字段或者方法结构的地址),在使用的时候直接在常量池中找,这样就节省了内存空间。
(2).访问标志 access_flags
用来表示是一个类还是接口,还有访问权限,是不是抽象类,是不是一个注解,是不是一个枚举
(3).类索引、父类索引、接口索引集合
这个不解释
(4) 字段表集合
字段表(
field_info
)用于描述接口或者类中声明的变量。字段(
field
)包括类级变量以
及实例级变量,但不包括在方法内部声明的局部变量。我们可以想一想在
Java
中描述一个字
段可以包含什么信息?可以包括的信息有:字段的作用域(
public
、
private
、
protected
修饰
符)、是实例变量还是类变量(
static
修饰符)、可变性(
final
)、并发可见性(
volatile
修饰
符,是否强制从主内存读写)、可否被序列化(
transient
修饰符)、字段数据类型(基本类
型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰
符,要么没有,很适合使用标志位来表示。
而字段叫什么名字、字段被定义为什么数据类
型,这些都是无法固定的,只能引用常量池中的常量来描述。
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本
Java
代
码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类
实例的字段。另外,在
Java
语言中字段是无法重载的,两个字段的数据类型、修饰符不管是
否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,
那字段重名就是合法的。
描述符的作用是
用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
(5).方法表集合
如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。
Class
文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如
同字段表一样,依次包括了访问标志(
access_flags
)、名称索引(
name_index
)、描述符索
引(
descriptor_index
)、属性表集合(
attributes
)几项
也许有的读者会产生疑问,方法的定义可以通过访问标志、名称索引、描述
符索引表达清楚,但方法里面的代码去哪里了?方法里的
Java
代码,经过编译器编译成字节
码指令后,存放在方法属性表集合中一个名为
“Code”
的属性里面,属性表作为
Class
文件格式
中最具扩展性的一种数据项目
如果父类方法在子类中没有被重写(
Override
),方法表集合
中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最
典型的便是类构造器
“
<
clinit
>
”
方法和实例构造器
“
<
init
>
”方法。在Java
语言中,要重载(
Overload
)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java
语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class
文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。
(6) 属性表集合
属性表(
attribute_info
)在前面的讲解之中已经出现过数次,在
Class
文件、字段表、方
法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
与
Class
文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍
微宽松了一些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人
实现的编译器都可以向属性表中写入自己定义的属性信息,
Java
虚拟机运行时会忽略掉它不
认识的属性。
2.加载在JVM中如何保存
这就要提到类的加载:
加载、验证、准备、解析、初始化、使用、卸载
在
Class
文件中描述的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。而虚拟机如何加载这些Class
文件?
Class
文件中的信息进入到虚拟机后会发生什么变化?
虚拟机把描述类的数据从
Class
文件加载到内存,并对数据进行校验、转换解析和初始
化,最终形成可以被虚拟机直接使用的
Java
类型,这就是虚拟机的类加载机制。
与那些在编译时需要进行连接工作的语言不同,在
Java
语言里面,类型的加载、连接和
初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开
销,但是会为
Java
应用程序提供高度的灵活性,
Java
里天生可以动态扩展的语言特性就是依
赖运行期动态加载和动态连接这个特点实现的。例如,如果编写一个面向接口的应用程序,
可以等到运行时再指定其实际的实现类;用户可以通过
Java
预定义的和自定义类加载器,让
一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为程序代码的一部
分,这种组装应用程序的方式目前已广泛应用于
Java
程序之中。从最基础的
Applet
、
JSP
到相
对复杂的
OSGi
技术,都使用了
Java
语言运行期类加载的特性。
加载、验证、准备、初始化和卸载这
5
个阶段的顺序是确定的,类的加载过程
必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶
段之后再开始,这是为了支持
Java语言的运行时绑定(也称为动态绑定或晚期绑定)。注
意,这里笔者写的是按部就班地
“
开始
”
,而不是按部就班地
“
进行
”
或
“
完成
”
,强调这点是因
为这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活
另外一个阶段。
(1).加载
“
加载
”
是
“
类加载
”
(
Class Loading
)过程的一个阶段,希望读者没有混淆这两个看起来
很相似的名词。在加载阶段,虚拟机需要完成以下
3
件事情:
1
)通过一个类的全限定名来获取定义此类的二进制字节流。
2
)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3
)在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据
的访问入口。
虚拟机规范的这
3
点要求其实并不算具体,因此虚拟机实现与具体应用的灵活度都是相
当大的。例如
“
通过一个类的全限定名来获取定义此类的二进制字节流
”
这条,它没有指明二
进制字节流要从一个
Class
文件中获取,准确地说是根本没有指明要从哪里获取、怎样获取。
虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的
“
舞台
”
,
Java
发展历程中,充满
创造力的开发人员则在这个
“
舞台
”
上玩出了各种花样,许多举足轻重的
Java
技术都建立在这
一基础之上,例如:
从
ZIP
包中读取,这很常见,最终成为日后
JAR
、
EAR
、
WAR
格式的基础。
从网络中获取,这种场景最典型的应用就是
Applet
。
运行时计算生成,这种场景使用得最多的就是动态代理技术,在
java.lang.reflect.Proxy
中,就是用了
ProxyGenerator.generateProxyClass
来为特定接口生成形式为
“*$Proxy”
的代理类
的二进制字节流。
由其他文件生成,典型场景是
JSP
应用,即由
JSP
文件生成对应的
Class
类。
从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如
SAP Netweaver
)
可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由
Java
虚拟
机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(
Element
Type
,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之
中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据
结构。然后在内存中实例化一个
java.lang.Class
类的对象(并没有明确规定是在
Java
堆中,对
于
HotSpot
虚拟机而言,
Class
对象比较特殊,它虽然是对象,但是存放在方法区里面),这
个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,
加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属
于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
(2) 验证
验证是连接阶段的第一步,这一阶段的目的是为了确保
Class
文件的字节流中包含的信息
符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java
语言本身是相对安全的语言(依然是相对于
C/C++
来说),使用纯粹的
Java
代码无
法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在
的代码行之类的事情,如果这样做了,编译器将拒绝编译。但前面已经说过,
Class
文件并不
一定要求用
Java
源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编
写来产生
Class
文件。在字节码语言层面上,上述
Java
代码无法做到的事情都是可以实现的,
至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可
能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工
作。
(3) 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存
都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这
时候进行内存分配的仅包括类变量(被
static
修饰的变量),而不包括实例变量,实例变量将
会在对象实例化时随着对象一起分配在
Java
堆中。其次,这里所说的初始值
“
通常情况
”
下是
数据类型的零值:
例如 public static int value=123;
那变量
value
在准备阶段过后的初始值为
0
而不是
123
,因为这时候尚未开始执行任何
Java
方法,而把
value
赋值为
123
的
putstatic
指令是程序被编译后,存放于类构造器<
clinit
>()方
法之中,所以把
value
赋值为
123
的动作将在初始化阶段才会执行。
上面提到,在
“
通常情况
”
下初始值是零值,那相对的会有一些
“
特殊情况
”
:如果类字段
的字段属性表中存在
ConstantValue
属性,那在准备阶段变量
value
就会被初始化为
ConstantValue
属性所指定的值,假设上面类变量
value
的定义变为:
public static final int value=123
;
编译时
Javac
将会为
value
生成
ConstantValue
属性,在准备阶段虚拟机就会根据
ConstantValue
的设置将
value
赋值为
123
。
(4).解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在前一章讲
解
Class
文件格式的时候已经出现过多次,在
Class
文件中它以
CONSTANT_Class_info
、
CONSTANT_Fieldref_info
、
CONSTANT_Methodref_info
等类型的常量出现,那解析阶段中所
说的直接引用与符号引用又有什么关联呢?
符号引用(
Symbolic References
):符号引用以一组符号来描述所引用的目标,符号可
以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的
内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各
不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义
在
Java
虚拟机规范的
Class
文件格式中。
直接引用(
Direct References
):直接引用可以是直接指向目标的指针、相对偏移量或是
一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引
用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目
标必定已经在内存中存在。
虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行
anewarray
、
checkcast
、
getfield
、
getstatic
、
instanceof
、
invokedynamic
、
invokeinterface
、
invokespecial
、
invokestatic
、
invokevirtual
、
ldc
、
ldc_w
、
multianewarray
、
new
、
putfield
和
putstatic
这
16
个用于
操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
对同一个符号引用进行多次解析请求是很常见的事情,除
invokedynamic
指令以外,虚拟
机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识
为已解析状态)从而避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需
要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解
析请求就应当一直成功;同样的,如果第一次解析失败了,那么其他指令对这个符号的解析
请求也应该收到相同的异常。
对于
invokedynamic
指令,上面规则则不成立。当碰到某个前面已经由
invokedynamic
指令
触发过解析的符号引用时,并不意味着这个解析结果对于其他
invokedynamic
指令也同样生
效。因为
invokedynamic
指令的目的本来就是用于动态语言支持(目前仅使用
Java
语言不会生
成这条字节码指令),它所对应的引用称为
“
动态调用点限定符
”
(
Dynamic Call Site
Specifier
),这里
“
动态
”
的含义就是必须等到程序实际运行到这条指令的时候,解析动作才
能进行。相对的,其余可触发解析的指令都是
“
静态
”
的,可以在刚刚完成加载阶段,还没有
开始执行代码时就进行解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点
限定符
7
类符号引用进行,分别对应于常量池的
CONSTANT_Class_info
、
CONSTANT_Fieldref_info
、
CONSTANT_Methodref_info
、
CONSTANT_InterfaceMethodref_info
、
CONSTANT_MethodType_info
、
CONSTANT_MethodHandle_info
和
CONSTANT_InvokeDynamic_info 7
种常量类型
。下面将讲
解前面
4
种引用的解析过程,对于后面
3
种,与
JDK 1.7
新增的动态语言支持息息相关,由于
Java
语言是一门静态类型语言,因此在没有介绍
invokedynamic
指令的语义之前,没有办法将
它们和现在的
Java
语言对应上
a.
类或接口的解析
假设当前代码所处的类为
D
,如果要把一个从未解析过的符号引用
N
解析为一个类或接
口
C
的直接引用,那虚拟机完成整个解析的过程需要以下
3
个步骤:
1
)如果
C
不是一个数组类型,那虚拟机将会把代表
N
的全限定名传递给
D
的类加载器去
加载这个类
C
。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关
类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,
解析过程就宣告失败。
2
)如果
C
是一个数组类型,并且数组的元素类型为对象,也就是
N
的描述符会是类
似
“[Ljava/lang/Integer”
的形式,那将会按照第
1
点的规则加载数组元素类型。如果
N
的描述符
如前面所假设的形式,需要加载的元素类型就是
“java.lang.Integer”
,接着由虚拟机生成一个
代表此数组维度和元素的数组对象。
3
)如果上面的步骤没有出现任何异常,那么
C
在虚拟机中实际上已经成为一个有效的类
或接口了,但在解析完成之前还要进行符号引用验证,确认
D
是否具备对
C
的访问权限。如
果发现不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。
类或接口的直接引用指的是什么?
我的理解就是这个类或接口的信息存放的地址。
b.
字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内
class_index
项中索引的
CONSTANT_Class_info
符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在
解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。
如果解析成功完成,那将这个字段所属的类或接口用
C
表示,虚拟机规范要求按照如下步骤
对
C
进行后续字段的搜索。
1
)如果
C
本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段
的直接引用,查找结束。
2
)否则,如果在
C
中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的
父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段
的直接引用,查找结束。
3
)否则,如果
C
不是
java.lang.Object
的话,将会按照继承关系从下往上递归搜索其父
类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的
直接引用,查找结束。
4
)否则,查找失败,抛出
java.lang.NoSuchFieldError
异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段
的访问权限,将抛出
java.lang.IllegalAccessError
异常。
字段符号引用解析成直接引用?
我的理解就是字段在字段表中的内存地址
c.类方法解析
类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的
class_index项中
索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用
C
表示这个类,接下来
虚拟机将会按照如下步骤进行后续的类方法搜索。
1
)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现
class_index
中索引的
C
是个接口,那就直接抛出
java.lang.IncompatibleClassChangeError
异常。
2
)如果通过了第
1
步,在类
C
中查找是否有简单名称和描述符都与目标相匹配的方法,
如果有则返回这个方法的直接引用,查找结束。
3
)否则,在类
C
的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如
果有则返回这个方法的直接引用,查找结束。
4
)否则,在类
C
实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符
都与目标相匹配的方法,如果存在匹配的方法,说明类
C
是一个抽象类,这时查找结束,抛
出
java.lang.AbstractMethodError
异常。
5
)否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不
具备对此方法的访问权限,将抛出
java.lang.IllegalAccessError
异常。
d.
接口方法解析
接口方法也需要先解析出接口方法表的
class_index项中索引的方法所属的类或接口的符
号引用,如果解析成功,依然用
C
表示这个接口,接下来虚拟机将会按照如下步骤进行后续
的接口方法搜索。
1
)与类方法解析不同,如果在接口方法表中发现
class_index
中的索引
C
是个类而不是接 口,那就直接抛出java.lang.IncompatibleClassChangeError
异常。
2
)否则,在接口
C
中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返
回这个方法的直接引用,查找结束。
3
)否则,在接口
C
的父接口中递归查找,直到
java.lang.Object
类(查找范围会包括
Object
类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方
法的直接引用,查找结束。
4
)否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
由于接口中的所有方法默认都是
public
的,所以不存在访问权限的问题,因此接口方法
的符号解析应当不会抛出
java.lang.IllegalAccessError
异常。
(5) 初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应
用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化
阶段,才真正开始执行类中定义的
Java
程序代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通
过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始
化阶段是执行类构造器<
clinit
>()方法的过程。
<
clinit
>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块
(
static{}
块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决
定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前
面的静态语句块可以赋值,但是不能访问:
对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会
生成<
clinit
>()方法。但接口与类不同的是,执行接口的<
clinit
>()方法不需要先执行
父接口的<
clinit
>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另
外,接口的实现类在初始化时也一样不会执行接口的<
clinit
>()方法。
虚拟机会保证一个类的<
clinit
>()方法在多线程环境中被正确地加锁、同步,如果多
个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<
clinit
>()方法,其他
线程都需要阻塞等待,直到活动线程执行<
clinit
>()方法完毕。如果在一个类的<
clinit
>
()方法中有耗时很长的操作,就可能造成多个进程阻塞
,在实际应用中这种阻塞往往是
很隐蔽的。
二、JVM具体是如何执行代码的
1.执行引擎
执行引擎是
Java
虚拟机最核心的组成部分之一。
“
虚拟机
”
是一个相对于
“
物理机
”
的概
念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬
件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制
定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在
Java
虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种
虚拟机执行引擎的统一外观(
Facade
)。在不同的虚拟机实现里面,执行引擎在执行
Java
代
码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码
执行)两种选择
,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。
但从外观上看起来,所有的
Java
虚拟机的执行引擎都是一致的:输入的是字节码文件,处理
过程是字节码解析的等效过程,输出的是执行结果,本章将主要从概念模型的角度来讲解虚
拟机的方法调用和字节码执行。