目录
类加载的过程?类加载器?
详细过程的拆解
1. 加载
2. 链接
初始化
类加载器
启动类加载器(Bootstrap ClassLoader)
扩展类加载器(Extension ClassLoader)
应用程序类加载器(Application ClassLoader)
双亲委派机制
类加载的过程?类加载器?
JVM类加载机制大致分为:加载、验证、准备、解析(其中验证、准备和解析都为链接)、初始化、使用、卸载。
详细过程的拆解
1. 加载
- 通过类加载(也有叫装载)器,将
java
代码加载成.class
文件到内存中。 - 将读取到的
.class
数据存储到运行时内存区的方法区。
- 然后将其转换成一个与目标类型对应的
java.lang.Class
对象的实例。这个Class
对象在日后就会作为方法区中该类的各种数据的访问入口。
2. 链接
验证
验证是确保被加载的类(.class
文件的字节流),是否按照Java虚拟机的规范。确保不会造成安全问题。
文件格式验证
第一阶段要验证字节流是否符合 Class
文件格式的规范, 井且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
- 是否以魔数
0xCAFEBABE
开头 - 主、次版本号是否在当前虚拟机处理范围之内 。
- 常量池的常量中是否有不被支持的常量类型(检査常量
tag
标志)。 - 指向常量的各种索引值中是否有指向不存在的常量或不符合装型的常量 。
CONSTANT_Utf8_info
型的常量中是否有不符合UTF8
编码的数据Class
文件中各个部分及文件本身是否有被删除的或附加的其他信息实际上第一阶段的验证点还远不止这些, 这是其中的一部分。只有通过了这个阶段的验证之后, 字节流才会进入内存的方法区中进行存储, 所以后面的三个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。
元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java
语言规范的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了
java.lang.object
之外,所有的类都应当有父类) - 这个类的父类是否继承了不允许被继承的类(被finaI修饰的类)
- 如果这个类不是抽象类, 是否实現了其父类或接口之中要求实现的所有方法类中的字段、 方法是否与父类产生了矛盾(例如覆盖了父类的
final
字段, 或者出現不符合规则的方法重载, 例如方法参数都一致, 但返回值类型却不同等)
第二阶段的验证点同样远不止这些,这一阶段的主要目的是对类的元数据信息进行语义检验, 保证不存在不符合Java
语言规范的元数据信息。
字节码验证
第三阶段是整个验证过程中最复杂的一个阶段, 主要目的是通过数据流和控制流的分析,确定语义是合法的。符号逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:
- 保证任意时刻操作数栈的数据装型与指令代码序列都能配合工作, 例如不会出现类似这样的情况:在操作栈中放置了一个
int
类型的数据, 使用时却按long
类型来加载入本地变量表中。 - 保证跳转指令不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换是有效的, 例如可以把一个子类对象赋值给父类数据装型,这是安全的,但是把父类对象意赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、 完全不相干的一个数据类型, 则是危险和不合法的。即使一个方法体通过了字节码验证, 也不能说明其一定就是安全的。
符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候 , 这个转化动作将在连接的第三个阶段一一解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用) 的信息进行匹配性的校验, 通常需要校验以下内容:
- 符号引用中通过字将串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段 。
- 符号引用中的类、字段和方法的访问性(
private
、protected
、public
、default
)是否可被当前类访问 - 符号引用验证的目的是确保解析动作能正常执行, 如果无法通过符号引用验证, 将会抛出一个
java.lang.IncompatibleClassChangError
异常的子类, 如java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
准备
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:
public static int x = 8080;
实际上变量 x 在准备阶段过后的初始值为 0
而不是 8080
,将 x 赋值为 8080
的 put static
指令是程序被编译后,存放于类构造器方法之中。
但是注意如果声明为:
public static final int x = 8080;
在编译阶段会为 x 生成 ConstantValue
属性,在准备阶段虚拟机会根据 ConstantValue
属性将 x 赋值为 8080
。
对于该阶段有以下几个要注意的点:
- 这时候进行内存分配的仅包括类变量(
Class Variables
,即静态变量,被static
关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。 - 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。
不过有一点需要注意的是:JDK 7 之前,HotSpot
使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。相关阅读:《深入理解Java虚拟机(第3版)》勘误#75open in new window - 这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了
public static int value=111
,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111
,那么准备阶段 value 的值就被赋值为 111。
8中基本数据类型的初值为0;引用类型的初值都为null。
解析
解析是将类的二进制数据中的符号引用替换成直接引用(符号引用是用一组符号描述所引用的目标;直接引用是指向目标的指针。)
直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
可以认为是一些静态绑定的会被解析,动态绑定则只会在运行时进行解析;静态绑定包括一些 final(不可以重写,可以被重载)。static方法(只会属于当前的类),构造器(不会被重写)。
在解析阶段,虚拟机会把所有的类名、方法名、字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。
初始化
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。
初始化,就是为标记为常量值的字段赋值的过程。换句话说,只对static修饰的变量或语句块进行初始化。如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
初始化阶段是执行类构造器方法的过程。方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子方法执行之前,父类的方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成()方法。
注意以下几种情况不会执行类初始化:
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
- 定义对象数组,不会触发该类的初始化。
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
- 通过类名获取 Class 对象,不会触发类的初始化。
- 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
- 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。
涉及的问题
一个类的构造器,代码块、静态代码块、成员变量的执行顺序。
//父类
public class ParentClass {
private int p1 = getValue() ;
private static int p2 = getValue2();
public ParentClass(){
System.out.println("我是父构造器");
}
static {
System.out.println("我是父静态代码块1");
}
static {
System.out.println("我是父静态代码块2");
}
{
System.out.println("我是父代码块1");
}
{
System.out.println("我是父代码块2");
}
private int getValue(){
System.out.println("我是父成员变量p1");
return 1;
}
private static int getValue2(){
System.out.println("我是父静态成员变量p2");
return 1;
}
}
//子类
public class ChildClass extends ParentClass{
private int c1 = getValue() ;
private static int c2 = getValue2();
public ChildClass(){
System.out.println("我是子构造器");
}
static {
System.out.println("我是子静态代码块1");
}
static {
System.out.println("我是子静态代码块2");
}
{
System.out.println("我是子代码块1");
}
{
System.out.println("我是子代码块2");
}
private int getValue(){
System.out.println("我是子成员变量c1");
return 1;
}
private static int getValue2(){
System.out.println("我是子静态成员变量c2");
return 1;
}
public static void main(String[] args) {
ChildClass childClass = new ChildClass();
}
}
初始化的顺序:
- 父类的静态变量
- 父类的静态代码块
- 子类的静态变量
- 子类的静态代码块
- 父类的非静态变量
- 父类的非静态代码块
- 父类的构造方法
- 子类的非静态变量
- 子类的非静态代码块
- 子类的构造方法
类加载器
类加载器也有人叫类装载器。
我们都知道Java程序写好就是Java代码(.Java文件)存在磁盘中。然后通过(/bin/javac.exe)编译命令把.java文件编译成.class字节码文件,并存在磁盘中。
但是要运行程序,首先就是把.class文件加载到JVM的内存中才能使用,所以我们常看见的ClassLoader,就是负责把我们磁盘中的.class字节码文件,加载到JVM的内存中,并生成Java.lang.Class类的一个实例。
虚拟机设计团队把加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类,JVM 提供了 3 种类加载器:
启动类加载器(Bootstrap ClassLoader)
负责加载 JAVA_HOME\lib
目录中的,或通过-Xbootclasspath
参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar
)的类。它是由原生代码所写,并不是继承自java.lang.ClassLoader
扩展类加载器(Extension ClassLoader)
负责加载 JAVA_HOME\lib\ext
目录中的,或通过 java.ext.dirs
系统变量指定路径中的类库。
应用程序类加载器(Application ClassLoader)
负责加载用户路径(classpath
)上的类库。JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader
实现自定义的类加载器。
双亲委派机制
双亲委派机制是代理模式实现的。
白话文:当一个类要被加载时,它将会启动应用类加载器进行加载Test
类,但是这个系统类加载器不会真正去加载他,而是会调用看是否有父加载器,结果有,是扩展类加载器,扩展类加载器也不会直接去加载,它看自己是否有父加载器没,结果它还是有的,是根类加载器。
当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
采用双亲委派的一个好处是比如加载位于 rt.jar
包中的类 java.lang.Object
,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。