双亲委派模型与线程上下文类加载器

34 篇文章 69 订阅
7 篇文章 30 订阅

摘要:

  • SPI机制是什么,有哪些应用场景,又带来了哪些问题?
  • 双亲委派模型是Java推荐的类加载模型,但违背该模型的案例有哪些?为什么会违背,又是怎么解决这种case的?
  • JDBC驱动加载的案例有哪些,SPI机制为它带来了哪些方便?
  • 线程上下文类加载器的作用与应用场景?

一、引子

SPI机制简介

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

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

在这里插入图片描述

SPI机制带来的问题

Java 提供了很多服务SPI,允许第三方为这些接口提供实现。这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现则是由各供应商来完成。终端只需要将所需的实现作为 Java 应用所依赖的 jar 包包含进类路径(CLASSPATH)就可以了。问题在于SPI接口中的代码经常需要加载具体的实现类:SPI的接口是Java核心库的一部分,是由启动类加载器来加载的;而SPI的实现类是由系统类加载器来加载的。启动类加载器是无法找到 SPI 的实现类的(因为它只加载 Java 的核心库),按照双亲委派模型,启动类加载器无法委派系统类加载器去加载类。也就是说,类加载器的双亲委派模式无法解决这个问题。

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


二、线程上下文类加载器

线程上下文类加载器(context class loader)是从 JDK 1.2 开始引入的。Java.lang.Thread中的方法 getContextClassLoader()和 setContextClassLoader(ClassLoader cl)用来获取和设置线程的上下文类加载器。如果没有通过 setContextClassLoader(ClassLoader cl)方法进行设置的话,线程将继承其父线程的上下文类加载器。Java 应用运行的初始线程的上下文类加载器是系统类加载器,在线程中运行的代码可以通过此类加载器来加载类和资源。

线程上下文类加载器从根本解决了一般应用不能违背双亲委派模式的问题,使得java类加载体系显得更灵活。 上面所提到的问题正是线程上下文类加载器的拿手好菜。如果不做任何的设置,Java应用的线程上下文类加载器默认就是系统类加载器。因此,在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。

// Now create the class loader to use to launch the application  
try {  
    loader = AppClassLoader.getAppClassLoader(extcl);  
} catch (IOException e) {  
    throw new InternalError(  
"Could not create application class loader" );  
}  
  
// Also set the context class loader for the primordial thread.  
Thread.currentThread().setContextClassLoader(loader);

三. 违背双亲委派案例之JDBC

1、JDBC驱动注册的常用几种方式

Java数据库连接(Java Database Connectivity,简称 JDBC)是Java语言用来规范客户端程序如何访问数据库的应用程序接口,提供了诸如查询和更新数据库中数据的方法。 JDBC驱动包就是上述接口的实现,由数据库厂商开发,是java和具体数据库之间的连接桥梁。每一种数据库对应一款驱动jar,甚至每一个版本的数据库都有自己对应版本的驱动。我们知道,JDBC规范中明确要求Driver(数据库驱动)类必须向DriverManager注册自己,所以在与数据库交互前必须完成驱动注册,那么先来看看平时我们是如何注册JDBC驱动的。

方式一:Class.forName(“com.mysql.jdbc.Driver”)
 	try {
 			// 注册
            Class.forName(driver);
            conn = (Connection)DriverManager.getConnection(url, user, passwd);
        } catch (Exception e) {
            System.out.println(e);
        }

使用该方式注册的关键在于 Class.forName(driver);,这句话的作用是加载并初始化指定驱动。mysql jdbc正是在Driver初始化的时候完成注册:

package com.mysql.jdbc;

import com.mysql.jdbc.NonRegisteringDriver;
import java.sql.DriverManager;
import java.sql.SQLException;

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    public Driver() throws SQLException {
    }
	// 类初始化时完成驱动注册
    static {
        try {
            DriverManager.registerDriver(new Driver());
        } catch (SQLException var1) {
            throw new RuntimeException("Can\'t register driver!");
        }
    }
}
方式二:System.setProperty(“jdbc.drivers”,“com.mysql.jdbc.Driver”)
	try {
            //Class.forName(driver);
            System.setProperty("jdbc.drivers", driver);
            conn = (Connection)DriverManager.getConnection(url, user, passwd);
    } catch (Exception e) {
            System.out.println(e);
    }

