一、本文大纲
二、ClassLoader 类加载器
1、Java 中的类加载器以及双亲委派机制
Java 中的类加载器,是 Java 运行时环境的一部分,负责动态加载 Java 类到 Java 虚拟机的内存中。
有了类加载器,Java 运行系统不需要知道文件与文件系统。
那么类加载器,什么类都加载吗?加载的规则是什么?
Java 中的类加载器有四种,分别是:
- BootstrapClassLoader,顶级类加载器,加载JVM自身需要的类;
- ExtClassLoader,他负责加载扩展类,如 jre/lib/ext 或 java.ext.dirs 目录下的类;
- AppClassLoader,他负责加载应用类,所有 classpath 目录下的类都可以被这个类加载器加载;
- 自定义类加载器,如果你要实现自己的类加载器,他的父类加载器都是AppClassLoader。
类加载器采用了双亲委派模式,其工作原理是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
双亲委派模式的好处是什么?
第一,Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层次关系可以避免类的重复加载,当父类加载器已经加载过一次时,没有必要子类再去加载一次。
第二,考虑到安全因素,Java 核心 Api 类不会被随意替换,核心类永远是被上层的类加载器加载。如果我们自己定义了一个 java.lang.String 类,它会优先委派给 BootStrapClassLoader 去加载,加载完了就直接返回了。
如果我们定义了一个 java.lang.ExtString,能被加载吗?答案也是不能的,因为 java.lang 包是有权限控制的,自定义了这个包,会报一个错如下:
java.lang.SecurityException: Prohibited package name: java.lang
2、双亲委派机制源码浅析
Java 程序的入口就是 sun.misc.Launcher 类,我们可以从这个类开始看起。
下面是这个类的一些重要的属性,写在注释里了。
public class Launcher {
private static URLStreamHandlerFactory factory = new Launcher.Factory();
// static launchcher 实例
private static Launcher launcher = new Launcher();
// bootclassPath ,就是 BootStrapClassLoader 加载的系统资源
private static String bootClassPath = System.getProperty("sun.boot.class.path");
// 在 Launcher 构造方法中,会初始化 AppClassLoader,把它作为全局实例保存起来
private ClassLoader loader;
private static URLStreamHandler fileHandler;
......
}
这个类加载的时候,就会初始化 Launcher 实例,我们看一下无参构造方法。
public Launcher() {
Launcher.ExtClassLoader var1;
try {
// 获得 ExtClassLoader
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
// 获得 AppClassLoader,并赋值到全局属性中
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
// 把 AppClassLoader 的实例赋值到当前上下文的 ClassLoader 中,和当前线程绑定
Thread.currentThread().setContextClassLoader(this.loader);
// ...... 省略无关代码
}
可以看到,先获得一个 ExtClassLoader ,再把 ExtClassLoader 作为父类加载器,传给 AppClassLoader。最终会调用这个方法,把 ExtClassLoader 传给 parent 参数,作为父类加载器。
而在初始化 ExtClassLoader 的时候,没有传参:
Launcher.ExtClassLoader var1;
try {
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
而最终,给 ExtClassLoader 的 parent 传的参数是 null。可以先记住这个属性,下面在讲 ClassLoader 源码时会用到这个 parent 属性。
然后 Launcher 源码里面还有四个系统属性,值得我们运行一下看看,如下图
从上面的运行结果中,我们也可以轻易看到不同的类加载器,是从不同的路径下加载不同的资源。而即便我们只是写一个 Hello World,类加载器也会在后面默默给我们加载这么多类。
看完了 Launcher 类的代码,我们再来看 java.lang.ClassLoader 的代码,真正的双亲委派机制的源码是在这个类的 loaderClass 方法中。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查这个类是否已经被加载了,最终实现是一个 native 本地实现
Class<?> c = findLoadedClass(name);
// 如果还没有被加载,则开始架子啊
if (c == null) {
long t0 = System.nanoTime();
try {
// 首先如果父加载器不为空,则使用父类加载器加载。Launcher 类里提到的 parent 就在这里使用的。
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 如果父加载器为空(比如 ExtClassLoader),就使用 BootStrapClassloader 来加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
// 如果还没有找到,则使用 findClass 类来加载。也就是说如果我们自定义类加载器,就重写这个方法
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
这段代码还是比较清晰的,加载类的时候,首先判断类是不是已经被加载过了,如果没有被加载过,则看自己的父类加载器是不是为空。如果不为空,则使用父类加载器加载;如果父类加载器为空,则使用 BootStrapClassLoader 加载。
最后,如果还是没有加载到,则使用 findClass 来加载类。
类加载器的基本原理就分析到这里,下面我们再来分析一个 Java 中有趣的概念,SPI。
三、SPI 技术
1、什么是 SPI,为什么要有 SPI
SPI 全称(Service Provide Interface),在 JAVA 中是一个比较重要的概念,在框架设计中被广泛使用。
在框架设计中,要遵循的原则是对扩展开放,对修改关闭,保证框架实现对于使用者来说是黑盒。因为框架不可能做好所有的事情,只能把共性的部分抽离出来进行流程化,然后留下一些扩展点让使用者去实现,这样不同的扩展就不用修改源代码或者对框架进行定制。也就是我们经常说的面向接口编程。
我理解的 SPI 用更通俗的话来讲,就是一种可插拔技术。最容易理解的就是 USB,定义好 USB 的接口规范,不同的外设厂家根据 USB 的标准去制造自己的外设,如鼠标,键盘等。另外一个例子就是 JDBC,Java 定义好了 JDBC 的规范,不同的数据库厂商去实现这个规范。Java 并不会管某一个数据库是如何实现 JDBC 的接口的。
2、如何实现 SPI
这里我在 Github 上有一个工程,Flink-Practice,是公众号文章附带的代码,有需要可以下载:
实现 SPI 的话,要遵循下面的一些规范:
- 服务提供者提供了接口的具体实现后,需要在资源文件夹中创建 META-INF/services 文件夹,并且新建一个以全类名为名字的文本文件,文件内容为实现类的全名(如图中下面的红框);
- 接口实现类必须在工程的 classpath 下,也就是 maven 中需要加入依赖或者 jar 包引用到工程里(如图中的 serviceimpl 包,我就放在了当前工程下了,执行的时候,会把类编译成 class 文件放到当前工程的 classpath 下的);
- SPI 的实现类中,必须有一个不带参数的空构造方法
执行测试类之后输出如下:
可以看到,实现了提供方接口的类,都被执行了。
3、SPI 源码浅析
入口在 ServiceLoader.load 方法这里
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取当前线程的上下文类加载器。ContextClassLoader 是每个线程绑定的
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
首先需要知道,Thread.currentThread().getContextClassLoader(); 使用这个获取的类加载器是 AppClassLoader,因为我们的代码是在 main 函数执行的,而自定义的代码都是 AppClassLoader 加载的。
可以看到最终这个 classloader 是被传到这个地方
那么不传这个 loader 进来,就加载不到吗?答案是确实加载不到。
因为 ServiceLoader 是在 rt.jar 包中,而 rt.jar 包是 BootstrapClassLoader 加载的。而实现了接口提供者的接口的类,一般是第三方类,是在 classpath 下的,BootStrapClassLoader 能加载到 classpath 下的类吗?不能, AppClassLoader 才会去加载 classpath 的类。
所以,这里的上下文类加载器(ContextClassLoader ),它其实是破坏了双亲委派机制的,但是也为程序带来了巨大的灵活性和可扩展性。
其实 ServiceLoader 核心的逻辑就在这两个方法里
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
// 寻找 META-INF/services/类
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
// 解析这个类文件的所有内容
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 加载这个类
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
// 初始化这个类
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
}
寻找 META-INF/services/类,解析类的内容,构造 Class ,初始化,返回,就这么简单了。
4、SPI 的缺点以及 Dubbo 是如何重构 SPI 的
通过前面的分析,可以发现,JDK SPI 在查找扩展实现类的过程中,需要遍历 SPI 配置文件中定义的所有实现类,该过程中会将这些实现类全部实例化。如果 SPI 配置文件中定义了多个实现类,而我们只需要使用其中一个实现类时,就会生成不必要的对象。例如,在 Dubbo 中,org.apache.dubbo.rpc.Protocol 接口有 InjvmProtocol、DubboProtocol、RmiProtocol、HttpProtocol、HessianProtocol、ThriftProtocol 等多个实现,如果使用 JDK SPI,就会加载全部实现类,导致资源的浪费。
Dubbo SPI 不仅解决了上述资源浪费的问题,还对 SPI 配置文件扩展和修改。
首先,Dubbo 按照 SPI 配置文件的用途,将其分成了三类目录。
META-INF/services/ 目录:该目录下的 SPI 配置文件用来兼容 JDK SPI 。
META-INF/dubbo/ 目录:该目录用于存放用户自定义 SPI 配置文件。
META-INF/dubbo/internal/ 目录:该目录用于存放 Dubbo 内部使用的 SPI 配置文件。
然后,Dubbo 将 SPI 配置文件改成了 KV 格式,例如:
dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol
其中 key 被称为扩展名(也就是 ExtensionName),当我们在为一个接口查找具体实现类时,可以指定扩展名来选择相应的扩展实现。例如,这里指定扩展名为 dubbo,Dubbo SPI 就知道我们要使用:org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,只实例化这一个扩展实现即可,无须实例化 SPI 配置文件中的其他扩展实现类。
四、Flink 源码中使用到 SPI 和 Classloader 的地方
在 Flink 源码中,有很多这样的 SPI 扩展点
在 flink-clients 模块中
执行器工厂的接口,有本地执行器的实现和远程执行器工厂类的实现,这些都是通过 SPI 来实现的。
另外,在 Flink Clients 入口类 CliFronted 中,也使用了经典的 ContextClassLoader 用法,使用反射的方式来执行用户程序中编写的 main 方法
下一篇文章,我们来分析 Flink-Clients 的源码实现,敬请期待了