转载

破坏双亲委派加载机制

双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的类加载器实现方式。在 Java 的世界中大部分类加载器都遵循这个原则,但是显然也有例外。

在《深入理解 JVM 虚拟机》一书中,作者提出双亲委派模型目前出现过 3 次较大规模的“被破坏”情况。

第一次被破坏

第一次被破坏其实发生在双亲委派模型出现之前,也就是 JDK 1.2 发布之前,由于双亲委派模型在 JDK 1.2 之后才引入,而类加载器和抽象类 java.lang.ClassLoader 在 JDK 1.0 时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java 设计者在引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader 添加了一个新的 protected 方法 findClass() 方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法 loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的 loadClass()。双亲委派的具体逻辑就在这个方法之中,JDK 1.2 之后已经不再提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载器逻辑写到 findClass() 方法来完成加载,这样就可以保证写出来的类加载器都是符合双亲委派规则的。

第二次被破坏

第二次被破坏是由于这个模型自身的问题导致的,双亲委派很好地解决了各个类加载器的基础类统一的问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的 API,但世事往往没有绝对的完美,如果基础类又要调回用户的代码,那该怎么办?

这不是没有可能的事情,比如 JNDI 服务,JNDI 目前已经是 Java 的标准服务,它的代码由启动类加载器去加载(在 JDK 1.3 时放进去的 rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码啊,那可怎么办?

为了解决这个问题,Java 设计团队只好引入另外一个不太优雅的设计:线程上下文加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader()(原文在这里写成 setContextLoaser) 方法进行设置,如果创建线程时还未设置,它将从父线程中继承一个,如果在应用程序的全局范围内没有设置过的话,那么这个类加载器默认就是应用程序类加载器。

通过这个线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI 服务使用这个线程上下文类加载去加载所需要的 SPI 代码,也就是通过父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打破了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情,Java 中所有涉及 SPI 的加载动作都是采用的这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

第三次被破坏

第三次被破坏是由于用户对程序动态性追求而导致的,这里所说的“动态性”是指当前一些非常“热门”的名词:代码热替换(HotSwap)、模块热部署(Hot Deployment)等,说白了就是希望应用程序能像我们的计算机外设一样,插上鼠标和 U 盘不用重启机器就能立即使用。鼠标有问题就升级或者换个鼠标,不用停机也不用重启。对于实际生产系统来说,关机重启一次可能就要被列为生产事故。因此这种情况下热部署就非常有吸引力。

在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。当接收到类加载请求时,OSGI 将按照下面的顺序进行类搜索:

  1. 将以 java.* 开头的类委派给父类加载器加载
  2. 否则,将委派列表名单内的类委派给父类加载器进行加载
  3. 否则,将 Import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载
  4. 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载
  5. 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载
  6. 否则,查找 Dynamic Import 列表的 Bundle,委派给对应的 Bundle 的类加载器加载
  7. 否则,类查找失败

上述的查找顺序只有开头两点仍然符合双亲委派模型,其余的类查找都是在平级的类加载器中进行的。

破坏双亲委派的原理

针对书中介绍的三种情况,往往使用最多的是线程上下文类加载器(TCCL,ThreadContextClassLoader)来破坏双亲委派机制。

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。

这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI 接口中的代码经常需要加载具体的实现类。那么问题来了,SPI 的接口是 Java 核心库的一部分,是由 启动类加载器(Bootstrap Classloader)来加载的;SPI 的实现类是由系统类加载器(System ClassLoader) 来加载的。引导类加载器是无法找到 SPI 的实现类的,因为依照双亲委派模型,BootstrapClassloader 无法委派 AppClassLoader 来加载类。

而线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。

那么我们来以 JDBC 源码来看看是如何破坏双亲委派模型的。 JDBC 也属于 SPI,其 Driver 接口是 Java 核心类库的一部分,但是 Driver 的实现类却是由第三方实现,是需要使用系统类加载器进行加载的,符合上述说的情况。

JDBC 案例分析

我们先来看平时是如何使用 MySQL 获取数据库连接的:

// 加载Class到AppClassLoader(系统类加载器),然后注册驱动类
// Class.forName("com.mysql.jdbc.Driver").newInstance(); 
String url = "jdbc:mysql://localhost:3306/testdb";    
// 通过java库获取数据库连接
Connection conn = java.sql.DriverManager.getConnection(url, "name", "password");

以上就是 MySQL 注册驱动及获取 Connection 的过程,各位可以发现经常写的 Class.forName 被注释掉了,但依然可以正常运行,这是为什么呢?这是因为从 JDK 1.6 开始自带的 JDBC 4.0 版本已支持 SPI 服务加载机制,只要 MySQL 的 jar 包在类路径中,就可以注册 MySQL 驱动。

那到底是在哪一步自动注册了 MySQL Driver 的呢?重点就在 DriverManager.getConnection() 方法中。我们都知道调用一个类的静态方法会自动初始化该类(前提是该类还没有被初始化过),进而执行其静态代码块,DriverManager 中的静态代码块如下:

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

初始化方法 loadInitialDrivers() 的代码如下:

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;
    }
    // 通过SPI加载驱动类
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
            Iterator<Driver> driversIterator = loadedDrivers.iterator();
            try{
                while(driversIterator.hasNext()) {
                    driversIterator.next();
                }
            } catch(Throwable t) {
                // Do nothing
            }
            return null;
        }
    });
    // 继续加载系统属性中的驱动类
    if (drivers == null || drivers.equals("")) {
        return;
    }
    
    String[] driversList = drivers.split(":");
    println("number of Drivers:" + driversList.length);
    for (String aDriver : driversList) {
        try {
            println("DriverManager.Initialize: loading " + aDriver);
            // 使用AppClassloader加载
            Class.forName(aDriver, true,
                    ClassLoader.getSystemClassLoader());
        } catch (Exception ex) {
            println("DriverManager.Initialize: load failed: " + ex);
        }
    }
}

