类加载
从class文件到内存中的类,按先后顺序需要经过加载、链接、初始化三个步骤,内存中的类没有经过初始化是不能使用的。
加载过程
加载的过程就是查找字节流的过程,对于数组类来说没有对应的字节流,是由Java虚拟机直接生成的,对于其他类来说则需要借助类加载器完成查找字节流的过程。
加载器和双亲委派模型
所有的类都有个顶级类加载器BootstrapClassLoader,启动类加载器由 C++ 实现,没有对应的 Java 对象所以在 Java中只能用null来指代。
除了启动类加载器,其他类加载器都是 java.lang.ClassLoader
的子类,因此有对应的 Java 对象,这些类加载器需要由另一个加载器比如启动类加载器加载到 Java 虚拟机中,才能执行类加载
类加载的过程中,先检查是否已经加载过,若没有加载则调用父类加载器的loadClass()
方法,每一层的类加载器都是如此,所有的请求都会传送到顶层的类加载器中,父加载器反馈自己无法完成这个加载请求后,子加载器才会尝试自己去加载。若父类加载器为空则默认使用启动类加载器作为父加载器。若父加载失败,抛出ClassNotFoundException
异常后,调用自己的findClass()
方法进行加载。
双亲委派模型作用:
比如现在黑客自己定义了一个java.lang.String
类,该String
类和系统的String
类有一样的功能,可能只是该其中的一些东西,加入一些病毒代码,没有双亲委派模型,JVM 可能就会认为黑客自己定义的java.lang.String
类才是系统的String
类,但是在双亲委派模型下,最顶端的类加载器加载系统的java.lang.String
,自定义的类加载器无法加载java.lang.String
类。
加载器
Java9之前,启动类加载器负载加载最基础、最重要的类,比如存放在JRE
的lib
目录下的jar
包中的类(还有虚拟机参数-Xbootclasspath指定的类),除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由Java核心类库提供。
链接
链接指的是将创建成的类合并到Java虚拟机中,让他能够执行的过程,可分为验证、准备以及解析三个阶段。
验证
验证阶段的目的,在于确保被加载的类能够满足Java虚拟机的约束条件,就好比做一件事可能需要交给上级审核之后才能才能继续下面的工作,通常来说Java编译器生成的类文件必然满足于Java虚拟机的约束条件。
准备
准备阶段的目的则是为加载的静态字段分配内存,Java代码对具体静态字段的初始化会放到初始化阶段,除了分配内存以外,部分Java虚拟机还会在此阶段构造其他类层次相关的数据结构,比如实现虚方法的动态绑定的方法表。
比如
1 | private static int value=123; |
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()
方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
解析
在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
Java 虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。
初始化
在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。
如果直接赋值的静态字段被final修饰,并且它的类型是基本类型或字符串时,那么这个字段会被Java编译器标记为常量值,初始化直接由Java虚拟机完成,除此之外的直接赋值操作,和静态代码块中的代码,都会被Java编译器放在<clinit>()
方法中。
初始化就是为标记为常量值的字段赋值,和执行<clinit>()
方法的过程,Java虚拟机会通过加锁来确保<clinit>()
方法执行一次。
初始化完成之后,类才正式成为可执行的状态。JVM 中类的初始化何时会被触发呢?
- 虚拟机启动时,初始化用户指定的主类
- 用到
new xxx
类的时候,初始化new指令的目标类 - 遇到调用静态方法的指令,初始化静态方法所在的类
- 遇到访问静态指令的时候,初始化静态指令所在的类
- 子类的初始化会触发父类的初始化
- 如果一个接口定义了default方法,直接实现或间接实现这个接口的类的初始化,也会触发这个接口的初始化
- 使用反射API对某个类进行反射调用的时候,初始化这个类
- 初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类
双亲委派模型详解
我们先写出如下代码
1 | public class DemoApplication { |
输出结果如下:
1 | sun.misc.Launcher$AppClassLoader@18b4aac2 |
类加载器这个体系结构中,最高的一层也就是Bootstrap,在JVM启动时创建,通常由操作系统相关的本地代码实现。是最根基的类加载器,它负责将 <JAVA_HOME>/lib
路径下的核心类库或 -Xbootclasspath
参数指定的路径下的jar包加载到内存中
第二层是Extension ClassLoader(JDK9以后是Platform ClassLoader),它负责加载 <JAVA_HOME>/lib/ext
目录下或者由系统变量 -Djava.ext.dir
指定位路径中的类库
第三层是Application ClassLoader的应用类加载器,主要是加载用户定义的CLASSPATH路径下的类。
只有Bootstrap不是由Java语言实现的
双亲委派机制的具体实现
低层次的类加载器不能覆盖高层次类加载器已经加载的类,如果低层次类加载器想加载一个还没被加载过的类,需要逐级向上询问这个类是否已经被加载,被问到的类加载器会看看自己是否已经加载过这个类,如果没有他要问自己的父加载器自己能不能加载这个类,父加载器也会看自己有没有加载过这个类,并且问父加载器的父加载器自己能不能加载这个类,直到问到Bootstrap ClassLoader,然后再逐级乡下尝试能不能加载这个类,直到问到发起类加载请求的类加载器。
源代码如下:
在java.lang.ClassLoader的loadClass()方法中,先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
1 | protected Class<?> loadClass(String name, boolean resolve) |
双亲委派机制的优势
系统的安全性:比如String类,存放在rt.jar中,无论哪一个类加载器要去加载这个类,一定最后会让Bootstrap进行加载,防止有心人进行恶意替换。
自定义类加载器
继承 ClassLoader
,重写 findClass()
方法,调用 defineClass()
类。
代码如下,我在 /home/gzr/
目录下放了个 Hello.class
, Hello.java
代码如下。使用 javac
命令编译了一下。
1 | public class Hello { |
自定义类加载器代码如下:
1 | package com.gzr.classloader; |
执行结果如下:
1 | com.gzr.classloader.CustomClassLoader@27c170f0 |
为什么要自定义类加载器?
隔离加载类
在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境,比如阿里的有些容器框架就是通过自定义类加载器来确保应用中以来的jar包不会影响到中间件运行的jar包。
一般中间件都会有自己的jar包,同一个工程引用多个框架的时候,导致某些类存在包路径、类名相同的情况,引起类的冲突,导致应用程序异常。主流的容器类框架都会自定义类加载器,实现不同中间件之间的类隔离,有效避免类冲突。
修改类加载方式
类的加载模型并非强制,除Boostrap外,其他的加载并非一定要引入,可以根据实际情况在某个时间点按需动态加载。
扩展加载源
可以从数据库,网络等地方进行加载类
防止源码泄露
Java代码可能会被篡改,可以进行编译加密,那么类加载也要自定义才能进行还原加密的字节码了。