理解类加载机制
Class文件是各种编译器编译生成的二进制文件,在Class文件中描述了各种与该类相关的信息,但是Class文件本身是一个静态的东西,想要使用某个类的话,需要java虚拟机将该类对应的Class文件加载进虚拟机中之后才能进行运行和使用。
举个例子,Class文件就好比是各个玩具设计商提供的设计方案,这些方案本身是不能直接给小朋友玩的,需要玩具生产商根据方案的相关信息制造出具体的玩具才可以给小朋友玩。那么不同的设计商有他们自己的设计思路,只要最终设计出来的方案符合生产商生产的要求即可。生产商在生产玩具时,首先会根据自己的生产标准对设计商提交来的方案进行阅读,审核,校验等一系列步骤,如果该方案符合生产标准,则会根据方案创建出对应的模具,当经销商需要某个玩具时,生产商则拿出对应的模具生产出具体的玩具,然后把玩具提交给经销商。
对于java而言,虚拟机就是玩具生产商,设计商提交过来的方案就是一个个的Class文件,方案创建的模具就
总的来说,类的加载过程,包括卸载在内的整个生命周期共有以下7个阶段:
加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,但是解析阶段不一定,在某些情况下解析可以在初始化之后再执行,为了支持java的运行时绑定,也成为动态绑定或晚期绑定。invokedynamic指令就是用于动态语言支持,这里“动态”的含义是必须等到城市实际运行到这条指令的时候,解析动作才开始执行。
加载
“加载”是“类加载”过程中的一个阶段,在加载阶段,虚拟机需要做以下3件事情:
-
通过类的全限定名获得该类的二进制字节流
-
将这个字节流所代表的静态存储结构转换成方法区中的某个运行时数据结构
- 在方法区内存(对于HotSpot虚拟机)中生成一个代表该类的java.lang.Class对象,作为访问方法区中该类的运行时数据结构的外部接口
加载阶段中“通过类的全限定名获得该类的二进制字节流”这个动作,被放到java虚拟机外部实现,目的是最大限度的让应用程序去决定该如何获取所需的类,而实现该动作的代码模块就是类加载器(ClassLoader),JVM提供了3种类加载器:
-
启动类加载器(Bootstrap ClassLoader):负责加载 JAVAHOME\lib 目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。
-
扩展类加载器(Extension ClassLoader):负责加载 JAVAHOME\lib\ext 目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
- 应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)上的类库。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中了。
验证
加载完成后,紧接着(更确切的说是交叉执行)虚拟机会对加载的字节流进行验证。虚拟机如果不检查输入的字节流,对其安全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃。验证阶段大致会完成4中不同的检验动作:
文件格式验证
文件格式验证主要是校验该字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机所接受。这个阶段包括但不限于以下验证点:
- 是否以魔数0xCAFEBABE开头
- 主、次版本号是否在当前虚拟机处理的范围之内
- 常量池中是否有不支持的常量类型(通过tag校验)
- 常量的索引是否有指向不存在或不符合类型的常量
- ...
元数据验证
元数据验证主要是对字节流中的描述信息(描述符)进行语义分析,以确保其描述的信息符合java语言规范的要求。这个阶段包括但不限于以下验证点:
- 这个类是否有父类,除了java.lang.Object,所有的类都应该有父类
- 这个类的父类是否继承了不允许被继承的类,如被final修饰的类
- 如果这个类不是抽象类,是否实现了父类或接口中要求的所有的方法
- ...
字节码验证
字节码验证主要是对类的方法体进行分析,确保在方法运行时不会有危害虚拟机的事件发生。这个阶段包括但不限于以下验证点:
- 操作数栈的数据类型与指令码中所需类型是否相符
- 校验跳转指令是否会跳转到方法体以外的字节码指令上
- 校验方法体中类型转换是否是有效的
- ...
符号引用验证
符号引用验证主要是对类自身以外的信息进行匹配新校验,包括常量池中的各种符号引用。这个阶段包括但不限于以下验证点:
- 符号引用中通过字符串描述的全限定类名是否能找到对应的类
- 在指定的类中是否存在符合方法的字段描述符以及简单名称所描述的字段和方法
- ...
准备
准备阶段是为类变量(static)在方法区中分配内存并设置初始值(默认值,如int的默认值为0)的阶段。实例变量将会在对象实例化时随着对象一起分配在java堆中。为类变量设置初始值跟该变量是否有final修饰符有关系。 如果没用final进行修饰,如下列的代码:
// 准备阶段执行完成后,value变量的值为int的“零值”,即:0
// 把value赋值为10的putstatic指令是程序被编译后,
// 存放于类构造器<clinit>()方法中的,所以具体赋值的操作会在初始化阶段执行
public static int value = 10;
如果使用了final进行修饰,如下列的代码:
// 如果类字段的字段属性表中有ConstantValue属性,
// 则会在准备阶段将变量的值初始化为ConstantValue所指定的值
// 准备阶段执行完成后,VALUE变量的值被赋值为20
public final static int VALUE = 20;
解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程,在该阶段将会进行符号引用的校验。 符号引用是Class文件中用来描述所引用目标的符号,可以是任何形式的字面量。 直接引用是虚拟机在内存中引用具体类或接口的,可以是直接指向目标的指针,相对偏移量或者是一个能间接定位到目标的句柄。 简单的来说,符号引用是Class类文件用来定位目标的,直接引用是虚拟机用来在内存中定位目标的。
初始化
初始化阶段是执行类的构造器方法<clinit>()的过程,类的构造方法由类变量的赋值动作和静态语句块按照在源文件中出现的顺序合并而成,该合并操作由编译器完成。
- <clinit>()方法对于类或接口不是必须的,如果一个类中没有静态代码块,也没有静态变量的赋值操作,那么编译器不会生成<clinit>()方法
- <clinit>()方法与实例构造器方法<init>()不同,不需要显式的调用父类的方法,虚拟机会保证父类的优先执行
- 为了防止多次执行,虚拟机会确保方法在多线程环境下被正确的加锁同步执行,如果有多个线程同时初始化一个类,那么只有一个线程能够执行方法,其它线程进行阻塞等待,直到执行完成
- 执行接口的方法是不需要先执行父接口的,只有使用父接口中定义的变量时,才会执行。
java虚拟机规范严格规定了有且只有一下5中情况必须立即对类进行初始化:
- 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,生成这4条指令的最常见的java代码场景是:使用new实例化对象时,读取或设置类的静态字段时,调用一个类的静态方法时
- 使用java.lang.reflect对类进行反射调用时,如通过Class.forName()创建对象时
- 当初始化一个类时,如果父类还没有初始化,则要先触发父类的初始化,即先要执行父类的构造器方法<clinit>()
- 启动虚拟机时,需要初始化包含main方法的类
- 在JDK1.7中,如果java.lang.invoke.MethodHandler实例最后的解析结果REFgetStatic、REFputStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化
以下几种情况,不会触发类初始化:
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
class Parent { static int a = 100; static { System.out.println("parent init!"); } } class Child extends Parent { static { System.out.println("child init!"); } } public class Init{ public static void main(String[] args){ // 只会执行父类的初始化,不会执行子类的初始化 // 将打印:parent init! System.out.println(Child.a); } }
- 定义对象数组,不会触发该类的初始化。
public class Init{ public static void main(String[] args){ // 不会有任何输出 Parent[] parents = new Parent[10]; } }
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类的初始化。
class Const { static final int A = 100; static { System.out.println("Const init"); } } public class Init{ public static void main(String[] args){ // Const.A会存入Init类的常量池中,调用时并不会触发Const类的初始化 // 将打印:100 System.out.println(Const.A); } }
- 通过类名获取Class对象,不会触发类的初始化。
class Cat { private string name; static { System.out.println("Cat is loaded"); } } public class Init{ public static void main(String[] args){ // 不会打印任何信息 Class catClazz = Class.class; } }
- 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
class Cat { private string name; static { System.out.println("Cat is loaded"); } } public class Init{ public static void main(String[] args) throws ClassNotFoundException{ // 不会打印任何信息 Class catClazz = Class.forName("com.test.Cat",false,Cat.class.getClassLoader()); } }
- 通过ClassLoader默认的loadClass方法,也不会触发初始化动作
class Cat { private string name; static { System.out.println("Cat is loaded"); } } public class Init{ public static void main(String[] args) throws ClassNotFoundException{ // 不会打印任何信息 new ClassLoader(){}.loadClass("com.test.Cat"); } }
最后,附上一幅Class类加载过程的思维导图:
是Class文件加载进虚拟机中的类,生产的玩具就是类的实例对象。
因此,从Class文件到对象需要经过的步骤大致为: Class文件-->类-->实例对象 而类的加载机制,就是负责将Class文件转换成虚拟机中的类的一个过程。
总的来说,类的加载过程,包括卸载在内的整个生命周期共有以下7个阶段:
加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,但是解析阶段不一定,在某些情况下解析可以在初始化之后再执行,为了支持java的运行时绑定,也成为动态绑定或晚期绑定。invokedynamic指令就是用于动态语言支持,这里“动态”的含义是必须等到城市实际运行到这条指令的时候,解析动作才开始执行。
加载
“加载”是“类加载”过程中的一个阶段,在加载阶段,虚拟机需要做以下3件事情:
-
通过类的全限定名获得该类的二进制字节流
-
将这个字节流所代表的静态存储结构转换成方法区中的某个运行时数据结构
- 在方法区内存(对于HotSpot虚拟机)中生成一个代表该类的java.lang.Class对象,作为访问方法区中该类的运行时数据结构的外部接口
加载阶段中“通过类的全限定名获得该类的二进制字节流”这个动作,被放到java虚拟机外部实现,目的是最大限度的让应用程序去决定该如何获取所需的类,而实现该动作的代码模块就是类加载器(ClassLoader),JVM提供了3种类加载器:
-
启动类加载器(Bootstrap ClassLoader):负责加载 JAVAHOME\lib 目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。
-
扩展类加载器(Extension ClassLoader):负责加载 JAVAHOME\lib\ext 目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
- 应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)上的类库。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中了。
验证
加载完成后,紧接着(更确切的说是交叉执行)虚拟机会对加载的字节流进行验证。虚拟机如果不检查输入的字节流,对其安全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃。验证阶段大致会完成4中不同的检验动作:
文件格式验证
文件格式验证主要是校验该字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机所接受。这个阶段包括但不限于以下验证点:
- 是否以魔数0xCAFEBABE开头
- 主、次版本号是否在当前虚拟机处理的范围之内
- 常量池中是否有不支持的常量类型(通过tag校验)
- 常量的索引是否有指向不存在或不符合类型的常量
- ...
元数据验证
元数据验证主要是对字节流中的描述信息(描述符)进行语义分析,以确保其描述的信息符合java语言规范的要求。这个阶段包括但不限于以下验证点:
- 这个类是否有父类,除了java.lang.Object,所有的类都应该有父类
- 这个类的父类是否继承了不允许被继承的类,如被final修饰的类
- 如果这个类不是抽象类,是否实现了父类或接口中要求的所有的方法
- ...
字节码验证
字节码验证主要是对类的方法体进行分析,确保在方法运行时不会有危害虚拟机的事件发生。这个阶段包括但不限于以下验证点:
- 操作数栈的数据类型与指令码中所需类型是否相符
- 校验跳转指令是否会跳转到方法体以外的字节码指令上
- 校验方法体中类型转换是否是有效的
- ...
符号引用验证
符号引用验证主要是对类自身以外的信息进行匹配新校验,包括常量池中的各种符号引用。这个阶段包括但不限于以下验证点:
- 符号引用中通过字符串描述的全限定类名是否能找到对应的类
- 在指定的类中是否存在符合方法的字段描述符以及简单名称所描述的字段和方法
- ...
准备
准备阶段是为类变量(static)在方法区中分配内存并设置初始值(默认值,如int的默认值为0)的阶段。实例变量将会在对象实例化时随着对象一起分配在java堆中。为类变量设置初始值跟该变量是否有final修饰符有关系。 如果没用final进行修饰,如下列的代码:
// 准备阶段执行完成后,value变量的值为int的“零值”,即:0
// 把value赋值为10的putstatic指令是程序被编译后,
// 存放于类构造器<clinit>()方法中的,所以具体赋值的操作会在初始化阶段执行
public static int value = 10;
如果使用了final进行修饰,如下列的代码:
// 如果类字段的字段属性表中有ConstantValue属性,
// 则会在准备阶段将变量的值初始化为ConstantValue所指定的值
// 准备阶段执行完成后,VALUE变量的值被赋值为20
public final static int VALUE = 20;
解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程,在该阶段将会进行符号引用的校验。 符号引用是Class文件中用来描述所引用目标的符号,可以是任何形式的字面量。 直接引用是虚拟机在内存中引用具体类或接口的,可以是直接指向目标的指针,相对偏移量或者是一个能间接定位到目标的句柄。 简单的来说,符号引用是Class类文件用来定位目标的,直接引用是虚拟机用来在内存中定位目标的。
初始化
初始化阶段是执行类的构造器方法<clinit>()的过程,类的构造方法由类变量的赋值动作和静态语句块按照在源文件中出现的顺序合并而成,该合并操作由编译器完成。
- <clinit>()方法对于类或接口不是必须的,如果一个类中没有静态代码块,也没有静态变量的赋值操作,那么编译器不会生成<clinit>()方法
- <clinit>()方法与实例构造器方法<init>()不同,不需要显式的调用父类的方法,虚拟机会保证父类的优先执行
- 为了防止多次执行,虚拟机会确保方法在多线程环境下被正确的加锁同步执行,如果有多个线程同时初始化一个类,那么只有一个线程能够执行方法,其它线程进行阻塞等待,直到执行完成
- 执行接口的方法是不需要先执行父接口的,只有使用父接口中定义的变量时,才会执行。
java虚拟机规范严格规定了有且只有一下5中情况必须立即对类进行初始化:
- 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,生成这4条指令的最常见的java代码场景是:使用new实例化对象时,读取或设置类的静态字段时,调用一个类的静态方法时
- 使用java.lang.reflect对类进行反射调用时,如通过Class.forName()创建对象时
- 当初始化一个类时,如果父类还没有初始化,则要先触发父类的初始化,即先要执行父类的构造器方法<clinit>()
- 启动虚拟机时,需要初始化包含main方法的类
- 在JDK1.7中,如果java.lang.invoke.MethodHandler实例最后的解析结果REFgetStatic、REFputStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化
以下几种情况,不会触发类初始化:
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
class Parent { static int a = 100; static { System.out.println("parent init!"); } } class Child extends Parent { static { System.out.println("child init!"); } } public class Init{ public static void main(String[] args){ // 只会执行父类的初始化,不会执行子类的初始化 // 将打印:parent init! System.out.println(Child.a); } }
- 定义对象数组,不会触发该类的初始化。
public class Init{ public static void main(String[] args){ // 不会有任何输出 Parent[] parents = new Parent[10]; } }
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类的初始化。
class Const { static final int A = 100; static { System.out.println("Const init"); } } public class Init{ public static void main(String[] args){ // Const.A会存入Init类的常量池中,调用时并不会触发Const类的初始化 // 将打印:100 System.out.println(Const.A); } }
- 通过类名获取Class对象,不会触发类的初始化。
class Cat { private string name; static { System.out.println("Cat is loaded"); } } public class Init{ public static void main(String[] args){ // 不会打印任何信息 Class catClazz = Class.class; } }
- 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
class Cat { private string name; static { System.out.println("Cat is loaded"); } } public class Init{ public static void main(String[] args) throws ClassNotFoundException{ // 不会打印任何信息 Class catClazz = Class.forName("com.test.Cat",false,Cat.class.getClassLoader()); } }
- 通过ClassLoader默认的loadClass方法,也不会触发初始化动作
class Cat { private string name; static { System.out.println("Cat is loaded"); } } public class Init{ public static void main(String[] args) throws ClassNotFoundException{ // 不会打印任何信息 new ClassLoader(){}.loadClass("com.test.Cat"); } }
最后,附上一幅Class类加载过程的思维导图:
来源:51CTO
作者:wx5d9ed7c8443c3
链接:https://blog.51cto.com/14570694/2464470