从上面可以看出 JDBC 中的 DriverManager 的加载 Driver 的步骤顺序依次是:

  1. 通过 SPI 方式,读取 META-INF/services 下文件中的类名,使用 TCCL(线程上下文类加载器) 加载;
  2. 通过 System.getProperty(“jdbc.drivers”) 获取设置,然后通过系统类加载器加载。

下面详细分析 SPI 加载的那段代码。

JDBC 中的 SPI

上面说了那么多 SPI,可能你还没弄懂什么是 SPI,所以先来看看什么是 SPI 机制,引用一段博文中的介绍:

SPI 机制简介

SPI 的全名为 Service Provider Interface,主要是应用于厂商自定义组件或插件中。在java.util.ServiceLoader 的文档里有比较详细的介绍。简单的总结下 java SPI 机制的思想:我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml 解析模块、JDBC 模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。 Java SPI 就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似 IOC 的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。

SPI 具体约定

Java SPI 的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在 jar 包的 META-INF/services/ 目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该 jar 包 META-INF/services/ 里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。JDK 提供服务实现查找的一个工具类:java.util.ServiceLoader。

知道 SPI 的机制后,我们来看刚才的代码:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();

try{
    while(driversIterator.hasNext()) {
        driversIterator.next();
    }
} catch(Throwable t) {
	// Do nothing
}

注意 driversIterator.next() 最终就是调用 Class.forName(DriverName, false, loader) 方法,也就是最开始我们注释掉的 Class.forName 加载驱动的方法。好,那句因 SPI 而省略的代码现在解释清楚了,那我们继续看给这个方法传的 loader 是怎么来的。

因为这句 Class.forName(DriverName, false, loader) 代码所在的类在 java.util.ServiceLoader 类中,而ServiceLoader.class 又加载在 BootrapLoader 中,因此传给 forName 的 loader 必然不能是 BootrapLoader。这时候只能使用 TCCL 了,也就是说把自己加载不了的类加载到 TCCL 中(通过 Thread.currentThread() 获取,简直作弊啊!)。

可以再看下 ServiceLoader.load(Class) 的代码,发现的确如此:

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

ContextClassLoader 默认存放了 AppClassLoader 的引用,由于它是在运行时被放在了线程中,所以不管当前程序处于何处(BootstrapClassLoader 或是 ExtClassLoader 等),在任何需要的时候都可以用 Thread.currentThread().getContextClassLoader() 取出应用程序类加载器来完成需要的操作。

