Java打破双亲委派浅析

admin 2022年11月9日11:51:45评论11 views字数 8700阅读29分0秒阅读模式

Java打破双亲委派浅析

介绍

Java项目运行,相关类需要加载到内存中,而负责这项工作的就是类加载器。在Java8和Java8之后的类加载器有一定的区别,所以下面进行了分版本记录。

类加载器

在Java8之前,类加载器分为:Bootstrap启动类加载器、Extension扩展类加载器、Application应用类加载器以及User用户自定义加载器。其相关的类库都是以jar包的形式存在,而Bootstrap负责的就是加载lib目录下的类库,它们是Java的核心类库。Extension加载的是lib目录下的ext目录下的jar包,意为扩展库,Application主要负责CLASSPATH路径下的类库,也就是项目中的类所在的位置。

它们的层级如下图所示:

Java打破双亲委派浅析

层级也就是双亲委派机制,例如我们自己新建的一个类,这个类肯定是给到Application加载的,但会经过一个流程,这个流程首先经过Application,Application不会进行加载,它会向上传递,给到Extension,而Extension也不会加载,会向上给到跟加载器,也就是Bootstrap,而Bootstrap会看自己负责的范围是否有这个类,没有的话,就向下传递给到Extension,Extension一看,也不是自己负责的类库,然后就向下又给到了Application,Application负责的就是用户自定义的类,然后最后由Application进行了加载。

从Java9开始,jar包进行了模块化,放在了jmod文件夹下,后缀为jmod文件。既然jar包可以进行拆分为模块,那么也就方便了扩展,原先的扩展jar包自然就不需要了,所以把Extension进行了替换,换成了Platform即平台类加载器。

Platform负责的就是核心类库以外的库,和Java8之前可以理解为差不多,但也有区别,区别在于加载顺序,现在还是应用程序准备加载一个类,先给到Application,Application不加载,会先给到BootStrap查一下,是否在核心库中,如果没在那就向上传递给到Platform,完事Platform没有找到,再给到Application进行加载。

Java打破双亲委派浅析

双亲委派

那么为什么要经过这样一个向上传递,又向下委派的流程呢,试想我们自己定义了一个String类,实现了一些功能,那么我们加载这个类用的时候你会发现并没有调用我们自己的,而是调用的核心类库中的String,就是因为上面这个流程,这个流程一个是保证了整个内存不会有重复的类出现,再一个保证了安全性。这个流程就叫做双亲委派机制。

实际测试的话,Java版本针对我们打破的话影响不大,所以后续就不分版本了,我们主要来看下其方法,首先我们先通过代码来加深对双亲委派机制的理解。

新建一个Test类,什么都不做。

package com.afa.test;
public class Test {}

然后我们写个main函数,代码如下:

package com.afa.test;
public class ClassLoaderTest { public static void main(String[] args) { ClassLoader c1 = Test.class.getClassLoader(); System.out.println(c1); System.out.println(c1.getParent()); System.out.println(c1.getParent().getParent()); ClassLoader c2 = int.class.getClassLoader(); System.out.println(c2); }}

我们通过getClassLoader获取了Test这个类的类加载器,打印的是AppClassLoader,然后又通过getParent得到了c1的父加载器和父父加载器,打印的分别是ExtClassLoader和null。

理论上Ext的父加载器应该是BootStrap,那么为什么是null呢,因为BootStrap不能直接获取,所以是null。后面我们通过c2查询了系统类库Int的类加载器,理论上应该也是BootStrap,发现也是null,上面代码的输出结果如下。

sun.misc.Launcher$AppClassLoader@18b4aac2sun.misc.Launcher$ExtClassLoader@1b6d3586nullnull

