自定义类加载器解决jar冲突问题
碎碎念
好吧,我是一个没有意志力,贪图安逸的人,懒惰、不思进取的人!好久没有发过文了,为啥?一个字:懒!
引言
目前维护的项目是一个非常非常原始的项目,这里可以发挥你的想象力,你能想到多原始就有多原始……
最近需要在原有环境中重新搭建一个新的工程,当然这个工程与原来其他七八十来个工程共用一个 JVM 环境。有经验的老铁看到这里脑海里肯定会有一堆的问题……好吧,其他暂不讨论,这次主要是解决 jar 冲突的问题。
因为原有工程中用到一个 jar 是低版本的,而新工程也用到这个 jar,但是需要高版本的。其他工程公用的低版本肯定是不能丢的,影响太大。那如果再引入高版本的 jar,肯定就会出现版本冲突问题。又因为时间紧迫,且为了安全高效,领导决定这个新工程按照原有模式进行开发,就是沿用原有工程的技术框架,能不改就不改,毕竟以后生产环境也是要在一起发布的,不能出现乱七八糟的问题。有些老铁就想到解决版本冲突 maven 不就可以帮我们管理引用的 jar 吗?那我要告诉你老铁,你真的还很年轻!俺们现在的技术架构,没有引入类似 maven 这项高级技术。别操……闭嘴!听我继续吹!
如果再为它单独申请一台服务器,明显也是不可能的,太不划算(但事实上他们真的要这么干!)。我个人认为也是没必要的。因为这么一个小的项目,而且技术上也没有提升,其他不说,就单单因为沿用了那么老的技术框架,我觉得它就不值得一台新服务器,内心感觉那样很浪费资源!
怎么办?!事情还是要继续做的,稍作思考后,我想到了自定义类加载器!用它来解决 jar 版本冲突应该可以的吧?
网上先搜搜了解下,但网上那些博客已经被资本侵蚀了,有用的不多,没用的一堆,但可以告诉你这个东西有人做过,可以试试!试试就试试,正好也好久没用复习了!
快速回顾
这里首先强烈推荐一本经典的关于系统了解 JVM 的书籍,想必你已经猜了,那就是周老板的《深入理解Java虚拟机》,现在已经出了第三版了,我之前看的是第二版,准准准准准准准备入手第三版看看,肯定也是一场盛宴,第二版每次想起都回味无穷!强烈推荐大家有条件的看看!!!好了,废话不多说,开撸!
类加载器是什么
类加载器负责实现类的加载动作,通过一个类的全限定名来获取描述此类的二进制字节流。
每一个类加载器,都有一个独立的类名称空间。即:在一个 JVM 中比较两个类是否“相等”,只有这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载他们的类加载器不同,这两个类就必定不相等。
简单一句话,在同一个 JVM 环境中,如果要指定一个类,需要加载它的类加载器和其全限定名一起。
类加载器分类
从虚拟机的角度来讲,只存在两种不同的类加载器:
启动类加载器(Bootstrap Classloader):使用 C++ 语言实现,是虚拟机自身的一部分;
负责加载在
其他类加载器:由 Java 语言实现,独立于虚拟机外部,并且全都继承自 java.lang.ClassLoader 抽象类:
扩展类加载器(Extension Classloader):负责加载
/lib/ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库。开发者可以直接使用此类加载器。 应用程序类加载器(Application ClassLoader):也称为系统类加载器,负责加载用户路径(ClassPath)下所指定的类库,也就是说我们平时所编写的代码都是通过此类加载器进行加载的。开发者可以直接使用这个类加载器,如果应用程序中没有自定义类加载器,一般情况下,这个就是应用程序中默认的类加载器。
我们应用程序都是由以上 3 种类加载器相互配合进行加载的,如果有必要,还可以自定义类加载器。通过继承 java.lang.ClassLoader 实现自己的类加载器。在程序运行期间动态加载 class 文件,体现了 Java 动态实时类装入特性。
这些类加载器之间的层次关系如下图:
双亲委派模型
不同类加载器之间的层次关系称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的父子关系并非继承,而是组合关系。
双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个类委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。但它并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式。
使用双亲委派模型对于保证 Java 程序的稳定运行很重要。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器都要加载这个类,但最终都会委派给启动类加载器来加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在 ClassPath 中,那么系统中将出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将变得一片混乱。
在双亲委派模型下,如果编写一个与 rt.jar 类库中已有类重名的 Java 类,你将会发现它可以正常发现,但是永远无法被加载运行。
双亲委派模型的实现
虽然双亲委派模型非常重要,但是其实现还是比较简单,逻辑也比较清晰易懂。实现双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法中。
其首先检查当前类是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
protected Class> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
/*
* 首先通过从已加载的类中查找当前类,来检查当前类是否已经被加载过,
* 在没有被加载过的情况下,执行加载逻辑
*/
Class> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 父类加载器是否为空,如果不为空则委派父加载器加载,否则启动类加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出 ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
/**
* 在父类加载器无法完成加载的情况下,调用本身的 findClass 方法来进行加载。
* findClass 是抽象类,需要在子类实现
*/
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
重点方法说明
类加载器中最重要的方法有以下三个,也是自定义类加载器时需要重点关注的:
loadClass:如上,该方法主要实现了双亲委派模型,是类加载器的模板方法,JVM 在加载类的时候都是通过 ClassLoader 的 loadClass 方法来加载的。如果要想打破双亲委派模型,需要重写 loadClass 方法。
findClass:类加载器加载类的具体实现方法。自定义类加载器通过实现这个方法来加载需要的类。
definedClass:在 findClass 中使用,通过调用传入的一个 Class 文件的字节数组,就可以在方法区生成一个 Class 对象,也就是 findClass 实现了类加载的功能。
特别提一下,URLClassLoader 是 JDK 已经实现的从指定URL地址来加载的类加载器,这个 URL 可以是本地目录,也可以是网络地址。扩展类加载器和应用类加载器就是继承自 URLClassLoader.java 这个类。
自定义类加载器
只说不练假把式,终于到本文的重点了。
快速回顾之后,我首先想到了一种自定义实现类加载器解决 jar 冲突的方法:
将引言中提到的新的高版本的 jar 放到磁盘的一个目录,而这个目录不属于启动类加载器所负责加载的目录;
编写一个自定义类加载器继承自 URLClassLoader 类,让其从我们指定的磁盘目录来加载;
使用自定义类加载器的时候,父类加载器设置为 null。
经过上面对 loadClass 方法的分析可以知道,这种情况下,当使用自定义类加载器加载一个类的时候,loadClass 方法中调用的应该是子类自己的 findClass 方法。并且在 URLClassLoader 中已经实现了从指定地址加载的机制,而我们需要做的就是指定加载的 URL 地址,事实是这样吗?一试便知!
/**
* 自定义类加载器
*/
public class CustomClassLoader extends URLClassLoader {
public CustomClassLoader(URL[] urls) {
// 父类加载器设置为 null
super(urls,null);
}
}
/**
* jar 包中的类
*/
public class D {
public static String innerInvoke(){
System.out.println("from D.class innerInvoke:" + System.currentTimeMillis());
return "from D.class innerInvoke:" + System.currentTimeMillis();
}
}
public class DateUtils {
public static String yyyy_MM_dd_HH_mm_ss = "yyyy-MM-dd HH:mm:ss";
public static String sysDateTime(){
System.out.println("CustomClassLoader invoke DateUtils.sysDateTime");
Date date = new Date();
SimpleDateFormat smf = new SimpleDateFormat(yyyy_MM_dd_HH_mm_ss);
D.innerInvoke();
return smf.format(date);
}
}
/**
* ClassPath 下的类,由 AplicationClassLoader 加载
*/
public class DateUtils {
public static String yyyy_MM_dd_HH_mm_ss = "yyyy-MM-dd HH:mm:ss";
public static String sysDateTime(){
System.out.println("AplicationClassLoader invoke DateUtils.sysDateTime");
Date date = new Date();
SimpleDateFormat smf = new SimpleDateFormat(yyyy_MM_dd_HH_mm_ss);
D.innerInvoke();
return smf.format(date);
}
}
/**
* 测试类
*/
public class CustomClassLoaderTest {
private static final Logger logger = LogManager.getLogger();
private URL[] urls;
@Before
public void init() throws Exception {
urls = new URL[1];
File file = new File("E:\\jar\\commons-1.0-SNAPSHOT.jar");
urls[0] = file.toURI().toURL();
}
@Test
public void methodInvokeTest() throws Exception{
CustomClassLoader customClassLoader = new CustomClassLoader(urls);
Class> aClass = customClassLoader.loadClass("com.syh.utils.DateUtils");
Method method = aClass.getMethod("sysDateTime");
System.out.println(method.invoke(aClass.newInstance()));
System.out.println(DateUtils.sysDateTime());
}
}
----------------------------------------运行结果-----------------------------------------
CustomClassLoader Loading DateUtils.class
CustomClassLoader invoke DateUtils.sysDateTime
from D.class innerInvoke:1635697276791
2021-11-01 00:21:16
ApplicationClassLoader invoke DateUtils.sysDateTime
2021-11-01 00:21:16
Process finished with exit code 0
非常骚气,果然可以!!!!!!!!!!只需要在调用父类加载器的时候指定加载地址,并且将父类加载器设为 null !
但是程序中不可能只一次,或者只在一个地方使用我们这个自定义类加载器,但是每次都要重新创建吗?要知道类加载器,并不是一次性将目录下全部的类都加载到虚拟机,而是在需要使用某个类的时候才去加载,而且加载后会缓存起来,在下载执行加载的时候会先去缓存查看是不是已经加载过,如果加载过就直接返回而不是再次加载。这个在 loadClass 方法分析的时候已经明确了。
那么,我们可以对自定义的类加载器进行优化,使其成为一个单例。如下
public class CustomClassLoader extends URLClassLoader {
private static volatile CustomClassLoader customClassLoader = null;
// 私有化构造器,禁止在外部进行创建
private CustomClassLoader(URL[] urls){
super(urls,null);
};
public static CustomClassLoader getInstance(URL[] urls){
if (customClassLoader == null){
synchronized (CustomClassLoader.class){
if (customClassLoader == null){
customClassLoader = new CustomClassLoader(urls);
}
}
}
return customClassLoader;
}
}
小结
以上,对于理解类加载器和双亲委派模型还是很有帮助的。但虽然实现了自定义类加载器从指定目录加载 jar,但也是很有局限性的,想一下,如果自定目录下的 jar 还需要调用 ClassPath 目录下的类,以上实现还可以正常运行吗?换句话说,就是自定义类加载器需要用到另外一个类加载器加载的类对象,该怎么办?
其实整体梳理下来,类加载器其实并不是太难,实现逻辑也很清晰。事实上,应该实现一个比较健壮、支持更多场景的自定义类加载器,起码满足我上面的那个问题,可能比较复杂,但是可以支持更多的场景。
关注下,期待下一篇吧~