JVM(复习)类加载机制
文章目录
类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载,验证,准备,解析,初始化,使用和卸载。
一,类加载阶段
《深入理解java虚拟机》中这样描述类的加载阶段
在加载阶段,虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取这个类的二进制字节流
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口。
我是这么理解的:
在这个阶段,类通过类加载器通过类的全限定类名,将类的二进制字节流转为方法区运行时的数据结构,这个数据结构在c++中是用instanceKlass描述,即相当于用instanceKlass来描述这个java类,这个instanceKlass的重要字段有:
- _java_mirror:java类的镜像,例如对String来说,镜像就是String.class,作用就是把instanceKlass暴露给java访问其他下面的字段(java不能直接访问instanceKlass)
- _super:即父类
- _fields:成员变量
- _methods:方法
- _constants:常量池
- _class_loader:类加载器
- _vtable:虚方法表
- _itable:接口方法表
instanceKlass在jdk1.7是存储在方法区中,1.8后存储在元空间
_java_mirror是存储在堆中
对象实例化过程
现在我要newPerson类的两个对象
Person p1 = new Person("张三",18);
Person p2 = new Person("李四",20);
类加载器完成Person类的加载后,元空间中存储该类在方法区中的数据结构instanceKlass,其中镜像_java_mirror指向在堆中创建的Person.class对象,这个对象作为访问元空间中各种数据类型的入口,每个Person实例的对象头都持有指向该镜像的指针(class pointer) JVM通过这个指针确定对象是哪个类的实例
对于数组类:
数组本身不通过类加载器创建,他是由java虚拟机直接创建的,但是数组类的元素类型最终也是靠类加载器去创建,
二,连接阶段
连接包括三个阶段:
- 验证
- 准备
- 解析
2.1验证
验证是连接的第一步,这一阶段是为了确保Class文件的字节流包含的信息符合java虚拟机的要求,并且不会危害java虚拟机自身的安全,如果验证到输入的字节流不符合Class文件格式的约束,虚拟机会抛出一个Java.lang.VerifyError异常或其子类异常
2.1.1文件格式验证
所谓文件格式验证就是验证生成的字节流是否符合clss文件格式的要求,并且能被当前版本的虚拟机处理
-
是否以魔数0xCAFEBABE开头
-
主次版本号是否在当前虚拟机处理范围之内
-
检查常量tag标志判断常量池的常量是否有不被支持的常量类型
-
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
…
之所以要有验证文件格式这一步,是了保证字节流能被正确的解析并存储在元空间中,之后的元数据验证,字节码验证都是在instanceKlass上进行,不会直接操作字节流
2.1.2元数据验证
元数据验证是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求
-
检查这个类是否有父类(所有类的父类都是java.lang.Object)
-
检查这个类继承的父类是否是被final修饰的,final修饰的类不可继承
-
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
…
2.1.3字节码验证
第二阶段对元数据信息中的数据类型做完检验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件
- 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,例如不会出现在操作数栈放置了一个int类型的数据,使用时却按long类型来加载本地变量表
- 作用域检验:保证跳转指令不会跳转到方法体以外的字节码指令上
- 类型转换校验:保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是反过来,就是不合法的
如果一个类方法体的字节码没有通过字节码验证,那肯定有问题,但如果对一个方法体通过了字节码验证,也不能说明其一定安全
2.1.4符号引用验证
-
符号引用中通过字符串描述的全限定名是否能找到对应的类
-
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
…
符号引用验证是确保解析能正确进行
2.2准备
准备阶段是为静态变量分配内存并设置静态变量初始值的阶段,这些变量使用的内存将在方法区分配,注意,实例变量将在对象实例化时随对象一起分配在java堆中
例如:
//准备期赋值为零值
private static int c;
//赋值在初始化阶段完成
private static int b = 123;
那么在准备阶段,b在方法区分配内存后,设置的初始值是0而不是123
而赋值为123是在初始化阶段才会执行
声明:
这是一个静态代码块,我们没写,显然是自动生成的,在类初始化时执行 ,可以看到,123被压到操作数栈栈顶,然后在putstatic设置静态变量b的值为123,(从常量池中找到的b),可以说明,真正赋值是在初始化时期发生
//static变量是被final修饰的字符串常量值是编译期可知的,赋值则在准备阶段完成private static final String a = "value123";//准备期赋值为零值//static变量是被final修饰的基本类型是编译期可知的,赋值则在准备阶段完成private static final int d = 1234;//赋值在初始化阶段完成
在看一个例子:
private static final String a = "aaaa";
private static String b = "bbbb";
对于b和上边一样,都是在初始化时期赋值:
但是对于a呢,被final修饰的静态变量,其赋值却是在准备期就已经完成,原因是该值在编译期可以确定
对于基本类型也一样
private static final int a = 123;
所以:
- 如果是final修饰的静态变量(字符串类型或者基本类型)在编译期值可以确定,赋值会在准备期完成
- 对于如果是final修饰的静态变量(除string的引用类型)赋值在初始化时期完成
2.3解析
解析阶段是虚拟机将常量池内的符号引用转为直接引用的过程
什么是符号引用?
对于上边我们提到的:
private static final int a = 123;
private static String b = "bbbb";
在常量池中是这样的:
其中a,b便是符号引用
直接引用就是该字段或方法的直接内存地址
解析发生时机
虚拟机规范并没有规定解析发生的具体时间,只要求在执行anewarray,checkcast,getfield,getstatic,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield,putstatic这16个指令之前,先对他们所引用的符号引用进行解析
三,初始化阶段
在准备期,变量已经赋值过一次零值,而在初始化阶段是根据程序员的要求去初始化变量值为指定的值,初始化阶段就是执行类构造器 < clinit>()方法的过程
3.1关于< clinit>()方法
-
该方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量,在前面的静态语句块可以赋值,但不能访问
static{ i = 0; System.out.println(i);//编译器会报“非法向前引用” } static int i = 1;
-
执行顺序问题
static class Parent{ public static String staticField = "父类静态变量"; public String field = "父类普通变量"; public Parent(){ System.out.println("父类构造函数"); } static { System.out.println(staticField); System.out.println("父类静态代码块"); } { System.out.println(field); System.out.println("父类代码块"); } } static class Sub extends Parent{ public static String staticField = "子类静态变量"; public String field = "子类普通变量"; public Sub(){ System.out.println("子类构造函数"); } static { System.out.println(staticField); System.out.println("子类静态代码块"); } { System.out.println(field); System.out.println("子类代码块"); } } public static void main(String[] args) { new Sub(); }
该方法执行顺序为:
-
该方法也不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成该方法
-
虚拟机会确保一个类的这个方法能被正确的加锁,解锁
3.2什么时候对类进行初始化
概括来说,类的初始化是懒惰的
所有的java虚拟机实现必须在每个类或者接口被java程序首次主动使用时才初始化他们
java程序对类的使用方式分为两种:
- 主动使用
- 创建类实例(new)
- 访问某个类或者接口的静态变量,或者对该静态变量赋值(putstatic,getstatic)
- 调用类的静态方法(invokestatic)
- 反射
- 初始化一个类的子类
- main方法所在类
- jdk7开始提供的动态语言支持
- 被动使用
以下是导致类初始化的一些情况:(对应上边的主动引用)
- main方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或者静态方法时会导致这个类初始化
- 子类访问父类的静态变量,只会触发父类的初始化
- 子类初始化时,如果父类还没初始化,会引发父类的初始化
- Class.forName等反射包对类进行反射调用时,如果类还没初始化,会首先触发其初始化
- new会导致初始化
下面通过几个例子去说明
子类访问父类的静态变量,只会触发父类的初始化
static class Parent{
public static String staticField = "父类静态变量";
public Parent(){
System.out.println("父类构造函数");
}
static {
System.out.println("父类静态代码块");
}
}
static class Sub extends Parent{
public static String Field = "子类静态变量";
public Sub(){
System.out.println("子类构造函数");
}
static {
System.out.println("子类静态代码块");
}
}
public static void main(String[] args) {
System.out.println(Parent.staticField);
}
结果:(构造函数内容需要执行new才会调用构造函数,但是执行了静态代码块,证明已经执行了< clinit >方法)
上边的调用改为调用子类的静态变量
这说明:访问子类的静态变量会导致子类的初始化,导致子类初始化时如果父类还没初始化,则父类先初始化
针对上边两个例子,说明对于静态变量,只有直接定义了该字段的类才会被初始化
在看一个例子
如果是调用final修饰的静态变量呢
static class Parent{
public static final String finalField = "静态final变量";
public static String staticField = "父类静态变量";
public Parent(){
System.out.println("父类构造函数");
}
static {
System.out.println("父类静态代码块");
}
}
public static void main(String[] args) {
System.out.println(Parent.finalField);
}
对于final修饰的静态变量(字符串类型和基本类型)如果编译期值可知,那么就直接在准备期赋值,无需在初始化调用< clinit >方法赋值,上面也说过了
注意,值一定是编译期可确定的
对于final修饰的值编译期不确定的,还是会导致直接定义该变量的类初始化
static class Parent{
public static final int finalRandomField = new Random().nextInt(2);
public static final String finalField = "静态final变量";
public static String staticField = "父类静态变量";
public Parent(){
System.out.println("父类构造函数");
}
static {
System.out.println("父类静态代码块");
}
}
public static void main(String[] args) {
System.out.println(Parent.finalRandomField);
}
对于数组呢
public static void main(String[] args) {
//数组类型的初始化是由jvm运行期动态生成
Parent[] p = new Parent[2];
//初始化的类是class [LMain$Parent;
System.out.println(p.getClass());
}
数组类型的初始化是由jvm运行期动态生成,,不会导致parent初始化
对于静态方法调用
static class Parent{
public static final int finalRandomField = new Random().nextInt(2);
public static final String finalField = "静态final变量";
public static String staticField = "父类静态变量";
public Parent(){
System.out.println("父类构造函数");
}
static {
System.out.println("父类静态代码块");
}
public static void staticMethod(){
System.out.println("调用类的静态方法");
}
}
public static void main(String[] args) throws ClassNotFoundException {
Parent.staticMethod();
}
调用类的静态方法会导致定义该方法的类初始化,如果有父类,父类先初始化
四,类加载器
4.1类加载器
Bootstrap ClassLoader(启动类加载器)
- 这个类加载器负责将一些核心的,被JVM识别的类加载进来,用C++实现,与JVM是一体的。
Extension ClassLoader(扩展类加载器)
- 这个类加载器用来加载 Java 的扩展库
Applicaiton ClassLoader(应用程序类加载器)
- 用于加载我们自己定义编写的类
User ClassLoader (用户自己实现的加载器)
- 当实际需要自己掌控类加载过程时才会用到,一般没有用到。
jvm规范允许类加载器在预料到某个类将要被使用时就预先加载他,如果在预先加载过程中遇到.class缺失或者存在错误,则类加载器会在程序首次主动使用该类时才报告错误,如果这个类一直没有被程序主动使用,那类加载器也不会报告这个错误
4.2双亲委派模型
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求都最终会传送到顶层的启动类加载器,只有当父类加载器反馈自己无法完成这个加载请求时(在他的搜索范围没有找到所需的类)子加载器才会尝试自己去加载。
为什么要这样?
因为同一个class文件被不同的类加载器加载后,就会不同的类,所以双亲委派模型保证了一个类只能有一个类加载器去加载
五,类文件结构
在上文中,我们有拿到类似这样的字节码
public class Test {
private int a;
public int getA() {
return a;
}
public void setA(int a) {
this.a = a;
}
public static void main(String[] args) {
}
}
从上面中,大概可以分为几类:
-
类的描述信息
-
类文件结构信息:
-
版本信息
-
类访问修饰符
-
常量池信息
-
方法信息
-
5.1.class类文件结构
任何一个Class文件都对应着唯一一个类或者接口的定义信息,Class文件是一组以8位字节为基础单位的二进制流
主要由一下几部分组成:
- 魔数和版本号信息
- 常量池
- 类或接口访问标志
- 类索引,父类索引与接口索引集合
- 字段表
- 方法表
- 属性表
5.1.1魔数和版本号信息
什么是魔数?
每个class文件的头4个字节称为魔数,他的唯一作用是确定这个文件是否是一个能被虚拟机接受的class文件,class文件的魔数值为0xCAFEBABE
魔数后边的4个字节分别是次版本号和主版本号,用于标识class文件的版本信息
高版本的jdk能向下兼容以前版本的class文件,低版本不能向上兼容,虚拟机将会拒绝执行此class文件
5.1.2常量池
常量池是class文件结构中和其他项目关联最多的数据类型,也是占用class文件空间最大的数据项目之一,同时还是class文件中第一个出现的表类型数据项目
我们可以将常量池看做是class文件的资源仓库,java类中定义的方法和变量信息都存储在常量池中,类中定义的信息由常量池来维护和存储
public class Test {
private int a;
public int getA() {
return a;
}
public void setA(int a) {
this.a = a;
}
public static void main(String[] args) {
}
}
先看这一张图(上边Test类的常量池信息):(最左一列是常量池索引值,可以通过索引,定位到常量池的信息)
从上图可以看出:
- 常量池是从索引值1开始的,第0项用于存储常量池的容量计数值
- 常量池第一项是一个方法引用,从索引为4找到该方法的类信息
- 从索引22找到了该类名是java/lang/Object
- 继续回到索引19,从索引19找到方法名信息:是Object类的构造方法
下边看自定义的字段a的常量池信息(探寻的原理也是跟上边相同)
可以看出,常量池主要存放两大类常量:
- 字面量
- 文本字符串
- final修饰的编译期可知的常量值
- 符号引用
- 类和接口的全限定名(java/lang/Object)
- 字段的名称和描述符 (a)
- 方法的名称和描述符([Ljava/lang/String;])V
所以,当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析到具体的内存地址之中
常量池中每一项常量都是一个表,有14种结构,共同特点是表开始的第一位都是一个u1类型的标志位,代表属于什么类型的常量
具体14中项目类型可以看如下的表:(下边的类型一项中的信息的中间项就对应上边截图)
5.1.3访问标志
在常量池之后紧接着的两个字节是访问标志,用于标识一些类或者接口层次的访问信息
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YfTG3ErQ-1574957406715)(C:\Users\12642\AppData\Roaming\Typora\typora-user-images\image-20191125180155059.png)]
在16进制中,如使用到则标记位1
5.1.4类索引,父类索引和接口索引集合
类索引,父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引各自指向一个类型是Class的类描述符常量,通过这个常量中的索引值可以找到该类的全限定类名
接口索引集合,第一项是接口计数器,标识实现接口的数量,如果没有实现任何接口,则计数器为0,后边不占用任何字节,对于接口也是通过Class类型去找
public class Test implements Serializable {
private int a;
public int getA() {
return a;
}
public void setA(int a) {
this.a = a;
}
}
Test继承关系就体现在了类索引,父类索引和接口索引集合中:
5.1.5字段表集合
字段表用来描述接口或类中声明的变量信息,包括类变量和实例变量,但不包括方法的局部变量
public class Test implements Serializable {
private int a;
private static int b;
public void setA(int aaaa) {
this.a = aaaa;
}
}
字段表包含信息:
-
访问修饰符
-
字段类型
对于数组类型,每一个维度将使用一个前置的“[”字符来描述。比如定义一个java.lang.String[][]类型的二维数组,将记录为[[Ljava/lang/String,一个double数组double[]将标记为[D。
当描述符用来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号()内。比如方法void inc()的描述符是:()V。方法java.lang.String toString()的描述符是:()Ljava/lang/String。方法int indexOf(char[] source,int sourceOffset,int sourceCount,char[] target,int targetOffset,int targetCount,int fromIndex)的描述符是:([CII[CIII)I。
5.1.6方法表集合
方法表和字段表结构相似,用来描述类中定义的方法
public class Test implements Serializable {
public String print(){
int value = 0;
value++;
return String.valueOf(value);
}
}
上边定义的两个方法的模型:
以print方法为例子,可见方法表有:
- 访问标志
- 名称索引
- 描述符索引
- 属性表集合
5.1.7属性表
属性表在前面出现了多次,在Class文件、字段表和方法表都可以携带自己的属性表集合,来描述某些场景专有的信息。
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制比较少,不要求严格的顺序,只要不与已有的属性名重复