本文转载自微信公众号「三太子敖丙」,作者三太子敖丙。转载本文请联系三太子敖丙公众号。
我敢打赌大家在开发过程中经常碰到一些类加载的问题,比如:
ClassNotFoundException
- Cause: java.lang.ClassNotFoundException: Cannot find class: com.cc.A
NoClassDefFoundError
- Cause: java.lang.NoClassDefFoundError: Cannot find class: com.cc.A
上述问题均和java类加载有关,如果不清楚JVM中类加载的原理,上述问题会让人郁闷至极,侥幸在网上找到解决方案也只是暂时解决问题,后续在另外的场景中碰到又会继续懵逼。
我这篇文章将对 Java 类加载器的双亲委派加载原理进行阐述,并结合实例程序深究类的双亲委派加载机制,大家彻底了解掌握类加载原理,清楚了类加载原理后,碰到上述类似问题就能快速解决,并在后续开发中避免类似问题。
什么是Java类加载?
java类加载器负责将编译好的 Java class 件加载到 Java 虚拟机(JVM)中的运行时数据区中,供执行引擎调用。
java类加载在JVM体系结构中的位置如图所示:
jvm体系结构原图
没有类加载机制,编写的java程序就没法在JVM中运行,因此掌握java类加载是非常重要的。
JVM类加载层级关系
执行java程序时,会启动一个JVM进程,JVM在启动时会做一些初始化操作,比如获取系统参数等等,然后创建一个启动类加载器,用于加载JVM运行时必须的一些类到内存中,同时也会创建其他两个类加载器扩展类加载器和系统类加载器。
启动类加载器、扩展类加载器和系统类加载器之间的关系如下图所示:
jvm内置classLoader
- **启动类加载器:**java虚拟机启动后创建的第一个类加载器,由C++语言实现,所以我们在java代码中查看其信息时,看到的均为null。
- 扩展类加载器:由启动类加载器加载,并将扩展类加载器中的parent的值设置为null(表示指向启动类加载器),同时继承自URLClassLoader。
- 系统类加载器:由启动类加载器加载,并将系统类加载期中的parent的值设置为上述创建的扩展类加载器。,同时继承自URLClassLoader。
在代码中可以通过如下方式查看类加载中的parent指向:
代码查看类加载器的parent
注意:这里的parent不是java的继承机制,而是类加载器中的一个实例属性,用于在类加载时的委托对象,parent属性定义在其所继承的ClassLoader中,定义如下所示。
- public abstract class ClassLoader {
- ....................
- // The parent class loader for delegation
- private final ClassLoader parent;
JVM类加载的默认加载路径
每种类型的类加载器默认都会有自己的加载路径,启动类加载器、扩展类加载器和系统类加载器的默认加载路径如下图所示:
三种类加载器的加载路径
如上图所示:
1、启动类加载器(BootClassLoader)由C++语言编写,负责在JVM启动时加载jdk自身的一些核心class类(jar包形式)到JVM中,加载时寻找资源的路径由只读系统属性:”sun.boot.class.path“ 指定,一般为:”JAVA_HOME/jre/classes“目录(在该目录下只能放class文件,jar包形式文件不生效)。
查看启动类加载类加载路径可以通过获取系统属性:”sun.boot.class.path“进行查看,如图所示:
lancher中设置启动类加载路径
启动类加载器加载路径
2、扩展类加载器(ExtClassLoader),负责加载位于系统属性:"java.ext.dirs"指向的目录下加载class文件(jar包或者直接class文件形式)到JVM中,比如通常ext类加载路径为:”$JAVA_HOMEx/jre/lib/ext“ 。
支持在JVM启动之前进行修改路径,运行中修改路径不生效,扩展类路径中仅支持jar包的加载。
查看扩展类加载器的类加载路径可以通过获取系统属性:”java.ext.dirs“进行查看或向上转型为URLClassLoader(上面说扩展类加载器继承自URLClassLoader),查看位于父类URLClassLoader中urls属性的方式进行查看,如图所示:
扩展类加载器路径
3、系统类加载器(AppClassLoader),负责加载应用classpath路径下的class文件(jar包或者直接class文件形式)到JVM中,当系统中没有设置classpath路径时,默认加载当前路径下的class文件。
查看系统类加载器的类加载路径可以通过获取系统属性:”java.class.path“进行查看或向上转型为URLClassLoader上面说扩展类加载器继承自URLClassLoader),查看位于父类URLClassLoader中urls属性的方式进行查看,如图所示:
系统类加载路径
JVM类加载双亲委托机制
JVM加载class类文件到虚拟机时,默认首先采用系统类加载器去加载用到的class类,采用的是双亲委托加载机制。
所谓双亲委托,顾名思义,就是当前类加载器(以系统类加载器为例)在加载一个类时,委托给其双亲(注意这里的双亲指的是类加载器中parent属性指向的类加载器)先进行加载。
双亲类加载器在加载时同样委托给自己的双亲,如此反复,直到某个类加载器没有双亲为止(通常情况下指双亲为null,也即为当前的双亲为扩展类加载器,其parent为启动类加载器),然后开始在依次在各自的类路径下寻找、加载class类。
如下图所示:
双亲委派
双亲委托加载实例实例
采用JDK版本
- java version "1.8.0_261" Java(TM) SE Runtime Environment (build 1.8.0_261-b12) Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, mixed mode)
本实例涉及到两个类:TestMain.java 和 A.java,期中TestMain为启动类,在启动类中调用类A中的方法执行进行输出,分别输出启动类和被依赖类的类加载器信息,类定义如下所示:
A_java
我们将两个java文件拷贝到某个目录下,在我本地比如放在E:\java_app目录下,windows下打开命令行窗口,切换到E:\java_app,对当前java文件进行编译,执行命令javac TestMain.java。
此时会在当前目录下生产对应的class文件(这里只需要对TestMain执行编译命令,因为TestMain依赖了A,所以Jdk编译器就会自动先去编译依赖的A),如图所示:
编译命令
接下来我们将观察java类加载机制是怎样实现双亲委托加载的。
委托给扩展类加载器加载
由于扩展类在自身类路径下加载只支持寻找jar包的方式,因此我们通过工具将A.class文件打包进A.jar。
然后将A.jar放置到扩展类加载路径:$JAVA_HOME/jre/lib/ext,同时保留当前目录中的A.class文件。如图所示:
扩展委派
此时在当前目录:E:\java_app下仍然保留有A.class文件,在扩展类加载器路径下多了一个包含了A.class的A.jar文件,在当前目录下执行java命令执行TestMain,命令为:java TestMain,输出如下所示:
扩展委派结果
由上图输出结果可知,class A虽然在系统类加载器的加载路径中,但由于类加载的委托机制,A首先将由系统类加载器委托给其双亲扩展类加载器进行加载,刚好在扩展类加载器的加载路径中包含了A.class(包含在A.jar中),所以A最终由扩展类加载器进行了加载。
委托给启动类加载器进行加载
通常情况下,普通类的加载不应该委托给启动类加载器进行加载,因为前面说过启动类加载器由C++实现,在java虚拟机启动时生成的,在java环境中获取她的信息均为null。
本实例为了探究类加载的双亲委托机制,所以特意将构造一个将普通类委托给其加载的场景。
前面在讲到启动类加载器加载路径时指出了启动类加载器的加载路径由只读系统属性”sun.boot.class.path“ 指定,且仅支持加载该目录下固定的jar文件。
在jdk8中还有”$JAVA_HOME/jre/classes“目录也是启动类加载器加载的路径(该路径默认可能不存在,可以手工创建一个),在该目录下只能放class文件,jar包形式文件不生效。
因此,本实例程序将当前目录下的A.class文件拷贝到启动类加载器的类路径:”$JAVA_HOME/jre/classes“中,同时保留当前目录中的A.class文件,也保留扩展类加载器类路径中的A.jar。
类存放路径如图所示:
委派启动
在当前目录:E:\java_app目录下执行命令运行TestMain,命令为:java TestMain,输出如下所示:
委派启动结果
由上图输出结果可知,class A虽然在系统类加载器的加载路径中,也存在扩展类加载器的加载路径中,但由于类加载的委托机制,A首先将由系统类加载器委托给其双亲扩展类加载器进行加载。
扩展类加载器又会继续进行委托加载(实际上因为扩展类加载器的parent:启动类加载器为null,所以此时的委托动作实际上就是去启动类加载器的加载路径中寻找class A),最终由启动类加载进行了A的加载。
双亲委托加载方向
类加载器在加载类时,只能向上递归委托其双亲进行类加载,而不可能从双亲再反向委派当前类加载器来进行类加载。
在中国象棋中,卒子过河之后的行走轨迹永远只能是前进或者左右平移,可以很形象的比作双亲委托类加载的这种方向性。
- 卒子过河比喻当前类加载器委派其双亲加载了某个类。这个类的后续依赖的加载已经和当前类加载器没有关系。
- 过河之后的卒子只能前进,表示双亲在加载类的依赖类时,只能继续递归进行双亲委派。
- 左右平移表示双亲在递归双亲委派加载失败后,在双亲类加载器自己的加载路径中进行加载。
为了表明委派具有方向性,我们继续拿上面的TestMain.class和A.class两个类做实验。
上述委托实例中我们的场景时是:TestMain中依赖了A,我们将A通过双亲委托方式进行了加载,本次实验中,我们将TestMain委托给双亲加载。
参照上述的操作步骤,将TestMain.class打进TestMain.jar中,放到扩展类加载器的加载路径中,同时也保留TestMain.class到当前目录,如下图所示:
委派加载顺序1
切换到当前应用目录,执行java命令运行程序:java TestMain,执行结果如下所示:
委派顺序执行结果
如上图所示,出现错误了,TestMain被扩展类加载器加载了,依赖的A却没有能被加载到。
原因就是上述说的委派加载具有方向性导致的:
1、运行java命令执行TestMain程序时,系统类加载器准备加载TestMain,根据双亲委派机制,先委派给其双亲进行加载,最后,双亲扩展类加载器在其加载路径中的TestMain.jar中找到了TestMain.class,完成了TestMain的加载。
2、TestMain中依赖了A,此时,会根据加载了TestMain的类加载器:扩展类加载器去加载A,加载方式根据委托机制递归委托给双亲加载,扩展类加载器的双亲为启动类加载器,在启动类加载器的加载路径中不存在A,加载失败,此时由扩展类加载器在自己的加载路径中加载A,也因为加载路径中没有A.class存在,A.class存在于系统类加载器的加载路径中,但是扩展类加载器不会再返回去委托系统类加载器进行加载,所以直接抛出加载失败异常,出现了上述的错误。
总结这次大致介绍了java的类加载在整个JVM中的作用,详细介绍了JVM中的启动类加载器、扩展类加载器和系统类加载器三者之间的关系,并结合实例着重介绍了类加载的双亲委派加载原理,理解java的双亲委派加载原理之后,就能在后续的程序开发设计中在程序的动态设计这块掌握更多高级技能,开发出更加优秀的产品。