到这儿差不多把 SPI 机制解释清楚了。直白一点说就是,我(JDK)提供了一种帮你(第三方实现者)加载服务(如数据库驱动、日志库)的便捷方式,只要你遵循约定(把类名写在 /META-INF 里),那当我启动时我会去扫描所有 jar 包里符合约定的类名,再调用 forName 加载,但我的 ClassLoader 是没法加载的,那就把它加载到当前执行线程的 TCCL 里,后续你想怎么操作(驱动实现类的 static 代码块)就是你的事了。

好,刚才说的驱动实现类就是 com.mysql.jdbc.Driver,它的静态代码块里头又写了什么呢?是否又用到了 TCCL 呢?我们继续看下一个例子。

校验实例的归属

com.mysql.jdbc.Driver 加载后运行的静态代码块:

static {
	try {
		// Driver已经加载到TCCL中了,此时可以直接实例化
		java.sql.DriverManager.registerDriver(new com.mysql.jdbc.Driver());
	} catch (SQLException E) {
		throw new RuntimeException("Can't register driver!");
	}
}

registerDriver 方法将 driver 实例注册到系统的 java.sql.DriverManager 类中,其实就是 add 到它的一个名为 registeredDrivers 的静态成员 CopyOnWriteArrayList 中 。

到此驱动注册基本完成,接下来我们回到最开始的那段样例代码:java.sql.DriverManager.getConnection()。它最终调用了以下方法:

private static Connection getConnection(
     String url, java.util.Properties info, Class<?> caller) throws SQLException {
     /* 传入的caller由Reflection.getCallerClass()得到,该方法
      * 可获取到调用本方法的Class类,这儿获取到的是当前应用的类加载器
      */
     ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
     synchronized(DriverManager.class) {
         if (callerCL == null) {
             callerCL = Thread.currentThread().getContextClassLoader();
         }
     }

     if(url == null) {
         throw new SQLException("The url cannot be null", "08001");
     }

     SQLException reason = null;
     // 遍历注册到registeredDrivers里的Driver类
     for(DriverInfo aDriver : registeredDrivers) {
         // 检查Driver类有效性
         if(isDriverAllowed(aDriver.driver, callerCL)) {
             try {
                 println("    trying " + aDriver.driver.getClass().getName());
                 // 调用com.mysql.jdbc.Driver.connect方法获取连接
                 Connection con = aDriver.driver.connect(url, info);
                 if (con != null) {
                     // Success!
                     return (con);
                 }
             } catch (SQLException ex) {
                 if (reason == null) {
                     reason = ex;
                 }
             }

         } else {
             println("    skipping: " + aDriver.getClass().getName());
         }

     }
     throw new SQLException("No suitable driver found for "+ url, "08001");
 }
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
    boolean result = false;
    if(driver != null) {
        Class<?> aClass = null;
        try {
	    // 传入的classLoader为调用getConnetction的当前类加载器,从中寻找driver的class对象
            aClass =  Class.forName(driver.getClass().getName(), true, classLoader);
        } catch (Exception ex) {
            result = false;
        }
	// 注意,只有同一个类加载器中的Class使用==比较时才会相等,此处就是校验用户注册Driver时该Driver所属的类加载器与调用时的是否同一个
	// driver.getClass()拿到就是当初执行Class.forName("com.mysql.jdbc.Driver")时的应用AppClassLoader
        result = ( aClass == driver.getClass() ) ? true : false;
    }

    return result;
}

由于 TCCL 本质就是当前应用类加载器,所以之前的初始化就是加载在当前的类加载器中,这一步就是校验存放的 driver 是否属于调用者的 Classloader。例如在下文中的 Tomcat 里,多个 webapp 都有自己的 Classloader,如果它们都自带 mysql-connect.jar 包,那底层 Classloader 的 DriverManager 里将注册多个不同类加载器的 Driver 实例,想要区分只能靠 TCCL 了。

Tomcat与Spring的类加载案例

Tomcat 中的类加载器