getClassLoader是获取其加载器,而把这个类加载到JVM需要用到loadClass方法。关于loadClass的具体代码如下,逻辑参考如下注释:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {    synchronized (getClassLoadingLock(name)) {        // findLoadedClass用来查找该类是否已经被加载,如果已经加载到内存了,那么就直接返回该类的Class对象        Class<?> c = findLoadedClass(name);        // 如果没有被加载,那么c就为空,走进该if语句        if (c == null) {            long t0 = System.nanoTime();            try {                // 进来后先判断其父类加载器(parent代表父类加载器)是否为null,上面验证过BootStrap就是null,也就是判断是否为根加载器                if (parent != null) {                    // 如果不是根加载器那么就调用其父类加载器进行加载,也就是loadClass方法                    c = parent.loadClass(name, false);                } else {                    // 如果是根加载器的话,就调用findBootstrapClassOrNull方法,查找是否在自己负责加载的范围内                    c = findBootstrapClassOrNull(name);                }            } catch (ClassNotFoundException e) {                // ClassNotFoundException thrown if class not found                // from the non-null parent class loader            }            // 例如我们自定义的类,不在根加载器范围内,那么此时c还是null,就走到了这个if语句中            if (c == null) {                // If still not found, then invoke findClass in order                // to find the class.                long t1 = System.nanoTime();                // 此时就调用findClass,简单理解就是查找本地类,当类加载器走到最后用户这里时就在本地查找,如果还没有,就报异常了                // 注意这个findClass跟进后是个空实现,那么也就意味着我们要实现该方法                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; }}

上面这段代码正好就是双亲委派机制,了解后,我们要打破的话,其实只要重写ClassLoader的loadclass方法即可。

自定义类加载器

这里定义一个DefinedClassLoaderTest类为自定义的类加载器,用来打破双亲委派,它需要继承ClassLoader,,然后重写了loadClass方法,注意findClass也需要实现,代码和注释如下:

package com.afa.definedclassload;
import java.io.FileInputStream;
public class DefinedClassLoaderTest extends ClassLoader { private String classPath;
public DefinedClassLoaderTest(String classPath) { this.classPath = classPath; }
// 重写findClass protected Class<?> findClass(String name) throws ClassNotFoundException { try { // 下面的defineClass方法需要传入字节数组,所以这里需要先将class文件转换为字节,loadBytes为自定义方法 byte[] data = loadBytes(name); // findClass是要返回类的Class对象的,而defineClass方法可以将字节数组转换为class实例 return defineClass(name, data, 0, data.length);
} catch (Exception e) { e.printStackTrace(); } return null; }
// 读取类的class文件,并返回其字节 private byte[] loadBytes(String name) throws Exception { String path = name.replace('.', '/').concat(".class"); FileInputStream fileInputStream = new FileInputStream(classPath + "/" + path); // available会返回与之关联的文件的字节数 int len = fileInputStream.available(); byte[] data = new byte[len]; fileInputStream.read(data); fileInputStream.close(); return data; }
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> c = findLoadedClass(name); if (c == null) { // 类没有加载的话,直接判断是否是指定包下的类,如果不是,那么就还调用父类的loadClass,让它走正常流程 if (!name.startsWith("com.afa.test")) { c = this.getParent().loadClass(name); // 如果是的话,就直接调用findClass,所以我们只要把需要打破双亲委派的类放到test包下然后调用DefinedClassLoaderTest加载即可 } else { // findClass是空实现,所以需要重写 c = findClass(name); System.out.println("findClass"); } } if (resolve) { resolveClass(c); } return c; } }}

写个测试类测试,先用其它包下的Test类做测试,代码和注释如下:

package com.afa.test;
import com.afa.definedclassload.DefinedClassLoaderTest;
public class ClassLoaderTest { public static void main(String[] args) throws ClassNotFoundException { // 把class类所在的路径传递过去,后续loadClass方法会用到 DefinedClassLoaderTest defined = new DefinedClassLoaderTest("C:\Users\Administrator\IdeaProjects\AnnotationAndReflection\out\production\AnnotationAndReflection"); // 使用自定义的类加载器进行加载 Class<?> c1 = defined.loadClass("com.afa.reflection.Test"); // 看看是哪个类加载器 ClassLoader c2 = c1.getClassLoader(); System.out.println(c1); System.out.println(c2); }}

以下是输出结果:

class com.afa.reflection.Testclass sun.misc.Launcher$AppClassLoader

可见自定义的类的加载器是Application加载器,那么我们换成test包下的Test类重新进行测试,结果如下:

findClassclass com.afa.test.Testclass com.afa.definedclassload.DefinedClassLoaderTest

可见打印了findClass,说明走了findClass方法,加载器输出结果是Defined,是我们自定义的加载器,此时我们就打破了双委派,本来应该Application加载器加载的,现在变成了我们自定义的加载器。

SPI机制

除了自定义加载器外,SPI也可以打破该机制。关于SPI机制介绍,可参考《Java SPI》章节。

这里的SIP利用的其实就是Thread.currentThread().getContextClassLoader()来打破的,那么这是什么呢?下面通过JDBC连接Mysql来简单的分析一下。

Java为了连接数据库,开发了相关的接口,然后数据库厂商实现其接口,相当于驱动来进行连接。这里就涉及到了双委派问题,Java提供的接口属于核心类库,加载器应是BootStrap,而厂商实现其接口后,属于用户开发类,其加载器应该是Application。但Java进行数据库连接,调用驱动时,属于BootStrap调用用户类,这个违背了双委派机制,那么它是怎么实现的呢。

我们先来看下示例,在之前的写法中,JDBC连接,首先需要加载驱动,如下:

// 加载驱动Class.forName("com.mysql.cj.jdbc.Driver");
// 用户信息和URLString url = "jdbc:mysql://localhost:3306/jdbcstudy?useUnicode=true&characterEncoding=utf8&useSSL=true";String username = "root";String password = "123456";
// 连接成功,数据库对象 Connection 代表数据库Connection connection = DriverManager.getConnection(url, username, password);
// 执行sql的对象 statement 执行sql的对象Statement statement = connection.createStatement();
// 执行sql的对象 去执行sql,可能存在结果,存在时需查看结果String sql = "select * from users";
// 返回的结果集ResultSet resultset = statement.executeQuery(sql);
// 遍历循环结果集while (resultset.next()){ System.out.println("id=" + resultset.getObject("id")); System.out.println("name=" + resultset.getObject("name")); System.out.println("password=" + resultset.getObject("password")); System.out.println("email=" + resultset.getObject("email")); System.out.println("birthday=" + resultset.getObject("birthday"));}

这里的Class.forName使用的加载器是Application,通过下面测试代码可以看出来:

ClassLoader c1 = Class.forName("com.afa.test.Test").getClassLoader();System.out.println(c1);

然后测试mysql驱动类的加载器,也是Application,测试代码示例:

ClassLoader c1 = com.mysql.cj.jdbc.Driver.class.getClassLoader();System.out.println(c1);

所以我们可以直接用Class.forName去加载数据库的类,但后来升级后,写法有了改变,加载驱动这一步不用写了,直接DriverManager.getConnection就可以,注释掉驱动那行代码,发现程序依然可以运行。

我们先来看下DriverManager的加载器,示例代码:

ClassLoader c1 = DriverManager.class.getClassLoader();System.out.println(c1);

它的结果是null,很明显,它是由BootStrap来加载的,而我们不用写加载驱动代码,它就可以直接获取数据库连接对象,说明内部是有加载我们的数据库驱动类的,而我们的数据库驱动类的加载器是Application,那么它是怎么加载的呢,我们跟进DriverManager,它有一段静态代码块如下:

static {    loadInitialDrivers();    println("JDBC DriverManager initialized");}

主要就是调用了loadInitialDrivers这个方法,字面理解该函数就是用来加载驱动的,那么,我们调用getConnection时,静态代码块会被执行,跟进该方法,代码如下:

private static void loadInitialDrivers() {    String drivers;    try {        // 这个调用具体实现在下面        drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {            public String run() {                return System.getProperty("jdbc.drivers");            }        });    } catch (Exception ex) {        drivers = null;    }
AccessController.doPrivileged(new PrivilegedAction<Void>() { public Void run() { // 这段代码是加载初始化Driver的核心代码,我们发现使用了ServiceLoader ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class); Iterator<Driver> driversIterator = loadedDrivers.iterator(); try{ while(driversIterator.hasNext()) { driversIterator.next(); } } catch(Throwable t) { } return null; } });

ServiceLoader.load是SPI的写法,那么我们就可以在META-INF/services下找到其实现类,发现其类就是我们的驱动类com.mysql.cj.jdbc.Driver:

Java打破双亲委派浅析

还是原先的问题,DriverManager既然通过SPI调用了我们的数据库驱动类,那么就说明SPI是可以违背双委派的,我们跟进load来看下:

public static <S> ServiceLoader<S> load(Class<S> service) {    ClassLoader cl = Thread.currentThread().getContextClassLoader();    return ServiceLoader.load(service, cl);}

这里我们看到它返回了ClassLoader,也就是这个加载器,它可以加载我们的类,它是Thread.currentThread().getContextClassLoader()。它叫线程上下文类加载器。

线程上下文类加载器

所谓线程上下文类加载器,如果不进行设置,它会从父类进行继承加载器过来,默认的话,就是使用Application加载器,所以这也是DriverManager通过它来调用数据库驱动类的原因。

通俗讲,就是线程上下文加载器可以让父类加载器通过调用子类加载器来加载相关的类,从而打破的双亲委派机制。

总结

打破双亲委派的场景也很常见,例如上面说到的Mysql连接,以及Tomcat的多站点部署等等,都是打破双亲委派实现的。

原文始发于微信公众号(aFa攻防实验室):Java打破双亲委派浅析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年11月9日11:51:45
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Java打破双亲委派浅析https://cn-sec.com/archives/1397062.html

发表评论

匿名网友 填写信息