1 快速梳理Java类加载机制
- 三句话总结JDK8的类加载机制:
- 类缓存:每个类加载器对他加载过的类都有⼀个缓存;
- 双亲委派:向上委托查找,向下委托加载;
- 沙箱保护机制:不允许应⽤程序加载JDK内部的系统类;
1.1 JDK8 的类加载体系
-
先看一个简单的 Demo:
package com.roy.cl; public class LoaderDemo { public static String a ="aaa"; public static void main(String[] args) throws ClassNotFoundException { // 父子关系 AppClassLoader <- ExtClassLoader <- BootStrap Classloader ClassLoader cl1 = LoaderDemo.class.getClassLoader(); System.out.println("cl1 > " + cl1); System.out.println("parent of cl1 > " + cl1.getParent()); // BootStrap Classloader由C++开发,是JVM虚拟机的一部分,本身不是JAVA类。 System.out.println("grant parent of cl1 > " + cl1.getParent().getParent()); // String,Int等基础类由BootStrap Classloader加载。 ClassLoader cl2 = String.class.getClassLoader(); System.out.println("cl2 > " + cl2); System.out.println(cl1.loadClass("java.util.List").getClass().getClassLoader()); // java指令可以通过增加-verbose:class -verbose:gc 参数在启动时打印出类加载情况 // BootStrap Classloader,加载java基础类。这个属性不能在java指令中指定,推断不是由java语言处理。。 System.out.println("BootStrap ClassLoader加载目录:" + System.getProperty("sun.boot.class.path")); // Extention Classloader 加载JAVA_HOME/ext下的jar包。 可通过-D java.ext.dirs另行指定目录 System.out.println("Extention ClassLoader加载目录:" + System.getProperty("java.ext.dirs")); // AppClassLoader 加载CLASSPATH,应用下的Jar包。可通过-D java.class.path另行指定目录 System.out.println("AppClassLoader加载目录:" + System.getProperty("java.class.path")); } }
-
运行结果:这段代码演示了Java的类加载器层次结构及其加载路径,输出结果验证了Java的三层类加载器架构及其职责划分;
- 类加载器父子关系:
- AppClassLoader(应用类加载器)是当前类的加载器
- 其父加载器是ExtClassLoader(扩展类加载器)
- ExtClassLoader的父加载器是Bootstrap ClassLoader(显示为null,因为它是C++实现的)
- 核心类加载:
- String等基础类由Bootstrap ClassLoader加载(显示为null)
- java.util.List也由Bootstrap ClassLoader加载
- 各类加载器的加载路径:
- Bootstrap:加载JDK核心库(rt.jar等)
- ExtClassLoader:加载JRE扩展目录(lib/ext)
- AppClassLoader:加载应用classpath指定的路径
- 类加载器父子关系:
-
-
可以看到 JDK8 中的两个类加载体系:
- 左侧是JDK中实现的类加载器,通过
parent
属性形成⽗⼦关系。应⽤中⾃定义的类加载器的parent都是AppClassLoader
; - 右侧是JDK中的类加载器实现类,通过类继承的机制形成体系,可以通过继承相关的类实现⾃定义类加载器;
- 左侧是JDK中实现的类加载器,通过
-
JDK8中的类加载器都继承于⼀个统⼀的抽象类
ClassLoader
,类加载的核⼼也在这个⽗类中。其中,加载类的核⼼⽅法如下: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 thrown if class not found // from the non-null parent class loader } if (c == null) { // 发现⽗类加载起没有加载过,就⾃⾏解析class⽂件加载 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(); } } // 这⼀段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。运⾏时加载类,默认是⽆法进⾏链接步骤的。 if (resolve) { resolveClass(c); } return c; } }
- 这个⽅法就是最为核⼼的双亲委派机制,并且这个⽅法是
protected
声明的,这意味着这个⽅法是可以被⼦类覆盖的,所以双亲委派机制也是可以被打破的;
- 这个⽅法就是最为核⼼的双亲委派机制,并且这个⽅法是
-
当⼀个类加载器要加载⼀个类时,整体的过程就是通过双亲委派机制向上委托查找,如果没有查找到,就向下委托加载。整个过程整理如下图:
1.2 沙箱保护机制
- 见
01 全面理解JVM虚拟机
。
1.3 Linking 链接过程
-
在
ClassLoader
的loadClass
⽅法中,还有⼀个不起眼的步骤:resolveClass
,这是⼀个native
⽅法。其实现的过程称为 linking 链接,链接过程的实现功能如下图:- 其中关于半初始化状态就是JDK在处理⼀个类的
static
静态属性时,会先给这个属性分配⼀个默认值,作⽤是占住内存。然后等连接过程完成后,在后⾯的初始化阶段,再将静态属性从默认值修改为指定的初始值;
- 其中关于半初始化状态就是JDK在处理⼀个类的
-
例:
package com.roy.clinit; // Apple.apple访问了类的静态变量,会出触发的初始化,即加载-》链接-》初始化 // 当执行构造函数时,price还没有初始化完成,处于链接阶段的准备阶段,其值为默认值 0,这时构造函数的 price 就是 0,所以最终打印出来的结果是-10而不是10 class Apple { static Apple apple = new Apple(10); static double price = 20.00; double totalpay; public Apple(double discount) { System.out.println("====" + price); totalpay = price - discount; } } public class PriceTest01 { public static void main(String[] args) { System.out.println(Apple.apple.totalpay); } }
-
结果:
====0.0 -10.0
-
如果想要输出10,可以直接给
price
属性加一个final
关键字,该关键字表示该属性不可变,所以在赋初始值的时候直接赋值20;
-
-
思考:为什么在
ClassLoader
的这个loadClass
⽅法中,reslove参数只能传个false,⽽不让传true?-
这个参数会传递到
loadClass
方法中的:if (resolve) { resolveClass(c); }
-
原因:在创建对象的过程中,是需要申请内存的。JVM希望所有内存在进程启动的时候把类都创建好,而不能在运行过程中再去申请内存,即不让开发者去参与 Linking 的过程;
-
loadClass
和forName
的区别:-
在
LoaderDemo
类中添加一个静态代码块;static { System.out.println("Hello World"); }
-
编写一个 Test 类
package com.roy.cl; public class Test { public static void main(String[] args) throws ClassNotFoundException { ClassLoader cl1 = Test.class.getClassLoader(); cl1.loadClass("com.roy.cl.LoaderDemo"); System.out.println("========="); Class.forName("com.roy.cl.LoaderDemo"); } }
-
结果:可以发现第一个
loadClass
没有执行静态代码块中的打印方法,说明loadClass
没有执行 Linking 过程;========= Hello World
-
-
-
符号引用与直接引用:
- 如果A类中有⼀个静态属性,引⽤了另⼀个B类。那么在对类进⾏初始化的过程中,因为A和B这两个类都没有初始化,JVM并不知道A和B这两个类的具体地址。所以这时,在A类中,只能创建⼀个不知道具体地址的引⽤,指向B类。这个引⽤就称为符号引⽤;
- ⽽当A类和B类都完成初始化后,JVM⾃然就需要将这个符号引⽤转⽽指向B类具体的内存地址,这个引⽤就称为直接引⽤。
2 一个用类加载机制加薪的故事
-
故事背景:模拟⼀个OA系统,每个⽉需要定时计算⼤家的⼯资;
package com.roy.oa; public class OADemo1 { public static void main(String[] args) throws InterruptedException { Double salary = 15000.00; Double money = 0.00; //模拟不停机状态 while (true) { try { money = calSalary(salary); System.out.println("实际到手Money:" + money); }catch(Exception e) { System.out.println("加载出现异常 :"+e.getMessage()); } Thread.sleep(5000); } } private static Double calSalary(Double salary) { SalaryCaler caler = new SalaryCaler(); return caler.cal(salary); // return -1.00; } }
-
而⽽具体计算⼯资的⽅法,根据⾯向对象的设计思想,会交由⼀个单独的
SalaryCaler
类来处理;package com.roy.oa; public class SalaryCaler { public Double cal(Double salary) { return salary; } }
-
这时,⼀个程序员⽼王,想要给⼤家都偷偷加⼀点⼯资,于是他想到的⽅法是直接修改OA系统中计算⼯资的⽅法,给⼤家都加点⼯资:
package com.roy.oa; public class SalaryCaler { public Double cal(Double salary) { return salary * 1.4; } }
-
⽼王偷偷给⼤家加了⼯资,但是经理肯定是不会同意的。于是,一个程序员与资本家⽃智⽃勇涨薪的故事,拉开了序幕。
3 通过类加载器引入外部Jar包
-
计算⼯资的⽅法都在OA系统⾥,经理直接在代码仓库就能看到。于是⽼王就要开始思考,如何让经理看不到OA系统中计算⼯资的源码;
-
基础思路:
- 将计算⼯资的
SalaryCaler
类从OA系统中抽出来,放到另一个项目中,将该项目打成jar包; - 然后让OA系统能够从这个jar包中读取
SalaryCaler
类,这样就可以绕开经理的视线了;
- 将计算⼯资的
-
于是,就可以基于JDK提供的
URLClassLoader
加载器,从jar包当中加载工资计算类:package com.roy.oa; import java.net.URL; import java.net.URLClassLoader; public class OADemo2 { public static void main(String[] args) throws Exception { Double salary = 15000.00; Double money = 0.00; URL jarPath = new URL("jar包地址"); URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {jarPath}); //模拟不停机状态 while (true) { try { money = calSalary(salary, urlClassLoader); System.out.println("实际到手Money:" + money); }catch(Exception e) { e.printStackTrace(); System.out.println("加载出现异常 :"+e.getMessage()); } Thread.sleep(5000); } } private static Double calSalary(Double salary, ClassLoader classloader) throws Exception { Class<?> clazz = classloader.loadClass("com.roy.oa.SalaryCaler"); if(null != clazz) { Object object = clazz.newInstance(); // 利用反射调用该类中的cal方法 return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary); } return -1.00; } }
-
拓展思考: 在真实项⽬中,这个思路有什么⽤呢?
- 哪些jar包适合放到外部加载?
- 那些流程⽐较统⼀,但是具体实现规则容易经常产⽣变化的场景。例如:规则引擎、统⼀审批规则、订单状态规则…
- 外部jar包可以放到哪些地⽅?
- 远程Web服务器:URLClassLoader可以定义URL从远程Web服务器加载Jar包;
- maven仓库:drools规则引擎可以从maven仓库远程加载核⼼规则⽂件;
- 实现效果:当规则改变的时候,修改maven中的核心规则文件即可,对应的拉取了这个文件的工程就会修改规则。
- 哪些jar包适合放到外部加载?
4 自定义类加载器实现Class代码混淆
-
虽然经理在OA系统⾥看不到
SalaryCaler
类的源码了,但是通过OA系统的源码最终还是可以找到这个jar包。那么就可以对jar包进⾏反编译,查看到jar包对应的源码了。所以,⽼王还需要考虑如何对class⽂件进⾏代码混淆,让经理⽆法反编译出源码; -
解决的思路有两个:
- 简单⼀点的,将class⽂件的后缀改⼀下,从
.class
转为.myclass
; - 只是修改后缀,那么经理还可以把后缀改回来再反编译。所以稳妥⼀点的⽅法,是要改⼀改class⽂件当中的⼆进制内容;
- 简单⼀点的,将class⽂件的后缀改⼀下,从
-
JDK只能加载标准的class⽂件,所以这⼀类反常规的思路,JDK就没办法提供帮助了,此时就需要⽤⾃定义的类加载器来解决了;
- 关于如何实现⾃定义类加载器,可以查看
ClassLoader
类源码中开头的注释,⾥⾯介绍了如何实现⼀个NetWorkClassLoader
;
- 关于如何实现⾃定义类加载器,可以查看
-
于是,⽼王先在OA系统的工程项目中定义了⼀个⾃定义类加载器,实现从
.myclass
⽂件中加载类:package com.roy.oa; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.security.SecureClassLoader; //加载文件系统中的class文件 public class SalaryClassLoader extends SecureClassLoader { private String classPath; public SalaryClassLoader(String classPath) { this.classPath = classPath; } @Override protected Class<?> findClass(String fullClassName) throws ClassNotFoundException { //查找.myclass⽂件 String filePath = this.classPath + fullClassName.replace(".", "/").concat(".myclass"); int code; try { FileInputStream fis = new FileInputStream(filePath); fis.read(); ByteArrayOutputStream bos = new ByteArrayOutputStream(); try { while ((code = fis.read()) != -1) { bos.write(code); } } catch (IOException e) { e.printStackTrace(); } byte[] data = bos.toByteArray(); bos.close(); return defineClass(fullClassName, data, 0, data.length); } catch (Exception e) { e.printStackTrace(); } return null; } }
-
然后,在OA系统中通过这个⾃定义类加载器加载计算⼯资的
SalaryCaler
类;package com.roy.oa; public class OADemo3 { public static void main(String[] args) throws Exception { Double salary = 15000.00; Double money = 0.00; SalaryClassLoader salaryClassLoader = new SalaryClassLoader("jar包地址"); //模拟不停机状态 while (true) { try { money = calSalary(salary,salaryClassLoader); System.out.println("实际到手Money:" + money); }catch(Exception e) { System.out.println("加载出现异常 :"+e.getMessage()); System.exit(-1); // 删除没用的1 } Thread.sleep(5000); } } private static Double calSalary(Double salary,ClassLoader classloader) throws Exception { Class<?> clazz = classloader.loadClass("com.roy.oa.SalaryCaler"); if(null != clazz) { Object object = clazz.newInstance(); return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary); } return -1.00; } }
-
目前,老王只是修改了文件的后缀,但是还没有修改文件中的二进制。⼆进制⽂件不太好直接编辑,可以使⽤流的⽅式做⼀点修改,老王在
SalaryCaler
类所在的工程添加了下面这个类:package com.roy; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; public class FileTransferTest { public static void main(String[] args) throws Exception { FileInputStream fis = new FileInputStream("SalaryCaler.class文件的地址"); File targetFile = new File("SalaryCaler.myclass文件存放的路径"); if(targetFile.exists()) { targetFile.delete(); } FileOutputStream fos = new FileOutputStream(targetFile); int code = 0; fos.write(1); // 写一个没用的1 while((code = fis.read())!= -1 ) { fos.write(code); } fis.close(); fos.close(); System.out.println("文件转换完成"); } }
-
这样就能⽣成⼀个简单加密后的
.myclass
⽂件了,在class⽂件的标准内容前⾯加了⼀个没⽤的1,对应的类加载器只需要把这个1忽略掉就可以了。 -
拓展思考:
-
如何进⼀步提升关键代码的安全性?
- 目前这个算法太简单了,经理看看类加载器的源码就知道,只要把
.myclass
⽂件前⾯的1去掉,就能拿到原来的class⽂件内容,从⽽进⾏反编译。有没有什么算法,可以让经理推导不出原始的class⽂件内容呢?常⽤的加密算法就派上⽤场了。MD5、对称加密、⾮对称加密…… - 或者是不是能够有更多奇怪的思路,⽐如通过⾃定义类加载器A,从⼀个加密class⽂件当中加载出⼀个类加载器B,再⽤后⾯这个类加载器B,加载加密过的核⼼代码;
- 目前这个算法太简单了,经理看看类加载器的源码就知道,只要把
-
如何在真实项⽬中⽤上这种机制?
-
真实项⽬当中不会拿class⽂件直接部署,都是拿jar包进⾏部署。所以要做的是,在⾃定义类加载器中,将从硬盘上读取class⽂件的实现⽅式,改为从jar包当中读取class⽂件。这个通过⽂件流也很容易实现:
package com.roy.oa; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.net.URL; import java.net.URLConnection; import java.security.SecureClassLoader; /** * 从jar包中加载薪水计算类 */ public class SalaryJARLoader extends SecureClassLoader { private String jarFile; public SalaryJARLoader(String jarFile) { this.jarFile = jarFile; } @Override protected Class<?> findClass(String fullClassName) throws ClassNotFoundException { String classFilepath = fullClassName.replace('.', '/').concat(".class"); System.out.println("重新加载类:"+classFilepath); int code; try { // 访问jar包的url URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath); // InputStream is = jarURL.openStream(); URLConnection urlConnection = jarURL.openConnection(); // 不使用缓存 不然有些操作系统下会出现jar包无法更新的情况 urlConnection.setUseCaches(false); InputStream is = urlConnection.getInputStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream(); while ((code = is.read()) != -1) { bos.write(code); } byte[] data = bos.toByteArray(); is.close(); bos.close(); return defineClass(fullClassName, data, 0, data.length); } catch (Exception e) { e.printStackTrace(); System.out.println("加载出现异常 :"+e.getMessage()); throw new ClassNotFoundException(e.getMessage()); // return null; } } }
-
-
5 自定义类加载器实现热加载
-
⽼王通过重重考验,终于瞒过了经理。但是这时⼜遇到⼀个头疼的情况。总公司需要时不时地核算⼯资,⽼王⾃然想要在总公司核算⼯资之前将计算⼯资的⽅式改回去,避免露馅,然后等总公司核算完成了再改回来;
-
既然
SalaryCaler
类都是从jar包当中修改的,那么是不是直接修改jar包就可以了呢?很可惜,⽼王经过测试后,结果并不是那么令⼈满意。每次修改jar包后,都需要重启OA系统才能⽣效。总公司每次来核查⼯资就要重启⼀次OA系统,这样岂不是此地⽆银三百两了? -
其实深⼊分析就很容易找到原因:
SalaryCaler
类⽆法及时更新的根本原因就在于SalaryJARLoader
对它加载过的类都保存了⼀个缓存。只要这个缓存存在,SalaryClassLoader
就不会去jar包中加载,⽽是从缓存当中加载。⽽这个缓存是在JVM层⾯实现的,Java代码接触不到这个缓存,所以解决的思路⾃然就只能简单粗暴地连这个SalaryJARLoader
也⼀起重新创建⼀个了:package com.roy.oa; public class OADemo5 { public static void main(String[] args) throws Exception { Double salary = 15000.00; Double money = 0.00; //模拟不停机状态 while (true) { try { money = calSalary(salary); System.out.println("实际到手Money:" + money); }catch(Exception e) { System.out.println("加载出现异常 :"+e.getMessage()); } Thread.sleep(5000); } } private static Double calSalary(Double salary) throws Exception { SalaryJARLoader salaryClassLoader = new SalaryJARLoader("jar包地址"); System.out.println(salaryClassLoader.getParent()); Class<?> clazz = salaryClassLoader.loadClass("com.roy.oa.SalaryCaler"); if(null != clazz) { Object object = clazz.newInstance(); return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary); } return -1.00; } }
-
通过这种⽅式,每次都是创建出⼀个新的
SalaryJARLoader
对象,那么该类加载器的缓存肯定是空的,它⾃然就只能每次都从jar包当中加载类了。于是,⽼王可以愉快地随时切换jar包,实现热更新了; -
拓展思考:
- 这个热加载机制看似很好⽤,为什么在开源项⽬中没有⻅过这种⽤法?
- 很显然,这种热加载机制需要创建出⾮常多的ClassLoader对象,⽽一些用不着的ClassLoader对象加载过的缓存对象也会随之成为垃圾。这会让JVM中本来就不⼤的元数据区带来很⼤的压⼒,极⼤的增加GC线程的压⼒;
- 但是在项⽬开发时,其实是有⼀些办法可以实现这种类似的热更新机制。例如IDEA中的JRebel插件,还有之前介绍过的Arthas;
- 加载
SalaryCaler
的时候真的只加载⼀个类吗?- 把
SalaryJARLoader
加载过的类打印出来,可以发现,在加载SalaryCaler
时,其实不光加载了这个类,同时还加载了Double
和Object
两个类。这两个类哪⾥来的?这就是JVM实现的懒加载机制; - JVM为了提⾼类加载的速度,并不是在启动时直接把进程当中所有的类⼀次加载完成,⽽是在⽤到的时候才去加载,这就是懒加载。
- 把
- 这个热加载机制看似很好⽤,为什么在开源项⽬中没有⻅过这种⽤法?
6 打破双亲委派,实现同类多版本共存
-
就在⽼王跟资本家们⽃得不亦乐乎的时候,另⼀个新⼿程序员⼩王突然给⽼王来了个背刺。不知道什么原因,⼩王突然在OA系统当中也提交了个
SalaryCaler
类。这时⽼王突然发现,这个看似没⽤的SalaryCaler
类却突然导致刚刚还挺得意的热加载机制失效了。不管jar包如何更新,OA系统总是只加载⼩王提交的那个SalaryCaler
类; -
为什么会出现这种情况呢?这就是因为JDK的双亲委派机制。⾃定的
SalaryJARLoader
的parent属性指向的是JDK内的AppClassLoader
。⽽AppClassLoader
会加载OA系统当中的所有代码,当然就包括⼩王提交的SalaryCaler
类。这时,SalaryJARLoader
去加载SalaryCaler
类时,通过双亲委派,⾃然加载出来的就是APPClassloader
中的SalayCaler
了; -
所以,要保持热加载机制不失效,那就只能对这个双亲委派机制下⼿了。下⼿的逻辑也很简单,我们只需要让这个
SalaryCaler
类优先从jar包中加载就可以了:package com.roy.oa; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.net.URL; import java.net.URLConnection; import java.security.SecureClassLoader; /** * 从jar包中加载薪水计算类。 */ public class SalaryJARLoader6 extends SecureClassLoader { private String jarFile; public SalaryJARLoader6(String jarFile) { this.jarFile = jarFile; } @Override public Class<?> loadClass(String name,boolean resolve) throws ClassNotFoundException { //MAC 下会不断加载 Object 类,出现栈溢出的问题 // if(name.startsWith("com.roy")) { // return this.findClass(name); // }else { // return super.loadClass(name); // } // 把双亲委派机制反过来,先尝试自己从class中加载,加载不到再去父加载器中加载 Class<?> c = null; synchronized (getClassLoadingLock(name)) { c = findLoadedClass(name); // 从缓存中查找 if(c == null){ c = findClass(name); // 先尝试自己从class中加载 if(c == null){ c = super.loadClass(name,resolve); //找不到再委托给父加载器 } } } return c; } @Override protected Class<?> findClass(String fullClassName) throws ClassNotFoundException { String classFilepath = fullClassName.replace('.', '/').concat(".class"); System.out.println("重新加载类:"+classFilepath); int code; try { // 访问jar包的url URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath); URLConnection urlConnection = jarURL.openConnection(); urlConnection.setUseCaches(false); InputStream is = urlConnection.getInputStream(); // InputStream is = jarURL.openStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream(); while ((code = is.read()) != -1) { bos.write(code); } byte[] data = bos.toByteArray(); is.close(); bos.close(); return defineClass(fullClassName, data, 0, data.length); } catch (Exception e) { // e.printStackTrace(); //当前类加载器出现异常,就会通过双亲委派,交由父加载器去加载 // System.out.println("加载出现异常 :"+e.getMessage()); // throw new ClassNotFoundException(e.getMessage()); return null; } } }
-
拓展思考
-
我们可以通过打破双亲委派绕过JDK的沙箱保护机制吗?
- 显然不能。因为JDK内部的三个类加载器示例的实现是改不了的。只要这三个类加载器的加载改不了,那么JDK中那些核⼼的类就还是安全的;
- 其实,这个问题也可以延伸到JDK8往后的版本当中。从JDK9开始,JDK中引⼊了模块化机制,⽽内部的类加载器实现也随之做了翻天覆地的改变;
- 每个类加载器不再是单独负责⼀个⼯作⽬录,⽽是改为分⼯负责⼀部分的模块;
- 但是,对于⾃定义类加载器,JDK还是保留了原有的双亲委派机制。之后在分析JDK17的类加载机制时可以看到,虽然JDK17内部的加载机制发⽣了变化,但是这些案例,⼏乎都可以平滑地转移过去;
-
在真实项⽬中,有什么样的业务场景需要打破双亲委派呢?
-
双亲委派机制是⾮常基础的⼀个底层体系,很多重要框架都需要进⾏定制;
-
例如Tomcat的类加载体系如下:
- Tomcat的⼏个主要类加载器:
- commonClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
- catalinaClassLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可⻅;
- sharedClassLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可⻅,但是对于Tomcat容器不可⻅;
- WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可⻅,⽐如加载war包⾥相关的类,每个war包应⽤都有⾃⼰的WebappClassLoader,实现相互隔离,⽐如不同war包应⽤引⼊了不同的spring版本,这样实现就能加载各⾃的Spring版本或其他框架版本;
- Jsp类加载器:针对每个JSP⻚⾯创建⼀个加载器。这个加载器⽐较轻量级,所以Tomcat还实现了热加载,也就是JSP只要修改了,就创建⼀个新的加载器,从⽽实现了JSP⻚⾯的热更新。
-
-
7 使⽤类加载器能不能不⽤反射?
-
⽼王分析了热加载器失效的原因,其实就是因为在OA应⽤的多个类加载器中,同时存在了
SalaryCaler
类的多个版本; -
AppClassLoader
中的SalaryCaler
对象,可以直接new出来,但是SalaryJARLoader
中的那个SalaryCaler
对象,在之前的例⼦当中,都只能通过很别扭的反射来使⽤。同样都是SalaryCaler
,就不能让它也像⼀个正常的类那样使⽤吗? -
于是,⽼王想到了⼀个简单粗暴的⽅式,明明都是
SalaryCaler
对象,那是不是可以直接做类型转换呢?像这样:package com.roy.oa; public class OADemo7 { public static void main(String[] args) throws Exception { Double salary = 15000.00; Double money = 0.00; //模拟不停机状态 while (true) { SalaryCaler caler = new SalaryCaler(); System.out.println("应该到手Money:" + caler.cal(salary)); SalaryJARLoader6 salaryJARLoader = new SalaryJARLoader6("jar包地址"); Class<?> clazz = salaryJARLoader.loadClass("com.roy.oa.SalaryCaler"); Object obj = clazz.newInstance(); // 反射太麻烦,能不能进行类型强转?下面就对clazz进行强转 SalaryCaler caler2 = (SalaryCaler)obj; money = caler2.cal(salary); money=(Double)clazz.getMethod("cal", Double.class).invoke(obj, salary); System.out.println("实际到手Money:" + money); System.out.println("============"); Thread.sleep(5000); } } private static Double calSalary(Double salary) throws Exception { SalaryJARLoader6 salaryClassLoader = new SalaryJARLoader6("jar包地址"); Class<?> clazz = salaryClassLoader.loadClass("com.roy.oa.SalaryCaler"); // System.out.println(clazz.getClassLoader()); // System.out.println(clazz.getClassLoader().getParent()); if(null != clazz) { Object object = clazz.newInstance(); return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary); } return -1.00; } }
-
理想很美好,现实很⻣感。这样强⾏的类型转换,只会得到⼀个让⼈怀疑⼈⽣的异常:
Exception in thread "main" java.lang.ClassCastException: com.roy.oa.SalaryCaler cannot becast to com.roy.oa.SalaryCaler
- 即我不能转换成我;
- 虽然类名一样,但是来自于不同的类加载器,所以他们是不同的类;
-
有什么办法能够摆脱这个别扭的反射机制呢?
- 这时,JDK提供的SPI扩展机制就映⼊眼帘了;
- JDK提供了⼀种SPI扩展机制,其核⼼是通过这个神奇的API:ServiceLoader.load(SalaryCalService.class) 就可以查找到某⼀个接⼝的全部实现类;
- 应⽤所需要的,是提供⼀个配置⽂件,这个配置⽂件需要放在
${classpath}/META-INF/services
这个固定的⽬录下,然后配置文件的 ⽂件名是传⼊接⼝的全类名,⽽⽂件的内容则是⼀⾏表示⼀个实现类的全类名;${classpath}
表示JAVA项⽬的依赖路径,可以放在依赖的jar包当中,也可以放到当前项⽬下,所以SPI机制是⼀种⾮常好的扩展机制;- 很多开源框架都⼤量运⽤SPI机制来保留功能扩展点,最典型的是
ShardingSphere
; - ⽽SpringBoot也是围绕SPI机制提供功能扩展,只不过SpringBoot的SPI机制是⾃⼰实现的,⽽没有⽤JDK提供的;
-
⽽这个⼤家司空⻅惯的SPI机制,其实在它具体实现时,也是传⼊了ClassLoader的(查看**ServiceLoader.load(SalaryCalService.class)**中的
load
方法的源码):public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); }
-
所以就可以⽤这样的⽅式,定义⼀个统⼀的接⼝,⽽将这些不同的实现类都作为接⼝的不同实现去加载。这样,虽然多定义了⼀个接⼝,但是⾄少摆脱了那些别扭的反射代码:
package com.roy.oa; import java.util.Iterator; import java.util.ServiceLoader; public class OADemo8 { public static void main(String[] args) throws Exception { Double salary = 15000.00; //使用 SalaryJARLoader6,就需要在 OA系统所在的工程中添加 SPI 的配置文件(见上图) while (true) { SalaryJARLoader6 salaryJARLoader = new SalaryJARLoader6("jar包地址"); SalaryCalService salaryService = getSalaryService(salaryJARLoader); System.out.println("应该到手Money:" + salaryService.cal(salary)); SalaryJARLoader6 salaryJARLoader2 = new SalaryJARLoader6("jar包地址"); SalaryCalService salaryService2 = getSalaryService(salaryJARLoader2); System.out.println("实际到手Money:" + salaryService2.cal(salary)); Thread.sleep(5000); } } private static SalaryCalService getSalaryService(ClassLoader classloader){ SalaryCalService service = null; // ServiceLoader.load(SalaryCalService.class,classloader); ClassLoader c1 = Thread.currentThread().getContextClassLoader(); try{ Thread.currentThread().setContextClassLoader(classloader); ServiceLoader<SalaryCalService> services = ServiceLoader.load(SalaryCalService.class); //这里只需要拿SPI加载到的第一个实现类 Iterator<SalaryCalService> iterator = services.iterator(); if(iterator.hasNext()){ service = iterator.next(); } }finally { Thread.currentThread().setContextClassLoader(c1); } return service; } }
-
拓展思考
-
在上面的示例当中,按照SPI配置⽂件中的要求,在计算⼯资的方法所在的工程中,
SalaryCalService
的实现类就必须放在指定的路径下; -
那么可不可以把SPI配置⽂件也放在计算工资的方法所在的工程中,到时候一起打包到jar包⾥⾯?这样子编写这个实现类的程序员就可以自己定义SPI配置文件中的路径,自己决定要将这个实现类放在哪里了。那么如果想要在jar包当中⾃⼰定义
SalaryCalService
的实现类,要怎么办? -
在上面讲到,这个配置⽂件需要放在
${classpath}/META-INF/services
这个固定的⽬录下,那么什么是classpath
?我们目前的实例代码中,都只是加载jar包中的一个类的class文件,而不是加载整个jar包;- 将OA系统中的SPI配置文件删掉,将计算⼯资的方法所在的工程重新打成jar包,在该jar包的
${classpath}/META-INF/services
下添加SPI配置文件,一样不可行。原因就是没有加载整个jar包,所以读取不到配置文件;
- 将OA系统中的SPI配置文件删掉,将计算⼯资的方法所在的工程重新打成jar包,在该jar包的
-
实现方案1:使用
URLClassLoader
类加载器,加载整个jar包package com.roy.oa; import java.net.URL; import java.net.URLClassLoader; import java.util.Iterator; import java.util.ServiceLoader; public class OADemo9 { public static void main(String[] args) throws Exception { Double salary = 15000.00; //使用 URLClassLoader,就不需要在 OADemo 中添加 SPI 的配置文件,直接在 SalaryCaler.jar中添加 SPI 配置文件即可 //将实现类和SPI 配置文件放在一起,更符合工程化的思想 while (true) { String jarPath1 = "jar包地址"; URLClassLoader urlClassLoader1 = new URLClassLoader(new URL[] {new URL(jarPath1)}); SalaryCalService salaryService1 = getSalaryService(urlClassLoader1); System.out.println("应该到手Money:" + salaryService1.cal(salary)); String jarPath2 = "jar包地址"; URLClassLoader urlClassLoader2 = new URLClassLoader(new URL[] {new URL(jarPath2)}); SalaryCalService salaryService2 = getSalaryService(urlClassLoader2); System.out.println("实际到手Money:" + salaryService2.cal(salary)); SalaryCalService salaryCalService3 = getSalaryService(null); System.out.println("OA系统中计算的Money:"+salaryCalService3.cal(salary)); Thread.sleep(5000); } } private static SalaryCalService getSalaryService(ClassLoader classloader){ ServiceLoader<SalaryCalService> services; if(null == classloader){ services = ServiceLoader.load(SalaryCalService.class); }else{ ClassLoader c1 = Thread.currentThread().getContextClassLoader(); Thread.currentThread().setContextClassLoader(classloader); services = ServiceLoader.load(SalaryCalService.class); Thread.currentThread().setContextClassLoader(c1); } SalaryCalService service = null; if(null != services){ //这里只需要拿SPI加载到的第一个实现类 Iterator<SalaryCalService> iterator = services.iterator(); if(iterator.hasNext()){ service = iterator.next(); } } return service; } }
-
实现方案2:使用
java -cp
指令,后面跟上要加载的jar包,这样就可以把对应的jar包加载到工程下;- IDEA启动项目时,也是使用的这个指令。
-