在 Tomcat 目录结构中,有三组目录( /common/* , /server/*shared/* )可以存放公用 Java 类库,此外还有第四组 Web 应用程序自身的目录 /WEB-INF/* ,把 Java 类库放置在这些目录中的含义分别是:

  • 放置在 common 目录中:类库可被 Tomcat 和所有的 Web 应用程序共同使用。
  • 放置在 server 目录中:类库可被 Tomcat 使用,但对所有的 Web 应用程序都不可见。
  • 放置在 shared 目录中:类库可被所有的 Web 应用程序共同使用,但对 Tomcat 自己不可见。
  • 放置在 /WebApp/WEB-INF 目录中:类库仅仅可以被此 Web 应用程序使用,对 Tomcat 和其他 Web 应用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,如下图所示:

破坏双亲委派加载机制

灰色背景的 3 个类加载器是 JDK 默认提供的类加载器,这 3 个加载器的作用前面已经介绍过了。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebAppClassLoader 则是 Tomcat 自己定义的类加载器,它们分别加载 /common/*/server/*/shared/*/WebApp/WEB-INF/* 中的 Java 类库。其中 WebApp 类加载器和 JSP 类加载器通常会存在多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 JSP 类加载器。

从图中的委派关系中可以看出,CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 自己能加载的类则与对方相互隔离。WebAppClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebAppClassLoader 实例之间相互隔离。而 JasperLoader 的加载范围仅仅是这个 JSP 文件所编译出来的那一个 Class,它出现的目的就是为了被丢弃:当服务器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过再建立一个新的 JSP 类加载器来实现 JSP 文件的 HotSwap 功能。

Spring 加载问题

Tomcat 加载器的实现清晰易懂,并且采用了官方推荐的“正统”的使用类加载器的方式。这时作者提一个问题:如果有 10 个 Web 应用程序都用到了 Spring 的话,可以把 Spring 的 jar 包放到 common 或 shared 目录下让这些程序共享。Spring 的作用是管理每个 web 应用程序的 bean, getBean 时自然要能访问到应用程序的类,而用户的程序显然是放在 /WebApp/WEB-INF 目录中的(由 WebAppClassLoader 加载),那么在 CommonClassLoader 或 SharedClassLoader 中的 Spring 容器如何去加载并不在其加载范围的用户程序(/WebApp/WEB-INF/)中的 Class 呢?

实际上,Spring 根本不会去管自己被放在哪里,它统统使用 TCCL 来加载类,而 TCCL 默认设置为了 WebAppClassLoader,也就是说哪个 WebApp 应用调用了 Spring,Spring 就去取该应用自己的 WebAppClassLoader 来加载 bean,简直完美~

源码分析

有兴趣的可以接着看看具体实现:

在 web.xml 中定义的 listener 为 org.springframework.web.context.ContextLoaderListener ,它最终调用了 org.springframework.web.context.ContextLoader 类来装载 bean,具体方法如下(删去了部分不相关内容):

public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
	try {
		// 创建WebApplicationContext
		if (this.context == null) {
			this.context = createWebApplicationContext(servletContext);
		}
		// 将其保存到该webapp的servletContext中		
		servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);
		// 获取线程上下文类加载器,默认为WebAppClassLoader
		ClassLoader ccl = Thread.currentThread().getContextClassLoader();
		// 如果spring的jar包放在每个webapp自己的目录中
		// 此时线程上下文类加载器会与本类的类加载器(加载spring的)相同,都是WebAppClassLoader
		if (ccl == ContextLoader.class.getClassLoader()) {
			currentContext = this.context;
		}
		else if (ccl != null) {
			// 如果不同,也就是上面说的那个问题的情况,那么用一个map把刚才创建的WebApplicationContext及对应的WebAppClassLoader存下来
			// 一个webapp对应一个记录,后续调用时直接根据WebAppClassLoader来取出
			currentContextPerThread.put(ccl, this.context);
		}
		
		return this.context;
	}
	catch (RuntimeException ex) {
		logger.error("Context initialization failed", ex);
		throw ex;
	}
	catch (Error err) {
		logger.error("Context initialization failed", err);
		throw err;
	}
}

具体说明都在注释中,Spring 考虑到了自己可能被放到其他位置,所以直接用 TCCL 来解决所有可能面临的情况。

总结

通过上面的两个案例分析,我们可以总结出线程上下文类加载器的适用场景:

  1. 当高层提供了统一接口让低层去实现,同时又要是在高层加载(或实例化)低层的类时,必须通过线程上下文类加载器来帮助高层的 ClassLoader 找到并加载该类。
  2. 当使用本类托管类加载,然而加载本类的 ClassLoader 未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管。

参考文章

  1. 真正理解线程上下文类加载器(多案例分析)
  2. JVM双亲委派机制与Tomcat
原文  https://bestzuo.cn/posts/1873575030.html
正文到此结束
Loading...