类的生命周期和加载过程
类的生命周期可以划分为 7 个阶段
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
其中,第 1~5 阶段,即加载、验证、准备、解析、初始化,统称为「类加载」,如下图所示。
1.加载
加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class
对象,这个 Class
对象就是这个类各种数据的访问入口。
该过程可以总结为「JVM 加载 Class
字节码文件到内存中,并在方法区创建对应的 Class
对象」。
2.验证
当 JVM 加载完 Class
字节码文件,并在方法区创建对应的 Class
对象之后,JVM 便会启动对该字节码流的校验,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行。
这个校验过程,大致可以分为下面几个类型
- JVM 规范校验
0x cafe babe
- 代码逻辑校验
- JVM 会对代码组成的数据流和控制流进行校验,确保 JVM 运行该字节码文件后不会出现致命错误。
- 例如,一个方法要求传入
int
类型的参数,但是使用它的时候却传入了一个String
类型的参数。
3.准备
准备阶段中,JVM 将为类变量分配内存并初始化。
准备阶段,有两个关键点需要注意
- 内存分配的对象
- 初始化的类型
内存分配的对象
Java 中的变量有「类变量」和「类成员变量」两种类型。「类变量」指的是被 static
修饰的变量,而其他所有类型的变量都属于「类成员变量」。 在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始。
public static int factor = 3; public String website = "www.google.com"; 复制代码
如上代码,在准备阶段,只会为 factor
变量分配内存,而不会为 website
变量分配内存。
初始化的类型
在准备阶段,JVM 会为「类变量」分配内存并为其初始化。这里的「初始化」指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。
public static int sector = 3; 复制代码
如上代码,在准备阶段后, sector
的值将是 0,而不是 3。
如果一个变量是常量(被 static final
修饰)的话,那么在准备阶段,变量便会被赋予用户希望的值。 final
关键字用在变量上,表示该变量不可变,一旦赋值就无法改变。所以,在准备阶段中,对类变量初始化赋值时,会直接赋予用户希望的值。
public static final int number = 3; 复制代码
如上代码,在准备阶段后, number
的值将是 3,而不是 0。
4.解析
解析过程中,JVM 针对「类或接口」、「字段」、「类方法」、「接口方法」、「方法类型」、「方法句柄」、「调用点限定符」这 7 类引用进行解析。解析过程的主要任务是将其在常量池中的符号引用,替换成其在内存中的直接引用。
5.初始化
到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。在这个阶段,JVM 会根据语句执行顺序对类对象进行初始化。
一般来说,当 JVM 遇到下面 5 种情况的时候会触发初始化
- 遇到
new
、getstatic
、putstatic
、invokestatic
这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。- 生成这 4 条指令的最常见的 Java 代码场景是使用
new
关键字实例化对象的时候、读取或设置一个类的静态字段(被final
修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 生成这 4 条指令的最常见的 Java 代码场景是使用
- 使用
java.lang.reflect
包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。 - 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含
main()
方法的那个类),虚拟机会先初始化这个主类。 - 当使用 JDK 1.7 动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果是REF_getstatic
、REF_putstatic
、REF_invokeStatic
的方法句柄,并且这个方法句柄所对应的类没有进行初始化时,则需要先出触发其初始化。
6.使用
当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。
7.卸载
当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class
对象,最后负责运行的 JVM 也退出内存。
对类加载的理解
下面,将通过几个案例,对类加载的 5 个阶段加深理解。
类初始化方法和对象初始化方法
public class Book { public static void main(String[] args) { System.out.println("Hello Liu Baoshuai"); } Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } { System.out.println("书的普通代码块"); } int price = 110; static{ System.out.println("书的静态代码块"); } static int amount = 112; } 复制代码
运行上述代码,输出信息如下。
书的静态代码块 Hello Liu Baoshuai 复制代码
下面对输出结果进行分析。
根据「类的生命周期和加载过程 / 5.初始化」章节中提到的「当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()
方法的那个类),虚拟机会先初始化这个主类」可知,我们将会进行类的初始化。
Java 源代码中有构造方法这个概念。但编译为字节码后,是没有构造方法这个概念的,只有「类初始化方法」和「对象初始化方法」。
- 「类初始化方法」
- 编译器会按照代码出现的顺序,收集类变量的赋值语句、静态代码块,最终组成类初始化方法。
- 类初始化方法一般在类初始化的时候执行。
上面的例子中,其类初始化方法如下。
static { System.out.println("书的静态代码块"); } static int amount = 112; 复制代码
- 「对象初始化方法」
- 编译器会按照代码出现的顺序,收集成员变量的赋值语句、普通代码块, 最后 收集构造函数的代码,最终组成对象初始化方法。 注意,构造函数的代码一定是被放在最后的。
- 对象初始化方法一般在实例化类对象的时候执行。
上面的例子中,其对象初始化方法如下。
{ System.out.println("书的普通代码块"); } int price = 110; //注意,构造函数的代码一定是被放在最后的 Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } 复制代码
结合「类初始化方法」和「对象初始化方法」的分析,再回过头看上述例子,就不难得出结论了。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。所以开始执行「初始化」过程。
main
方法中,并没有实例化对象,所以只执行「类初始化方法」,如下所示。因此,会输出书的静态代码块
。
static { System.out.println("书的静态代码块"); } static int amount = 112; 复制代码
- 初始化过程执行完毕后,继续执行
main()
方法。因此,会输出Hello Liu Baoshuai
。
案例引申
下面,对上述测试案例进一步引申,修改 main()
方法,代码如下所示。
public class Book { public static void main(String[] args) { System.out.println("Hello Liu Baoshuai" + new Book().price); } Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } { System.out.println("书的普通代码块"); } int price = 110; static{ System.out.println("书的静态代码块"); } static int amount = 112; } 复制代码
运行上述代码,输出信息如下。
书的静态代码块 书的普通代码块 书的构造方法 price=110,amount=112 Hello Liu Baoshuai110 复制代码
下面对输出结果进行分析。
书的静态代码块
static { System.out.println("书的静态代码块"); } static int amount = 112; 复制代码
- 「类初始化方法」执行完毕后,继续执行
main()
方法。遇到了new Book()
语句,所以触发执行「对象初始化方法」,如下所示。
// part 1 { System.out.println("书的普通代码块"); } // part 2 int price = 110; // part 3 Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } 复制代码
- 需要注意的是,
part 1
和part 2
的先后顺序,是根据它们在代码中出现的顺序决定的。part 3
部分是构造函数部分,这部分永远是出现最后的,和它在代码中的顺序无关。在代码中,part 3
部分虽然出现在part 1
和part 2
的前面,但在「对象初始化方法」中,它永远是出现在最后的。 - 此外,由于
part 2
出现在part 3
前面,所以输出price
的值是 110,而不是 0。
继承关系下类的加载情况
class Grandpa { static { System.out.println("爷爷在静态代码块"); } } class Father extends Grandpa { static { System.out.println("爸爸在静态代码块"); } public static int factor = 25; public Father() { System.out.println("我是爸爸~"); } } class Son extends Father { static { System.out.println("儿子在静态代码块"); } public Son() { System.out.println("我是儿子~"); } } public class InitializationDemo { public static void main(String[] args) { System.out.println("爸爸的岁数:" + Son.factor); //入口 } } 复制代码
运行上述代码,输出信息如下。
爷爷在静态代码块 爸爸在静态代码块 爸爸的岁数:25 复制代码
下面对输出结果进行分析。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。所以开始执行「初始化」过程。
main
方法中,并没有实例化对象,所以只执行「类初始化方法」,不会执行「对象初始化方法」。- 根据「类的生命周期和加载过程 / 5.初始化」章节中提到的「当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化」可知,进行
Son
初始化时,会先进行父类Father
的初始化。同理,进行Father
初始化时,会先进行父类Grandpa
的初始化。所以,程序会输出如下信息。
爷爷在静态代码块 爸爸在静态代码块 复制代码
- 继续,执行
main()
方法中的System.out.println
语句,程序会输出爸爸的岁数:25
。
也许会有人问为什么没有输出「儿子在静态代码块」这个字符串?这是因为对于静态字段,只有直接定义这个字段的类才会被初始化,才会执行该类的「类初始化方法」。因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
继承关系下实例化对象
class Grandpa { static { System.out.println("爷爷在静态代码块"); } public Grandpa() { System.out.println("我是爷爷~"); } } class Father extends Grandpa { static { System.out.println("爸爸在静态代码块"); } public Father() { System.out.println("我是爸爸~"); } } class Son extends Father { static { System.out.println("儿子在静态代码块"); } public Son() { System.out.println("我是儿子~"); } } public class InitializationDemo { public static void main(String[] args) { new Son(); //入口 } } 复制代码
运行上述代码,输出信息如下。
爷爷在静态代码块 爸爸在静态代码块 儿子在静态代码块 我是爷爷~ 我是爸爸~ 我是儿子~ 复制代码
下面对输出结果进行分析。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。所以开始执行「初始化」过程。
- 根据「类的生命周期和加载过程 / 5.初始化」章节中提到的「当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化」可知,进行
Son
初始化时,会先进行父类Father
的初始化。同理,进行Father
初始化时,会先进行父类Grandpa
的初始化。所以,程序会输出如下信息。
爷爷在静态代码块 爸爸在静态代码块 儿子在静态代码块 复制代码
main()
方法的new Son()
语句将触发实例化对象,调用Son
的构造函数,调用子类的构造函数时会先调用父类的构造函数。所以,程序会输出如下信息。
我是爷爷~ 我是爸爸~ 我是儿子~ 复制代码
类初始化方法中执行对象初始化方法
public class Book { public static void main(String[] args) { staticFunction(); } static Book book = new Book(); //注意该语句 static { System.out.println("书的静态代码块"); } { System.out.println("书的普通代码块"); } Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } public static void staticFunction(){ System.out.println("书的静态方法"); } int price = 110; static int amount = 112; } 复制代码
运行上述代码,输出信息如下。
书的普通代码块 书的构造方法 price=110,amount=0 书的静态代码块 书的静态方法 复制代码
下面对输出结果进行分析。
- 准备阶段中,会为类变量分配内存和进行初始化。此时,
book
实例变量被初始化为null
,amount
变量被初始化为 0。 - 进入初始化阶段后,因为
Book
类的main()
方法是程序的入口,所以 JVM 会初始化Book
类,执行「类初始化方法」,如下所示。
static Book book = new Book(); //注意该语句 static { System.out.println("书的静态代码块"); } static int amount = 112; 复制代码
- 如上代码所示,会先执行
static Book book = new Book()
。这条语句又触发了类的实例化,所以会执行「对象初始化方法」,如下所示。
// part 1 { System.out.println("书的普通代码块"); } // part 2 int price = 110; // part 3 Book() { System.out.println("书的构造方法"); System.out.println("price=" + price +",amount=" + amount); } 复制代码
- 需要注意的是,
part 1
和part 2
的先后顺序,是根据它们在代码中出现的顺序决定的。part 3
部分是构造函数部分,这部分永远是出现最后的,和它在代码中的顺序无关。在代码中,part 3
部分虽然出现在part 2
的前面,但在「对象初始化方法」中,它永远是出现在最后的。 - 此外,由于
part 2
出现在part 3
前面,所以输出price
的值是 110,而不是 0。 - 至此,程序输出如下。
书的普通代码块 书的构造方法 price=110,amount=0 复制代码
- 继续,执行完
static Book book = new Book()
语句后,回到步骤 2 中,执行「类初始化方法」。此时,程序会输出书的静态代码块
。 - 继续,执行
main()
方法的staticFunction();
语句。此时,程序会输出书的静态方法
。
类加载机制和类加载器
什么是类加载机制
当编译器将 Java 源码编译为字节码之后,虚拟机便可以将字节码读取进内存,从而进行解析、运行等整个过程。我们将这个过程称为 Java 虚拟机的「类加载机制」。
「类加载机制」中,通过类加载器( classloader
)来完成类加载的过程。
类加载器
什么是类加载器
通过一个类全限定名称来获取其二进制文件( .class
)流的工具,被称为类加载器( classloader
)。
Java支持的4种classloader
如上图所示,Java 支持 4 种 classloader
- 启动类加载器(
Bootstrap ClassLoader
)- 用于加载 Java 的核心类
- 它不是一个 Java 类,是由底层的 C++ 实现。因此,启动类加载器不属于 Java 类库,无法被 Java 程序直接引用。
Bootstrap ClassLoader
的parent
属性为null
- 标准扩展类加载器(
Extention ClassLoader
)- 由
sun.misc.Launcher$ExtClassLoader
实现 - 负责加载
JAVA_HOME
下libext
目录下的或者被java.ext.dirs
系统变量所指定的路径中的所有类库
- 由
- 应用类加载器(
Application ClassLoader
)sun.misc.Launcher$AppClassLoader
- 用户自定义类加载器(
User ClassLoader
)- 当上述 3 种类加载器不能满足开发需求时,用户可以自定义加载器
- 自定义类加载器时,需要继承
java.lang.ClassLoader
类。如果不想打破双亲委派模型,那么只需要重写findClass
方法即可;如果想打破双亲委派模型,则需要重写loadClass
方法
前 3 种 classloader
均继承了抽象类 ClassLoader
,其源码如下,该抽象类拥有一个 parent
属性,用于指定其父类的加载器。
public abstract class ClassLoader { private static native void registerNatives(); static { registerNatives(); } // The parent class loader for delegation // Note: VM hardcoded the offset of this field, thus all new fields // must be added *after* it. private final ClassLoader parent; protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); } // ... protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // First, check if the class has already been loaded Class c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClass0(name); } } catch (ClassNotFoundException e) { // If still not found, then invoke findClass in order // to find the class. c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } } 复制代码
可以通过下面这种方式,打印加载路径及相关 jar。
System.out.println("boot:" + System.getProperty("sun.boot.class.path")); System.out.println("ext:" + System.getProperty("java.ext.dirs")); System.out.println("app:" + System.getProperty("java.class.path")); 复制代码
自定义类加载器
此处给出一个自定义类加载器示例。
package com.lbs0912.java.demo; import java.io.IOException; import java.io.InputStream; public class ConsumerClassLoaderDemo extends ClassLoader { public static void main(String[] args) throws Exception { ClassLoader myClassLoader = new ConsumerClassLoader(); Object obj = myClassLoader.loadClass("com.lbs0912.java.demo.ConsumerClassLoaderDemo").newInstance(); ClassLoader classLoader = obj.getClass().getClassLoader(); // BootStrapClassLoader在Java中不存在的,因此会是null while (null != classLoader) { System.out.println(classLoader); classLoader = classLoader.getParent(); } } } class ConsumerClassLoader extends ClassLoader { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { try { String classFile = name.substring(name.lastIndexOf(".") + 1) + ".class"; InputStream in = getClass().getResourceAsStream(classFile); if (null == in) { return super.loadClass(name); } byte[] bytes = new byte[in.available()]; in.read(bytes); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name); } } } 复制代码
控制台输入如下
com.lbs0912.java.demo.ConsumerClassLoader@266474c2 sun.misc.Launcher$AppClassLoader@18b4aac2 sun.misc.Launcher$ExtClassLoader@63947c6b 复制代码
Java 9 中类加载器的变化
类加载机制的特点
「类加载机制」中,通过「类加载器( classloader
)」来完成类加载的过程。Java 中的类加载机制,有如下 3 个特点
- 双亲委派
- JVM 中,类加载器默认使用双亲委派原则
- 负责依赖
- 如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。
- 缓存加载
- 为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载。
下面对「双亲委派」进行说明。
双亲委派
什么是双亲委派
JVM 中,类加载器默认使用双亲委派原则。
双亲委派机制是一种任务委派模式,是 Java 中通过加载工具( classloader
)加载类文件的一种具体方式。 具体表现为
BootstrapClassLoader AppClassLoader
父委派模型被翻译成了双亲委派机制
The Java platform uses a delegation model for loading classes. The basic idea is that every class loader has a “parent” class loader. When loading a class, a class loader first “delegates” the search for the class to its parent class loader before attempting to find the class itself. —— Oracel Document Java 平台通过委派模型去加载类。每个类加载器都有一个父加载器。当需要加载类时,会优先委派当前所在的类的加载器的父加载器去加载这个类。如果父加载器无法加载到这个类时,再尝试在当前所在的类的加载器中加载这个类。
参考上述 Oracle 官网文档描述,Java 的类加载机制,更准确的说,应该叫做 “父委派模型”。但由于翻译问题,被称为了 “双亲委派机制”。参考 Java类加载机制-双亲委派机制还是应该叫做“父委派模型” | CSDN 了解更多。
双亲
classloader
类存在一个 parent
属性,可以配置双亲属性。默认情况下,JDK 中设置如下。
ExtClassLoader.parent=null; AppClassLoader.parent=ExtClassLoader //自定义 XxxClassLoader.parent=AppClassLoader 复制代码
需要注意的是,启动类加载器( BootstrapClassLoader
)不是一个 Java 类,它是由底层的 C++ 实现,因此启动类加载器不属于 Java 类库,无法被 Java 程序直接引用,所以 ExtClassLoader.parent=null;
。
委派
双亲设置之后,便可以委派了。委派过程也就是类文件加载过程。
ClassLoader
里面有 3 个重要的方法,即
loadClass() findClass() defineClass()
实现双亲委派的代码都集中在 java.lang.ClassLoader
的 loadClass()
方法中。
public abstract class ClassLoader { // 委派的父类加载器 private final ClassLoader parent; public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); } protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 保证该类只加载一次 synchronized (getClassLoadingLock(name)) { // 首先,检查该类是否被加载 Class<?> c = findLoadedClass(name); if (c == null) { try { if (parent != null) { //父类加载器不为空,则用该父类加载器 c = parent.loadClass(name, false); } else { //若父类加载器为空,则使用启动类加载器作为父类加载器 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { //若父类加载器抛出ClassNotFoundException , //则说明父类加载器无法完成加载请求 } if (c == null) { //父类加载器无法完成加载请求时 //调用自身的findClass()方法进行类加载 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } } protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); } } 复制代码
上述代码的主要步骤如下
- 先检查类是否已经被加载过
- 若没有加载,则调用父加载器的
loadClass()
方法进行加载 - 若父加载器为空,则默认使用启动类加载器作为父加载器
- 如果父类加载失败,抛出
ClassNotFoundException
异常后,再调用自己的findClass()
方法进行加载
此处给出一个加载时序图,加深理解。
loadClass、findClass、defineClass 方法的区别
ClassLoader
中和类加载有关的方法有很多,前面提到了 loadClass()
,除此之外,还有 findClass()
和 defineClass()
等。这3个方法的区别如下
loadClass()
:默认的双亲委派机制在此方法中实现findClass()
:根据名称或位置加载.class
字节码definclass()
:把.class
字节码转化为Class
对象
双亲委派的优点
避免类的重复加载
通过委派的方式,可以避免类的重复加载。当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。
保证安全性
通过双亲委派的方式,可以保证安全性 。因为 BootstrapClassLoader
在加载的时候,只会加载 JAVA_HOME
中的 jar 包里面的类,如 java.lang.String
,那么这个类是不会被随意替换的,除非有人跑到你的机器上,破坏你的 JDK。
双亲委派的缺点
在双亲委派中,子类加载器可以使用父类加载器已经加载过的类,但是父类加载器无法使用子类加载器加载过的类(类似继承的关系)。
Java 提供了很多服务提供者接口(SPI, Service Provider Interface
),它可以允许第三方为这些接口提供实现,比如数据库中的 SPI 服务 - JDBC。这些 SPI 的接口由 Java 核心类提供,实现者确是第三方。如果继续沿用双亲委派,就会存在问题,提供者由 Bootstrap ClassLoader 加载,而实现者是由第三方自定义类加载器加载。这个时候,顶层类加载就无法使用子类加载器加载过的类。
要解决上述问题,就需要打破双亲委派原则。
打破双亲委派模型
- ref 1- 破坏双亲委派模型 | 掘金
双亲委派模型并不是一个强制性约束,而是 Java 设计者推荐给开发者的类加载器的实现方式。在一定条件下,为了完成某些操作,可以 “打破” 模型。
打破双亲委派模型的方法主要包括
loadClass()
重写 loadClass 方法
在双亲委派的过程,都是在 loadClass()
方法中实现的,因此要想要破坏这种机制,可以自定义一个类加载器,继承 ClassLoader
并重写 loadClass()
方法即可,使其不进行双亲委派。
利用线程上下文加载器
利用线程上下文加载器( Thread Context ClassLoader
)也可以打破双亲委派。
Java 应用上下文加载器默认是使用 AppClassLoader
。若想要在父类加载器使用到子类加载器加载的类,可以使用 Thread.currentThread().getContextClassLoader()
。
比如我们想要加载资源可以使用以下方式。
// 使用线程上下文类加载器加载资源 public static void main(String[] args) throws Exception{ String name = "java/sql/Array.class"; Enumeration<URL> urls = Thread.currentThread().getContextClassLoader().getResources(name); while (urls.hasMoreElements()) { URL url = urls.nextElement(); System.out.println(url.toString()); } } 复制代码
//程序输出 jar:file:/Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar!/java/sql/Array.class jar:file:/Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar!/java/sql/Array.class 复制代码
类加载器的应用
依赖冲突
在 Maven 工程中,经常会出现依赖冲突,抛出 NoSuchMethodException
异常。如下图所示,业务依赖了消息中间件和微服务中间件,每个模块依赖的 fastjson
版本各不相同。根据引用路径最短原则,工程中实际最终引入的 fastjson
版本为 fastjson-1.0
。
因此,在调用 classA
的 method2()
时候,就会抛出 NoSuchMethodException
异常。
此处介绍一下阿里的潘多拉( pandora
) 是如何解决依赖冲突的。潘多拉中,通过自定义类加载器,为每个中间件自定义一个加载器,这些加载器之间的关系是平行的,彼此没有依赖关系。这样每个中间件的 classloader
就可以加载各自版本的 fastjson
。
一个类的全限定名以及加载该类的加载器,两者共同形成了这个类在 JVM 中的惟一标识,这也是阿里潘多拉实现依赖隔离的基础。
可能到这里,你又会有新的疑惑,根据双亲委托模型, App Classloader
分别继承了 Custom Classloader
,那么业务包中的 fastjson
的 class
在加载的时候,会先委托到 Custom ClassLoader
,这样不就会导致自身依赖的 fastjson
版本被忽略吗?确实如此,所以潘多拉又是如何做的呢?
如上图所示
- 首先每个中间件对应的
ModuleClassLoader
在加载对应的class
文件的同时,根据中间件配置的export.index
信息,将要需要透出的class
(主要是提供 API 接口的相关类)索引到exportedClassHashMap
中 - 然后应用程序的类加载器会持有这个
exportedClassHashMap
- 因此应用程序代码在
loadClass
的时候,会优先判断exportedClassHashMap
是否存在当前类。如果存在,则直接返回;如果不存在,则再使用传统的双亲委托机制来进行类加载。 - 这样中间件
MoudleClassloader
不仅实现了中间件的加载,也实现了中间件关键服务类的透出。
上述过程对应代码如下。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { //导出类中是否存在 若存在则直接返回 if(classCache != null && classCache.containsKey(name)){ return classCache.get(name); } //双亲委托加载机制 synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } } 复制代码
Tomcat 中打破双亲委派
我们知道,Tomcat 是一个 web 容器,那么一个 web 容器可能需要部署多个应用程序。
不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。如多个应用都要依赖 hollis.jar
,但是 A 应用需要依赖 1.0.0 版本,但是 B 应用需要依赖 1.0.1 版本。这两个版本中都有一个类是 com.hollis.Test.class
。
如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类。
所以,Tomcat 破坏双亲委派原则,提供隔离的机制,为每个 web 容器单独提供一个 WebAppClassLoader
加载器。 工作流程如下
- 为每一个应用提供一个
WebAppClassLoader
加载器,负责加载应用自身目录下的class
文件,从而实现隔离。 - 只有当加载不到时,才向上委派到通用的加载器
CommonClassLoader
进行加载。
热加载
通过将一个模块和该模块的类加载器的替换,可以实现热加载。
结合下图,介绍下 Spring 官方推荐的热加载方案 —— Spring boot devtools。
RestartClassLoader
为自定义的类加载器,其核心是 loadClass
的加载方式。Spring boot devtools 中修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,则从 parent 进行加载。 这样保证了业务代码可以优先被 RestartClassLoader
加载,进而通过重新加载 RestartClassLoader
完成应用代码部分的重新加载。
上述过程对应代码如下。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { String path = name.replace('.','/').concat(".class"); ClassLoaderFile file = this.updatedFiles.getFile(path); if(file != null && file.getKind() == Kind.DELETED){ throw new ClassNotFoundException(name); } //双亲委托加载机制 synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> loadedClass = findLoadedClass(name); if (c == null) { try { //优先从自己加载(编译生成的target/classes目录) loadedClass = findClass(name); } catch (ClassNotFoundException e) { //如果没有加载到 则从父类加载 loadedClass = Class.forName(name,false,getParent()); } } if (resolve) { resolveClass(loadedClass); } return loadedClass; } } 复制代码
热部署
热部署原理大体同热加载,如上图所示,将每个业务方通过一个 classloader
来加载。基于「类的隔离机制」,可以保障各个业务方的代码不会相互影响,同时也可以做到各个业务方进行独立的发布。
加密保护
出于技术保护或安全的目的,存在对 jar 包进行加密保护的诉求。
对 jar 包进行加密,本质上还是对字节码文件的操作。加密前后,不能影响 class
文件的正常加载过程,因此,加密保护步骤可划分为
- 在打包的时候对
class
进行正向的加密操作 - 在加载
class
文件之前通过自定义classloader
先进行反向的解密操作 - 最后,按照标准的
class
文件标准进行加载
只有在实现了解密方法的 classloader
的加载下,加密的 jar 包才可以被正常加载。上述过程对应代码如下。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { Class<?> clasz = findLoadedClass(name); if(clasz != null){ return clasz; } //提前对class文件进行解密 try{ //读取经过加密的类文件 byte classData[] = util.readFile(name + ".class"); if(classData != null){ byte decryptedClassData[] = cipher.doFinal(classData); //解密 //再把它转换成一个类 clasz = defineClass(name,decryptedClassData,0,decryptedClassData.length); } }catch (FileNotFoundException e){ e.printStackTrace(); } //必须的步骤2: 如果上面没有成功 //尝试用默认的classloader装入它 if(resolve && clasz != null){ clasz = findSystemClass(name); } if (resolve) { resolveClass(clasz); } return clasz; } 复制代码
SPI 机制
- ref 1- Java常用机制 - SPI机制详解 | Java全栈知识体系
- ref 2- 高级开发必须理解的Java中SPI机制
- ref 3- SPI 机制是「可插拔」的奥义所在,SpringBoot Starter 也利用了这个特性
什么是 SPI
服务提供接口( SPI
, Service Provider Interface
) 是 JDK 内置的一种「服务提供发现机制」,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件(可通过 SPI 机制实现模块化)。SPI 的整体机制图如下。
Java 的 SPI 机制可以为某个接口寻找服务实现。SPI 机制主要思想是将装配的控制权移到程序之外,在「模块化设计」中这个机制尤其重要,其核心思想就是「解耦」。Java SPI 实际上是 “基于接口的编程 + 策略模式 + 配置文件” 组合实现的动态加载机制。
使用示例
Java 中使用 SPI 的步骤如下图所示,主要包括 3 步
- 目录创建
- Java 中,在 ClassPath 下创建
META-INF/services
目录
- Java 中,在 ClassPath 下创建
- 文件创建
SPI
- 开始使用
- 通过
Service.load()
获得服务提供接口(SPI
)的所有实现类
- 通过
下面给出一个具体的例子,展示 SPI 的使用。项目结构如下图所示。
- 创建
ICustomSvc
接口,作为服务提供接口(SPI
)
public interface ICustomSvc { String getName(); } 复制代码
- 创建接口的实现者
CustomSvcOne
、CustomSvcTwo
。实际应用中,接口实现者为第三方厂商提供。开发者可通过jar
包导入或maven
依赖方式集成到自己的工程。
public class CustomSvcOne { @Obverride public String getName(){ return "CustomSvcOne"; } } public class CustomSvcTwo { @Obverride public String getName(){ return "CustomSvcTwo"; } } 复制代码
- 通过
Service.load()
获得服务提供接口(SPI
)的所有实现类。
public class CustomTest { public static void main(String[] args){ ServiceLoader<ICustomSvc> svcs = Service.load(ICustomSvc.class); svcs.forEach(s -> System.out.println(s.getName())); } 复制代码
- 程序输出如下。
cbuc.life.spi.service.impl.CustomSvcOne cbuc.life.spi.service.impl.CustomSvcTwo 复制代码
SPI 机制的实现原理
从「SPI 的使用示例」中可知,使用 SPI 时,要通过 Service.load()
获得服务提供接口( SPI
)的所有实现类,得到的是一个 ServiceLoader<S>
类型的数据结构。
不妨看一下 JDK 中 ServiceLoader<S>
方法的具体实现。
//ServiceLoader实现了Iterable接口,可以遍历所有的服务实现者 public final class ServiceLoader<S> implements Iterable<S> { //查找配置文件的目录 private static final String PREFIX = "META-INF/services/"; //表示要被加载的服务的类或接口 private final Class<S> service; //这个ClassLoader用来定位,加载,实例化服务提供者 private final ClassLoader loader; // 访问控制上下文 private final AccessControlContext acc; // 缓存已经被实例化的服务提供者,按照实例化的顺序存储 private LinkedHashMap<String,S> providers = new LinkedHashMap<>(); // 迭代器 private LazyIterator lookupIterator; //重新加载,就相当于重新创建ServiceLoader了,用于新的服务提供者安装到正在运行的Java虚拟机中的情况。 public void reload() { //清空缓存中所有已实例化的服务提供者 providers.clear(); //新建一个迭代器,该迭代器会从头查找和实例化服务提供者 lookupIterator = new LazyIterator(service, loader); } //私有构造器 //使用指定的类加载器和服务创建服务加载器 //如果没有指定类加载器,使用系统类加载器,就是应用类加载器。 private ServiceLoader(Class<S> svc, ClassLoader cl) { service = Objects.requireNonNull(svc, "Service interface cannot be null"); loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null; reload(); } //解析失败处理的方法 private static void fail(Class<?> service, String msg, Throwable cause) throws ServiceConfigurationError { throw new ServiceConfigurationError(service.getName() + ": " + msg, cause); } private static void fail(Class<?> service, String msg) throws ServiceConfigurationError { throw new ServiceConfigurationError(service.getName() + ": " + msg); } private static void fail(Class<?> service, URL u, int line, String msg) throws ServiceConfigurationError { fail(service, u + ":" + line + ": " + msg); } //解析服务提供者配置文件中的一行 //首先去掉注释校验,然后保存 //返回下一行行号 //重复的配置项和已经被实例化的配置项不会被保存 private int parseLine(Class<?> service, URL u, BufferedReader r, int lc, List<String> names) throws IOException, ServiceConfigurationError { //读取一行 String ln = r.readLine(); if (ln == null) { return -1; } //#号代表注释行 int ci = ln.indexOf('#'); if (ci >= 0) ln = ln.substring(0, ci); ln = ln.trim(); int n = ln.length(); if (n != 0) { if ((ln.indexOf(' ') >= 0) || (ln.indexOf('\t') >= 0)) fail(service, u, lc, "Illegal configuration-file syntax"); int cp = ln.codePointAt(0); if (!Character.isJavaIdentifierStart(cp)) fail(service, u, lc, "Illegal provider-class name: " + ln); for (int i = Character.charCount(cp); i < n; i += Character.charCount(cp)) { cp = ln.codePointAt(i); if (!Character.isJavaIdentifierPart(cp) && (cp != '.')) fail(service, u, lc, "Illegal provider-class name: " + ln); } if (!providers.containsKey(ln) && !names.contains(ln)) names.add(ln); } return lc + 1; } //解析配置文件,解析指定的url配置文件 //使用parseLine方法进行解析,未被实例化的服务提供者会被保存到缓存中去 private Iterator<String> parse(Class<?> service, URL u) throws ServiceConfigurationError { InputStream in = null; BufferedReader r = null; ArrayList<String> names = new ArrayList<>(); try { in = u.openStream(); r = new BufferedReader(new InputStreamReader(in, "utf-8")); int lc = 1; while ((lc = parseLine(service, u, r, lc, names)) >= 0); } return names.iterator(); } //服务提供者查找的迭代器 private class LazyIterator implements Iterator<S> { Class<S> service;//服务提供者接口 ClassLoader loader;//类加载器 Enumeration<URL> configs = null;//保存实现类的url Iterator<String> pending = null;//保存实现类的全名 String nextName = null;//迭代器中下一个实现类的全名 private LazyIterator(Class<S> service, ClassLoader loader) { this.service = service; this.loader = loader; } private boolean hasNextService() { if (nextName != null) { return true; } if (configs == null) { try { String fullName = PREFIX + service.getName(); if (loader == null) configs = ClassLoader.getSystemResources(fullName); else configs = loader.getResources(fullName); } } while ((pending == null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending = parse(service, configs.nextElement()); } nextName = pending.next(); return true; } private S nextService() { if (!hasNextService()) throw new NoSuchElementException(); String cn = nextName; nextName = null; Class<?> c = null; try { c = Class.forName(cn, false, loader); } if (!service.isAssignableFrom(c)) { fail(service, "Provider " + cn + " not a subtype"); } try { S p = service.cast(c.newInstance()); providers.put(cn, p); return p; } } public boolean hasNext() { if (acc == null) { return hasNextService(); } else { PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() { public Boolean run() { return hasNextService(); } }; return AccessController.doPrivileged(action, acc); } } public S next() { if (acc == null) { return nextService(); } else { PrivilegedAction<S> action = new PrivilegedAction<S>() { public S run() { return nextService(); } }; return AccessController.doPrivileged(action, acc); } } public void remove() { throw new UnsupportedOperationException(); } } //获取迭代器 //返回遍历服务提供者的迭代器 //以懒加载的方式加载可用的服务提供者 //懒加载的实现是:解析配置文件和实例化服务提供者的工作由迭代器本身完成 public Iterator<S> iterator() { return new Iterator<S>() { //按照实例化顺序返回已经缓存的服务提供者实例 Iterator<Map.Entry<String,S>> knownProviders = providers.entrySet().iterator(); public boolean hasNext() { if (knownProviders.hasNext()) return true; return lookupIterator.hasNext(); } public S next() { if (knownProviders.hasNext()) return knownProviders.next().getValue(); return lookupIterator.next(); } public void remove() { throw new UnsupportedOperationException(); } }; } //为指定的服务使用指定的类加载器来创建一个ServiceLoader public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) { return new ServiceLoader<>(service, loader); } //使用线程上下文的类加载器来创建ServiceLoader public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } //使用扩展类加载器为指定的服务创建ServiceLoader //只能找到并加载已经安装到当前Java虚拟机中的服务提供者,应用程序类路径中的服务提供者将被忽略 public static <S> ServiceLoader<S> loadInstalled(Class<S> service) { ClassLoader cl = ClassLoader.getSystemClassLoader(); ClassLoader prev = null; while (cl != null) { prev = cl; cl = cl.getParent(); } return ServiceLoader.load(service, prev); } public String toString() { return "java.util.ServiceLoader[" + service.getName() + "]"; } } 复制代码
ServiceLoader
实现了Iterable
接口,所以它有迭代器的属性。ServiceLoader
实现了迭代器的hasNext
和next
方法。ServiceLoader
持有了private LazyIterator lookupIterator;
,这是一个懒加载类型的迭代器 迭代器(懒加载迭代器)。hasNextService()
方法中获取fullName
时,使用到了前缀PREFIX
,这个值是"META-INF/services/"
。所以,在创建配置文件时,其路径是 ClassPath 下的META-INF/services/
。- SPI 机制中,通过反射方法
Class.forName()
加载类对象,并用newInstance
方法将类实例化,并把实例化后的类缓存到providers
对象中(其类型为LinkedHashMap<String,S>
),最后返回实例对象。 ServiceLoader
不是实例化以后,就去读取配置文件中的具体实现并进行实例化,而是等到使用迭代器去遍历的时候,才会加载对应的配置文件去解析,调用hasNext
方法的时候会去加载配置文件进行解析,调用next
方法的时候进行实例化并缓存。- 所有的配置文件只会加载一次,服务提供者也只会被实例化一次,重新加载配置文件可使用
reload
方法。
SPI 机制的应用
SPI 机制应用较为广泛,包括
- 数据库驱动 JDBC DriveManager
- 日志库门面 Common-Logging
- 插件体系
- Spring 中使用 SPI
以「 JDBC DriveManager」为例,简要介绍下 SPI 机制的应用。
- Java 定义服务提供接口(SPI),提供一个标准,如
java.sql.Driver
。 - 具体厂商或框架来实现这个 SPI 接口,比如
me.cxis.sql.MyDriver
。 - 具体厂商或框架创建配置文件,在
META-INF/services
目录下定义一个名字为接口全限定名的文件,如java.sql.Driver
文件。文件内容是具体的实现名字,如me.cxis.sql.MyDriver
。 - 开发者引用具体厂商的
jar
包进行业务逻辑开发。
//获取ServiceLoader ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class); //获取迭代器 Iterator<Driver> driversIterator = loadedDrivers.iterator(); //遍历 while(driversIterator.hasNext()) { driversIterator.next(); //可以做具体的业务逻辑 } 复制代码
SPI 机制的缺点
- 不能按需加载
- 需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。
- 如果不想用某些实现类,或者某些类实例化很耗时,就会造成浪费。
- 获取某个实现类的方式不够灵活
Iterator
- 多线程下不安全
- 多个并发多线程使用
ServiceLoader
类的实例,是不安全的
- 多个并发多线程使用
API 和 SPI
- ref 1- 小议 SPI 和 API | Blog
在服务/客户(S/C)系统中
- API 中,接口的实现在服务端
- SPI 中,接口的实现在客户端
Spring SPI
SPI 机制不仅在 JDK 中实现,Spring 及 Dubbo 框架也都有对应的 SPI 机制。
在 Spring Boot 中好多配置和实现都有默认的实现,我们如果想要修改某些配置,我们只需要在配置文件中写上对应的配置,项目应用的便是我们定义的配置内容。这正是通过 SPI 机制实现的。
- 我们将想要注入 IoC 容器的类的全类限定名写到
META-INF/spring.factories
文件中 - Spring Boot 程序启动时,使用
SpringFactoriesLoader
进行加载,扫描每个 jar 包class-path
目录下的META-INF/spring.factories
配置文件,然后解析properties
文件,找到指定名称的配置
Java SPI 与 Spring SPI 的区别
- JDK 使用的加载工具类是
ServiceLoader
,而 Spring 使用的是SpringFactoriesLoader
。 - JDK 目录命名方式是
META-INF/services/提供方接口全类名
,而 Spring 使用的是META-INF/spring-factories
。