@Catyee
2021-05-10T09:08:39.000000Z
字数 26868
阅读 1176
工作总结
对于sqoop或者datax这样以JDBC通用方式来进行数据迁移的工具来说,总是绕不开驱动包的加载。sqoop和datax只是迁移工具,运行任务是一次性的,每次都会重新加载驱动,所以不会暴露驱动包加载相关的问题;但当我们考虑实现一个数据迁移服务的时候,由于服务是常驻的,就需要仔细考虑驱动包的加载问题了。
通过jdbc的方式进行数据迁移总是绕不过要加载驱动包,jdbc的驱动包就是一个普通的jar包,jar包中都是编译好的class文件,加载驱动包实际上就是将驱动包中的类加载到jvm中。一个关系型数据库的发展往往都是一个版本迭代的过程,驱动包也会有多个版本,虽然数据库厂商一般都会尽量保证驱动主要功能的兼容性,但是由于版本的迭代谁也无法无法保证绝对的兼容,有时候我们需要加载同一个库的不同版本驱动。由于默认的双亲委托机制,当我们在一个服务中加载了一个驱动之后再想加载另一个版本的驱动就比较困难了。注意这里指得是服务,服务是一个长久运行的后台程序,jvm进程会在后台常驻;而对于工具来说一般跑一次任务,任务结束之后jvm就停掉了,所以服务中才会有加载多个版本驱动的困难,而工具中没有,这是两者的区别。
举个例子,开源版本的datax就是一个迁移工具,而不是一个迁移服务,datax跑完一次迁移任务实际上就是jvm的启动-->执行迁移任务-->jvm停止的过程,由于每次执行任务都会重新启动jvm并加载驱动,所以不会出现同时加载多个版本驱动的情况,比如现在有两个迁移任务,一个是mysql5.6的迁移,一个是mysql8.0的迁移,假如要用到mysql不同版本的驱动包,第一次我们进行mysql5.6的数据迁移的时候,我们需要把对应的驱动包(假设为mysql5.1.45)放到mysql plugin的目录下,然后启动datax,执行迁移任务的时候datax会把该驱动加载到jvm中,任务执行完之后jvm停止,加载的类自然也都卸载掉了。第二次我们进行mysql8.0的数据迁移的时候,我们需要先将前一个版本的驱动(mysql5.1.45)移除目录,然后手动把另一个版本的驱动(假设为mysql8.0.21)放入到mysql plugin的目录,然后执行迁移任务,才可以正确执行。
有人会说如果我们同时在目录中放入两个版本的驱动会怎么样?让我们复习一下类加载的双亲委派机制:
双亲委派机制是指一个类加载器收到一个类加载的请求时,该类加载器首先在自己加载的类中按类的全限定名搜索该类是否已经加载(findLoadedClass方法),如果没有加载,就会把类加载的请求委派给父类加载器,递归这个操作,一直到最顶层的父类加载器,父类加载器也会在自己加载的类中按类的全限定名进行搜索,如果搜索不到会自己尝试加载(findLoadedClass方法),如果加载失败子类加载器才会尝试自己去加载。以下是jdk1.8中ClassLoader源码,注意仔细阅读注释部分:
// jdk1.8中ClassLoader源码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1、先搜索该类是否已经被加载
// findLoadedClass方法最终会调用一个native方法,该方法按照的类全限定名和类加载器去搜索该类是否已经被加载
// 注意,判断类是否被加载是类加载器和全限定名共同决定的,实际上是用了一个map的结构保存加载的类,map的key是由类的全限定名和类加载器计算得到的 (参考:https://zhuanlan.zhihu.com/p/49920133)
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 2、委托父加载器进行加载
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) {
long t1 = System.nanoTime();
// 3、类的具体加载方式 (由于前面进行了递归调用,所以会先调用父加载器的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;
}
}
当两个不同版本的驱动都放入到目录中,jvm会按照名称的排序顺序进行加载(也就是先加载mysql5.1.45),如果两个驱动包中某些类的全限定名完全一样,由于双亲委派机制,jvm只会加载一次(由于先加载了mysql5.1.45中的类,mysql8.0.21中全限定名相同的类就不会加载),所以同时加载多个版本的驱动在执行任务的时候可能会出现意料之外的错误。对于datax来说只要每次放入正确的驱动包,就不会出现因为驱动版本不同而造成的问题,但如果是一个数据迁移服务就不一样了,当第一次加载了mysql5.1.45的驱动包之后,由于服务不会停,只要相关类没有卸载,jvm是不会再加载mysql8.0.21里面全限定名相同的类的。由此可见开发一个数据迁移的服务和开发一个数据迁移的工具是不一样的,需要慎重考虑驱动的管理。
所谓驱动的管理也就是管理驱动的版本和来源。驱动有两种来源,一种是我们开发者提供(比如在maven依赖中添加驱动包的依赖),第二种是用户自己提供(比如提供一个驱动包上传的接口,由用户自己上传驱动)。开发者提供的好处是可以保证驱动包的正确性,坏处是开发者只能提供有限几个数据库的驱动,而且是固定版本的驱动。选择jdbc进行数据迁移一般都是因为jdbc的通用性,理论上所有关系型数据库都可以使用一套代码实现进行数据的迁移,但是开发者并不能预料到用户用的是哪种数据库以及哪个版本(没办法在maven依赖中将所有关系库的驱动都添加进去),所以最好还是提供接口让用户自己上传,由用户按需提供不同的驱动。
但如果让用户自己上传就没办法完全保证驱动包的正确性了,用户可能从网上随便下载了一个驱动包,也可能用错了驱动的版本,一旦将错误的驱动包加载到jvm中,就像刚刚分析过的那样,由于类加载的机制,就算之后上传了正确的驱动包可能也没法正常工作,除非重启服务,而这对于服务来说重启通常是无法忍受的。
那么关键在于我们需要一种类加载隔离的方式,让每个加载的驱动包可以彼此隔离,互不干扰。
一般我们加载jvm外部的jar包都会使用URLClassLoader,加载驱动也不会例外,使用URLClassLoader加载外部驱动的方式:
// driverClass是关系库的驱动类名,比如"com.mysql.jdbc.Driver"
URL driverFileURL = new File(driverPath).toURI().toURL();
URL[] fileURLs = new URL[] {driverFileURL};
URLClassLoader urlClassLoader = new URLClassLoader(fileURLs);
Driver driver = (Driver) Class.forName(driverClass, true, urlClassLoader).newInstance();
// 或者
Driver driver1 = (Driver) urlClassLoader.loadClass(driverClass).newInstance();
但是在这种使用方式中URLClassLoader依然采用父类委派的加载机制,其父加载器是AppClassLoader(AppClassLoader的父加载器是ExtClassLoader,ExtClassLoader的父加载器是BootstrapClassLoader),所以这样使用URLClassLoader并不能实现类加载隔离,这里可以做个小实验:
public class URLClassLoaderTest {
private static final String DRIVER_FILE_8 = "/tmp/mysql-connector-java-8.0.21.jar";
private static final String DRIVER_FILE_5 = "/tmp/mysql-connector-java-5.1.45.jar";
public static void main(String[] args) throws Exception {
// 先new一个URLClassLoader加载mysql8的驱动
File mysql8DriverFile = new File(DRIVER_FILE_8);
URLClassLoader mysql8ClassLoader = new URLClassLoader(new URL[] {mysql8DriverFile.toURI().toURL()});
Class mysql8DriverClass = mysql8ClassLoader.loadClass("com.mysql.jdbc.Driver");
// 再new一个URLClassLoader加载mysql5的驱动
File mysql5DriverFile = new File(DRIVER_FILE_5);
URLClassLoader mysql5ClassLoader = new URLClassLoader(new URL[] {mysql5DriverFile.toURI().toURL()});
Class mysql5DriverClass = mysql5ClassLoader.loadClass("com.mysql.jdbc.Driver");
// 比较加载的两个是否是同一个类,并观察实际加载类的ClassLoader
System.out.println(mysql8DriverClass + " " + mysql8DriverClass.getClassLoader());
System.out.println(mysql5DriverClass + " " + mysql5DriverClass.getClassLoader());
System.out.println(mysql8DriverClass.equals(mysql5DriverClass));
}
执行结果如下:
class com.mysql.jdbc.Driver sun.misc.Launcher$AppClassLoader@18b4aac2
class com.mysql.jdbc.Driver sun.misc.Launcher$AppClassLoader@18b4aac2
true
Process finished with exit code 0
可以看到即使我们使用了不同的URLClassLoader对象去加载两个不同版本的驱动包,由于父类委派机制,最后真正加载的都是URLClassLoader的共同父加载器:AppClassLoader,虽然要加载的类虽然来自不同的驱动包,但是全限定名是一样的,jvm不会再进行加载,所以第二次返回的Class仍然是mysql8的Class,是同一个对象,所以equals方法返回true。由此可见以这种方式使用URLClassLoader是做不到类加载隔离的。
当发现使用已有的ClassLoader无法解决问题的时候,我们能想到的就是自己实现一个ClassLoader,用来完成类加载隔离的功能。但是在真正动手实现之前,我们要先弄清楚实现类加载隔离的可行性,也就是实现类加载隔离的理论依据。在上面ClassLoader源码码的注释中有提到在jvm的底层实现中是用一个map来保存已经加载的类的,map的key是通过类的全限定名和加载这个类的类加载器计算得到的,也就是说在jvm中要判定一个类是不是唯一的需要看两方面,一方面是看这个类的全限定名是否是一样的,另一方面就要看加载这个类的类加载器对象是否是同一个,如果全限定名一样但是类加载器对象不一样,jvm依然会认为这两个是不一样的类,所以要实现类加载隔离只需要让不同的类加载器去加载驱动包就可以了,这里"不同的类加载器"只需要是不同对象,上面的例子中我们使用URLclassloader加载每次都new了一个新的对象,按刚刚的说法这就是两个不同的类加载器,为什么URLclassloader做不到类加载隔离呢?原因是URLclassloader使用了父类委派机制,类的加载先交给父加载器加载,虽然是不同的URLClassLoader对象,但是它们有共同的父加载器对象(AppClassLoader),加载这个类的最终的类加载器对象是一样的,全限定名也一样,jvm就会判定加载的类是相同的,并且已经加载过了,就不会再进行加载,所以即使每次new不同的URLClassLoader对象也无法实现类加载隔离。
URLClassLoader之所以不能实现类加载隔离原因就是URLClassLoader遵循了父类委派机制,那我们要实现类加载隔离很自然的想法就是破坏父类委托机制,优先自己加载,自己加载不到的时候再使用父类加载器,这其实就是类加载隔离的核心机制。知道原理之后实现其实很简单,这里提供一种最简单的实现方式,URLClassLoader在创建的时候如果不显示指定父类加载器,则父类加载器是AppClassLoader,但URLClassLoader也支持显示指定父类加载器,最简单的实现就是将父类加载器显示指定为null,这样可以让URLClassLoader自己去加载jar包:
public class URLClassLoaderTest {
private static final String DRIVER_FILE_8 = "/tmp/mysql-connector-java-8.0.21.jar";
private static final String DRIVER_FILE_5 = "/tmp/mysql-connector-java-5.1.45.jar";
public static void main(String[] args) throws Exception {
// 先new一个URLClassLoader加载mysql8的驱动, 注意创建URLClassLoader的时候显示指定parent为空
File mysql8DriverFile = new File(DRIVER_FILE_8);
URLClassLoader mysql8ClassLoader = new URLClassLoader(new URL[] {mysql8DriverFile.toURI().toURL()}, null);
Class mysql8DriverClass = mysql8ClassLoader.loadClass("com.mysql.jdbc.Driver");
// 再new一个URLClassLoader加载mysql5的驱动, 注意创建URLClassLoader的时候显示指定parent为空
File mysql5DriverFile = new File(DRIVER_FILE_5);
URLClassLoader mysql5ClassLoader = new URLClassLoader(new URL[] {mysql5DriverFile.toURI().toURL()}, null);
Class mysql5DriverClass = mysql5ClassLoader.loadClass("com.mysql.jdbc.Driver");
// 比较加载的两个是否是同一个类,并观察实际加载类的ClassLoader
System.out.println(mysql8DriverClass + " " + mysql8DriverClass.getClassLoader());
System.out.println(mysql5DriverClass + " " + mysql5DriverClass.getClassLoader());
System.out.println(mysql8DriverClass.equals(mysql5DriverClass));
}
}
执行结果如下:
class com.mysql.jdbc.Driver java.net.URLClassLoader@41a4555e
class com.mysql.jdbc.Driver java.net.URLClassLoader@782830e
false
Process finished with exit code 0
可以看到实际加载类的类加载器不再是AppClassLoader的对象,而是URLClassLoader的对象,最终加载了不同的类,所以equals方法返回false。当然这里只是演示了一种最简单的实现,实际开发过程中要考虑的问题会更多,所以最好是按需实现一个自定义的ClassLoader,实现的过程并不重要,关键在于明白其中的原理。
JAVA9中最重要的新特性就是实现了模块化的功能,其背后的原理也就是类加载隔离;在java中说到模块化就不得不提OSGI标准,OSGI标准是OSGI Alliance组织制定的Java模块化规范,实现了OSGI标准的最著名的开源项目是Eclipse开源的模块化系统框架Equinox,官网地址是: Equinox官方地址,其背后实现原理也就是类加载隔离;tomcat的底层也实现了类加载隔离的类加载器,所以类加载隔离的应用场景其实比想象中的更广。
一般我们都会选择通过DriverManager来获取jdbc连接,当我们实现类加载隔离之后,如果我们仍然使用DriverManager的方式获取连接,比如下面这段代码:
// driverClass是关系库的驱动类名,比如"com.mysql.jdbc.Driver"
URL driverFileURL = new File(driverPath).toURI().toURL();
URL[] fileURLs = new URL[] {driverFileURL};
// 指定了父类加载器为null实现类加载隔离
URLClassLoader urlClassLoader = new URLClassLoader(fileURLs,null);
Driver driver = (Driver) urlClassLoader.loadClass(driverClass).newInstance();
DriverManager.registerDriver(driver);
// 通过DriverManager获取连接
try (Connection connection = DriverManager.getConnection(jdbcUrl, username, password)) {
...
}
我们期望程序会以不同的驱动获取到连接,但其实不会。这里要分两种情况讨论,最终我们会发现这两种情况都不符合我们的期望,我们以加载Mysql的驱动为例。
一般服务都会有一个持久层,将一些信息持久化到数据库,如果你刚好选择了mysql作为来存储数据,那在启动服务的时候持久层会加载mysql的驱动,使用的是(AppClassLoader),并注册到DriverManager中,所以第一种情况就是jvm中已经有系统类加载器加载的mysql驱动了,看下面这段代码,
public class URLClassLoaderTest {
private static final String DRIVER_FILE_8 = "/tmp/mysql-connector-java-8.0.21.jar";
private static final String DRIVER_FILE_5 = "/tmp/mysql-connector-java-5.1.45.jar";
private static final String JDBC_URL = "jdbc:mysql://localhost:3306?useSSL=false";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";
public static void main(String[] args) throws Exception {
// 场景模拟:让jvm中有系统加载的驱动, 驱动类初始化的时候会向DriverManager注册一个驱动对象
// 为了进行区分,这里使用mysql5.1.47的驱动,在maven中引入5.1.47的依赖,这样系统类加载器就可以加载了
Class.forName("com.mysql.jdbc.Driver");
System.out.println("=====================================");
Thread thread1 = new Thread(() -> {
try {
// 先new一个URLClassLoader加载mysql8.0.21的驱动, 注意new URLClassLoader的时候手动指定parent为空
File mysql8DriverFile = new File(DRIVER_FILE_8);
URLClassLoader mysql8ClassLoader = new URLClassLoader(new URL[]{mysql8DriverFile.toURI().toURL()}, null);
Driver driver8 = (Driver) mysql8ClassLoader.loadClass("com.mysql.jdbc.Driver").newInstance();
System.out.println(String.format("Thread[%s]: driver8:[%s]", Thread.currentThread().getName(), driver8));
DriverManager.registerDriver(driver8); // 注册驱动
try (Connection conn = DriverManager.getConnection(JDBC_URL, USERNAME, PASSWORD)) {
String info = String.format("Thread[%s]: Driver product: [%s], version: [%s], max connection: [%s]",
Thread.currentThread().getName(),
conn.getMetaData().getDatabaseProductName(),
conn.getMetaData().getDriverVersion(),
conn.getMetaData().getMaxConnections()
);
System.out.println(info);
} finally {
// 一般开发者会想到注册驱动之后需要卸载驱动,但其实会报错,没有权限进行卸载(后面会讲解原因)
// DriverManager.deregisterDriver(driver8);
}
} catch (Exception e) {
e.printStackTrace();
}
}, "thread1");
thread1.start();
thread1.join(); // 串行执行
System.out.println("=====================================");
Thread thread2 = new Thread(() -> {
try {
// 再new一个URLClassLoader加载mysql5.1.45的驱动, 注意new URLClassLoader的时候手动指定parent为空
File mysql5DriverFile = new File(DRIVER_FILE_5);
URLClassLoader mysql5ClassLoader = new URLClassLoader(new URL[]{mysql5DriverFile.toURI().toURL()}, null);
Driver driver5 = (Driver) mysql5ClassLoader.loadClass("com.mysql.jdbc.Driver").newInstance();
System.out.println(String.format("Thread[%s]: driver5:[%s]", Thread.currentThread().getName(), driver5));
DriverManager.registerDriver(driver5); // 注册驱动
try (Connection conn = DriverManager.getConnection(JDBC_URL, USERNAME, PASSWORD)) {
String info = String.format("Thread[%s]: Driver product: [%s], version: [%s], max connection: [%s]",
Thread.currentThread().getName(),
conn.getMetaData().getDatabaseProductName(),
conn.getMetaData().getDriverVersion(),
conn.getMetaData().getMaxConnections()
);
System.out.println(info);
} finally {
// 一般开发者会想到注册驱动之后需要卸载驱动,但其实会报错,没有权限进行卸载(后面会讲解原因)
// DriverManager.deregisterDriver(driver5);
}
} catch (Exception e) {
e.printStackTrace();
}
}, "thread2");
thread2.start();
thread2.join();
}
}
我们先模拟系统已经加载了mysql的驱动的情况,为了进行区分这里使用了mysql5.1.47版本的驱动,在maven依赖中引入,然后通过Class.forName()方法(默认会使用系统类加载器)进行加载,然后启动两个线程,在线程内部使用类加载隔离的方式来分别加载mysql8.0.21和5.1.45的驱动,预期是以不同的驱动获取到连接,但是我们看执行结果:
=====================================
Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.
Thread[thread1]: driver8:[com.mysql.jdbc.Driver@3bac5b74]
Thread[thread1]: Driver product: [MySQL], version: [mysql-connector-java-5.1.47 ( Revision: fe1903b1ecb4a96a917f7ed3190d80c049b1de29 )], max connection: [0]
=====================================
Thread[thread2]: driver5:[com.mysql.jdbc.Driver@125b5877]
Thread[thread2]: Driver product: [MySQL], version: [mysql-connector-java-5.1.47 ( Revision: fe1903b1ecb4a96a917f7ed3190d80c049b1de29 )], max connection: [0]
Process finished with exit code 0
会发现执行结果中两个线程使用的竟然都是系统类加载的5.1.47版本的驱动,这显然是不符合预期的。我们再来看第二种情况,如果服务使用的数据库不是mysql,而是其它数据库,也就是系统不会提前加载mysql驱动,所以第二种情况是jvm中没有系统类加载器加载的mysql驱动,将上面代码第14行的Class.forName("com.mysql.jdbc.Driver")注释掉并将maven依赖中引入的mysql5.1.47给剔除就可以模拟这种情况,这里不重复贴代码了,我们直接看执行的结果:
=====================================
Loading class `com.mysql.jdbc.Driver'. This is deprecated. The new driver class is `com.mysql.cj.jdbc.Driver'. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.
Thread[thread1]: driver8:[com.mysql.jdbc.Driver@73fbcfe2]
java.sql.SQLException: No suitable driver found for jdbc:mysql://localhost:3306?useSSL=false
at java.sql.DriverManager.getConnection(DriverManager.java:689)
at java.sql.DriverManager.getConnection(DriverManager.java:247)
at classloader.URLClassLoaderTest.lambda$main$0(URLClassLoaderTest.java:33)
at java.lang.Thread.run(Thread.java:748)
=====================================
Thread[thread2]: driver5:[com.mysql.jdbc.Driver@3fd59d9b]
java.sql.SQLException: No suitable driver found for jdbc:mysql://localhost:3306?useSSL=false
at java.sql.DriverManager.getConnection(DriverManager.java:689)
at java.sql.DriverManager.getConnection(DriverManager.java:247)
at classloader.URLClassLoaderTest.lambda$main$1(URLClassLoaderTest.java:61)
at java.lang.Thread.run(Thread.java:748)
Process finished with exit code 0
好家伙,会发现这次两个线程中甚至都没有获取到连接,而是都报错了,这显然不符合预期。我们来从DriverManager的源码中找原因:
public class DriverManager {
// DriverManager使用了一个CopyOnWriteArrayList保存了所有注册的Driver对象
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
... // 省略部分代码
// 最终所有获取连接的方法都会调用下面这个方法:
// 注意最后一个参数,caller是通过Reflection.getCallerClass()方法获取到的,是调用这个方法的类,即调用类
// Worker method called by the public getConnection() methods.
private static Connection getConnection(
String url, java.util.Properties info, Class<?> caller) throws SQLException {
// 获取调用类的类加载器,一般都是AppClassLoader
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized(DriverManager.class) {
// synchronize loading of the correct classloader.
if (callerCL == null) {
callerCL = Thread.currentThread().getContextClassLoader();
}
}
if(url == null) {
throw new SQLException("The url cannot be null", "08001");
}
println("DriverManager.getConnection(\"" + url + "\")");
// 这里使用了一个for循环,从CopyOnWriteArrayList依次取出驱动对象,进行驱动的检查,如果通过检查,然后尝试进行连接,一旦连接上了就直接返回。
for(DriverInfo aDriver : registeredDrivers) {
// isDriverAllowed()方法用于进行驱动的检查,其实是检查调用类的类加载器能否加载Driver,如果不能加载将返回false,如果能加载还会比较加载的类和注册的驱动对象的Class对象是否一样,不一样也会返回false
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println(" skipping: " + aDriver.getClass().getName());
}
}
// 获取连接时抛出异常
if (reason != null) {
println("getConnection failed: " + reason);
throw reason;
}
// 驱动检查不通过
println("getConnection: no suitable driver found for "+ url);
throw new SQLException("No suitable driver found for "+ url, "08001");
}
// 驱动检查的方法,注意第二个参数,这个classLoader是加载调用类的类加载器
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
boolean result = false;
if(driver != null) {
Class<?> aClass = null;
try {
aClass = Class.forName(driver.getClass().getName(), true, classLoader);
} catch (Exception ex) {
result = false;
}
result = ( aClass == driver.getClass() ) ? true : false;
}
return result;
}
}
可以看到DriverManager在内部使用了一个CopyOnWriteArrayList来保存向它注册的驱动对象,DriverManager在获取连接的时候使用了一个for循环,从CopyOnWriteArrayList依次取出驱动对象,然后进行驱动的检查,这个遍历CopyOnWriteArrayList的for循环和驱动检查的方法就是造成上面两种情况不符合预期的原因。
首先我们来看CopyOnWriteArrayList,在第一种情况中,CopyOnWriteArrayList中最终实际上是有三个Driver对象的,一个系统加载的mysql5.1.47的driver对象,一个mysql8.0.21的driver对象,一个mysql5.1.45的driver对象。for循环遍历的时候会先遍历到系统加载的mysql5.1.47的driver对象,因为它是最先被注册的。遍历到之后进入驱动检查的方法,驱动检查的方法第二个参数是加载调用类的类加载器,调用类是通过Reflection.getCallerClass()方法获取到的,在上面的例子中也就是URLClassLoaderTest.class,这个类的类加载器显然是AppClassLoader,在第60行通过这个类加载器尝试加载驱动,由于系统已经加载过mysql5.1.47的驱动类,不会再次加载,所以上面第60行返回的是已经加载过的mysql5.1.47的驱动类,并且65行也会返回true,所以驱动检查通过,DriverManager尝试获取连接,获取到连接就直接返回,所以我们看到第一种情况中两个线程中使用的都是mysql5.1.47的驱动。
在第二种情况中,CopyOnWriteArrayList中只有两个Driver对象,一个mysql8.0.21的driver对象,一个mysql5.1.45的driver对象,当for循环遍历的时候会先后遍历到这两个对象,但是在进行驱动检查的时候,第60行会尝试用AppClassLoader加载驱动类,很显然是加载不到的,所以第60行返回null,第65行返回false,最终遍历结束驱动检查不通过,直接报错。
所以当我们实现类加载隔离之后,我们要抛弃使用DriverManager获取连接的方式,那如何获取连接呢?直接通过Driver对象获取就可以了,推荐的方式如下:
Properties props = new Properties();
props.put("user", username);
props.put("password", password);
Driver driver = (Driver) urlClassLoader.loadClass(driverClass).newInstance();
try (Connection conn = driver.connect(jdbcUrl, props)) {
...
}
由于类加载隔离破坏了父类委派机制,jvm的元空间中加载的类可能会越来越多,尤其是周期调度类型任务,如果每调度一次就使用一个新的类加载器,然后重新加载一次,很快元空间就会爆炸。所以当我们实现类加载隔离的时候一定要考虑清楚加载的类应该什么时候卸载。那么类什么时候会卸载呢?让我们回顾一下类卸载的条件:
可以看到第一个条件还是很容易达成的,关键在于如何达成后两个条件,也就是类加载器对象和Class对象在什么时候回收,而要想搞清楚类加载器对象和Class对象什么时候被回收掉,首先要搞清楚类加载对象和类的Class对象会被谁引用。
首先我们来看第三个条件,如果想要Class对象能够回收掉,这个Class对象首先要没有在任何地方被显示的引用,简单来说就是在程序中没有任何一处显示的使用一个类的Class对象,比如Sample.class.getClassLoader()就是一次显示的使用Class对象(一般来说能够直接在代码中使用Sample.class这样的引用意味着这个类是在启动的时候就被jvm默认的类加载器加载进去了,这样的类本身就无法卸载)。为什么显示的使用了Sample.class之后Class对象就无法回收呢?因为Sample.class是一个指向了Class对象的引用。
这里要注意"对象"和"引用"在概念上的区别,很多人会认为Sample.class是Sample类的Class对象,其实这是不准确的,准确说应该是Sample.class是Sample类的Class对象的一个引用。java中的对象都存储在jvm堆中(Class对象也不例外),我们没办法直接访问和使用它,如果要访问和使用它就需要一个指向它的引用。怎么理解Sample.class是一个引用呢?看下面这样一个类:
public class Sample {
public static final Object obj = new Object();
}
我们会说obj是Sample类的一个静态属性,可以直接通过Sample.obj进行访问。类似的我们可以通过Sample.class来直接访问类的Class对象,这意味class是类的一个静态属性。实际上"Sample.class"中Sample代表类,点代表访问这个类的一个属性,这个属性的名称是class,通过类名能访问的是静态属性,所以class是类的一个静态属性,它指向了Sample类的Class对象。可以得出结论:java中每一个类都有一个静态属性class,它引用了这个类的Class对象。
除了Sample.class这样直接的引用方式,我们再来看还有哪些对象会引用Class对象。在Object类中有一个默认的getClass()方法(它是一个native方法),任何实例都可以调用这个方法来获得这个类的Class对象, 也就是说一个类的每一个实例都引用了这个类的Class对象(这个引用关系比较难以理解,可以参考:参照Openjdk源码分析Object.getClass()方法),这也是为什么一定要回收掉所有实例的原因,因为不回收掉所有实例,Class对象就无法回收,Class对象无法回收类就无法卸载。
另外在类加载器的内部会用一个Vector来存放它所加载类的Class对象,如下,这段代码可以在ClassLoader类中找到:
// The classes loaded by this class loader. The only purpose of this table
// is to keep the classes from being GC'ed until the loader is GC'ed.
private final Vector<Class<?>> classes = new Vector<>();
可以得出结论:ClassLoader对象引用了它加载的每个类的Class对象。反过来,我们也可以通过Sample.class.getClassLoader()方法来获取加载这个类的ClassLoader对象,这说明了类的Class对象引用了加载该类的ClassLoader对象。所以类的Class对象和加载这个类的ClassLoader对象是双向引用的关系,如果使用引用计数法,那Class对象和类加载器都将不可能被垃圾回收掉,因为他们互相引用,但是如果使用可达性分析,他们在都不可达时就会被回收掉。
由于Java虚拟机始终会引用Java虚拟机自带的类加载器(BootstrapClassLoader、ExtraClassLoader、AppClassLoader),这些类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。但如果是我们自定义的类加载器,只要设计和使用得当,是可以被回收掉的,自定义ClassLoader加载的类也可以被卸载。
回到驱动加载这个具体的场景,到了刚刚这一步依然没有结束,当我们精心设计了自定义的类加载器之后,按照我们的预想自定义的类加载器可以被回收掉,类也可以卸载掉,我们欢天喜地的去打印类加载卸载信息验证自定义的类加载器,结果被浇了一盆冷水——控制台根本没有打印类卸载信息。
我们可以通过参数来让jvm打印类加载和卸载的信息:
-verbose:class // 打印类加载卸载信息
-XX:+TraceClassLoading // 仅仅打印类加载信息
-XX:+TraceClassUnloading // 仅仅打印类卸载信息
我们可以设计一个程序,来观察驱动包加载和卸载情况,通过调小内存和显示的调用System.gc()方法来增加GC的频率,见下面的java代码,为了环境的干净,在运行之前请保证maven依赖中没有引入mysql的驱动,也没有其它jar包间接的引入了mysql的驱动(可以使用mvn dependency:tree命令排查)。然后在启动的时候加入以下的jvm参数:
-verbose:class -Xms10M -Xmx50M -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps
这些参数用于打印类加载和卸载的信息以及gc的信息,并调小了内存(可以按需进行增大和调小)。下面是代码:
public class UninstallClassTest {
private static final String DRIVER_FILE_8 = "/tmp/mysql-connector-java-8.0.21.jar";
private static final String JDBC_URL = "jdbc:mysql://localhost:3306?useSSL=false";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";
public static void main(String[] args) throws Exception {
ScheduledExecutorService scheduleTask = Executors.newScheduledThreadPool(1);
// 启动一个周期性调度的任务,这个任务每隔20秒显示的调用一次System.gc()方法,用于增加gc频率
scheduleTask.scheduleWithFixedDelay(() -> {
System.out.println(String.format("Thread:[%s] try to call full gc", Thread.currentThread().getName()));
System.gc();
}, 0, 20, TimeUnit.SECONDS);
// 单独启一个线程,线程中以类加载隔离的方式加载mysql8的驱动,并连接数据库执行一个具体的业务,在任务执行结束之后线程也会终结
Thread thread1 = new Thread(() -> {
try {
// 以类加载隔离的方式加载驱动
File mysql8DriverFile = new File(DRIVER_FILE_8);
URLClassLoader mysql8ClassLoader = new URLClassLoader(new URL[]{mysql8DriverFile.toURI().toURL()}, null);
Driver driver8 = (Driver) mysql8ClassLoader.loadClass("com.mysql.jdbc.Driver").newInstance();
System.out.println(String.format("Thread[%s]: driver8:[%s]", Thread.currentThread().getName(), driver8));
Properties properties = new Properties();
properties.put("user", USERNAME);
properties.put("password", PASSWORD);
// 获取连接,执行一个具体的业务
String sql = "select count(*) from par_db.par_table";
try (Connection connection = driver8.connect(JDBC_URL, properties);
Statement st = connection.createStatement()) {
if (st.execute(sql)) {
try (ResultSet rs = st.getResultSet()) {
while (rs.next()) {
System.out.println("Result count:" + rs.getString(1));
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}, "thread1");
thread1.start();
thread1.join();
}
}
因为类卸载是在gc的时候发生的,所以上面的代码先启动一个周期性的调度任务,每隔20秒显示的调用一次System.gc()方法,用于增加gc的频率。然后启动一个独立的线程,线程中以类加载隔离的方式加载mysql8的驱动,并连接数据库执行一个具体的业务,在任务执行结束之后线程也会终结,预期是独立线程结束后不久就会打印类卸载的信息,但实际上出了连续不断的gc信息并没有任何驱动类卸载的信息,以下为部分执行结果展示:
没有打印类卸载信息说明仍然有某些地方引用了类加载器或者驱动实例。不要停止程序,我们通过内存快照来分析对象的引用关系,命令如下:
jmap -dump:format=b,file=/tmp/heapdump.hprof <pid>
获取到内存快照的文件之后我们使用jprofile工具来分析这个内存快照的文件,jprofile显示的信息会很多,但是我们聚焦到驱动对象,也就是com.mysql.jdbc.Driver,在jprofile中通过URLClassLoader找到驱动对象,然后查看驱动对象的引用链,如图为结果:
发现有两个引用关系,一个是AbandonedConnectionCleanupThread类引用了URLClassLoader,另一个是DriverManager引用了驱动对象。我们去查看AbandonedConnectionCleanupThread的源码:
// mysql8.0.21驱动中AbandonedConnectionCleanupThread的源码
public class AbandonedConnectionCleanupThread implements Runnable {
... // 省略部分代码
static {
cleanupThreadExcecutorService = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "mysql-cj-abandoned-connection-cleanup");
t.setDaemon(true);
// Tie the thread's context ClassLoader to the ClassLoader that loaded the class instead of inheriting the context ClassLoader from the current
// thread, which would happen by default.
// Application servers may use this information if they attempt to shutdown this thread. By leaving the default context ClassLoader this thread
// could end up being shut down even when it is shared by other applications and, being it statically initialized, thus, never restarted again.
ClassLoader classLoader = AbandonedConnectionCleanupThread.class.getClassLoader();
if (classLoader == null) {
// This class was loaded by the Bootstrap ClassLoader, so lets tie the thread's context ClassLoader to the System ClassLoader instead.
classLoader = ClassLoader.getSystemClassLoader();
}
t.setContextClassLoader(classLoader);
return threadRef = t;
});
cleanupThreadExcecutorService.execute(new AbandonedConnectionCleanupThread());
}
... // 省略部分代码
public void run() {
// 使用了一个for死循环,只有在关闭线程池的时候接收到interrupt信号才会通过InterruptedException异常跳出死循环
for (;;) {
try {
checkContextClassLoaders();
Reference<? extends ConnectionImpl> ref = NonRegisteringDriver.refQueue.remove(5000);
if (ref != null) {
try {
((ConnectionPhantomReference) ref).cleanup();
} finally {
NonRegisteringDriver.connectionPhantomRefs.remove(ref);
}
}
} catch (InterruptedException e) {
threadRef = null;
return;
} catch (Exception ex) {
// Nowhere to really log this.
}
}
}
}
在源码中我们看到了这样一个静态代码块,静态代码块中使用单线程的线程池启动了一个线程,我们这里不分析这个线程的作用是什么,只要知道这个线程跑的任务中用了for的死循环,所以这个线程是一个不会停止的线程,除非这个关闭线程池的时候发送interrupt信号,for循环中捕获到InterruptedException异常之后就会跳出循环,线程就结束了。回到静态代码块,静态代码块中通过AbandonedConnectionCleanupThread.class.getClassLoader()获取到了加载这个类的类加载器,也就是URLClassLoader,然后把加载器设置到了线程,由于这个线程一直在运行,这个线程一直持有URLClassLoader的引用,导致URLClassLoader无法回收。
分析清楚AbandonedConnectionCleanupThread中的问题之后,我们再来看DriverManager对驱动对象的引用。在第三章中我们已经分析了DriverManager在类加载隔离场景下的弊端,代码中我们也没有使用DriverManager,按道理DriverManager应该不会持有驱动对象的引用才对,实际上在com.mysql.cj.jdbc.Driver类中也有一段静态代码块:
// mysql8.0.21中com.mysql.cj.jdbc.Driver类的源码
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
// Register ourselves with the DriverManager
static {
try {
java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");
}
}
... // 省略部分代码
}
这个静态代码块就很容易理解了,生成一个Driver对象,然后注册到DriverManager中。静态代码块是在类初始化的时候就执行的,而且只会执行一次,所以只要我们使用这个驱动类,就算不使用DriverManager,DriverManager也总是会持有一个驱动对象的引用。不仅仅是mysql,看过oracle、postgresql、db2、sql server这些关系数据库的jdbc驱动源码,都有这样一个向DriverManager注册驱动的静态代码块,所以是一个共性的问题。前面AbandonedConnectionCleanupThread问题是mysql独有的个例。
知道问题之后就好办了,AbandonedConnectionCleanupThread问题只要关闭线程池,DriverManager只需要清空CopyOnWriteArrayList就可以了(实际开发中不能这么简单粗暴,要仔细考虑这样操作的后果,思考更加周全的方式,这里只是为了展示原理),我们可以通过反射来实现,按照解决思路如下修改代码:
public class UninstallClassTest {
private static final String DRIVER_FILE_8 = "/tmp/mysql-connector-java-8.0.21.jar";
private static final String JDBC_URL = "jdbc:mysql://localhost:3306?useSSL=false";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";
public static void main(String[] args) throws Exception {
ScheduledExecutorService scheduleTask = Executors.newScheduledThreadPool(1);
// 启动一个周期性调度的任务,这个任务每隔20秒显示的调用一次System.gc()方法,用于增加gc频率
scheduleTask.scheduleWithFixedDelay(() -> {
System.out.println(String.format("Thread:[%s] try to call full gc", Thread.currentThread().getName()));
System.gc();
}, 0, 20, TimeUnit.SECONDS);
// 单独启一个线程,线程中以类加载隔离的方式加载mysql8的驱动,并连接数据库执行一个具体的业务,在任务执行结束之后线程也会终结
Thread thread1 = new Thread(() -> {
try {
// 以类加载隔离的方式加载驱动
File mysql8DriverFile = new File(DRIVER_FILE_8);
URLClassLoader mysql8ClassLoader = new URLClassLoader(new URL[]{mysql8DriverFile.toURI().toURL()}, null);
Driver driver8 = (Driver) mysql8ClassLoader.loadClass("com.mysql.jdbc.Driver").newInstance();
System.out.println(String.format("Thread[%s]: driver8:[%s]", Thread.currentThread().getName(), driver8));
Properties properties = new Properties();
properties.put("user", USERNAME);
properties.put("password", PASSWORD);
// 获取连接,执行一个具体的业务
String sql = "select count(*) from par_db.par_table";
try (Connection connection = driver8.connect(JDBC_URL, properties);
Statement st = connection.createStatement()) {
if (st.execute(sql)) {
try (ResultSet rs = st.getResultSet()) {
while (rs.next()) {
System.out.println("Result count:" + rs.getString(1));
}
}
}
} finally {
// 业务执行完之后执行清理动作
closeAbandonedConnectionCleanupThread(mysql8ClassLoader);
closeUrlClassLoader(mysql8ClassLoader);
cleanDriverManager();
}
} catch (Exception e) {
e.printStackTrace();
}
}, "thread1");
thread1.start();
thread1.join();
}
// 关闭AbandonedConnectionCleanupThread中的线程池
private static void closeAbandonedConnectionCleanupThread(URLClassLoader classLoader) throws Exception {
Class clazz = Class.forName("com.mysql.cj.jdbc.AbandonedConnectionCleanupThread", true, classLoader);
Method method = clazz.getMethod("uncheckedShutdown");
method.invoke(null);
System.out.println("== finished close default thread");
}
private static void closeUrlClassLoader(URLClassLoader classLoader) throws Exception {
classLoader.close();
classLoader.clearAssertionStatus();
System.out.println("== finished clean url classloader");
}
// 清理DriverManager中的CopyOnWriteArrayList
private static void cleanDriverManager() throws Exception {
Field field = DriverManager.class.getDeclaredField("registeredDrivers");
field.setAccessible(true);
CopyOnWriteArrayList list = ((CopyOnWriteArrayList) field.get(null));
for (Object obj : list) {
list.remove(obj);
}
System.out.println("== finished clean driver manager list");
}
}
修改完成之后再次执行,然后观察类卸载信息,这次没有出乎预料,看到了驱动类卸载的信息:
到了这一步类加载隔离场景下的驱动包加载和卸载才是真正完成了。
从上面的例子可以看出我们要警惕静态代码块,静态代码块中可能会有一些隐秘的引用关系导致类卸载失败。这种引用关系一般也很难发现,所以当我们开发类加载隔离相关功能的时候一定要进行充分的测试。