面试官:Java中什么地方违反了双亲委派模型,打破了双亲委派模型?

JAVA herman 551浏览
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:codedq,发送下载链接帮助你免费下载!
本博客日IP超过2000,PV 3000 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:codedq,之前的微信号好友位已满,备注:返现
受密码保护的文章请关注“业余草”公众号,回复关键字“0”获得密码
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领

摘要

  • 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 的实现如下所示。

JDBC SPI mysql的实现
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

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 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,如下图所示:

Tomcat 的双亲委派模型
Tomcat 的双亲委派模型

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

从图中的委派关系中可以看出,CommonClassLoader 能加载的类都可以被 CatalinaClassLoaderSharedClassLoader 使用,而 CatalinaClassLoaderSharedClassLoader 自己能加载的类则与对方相互隔离。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 未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管。

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加作者微信号1:xmtxtt(5000人已满),微信号2:xttblog(5000人已满),微信号3:codedq(超3800)。备注:“1”,添加博主微信拉你进微信群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作也可添加作者微信进行联系!

本文原文出处:业余草: » 面试官:Java中什么地方违反了双亲委派模型,打破了双亲委派模型?