这种方式是通过系统的属性设置注册驱动,最终还是通过系统类加载器完成。

	// DriverManager 中的静态代码块
 	static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
 	}

	// 初始化 DriverManager
    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;
        }
        // If the driver is packaged as a Service Provider, load it.
        // Get all the drivers through the classloader
        // exposed as a java.sql.Driver.class service.
        // ServiceLoader.load() replaces the sun.misc.Providers()

        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {

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

                /* Load these drivers, so that they can be instantiated.
                 * It may be the case that the driver class may not be there
                 * i.e. there may be a packaged driver with the service class
                 * as implementation of java.sql.Driver but the actual class
                 * may be missing. In that case a java.util.ServiceConfigurationError
                 * will be thrown at runtime by the VM trying to locate
                 * and load the service.
                 *
                 * Adding a try catch block to catch those runtime errors
                 * if driver not available in classpath but it's
                 * packaged as service and that service is there in classpath.
                 */
                try{
                    while(driversIterator.hasNext()) {
                        driversIterator.next();
                    }
                } catch(Throwable t) {
                // Do nothing
                }
                return null;
            }
        });

        println("DriverManager.initialize: jdbc.drivers = " + drivers);

        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);
                Class.forName(aDriver, true,
                        ClassLoader.getSystemClassLoader());
            } catch (Exception ex) {
                println("DriverManager.Initialize: load failed: " + ex);
            }
        }
    }

方式三:SPI服务加载机制注册驱动
	try {
			// Class.forName(driver);
            conn = (Connection)DriverManager.getConnection(url, user, passwd);
    } catch (Exception e) {
            System.out.println(e);
    }

各位可以发现,这种方式与第一种方式唯一的区别就是经常写的Class.forName被注释掉了,但程序依然可以正常运行,这是为什么呢?这是因为,从JDK1.6开始,Oracle就修改了加载JDBC驱动的方式,即JDBC4.0。在JDBC 4.0中,我们不必再显式使用Class.forName()方法明确加载JDBC驱动。当调用getConnection方法时,DriverManager会尝试自动设置合适的驱动程序。前提是,只要mysql的jar包在类路径中。

那到底是在哪一步自动注册了mysql driver的呢?我们接下来进一步分析。


2、SPI服务加载机制注册驱动原理分析

重点就在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 下文件中的类名,使用线程上下文类加载器加载;
  2. 通过System.getProperty(“jdbc.drivers”)获取设置,然后通过系统类加载器加载。

我们现在只讨论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()这条语句完成了驱动的注册工作,如下所示:

private S nextService() {
    if (!hasNextService())
        throw new NoSuchElementException();
    String cn = nextName;
    nextName = null;
    Class<?> c = null;
    try {
        // 加载实现类,注意还没有初始化;以JDBC为例,此时还没有完成驱动注册
        c = Class.forName(cn, false, loader);
    } catch (ClassNotFoundException x) {
        fail(service,
             "Provider " + cn + " not found");
    }
    // service就是SPI,以JDBC为例,service就是Java Driver接口;此处判断c是否为Driver的实现
    if (!service.isAssignableFrom(c)) {
        fail(service,
             "Provider " + cn  + " not a subtype");
    }
    try {
        // c是spi的实现,c.newInstance()会触发类的初始化动作,以JDBC为例,这一操作会完成驱动注册
        S p = service.cast(c.newInstance());
        providers.put(cn, p);
        return p;
    } catch (Throwable x) {
        fail(service,
             "Provider " + cn + " could not be instantiated",
             x);
    }
    throw new Error();          // This cannot happen
}

好,那句因SPI而省略的代码现在解释清楚了,那我们继续看给这个方法传的loader是怎么来的。因为Class.forName(DriverName, false, loader)代码所在的类在java.util.ServiceLoader类中,而ServiceLoader.class又加载在BootrapLoader中,因此传给 forName 的 loader 必然不能是BootrapLoader(启动类加载器只能加载java核心类库)。这时候只能使用线程上下文类加载器了:把自己加载不了的类加载到线程上下文类加载器中(通过Thread.currentThread()获取),而线程上下文类加载器默认是使用系统类加载器AppClassLoader。

回头再看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是没法加载的,那就把它加载到当前执行线程的线程上下文类加载器里,后续你想怎么操作就是你的事了。


四. Tomcat与Spring的类加载器案例

接下来将介绍《深入理解java虚拟机》一书中的案例,并解答它所提出的问题(部分类容来自于书中原文)。

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根本不会去管自己被放在哪里,它统统使用线程类加载器来加载类,而线程类加载器默认设置为了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考虑到了自己可能被放到其他位置,所以直接用线程上下文类加载器来解决所有可能面临的情况。


五. 总结

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

  • 当高层提供了统一接口让低层去实现,同时又要是在高层加载(或实例化)低层的类时,必须通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类。

  • 当使用本类托管类加载,然而加载本类的ClassLoader未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管。


六. 更多

更多关于JVM内存模型的结构、Java对象在虚拟机中的创建、定位过程、内存异常分析等相关知识的介绍,请各位看官移步我的博文请移步我的博文[《JVM 内存模型概述》]。


##引用:
《真正理解线程上下文类加载器(多案例分析)》
《java中的SPI机制》

  • 42
    点赞
  • 127
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值