类加载机制&方法调用的实现
一、类加载机制
Java 虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 java类型,这个过程被称作虚拟机的类加载机制。
1.1 类的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking),具体图下图所示:
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的动态绑定。在《Java虚拟机规范》中并没有进行强制约束什么情况下需要开始对类进行加载,这点交给虚拟机的具体实现来自由把握。
1.2 类加载的过程
当程序要使用某个类时,如果该类还未被加载到内存中,则系统会通过加载,连接,初始化三步来实现这个类进行初始化。
1.2.1 加载
加载,是指Java 虚拟机找到对应的.class文件,并根据字节流创建对应的java.lang.Class对象的过程。这个过程将class文件加载到虚拟机的内存,放在运行时区域的方法区内,然后在堆中创建java.lang.Class对象,用来封装类在方法区的数据结构。
加载过程:
1)Java虚拟机将.class文件读入内存,ClassLoader通过一个类的全限定名来获取此类的字节码文件(二进制字节流),并为之创建一个Class对象。
2)任何类被使用时系统都会为其创建一个且仅有一个Class对象。
3)这个Class对象描述了这个类创建出来的对象的所有信息,例如:成员变量,构造方法,成员方法。
Student类加载过程如下图所示:
1.2.2 连接
验证
验证阶段,主要目的在于确保Class文件的字节流中包含的信息符合当前虚拟机要求,不会危害虚拟机自身的安全。主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
准备
准备阶段,负责为类的静态成员分配内存,并设置默认初始值。【这里不包含final修饰的static ,因为final在编译的时候就已经分配了;不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象分配到Java堆中。】
解析
解析阶段,将常量池中的符号引用替换为直接引用的过程。
【说明】
符号引用:它是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能正确地定位到目标即可。
直接引用:它是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。可以理解为一个内存地址,或者一个偏移量。比如:类变量的直接引用是指向方法区的指针;而实例方法中实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。
举个例子,现在要调用work()方法,这个方法的地址是0x785AD。那么work就是符号引用,0x785AD就是直接引用。
在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。
1.2.3 初始化
初始化是类加载的最后阶段,它的主要目的是为常量值的字段赋值,换句话说,只对static修饰的变量或语句块进行初始化。执行其静态初始化器(静态代码块)和静态初始化成员变量。(前面已经对static 初始化了默认值,这里我们对它进行赋值,成员变量也将被初始化)
【注】如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类;如果同时包含多个静态变量和静态代码块时,则按照自上而下的顺序依次执行。
JVM 初始化步骤
:
1)假如这个类还没有被加载和连接,则程序先加载并进行连接该类
2)假如该类的直接父类还没有被初始化,则先初始化其直接父类
3)假如类中有初始化语句,则系统依次进行初始化
类初始化时机
:
只有当对类进行使用的时候才会对其进行初始化,类的主动使用主要包括一下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 通过反射方式创建某个类或者接口对象的Class对象(如Class.forName(“com.hundsun.Student”))
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
1.3 类加载器
类加载器的任务是根据类的全限定名来读取此类的二进制字节流到 JVM 中,然后转换成一个与目标类对象的java.lang.Class 对象的实例,在java 虚拟机提供三种类加载器,启动类加载器,扩展类加载器,应用类加载器。
- 启动类加载器(Bootstrap Class Loader)
这个类加载器负责加载存放在<JAVA_HOME>中的jre/lib/rt.jar里所有的class,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。它由c++实现,不是ClassLoader子类。
- 扩展类加载器(Extension Class Loader)
它负责加载 Java 平台中扩展功能的一些jar包,包括<JAVA_HOME>中的lib/ext/*.jar,或者被java.ext.dirs系统变量所指定的路径中所有的类库。它是一种Java系统类库的扩展机制,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。
- 应用程序类加载器(Application Class Loader)
应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
- 自定义类加载器(User Class Loader)
应用程序根据自身需要定义ClassLoader,ClassLoader 在加载过程中会按照自底向上的顺序检查,该类是否已被加载,从User Class Loader 到BootStrap Class Loader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类在所有ClassLoader 只被加载一次。
1.4 双亲委派模型
1.4.1 什么是双亲委派模型?
上图展示的是各种类加载器之间的层次关系,就被称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是使用组合(Composition)关系来复用父加载器的代码。
1.4.2 为什么要双亲委派模型?
双亲委派保证类加载器,自底而上的委派,又自顶而下的加载,保证每一个类在各个类加载器中都是同一个类,保证java官方的类库<JAVA_HOME>\lib和扩展类库<JAVA_HOME>\lib\ext的加载安全性,不会被开发者覆盖。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,那么Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。
例如:类java.lang.Object,它存放在rt.jar之中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器加载,因此Object类在程序的各种类加载器环境中都是同一个类。
1.4.3 双亲委派模型的工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完全这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
public abstract class ClassLoader {
// 每个类加载器都有一个父加载器
private final ClassLoader parent;
public Class<?> loadClass(String paramString) throws ClassNotFoundException {
return loadClass(paramString, false);
}
protected Class<?> loadClass(String paramString, boolean paramBoolean) throws ClassNotFoundException {
synchronized (getClassLoadingLock(paramString)) {
// 查找请求的类是否已经被加载过了
Class<?> clazz = findLoadedClass(paramString);
if (clazz == null) {
// 如果没有被加载过,先委派给父类加载器去加载,注意这个是递归调用
long l = System.nanoTime();
try {
if (this.parent != null) {
clazz = this.parent.loadClass(paramString, false);
} else {
// 如果父类加载器为空,查找Bootstrap加载器是不是加载过
clazz = findBootstrapClassOrNull(paramString);
}
} catch (ClassNotFoundException classNotFoundException) {
// 如果父类加载器抛出ClassNotFoundException,说明父类加载器无法完成加载请求
}
if (clazz == null) {
// 如果父类加载器没有加载成功时,再调用自己的findClass方法去进行类的加载
long l1 = System.nanoTime();
clazz = findClass(paramString);
PerfCounter.getParentDelegationTime().addTime(l1 - l);
PerfCounter.getFindClassTime().addElapsedTimeFrom(l1);
PerfCounter.getFindClasses().increment();
}
}
if (paramBoolean) {
resolveClass(clazz);
}
return clazz;
}
}
}
这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载。
1.4.4 如何破坏双亲委派模型?
双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。这个委派和加载顺序完全是可以被破坏的。
两个典型的方法:
- 自定义类加载器,重写loadClass方法
- 使用线程上下文类加载器
重写loadClass方法
双亲委派机制的实现是通过loadClass方法实现的,这个方法可以指定类通过什么加载器来进行加载,所以如果我们改写它的加载规则,就相当于打破了双亲委派机制。如果想自定义类加载器就需要继承
ClassLoader
类,并重写findClass
方法,我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑进行加载,如果父类加载失败,会自动调用自己的findClass()方法来完成加载。如果想不遵循双亲委派的类加载的逻辑顺序,还需要重写loadClass
方法。
举个例子进行说明,使用自定义的类加载器CustomClassLoader ,并重写 findClass ,代码如下:
public class CustomClassLoader extends ClassLoader {
public CustomClassLoader(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> findClass(final String name) throws ClassNotFoundException {
Class<?> clazz = null;
try {
String pathName = "D:\\project\\Practice\\mybatis\\01mybatisday01jdbc\\target\\classes\\" + name.replaceAll("\\.", "\\\\") + ".class";
// 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
byte[] classBytes = Files.readAllBytes(Paths.get(pathName));
// 调用defineClass将字节数组转成Class对象
clazz = defineClass(name, classBytes, 0, classBytes.length);
System.out.println("执行自己重写的findClass方法");
if (clazz == null) {
throw new ClassNotFoundException(name);
}
} catch (IOException e) {
System.out.println(e);
e.printStackTrace();
}
return clazz;
}
从上面的代码中可以得到几个关键信息:
1)JVM 的类加载器是分层次的,它们之间都存在某种特定的关系(父子关系),这种关系不是继承,而是组合,每个类加载器都有一个共同的属性 parent ,并指向自己的父加载器。【AppClassLoader
的parent
是ExtClassLoader
,ExtClassLoader
的parent
是BootstrapClassLoader
,但是BootstrapClassLoader
的parent
为null】
2)loadClass
方法的主要职责就是实现双亲委派机制:首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载。
3)findClass
方法的主要职责就是找到.class文件并把.class文件读到内存得到字节码数组,然后调用 defineClass方法得到 Class 对象。子类必须实现findClass。
4)defineClass
方法的职责是调用 native 方法把 Java 类的字节码解析成一个 Class 对象。
测试类如下:
public class ClassLoadTest {
public static void main(String[] args) {
try {
// 初始化CustomClassLoader,并将加载CustomClassLoader类的父类加载器设置未null
CustomClassLoader loader = new CustomClassLoader(null);
System.out.println("CustomClassLoader的父类加载器:" + loader.getParent());
// 调用loadClass加载com.study.mybatis.Bean.HelloWorld类,无法加载到该类时,会去调用findClass方法
Class<?> clazz = loader.loadClass("com.study.mybatis.Bean.HelloWorld");
System.out.println("HelloWorld的类加载器:" + clazz.getClassLoader());
Method method = clazz.getMethod("main", String[].class);
method.invoke(null, (Object) new String[]{});
} catch (Throwable e) {
System.out.println(e);
}
}
}
【解释说明】
由于java中所有类都继承Object
,当加载自定义类com.study.mybatis.Bean.HelloWorld
,之后还会加载其父类,而Object
是java的官方提供的类,它是由BootstrapClassLoader
加载的,跳过了扩展类加载器ExtClassLoader
和应用程序类加载器AppClassLoader
。既然如此,我们先将com.study.mybatis.Bean.HelloWorld
交给BootstrapClassLoader
加载即可。由于java中无法直接引用BootstrapClassLoader
,所以在初始化CustomClassLoader
的时候,传入parent
为null,也就是CustomClassLoader
的父类加载器设置为BootstrapClassLoader
。我们知道双亲委派的逻辑在loadClass
方法中,由于现在类加载器的关系为CustomClassLoader
---->BootstrapClassLoader
,所以在自定义类加载器CustomClassLoader
中无需重写loadClass
方法。
测试运行结果如下:
双亲委派模型被成功破坏,
HelloWorld
类将交由自定义的类加载器CustomClassLoader
进行加载。如果不破坏双亲委派的话,那么HelloWorld
类处于ClassPath
下,就应该由应用程序类加载器AppClassLoader
进行加载,所以上面这个例子真正破坏的是AppClassLoader
这一层的双亲委派。
线程上下文类加载器
线程上下文类加载器(Thread Context ClassLoader),这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法设置上下文类加载器。如果创建线程时还未设置,它将会从父线程中继承一个(parent = currentThread()),如果在应用程序的全局范围内都没有设置过的话,那这个类加载器就默认是应用程序类加载器,在该线程后续执行过程中就能把这个类加载器取(java.lang.Thread#getContextClassLoader)出来使用。
二、方法调用的底层实现
2.1 解析
在类加载的解析阶段,会将其中一部分符号引用解析为直接引用。这种解析能够成立的前提是:方法在程序运行之前就可以确定调用的版本,并且在运行期间不会改变,即“编译器可知,运行期不可变”。这类方法的调用被称为解析”。
这类方法主要有静态方法和私有方法两大类,静态方法与类型直接关联,私有方法在外部不可被访问,因此它们都适合在类加载阶段进行解析。
调用不同类型的方法,字节码指令集里对应着不同的指令。java 虚拟机支持的方法调用字节码指令有一下五种:
- invokestatic:用于调用静态方法
- invokespecial:用于调用实例构造器方法、私有方法和父类中的方法。
- invokevirtual:用于调用所有的虚方法。
- invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被invokestatic、invokedspecial这两个指令调用的方法,都可以在类加载的解析阶段确定其唯一的调用版本。符合这个条件的方法类型有:静态方法、私有方法、实例构造器、父类方法、final修饰的方法。这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”
2.2 分派
分派,是根据条件在多个方法版本中选择匹配的方法。主要通过invokevirtual
和invokeinterface
指令调用。通过分派调用过程,揭示多态性特征最基本的体现,如“重载”和“重写”。
静态分派:通过变量静态类型来定位方法版本,在重载中应用。
动态分派:通过变量实际类型来定位方法版本,在重写中应用。
这里所说的静与动的意思分别指的是编译期和运行时,变量定义时的静态类型
在编译期是可知的,而变量的实际类型
在编译期是不可确定的,只有在程序运行期才可知。例如,在B extends A, A a = new B();
中,对于变量a,A称为变量a的静态类型,B称为变量a的实际类型。
2.2.1 静态分派
静态分派的实现过程,它与Java语言多态性的一个重要体现——重载(Overload)有着很密切的关联。
通过实现的编码来了解什么是静态分派:
package com.study.mybatis.Controller;
public class StaticDispatch {
static abstract class Animal {
}
static class Cat extends Animal {
}
static class Dog extends Animal {
}
public void cry(Animal animal) {
System.out.println("动物们都会为自己发声");
}
public void cry(Cat cat) {
System.out.println("猫咪,喵喵叫");
}
public void cry(Dog dog) {
System.out.println("小狗,汪汪叫");
}
public static void main(String[] args) {
Animal cat = new Cat();
Animal dog = new Dog();
StaticDispatch sd = new StaticDispatch();
sd.cry(cat);
sd.cry(dog);
}
}
运行结果:
通过上面代码可以看出,在选择重载方法的版本时,编译器依据的是传入参数的静态类型
(当然也与入参的数量有关),而与其实际类型无关。静态类型是在编译期可知的,所以,在静态方法和私有方法重载时,编译期也是可以确定调用版本的。
反编译看一下执行的指令:
public static void main(java.lang.String[]);
Code:
0: new #7 // class com/study/mybatis/Controller/StaticDispatch$Cat
3: dup
4: invokespecial #8 // Method com/study/mybatis/Controller/StaticDispatch$Cat."<init>":()V
7: astore_1
8: new #9 // class com/study/mybatis/Controller/StaticDispatch$Dog
11: dup
12: invokespecial #10 // Method com/study/mybatis/Controller/StaticDispatch$Dog."<init>":()V
15: astore_2
16: new #11 // class com/study/mybatis/Controller/StaticDispatch
19: dup
20: invokespecial #12 // Method "<init>":()V
23: astore_3
24: aload_3
25: aload_1
26: invokevirtual #13 // Method cry:(Lcom/study/mybatis/Controller/StaticDispatch$Animal;)V
29: aload_3
30: aload_2
31: invokevirtual #13 // Method cry:(Lcom/study/mybatis/Controller/StaticDispatch$Animal;)V
34: return
通过反编译可以看出26行和31行invokevirtual
指令的符号引用都指向了常量池的#13
,而#13
就是sd.cry(Animal),所以调用的是同一个方法,打印出来的结果也就一样了。
2.2.2 动态分派
动态分派的实现过程,它与Java语言多态性的另外一个重要体现——重写(Override)有着很密切的关联。
package com.study.mybatis.Controller;
public class DynamicDispatch {
static abstract class Animal {
protected abstract void cry();
}
static class Cat extends Animal {
@Override
protected void cry() {
System.out.println("猫咪,喵喵叫");
}
}
static class Dog extends Animal {
@Override
protected void cry() {
System.out.println("小狗,汪汪叫");
}
}
public static void main(String[] args) {
Animal cat = new Cat();
Animal dog = new Dog();
cat.cry();
dog.cry();
cat = new Dog();
cat.cry();
}
}
运行结果:
通过上面代码可以看出,这里选择调用的方法版本是不可能再根据静态类型
来决定的,因为静态类型同样都是Animal 的两个变量 cat 和 dog 在调用 cry() 方法时产生了不同的行为,甚至变量 cat 在两次调用中还执行了两个不同的方法。导致这个现象的原因很明显,是因为这两个变量的实际类型
不同。
反编译看一下执行的指令:
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/study/mybatis/Controller/DynamicDispatch$Cat
3: dup
4: invokespecial #3 // Method com/study/mybatis/Controller/DynamicDispatch$Cat."<init>":()V
7: astore_1
8: new #4 // class com/study/mybatis/Controller/DynamicDispatch$Dog
11: dup
12: invokespecial #5 // Method com/study/mybatis/Controller/DynamicDispatch$Dog."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method com/study/mybatis/Controller/DynamicDispatch$Animal.cry:()V
20: aload_2
21: invokevirtual #6 // Method com/study/mybatis/Controller/DynamicDispatch$Animal.cry:()V
24: new #4 // class com/study/mybatis/Controller/DynamicDispatch$Dog
27: dup
28: invokespecial #5 // Method com/study/mybatis/Controller/DynamicDispatch$Dog."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method com/study/mybatis/Controller/DynamicDispatch$Animal.cry:()V
36: return
分析:
0~15行的字节码是准备动作,作用是建立 cat 和 dog 的内存空间、调用 Cat 和 Dog 类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源码中的这两行:
Animal cat = new Cat();
Animal dog = new Dog();
第16行和第20行的aload指令分别把刚刚创建的两个对象的引用压到栈顶;第17行和第21行是方法调用指令,都指向了常量池的#6
(注释显示了这个常量是 Animal.cry()的符号引用),都完全一样,但是这两句指令最终执行的目标方法并不相同。那看来解决问题的关键还必须从invokevirtual指令本身入手。
invokevirtual指令的运行时解析过程大致分为以下几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
本文是学习了《深入理解Java虚拟机》之后的总结,主要内容都来自于书中,也有作者的一些理解。一是为了梳理知识点,归纳总结,二是为了分享交流,如有理解错误之处还望各位高人指出。