这是个很简单的问题,解决方案只要把引起主动加载 com.mysql.jdbc.Driver 的代码去掉。
不过这是个很好的例子去理解类加载机制与 Java SPI 的实现。
1. 场景
先看触发这个问题的一个比较原始的场景:我们直接调用 JDBC 进行数据库查询。
1.1. 问题代码
使用的 mysql 驱动的版本为 6.0.6:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>6.0.6</version>
</dependency>
查询代码如下:
public static void main(String[] args) {
// 加载类
try {
Class.forName("com.mysql.jdbc.Driver");
Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
PreparedStatement pstmt = conn.prepareStatement(SQL);
ResultSet rs = pstmt.executeQuery();
...
} catch (SQLException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
我们目前用到的一些 ORM 库,底层都是使用 JDBC 进行数据库操作的,可以认为它们是对 JDBC 的进一步封装,为数据库操作加入面向对象的特性。ORM 实际上就是关系-对象映射。
要能操作数据库,还是离不开各大数据库生厂商提供的驱动。
回到基础的 JDBC 调用,一共为 5 大步骤:
- 加载驱动
- 建立数据库连接 Connection
- 创建执行 SQL 语句的 Statement
- 处理执行结果 ResultSet
- 释放资源。
虽然我们知道了结果,是那个地方发出了警告,
但为了探索原因,或者在原因尚不明确的情况下,还是要再问一句 Why ?
是哪个过程的警告?
1.2. 完整警告
这次控制台报出的完整警报如下:
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.
内容可以归纳为:
- com.mysql.jdbc.Driver 已经过时,现在用的是 com.mysql.cj.jdbc.Driver
- 该驱动已经通过 SPI 机制自动注册,不需要手动添加
虽然有警告,程序运行完全正常,没有影响。
即便如此,不能抱有侥幸心理,还是需要探究原因,顺便巩固一下 Java 和 Jvm 的知识。
2. 原因探索
根据警告的线索,先查 com.mysql.jdbc.Driver 类。
可以得知,mysql-connector-java 的 6.0.6 版本已经废弃了 com.mysql.jdbc.Driver,改用 com.mysql.cj.jdbc.Driver。
查看驱动的 jar 文件后确实如此。
这里是旧版的 com.mysql.jdbc.Driver:
package com.mysql.jdbc;
import java.sql.SQLException;
public class Driver extends com.mysql.cj.jdbc.Driver {
public Driver() throws SQLException {
}
static {
System.err.println("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.");
}
}
该废弃的 Driver 实际是 com.mysql.cj.jdbc.Driver 的子类,拥有完整的驱动实现。
可以看到警告信息在 static 语句块中。
保留 com.mysql.jdbc.Driver 的原因应该是为了和一些旧项目的代码进行兼容,确保升级 mysql 驱动后程序的稳定性。
然后通过 static 代码块的警告来提示使用者,需要调整代码了。
2.1. 谁触发了 static 语句?
根据对类的知识,我们知道 static 语句一般发生在类加载中。
然而类加载过程又是复杂的,可以大致分为三个阶段
- 加载。
- 连接,包括验证、准备、解析。
- 初始化。
而初始化就是 static 语句执行的时机。
从资料中找到了整个类声明周期的图,包括了它的加载过程:
image实际上,类加载到内存中,不一定会马上引起初始化。
也就是经过了加载、连接后,初始化不一定会执行。
这一块 JVM 规范做出来很强硬的约束。只有对类的主动引用引起的类加载,初始化才会被马上触发,这时候 static 语句才会执行。
JVM 规范实际上只给出了 5 个场景,有且只有这 5 个场景会去触发初始化。以下五个场景摘自周志明的 《深入理解 Java 虚拟机》第 2 版的描述:
- 遇到 new、getStatic、putStatic 或者 invokestatic 这 4 条字节码指令时,如果类没有进行初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。
也就是不满足这五个场景,及时类完成了加载和连接过程,也不会执行初始化。
是的,就是如此的粗暴。
然后有例子吗?有的,比如下面的程序:
public class TestDriverInit {
public static void main(String[] args) {
currentClasses();
System.out.println("调用 Driver.class :" + Driver.class + "\n");
currentClasses();
}
private static void currentClasses() {
System.out.println("当前加载好的类:");
ClassLoader loader = Thread.currentThread().getContextClassLoader();
try {
Field clsField = ClassLoader.class.getDeclaredField("classes");
clsField.setAccessible(true);
Vector<Class<?>> classes = (Vector<Class<?>>) clsField.get(loader);
for(Class c : classes) {
System.out.println(c);
}
} catch (IllegalAccessException | NoSuchFieldException e) {
e.printStackTrace();
}
System.out.println();
}
}
为了验证类是否被加载,我们采用反射的方法输出了 ClassLoader 的 classes 成员变量:
public abstract class ClassLoader {
...
private final Vector<Class<?>> classes = new Vector<>();
...
}
编译执行代码后,我们从控制台中发现了这样的输出:
当前加载好的类:
class com.intellij.rt.execution.application.AppMain
class com.intellij.rt.execution.application.AppMain$1
class steven.lee.jdbc.TestDriverInit
调用 Driver.class :class com.mysql.jdbc.Driver
当前加载好的类:
class com.intellij.rt.execution.application.AppMain
class com.intellij.rt.execution.application.AppMain$1
class steven.lee.jdbc.TestDriverInit
class com.mysql.cj.jdbc.NonRegisteringDriver
class com.mysql.cj.jdbc.Driver
class com.mysql.jdbc.Driver
通过打印 ClassLoader 已经加载的类的列表,我们知道这个 Driver 确实加载了。
然而,那个 Driver 的 static 语句块并没有执行,警告也没有被触发。
意料之外情理之中的结果。
我们的 Driver.class 使用,不属于 JVM 规范中的 5 点的任何之一。它不属于主动引用,而是对类的被动引用。
那初始化最终会触发吗?会的,等主动引用的 5 个场景之一发生了后,自然就触发了 static 语句块。
那么回到我们的问题代码,哪里符合了初始化 5 场景?
按照 JDBC 流程,我们第一步使用 Class.forName 方法对 mysql 驱动库的 Driver 类进行加载:
Class.forName("com.mysql.jdbc.Driver");
就是这一步触发了警报。
这里属于第 2 个场景,反射的使用,属于是对 Driver 的主动引用,触发了 static 。
知道了警告的触发后,我们接着思考下一个问题:该驱动已经通过 SPI 机制自动注册,不需要手动添加。
2.2. 什么时候自动注册驱动
要理解驱动自动注册的逻辑,需要理清楚两个问题:
- 注册的代码在哪里
- 怎么调用的
2.2.1. 注册的代码
我们知道了 com.mysql.jdbc.Driver 被 com.mysql.cj.jdbc.Driver 代替,于是我们在 jar 包中找到了它:
package com.mysql.cj.jdbc;
import com.mysql.cj.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!");
}
}
}
com.mysql.cj.jdbc.Driver 在 static 语句块中调用了 DriverManager.registerDriver(new Driver()) 进行了注册。
com.mysql.cj.jdbc.Driver 继承于 NonRegisteringDriver。
NonRegisteringDriver 才是 JDBC 规范的 java.sql.Driver 的实现的主体。
所以,注册的代码就在新的驱动类 com.mysql.cj.jdbc.Driver 的 static 代码块中。
通过上面我们对虚拟机的了解,com.mysql.cj.jdbc.Driver 必须满足上述的 5 个主动引用的场景,才会执行 static 代码块,驱动注册过程才能执行。
什么时候调用呢,我们看到了另一个关键词 Java SPI
2.2.2. 调用的时机
Java 的 SPI 全称 Service Provider Interface,源自服务提供者框架 Service Provider Framework。
是用来进行接口与服务实现分离的目的。
这样子,我们只需要面向接口编程,具体的实现可以通过配置来更换,解耦得很干净,同时因为面向接口编程,不需要因为实现的改变而修改代码。
这个也是很多 Java 框架的基础。
系统使用 ServiceLoader 来执行,按照规范会有个配置文件放在 META-INF 目录下。这是这个配置文件决定了要加载哪一个实现。
JDBC 也是基于这个实现的。
配置文件在 jar 包可以找到:
image就是 java.sql.Driver,文件内容正是新的驱动类 com.mysql.cj.jdbc.Driver。
那么 JDBC 什么时候会使用 ServiceLoader 去加载 java.sql.Driver 呢?
答案就在 DriverManager 中。查 DriverManager 的代码看到:
public class DriverManager {
...
/**
* Load the initial JDBC drivers by checking the System property
* jdbc.properties and then use the {@code ServiceLoader} mechanism
*/
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
...
}
在静态代码块中有 loadInitialDrivers 方法,根据语意应该是要加载初始化驱动。
我隐藏了一些细节代码,把重要步骤提现出来:
private static void loadInitialDrivers() {
...
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
...
while(driversIterator.hasNext()) {
driversIterator.next();
}
...
for (String aDriver : driversList) {
...
Class.forName(aDriver, true, ClassLoader.getSystemClassLoader());
...
}
}
所以,在 DriverManager 的类加载过程的初始化阶段,就会去注册驱动。
而实现的方式正式 Java SPI 机制,和上述警告发出的内容一致。
我们不需要再主动调用 Class.forName 去加载驱动到内存,并完成它的初始化,在下面这一句自然会发生:
Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);
也就是创建数据库连接的时候:
- 如果是第一次调用 DriverManager,触发类加载机制
- DriverManager 这里属于主动引用,如果未初始化,会执行类加载机制中的初始化过程
- 在初始化中,static 代码块会被执行
- static 代码块会调用 SPI 机制,从 mysql 驱动的 jar 包的 META-INF 中读取 java.sql.Driver 的实现类
- 加载类进入内存,完成注册
3. 总结
正如文章开头说的一样,这个问题很好解决,直接把 JDBC 的加载驱动步骤省去即可。
Class.forName("com.mysql.jdbc.Driver");
但这篇文章的目的更多地是做一个延伸,对 JVM 类加载的初始化过程,还有 SPI 机制有了一个更具体的体验。
网友评论