写点什么

不是单例的单例——巧用 ClassLoader

作者:PPPHUANG
  • 2023-05-15
    上海
  • 本文字数:7787 字

    阅读完需:约 26 分钟

不是单例的单例——巧用ClassLoader

本文通过如何将一个单例类实例化两次的案例,用代码实践来引入 Java 类加载器相关的概念与工作机制。理解并熟练掌握相关知识之后可以扩宽解决问题的思路,另辟蹊径,达到目的。

背景

单例模式是最常用的设计模式之一。其目的是保证一个类在进程中仅有一个实例,并提供一个它的全局访问方式。那什么场景下一个进程里需要单例类的两个对象呢?很明显这破坏了单例模式的设计初衷。


这里举例一个我司的特殊场景:


RPC 的调用规范是每个业务集群里只能有一个调用方,如果一个业务节点已经实例化了一个客户端,就无法再实例化另一个。这个规范的目的是让一个集群统一个调用方,方便服务数据的收集、展示、告警等操作。


一个项目有多个集群,多个项目组维护,各个集群都有一个共同特点,需要调用相同的 RPC 服务。如果严格按照上述 RPC 规范的话,每一个集群都需要申请一个自己调用方,每一个调用方都申请相同的 RPC 服务。这样做完全没有问题,只是相同的工作会被各个集群都做一遍,并且生成了多个 RPC 的调用方。


最终方案是将相同的逻辑代码打包成一个公用 jar 包,然后其他集群引入这个包就能解决我们上述的问题。这么做的话就碰到了 RPC 规范中的约束问题,jar 包里的公用逻辑会调用 RPC 服务,那么势必会有一个 RPC 的公用调用方。我们的业务代码里也会有自己业务需要调用的其他 RPC 服务,这个调用方和 jar 包里的调用方就冲突了,只能有一个调用方会被成功初始化,另一个则会报错。这个场景是不是就要实例化两个单例模式的对象呢。


有相关经验的读者可能会想到,能不能把各个集群中相同的工作抽取出来,做成一个类似网关的集群,然后各个集群再来调用这个公用集群,这样同一个工作也不会被做多遍,RPC 的调用方也被整合成了一个。这个方案也是很好的,考虑到一些客观因素,最终并没有选择这种方式。

实例化两个单例类

我们假设下述单例类代码是 RPC 的调用 Client:


public class RPCClient {    private static BaseClient baseClient;    private volatile static RPCClient instance;      static {        baseClient = BaseClient.getBaseClient();    }      private RPCClient() {       System.out.println("构造 Client");    }    public String callRpc() {        return "callRpc success";    }    public static RPCClient getClient() {        if (instance == null) {            synchronized (RPCClient.class) {                if (instance == null) {                    instance = new RPCClient();                }            }        }        return instance;    }}
复制代码


public class BaseClient {  ...  private BaseClient() {      System.out.println("构造 BaseClient");  }  ...}
复制代码


这个单例 Client 有一点点不同,就是有一个静态属性 baseClient,BaseClient 也是一个简单的单例类,构造方法里有一些打印操作,方便后续观察。baseClient 属性通过静态代码块来赋值。


我们可以想一想,有什么办法可以将这个单例的 Client 类实例化两个对象出来?

无所不能的反射大法

最容易想到的就是利用反射获取构造方法,来规避单例类私有化构造方法的约束来实例化:


Constructor<?> declaredConstructor = RPCClient.class.getDeclaredConstructor();declaredConstructor.setAccessible(true);Object rpcClient = declaredConstructor.newInstance();Method sayHi = rpcClient.getClass().getMethod("callRpc");Object invoke = sayHi.invoke(rpcClient);//执行输出//构造 Client//callBaseRpc successcallRpc success
复制代码


上述代码通过反射来获取私有化的构造方法,然后通过这个构造方法来实例化对象。这样确实能生成单例 RPCClient 的第二个对象。观察代码执行的输出能发现,通过反射生成的这个对象 rpcClient 确实是一个新对象,因为输出里有 RPCClient 构造方法的打印输出。但是并没有打印 BaseClient 这个对象的构造方法里的输出。rpcClient 这个对象里的 baseClient 永远都是只用一个,因为 baseClient 在静态代码块里赋值的,并且 BaseClient 又是一个单例类。这样,我们反射生成的对象与非反射生成的对象就不是完全隔离的。


上述的简单 Demo 里,使用反射好像都不太能够生成两个完全隔离的单例客户端。一个复杂的 RPC Client 类可远没有这么简单,Client 类里还有很多依赖的类,依赖的类里也会依赖其他类,其中不乏各种单例类。通过反射的方法好像行不太通。那还有什么方法能达到目的呢?

自定义类加载器

另一个方法是用一个自定义的类加载器来加载 RPCClient 类并实例化。业务代码默认使用的是 AppClassLoader 类加载器,这个类加载器来加载 RPCClient 类并实例化第一个 Client 对象,我们自定义的类加载器会加载并实例化第二个 Client 对象。那么在一个 JVM 进程里就存在了两个 RPCClient 对象了。这两个对象会不会存在上述反射中没有完全隔离的问题呢?


答案是不会。类加载是有传递性的,当一个类被加载时,这个类依赖的类如果需要加载,使用的类加载器就是当前类的类加载器。我们使用自定义类加载器加载 RPCClient 时,RPCClient 依赖的类也会被自定义加载器加载。这样依赖类也会被完全隔离,也就没有在上述反射中存在的 baseClient 属性还是同一个对象的情况。


自定义类加载器代码如下:


public class MyClassLoader extends ClassLoader{    @Override    public Class<?> loadClass(String name) {      //通过 findLoadedClass 判断是否已经被加载 (下文会补充)      Class<?> loadedClass = findLoadedClass(name);      //如果已加载返回已加载的类      if (loadedClass != null) {          return loadedClass;      }      //通过类名获取类文件      String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";      InputStream resourceAsStream = getClass().getResourceAsStream(fileName);      //如果查找不到文件 则委托父类加载器实现 这里的父加载器就是 AppClassLoader       if (resourceAsStream == null) {          return super.loadClass(name);      }      //读取文件 并加载类      byte[] bytes = new byte[resourceAsStream.available()];      resourceAsStream.read(bytes);      return defineClass(name, bytes, 0, bytes.length);   }}
复制代码


测试代码如下:


//实例化自定义类加载器MyClassLoader myClassLoader = new MyClassLoader();//获取当前线程的 ContextClassLoader 备用ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();//设置当前线程的 ContextClassLoader 为实例化的自定义类加载器(这么做的原因下文会补充)Thread.currentThread().setContextClassLoader(myClassLoader);//通过自定义类加载器加载 RPCClientClass<?> rpcClientCls = myClassLoader.loadClass("com.ppphuang.demo.classloader.single.RPCClient");//将当前线程的 ContextClassLoader 还原为初始的 contextClassLoaderThread.currentThread().setContextClassLoader(contextClassLoader);//通过反射获取该类的 getClient 方法Method getInstance = rpcClientCls.getMethod("getClient");getInstance.setAccessible(true);//调用 getClient 方法获取单例对象Object rpcClient = getInstance.invoke(rpcClientCls);//获取 callRpc 方法Method callRpc = rpcClientCls.getMethod("callRpc");//调用 callRpc 方法Object callRpcMsg = callRpc.invoke(rpcClient);System.out.println(callRpcMsg);//执行输出//构造 BaseClient//构造 Client//callBaseRpc successcallRpc success
复制代码


通过测试代码的输出可以看到,RPCClient BaseClient 这两个类构造方法里的打印都输出了,那就说明通过自定义类加载器实例化的两个对象都执行了构造方法。自然就跟直接调用 RPCClient.getClient() 生成的对象是完全隔离开的。


你可以通过代码注释,来理解一下测试代码的执行过程。


如果看到这里你还有一些疑问的话,我们再巩固一下类加载器相关的知识。

类与类加载器

默认类加载

在 Java 中有三个默认的类加载器:

BootstrapClassLoader

加载 Java 核心库(JAVA_HOME/jre/lib/rt.jar 或 sun.boot.class.path 路径下的内容)。用于提供 JVM 自身需要的类。由 C++ 加载,用如下代码去获取的话会显示为 null:


System.out.println(String.class.getClassLoader());
复制代码
ExtClassLoader

Java 语言编写,从 java.ext.dirs 系统属性所指定的目录中加载类,或从 JDK 的安装目录 jre/lib/ext 子目录下加载类。如果用户创建 的 jar 放在此目录下,也会自动由 ExtClassLoader 加载。


System.out.println(com.sun.crypto.provider.DESedeKeyFactory.class.getClassLoader());
复制代码
AppClassLoader

它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类,应用程序中默认是系统类加载器。


System.out.println(ClassLoader.getSystemClassLoader());
复制代码


如果我们没有特殊指定类加载器的话,JVM 进程中所有需要的类都会由上述三个类加载来完成加载。


每个 Class 对象的内部都有一个 classLoader 字段来标识自己是由哪个 ClassLoader 加载的:


class Class<T> {  private final ClassLoader classLoader;}
复制代码


你可以这样来获取某个类的 ClassLoader:


System.out.println(obj.getClass().getClassLoader());
复制代码

不同类加载器的影响

两个类相同的前提是类的加载器也相同,不同类加载器加载同一个 Class 也是不一样的 Class,会影响 equals、instanceof 的运算结果。


下面的代码展示了不同类加载器对类判等的影响,为了减少代码篇幅,代码省略了异常处理:


public class ClassLoaderTest {    public static void main(String[] args) {        ClassLoader myClassLoader = new ClassLoader() {            @Override            public Class<?> loadClass(String name) {                Class<?> loadedClass = findLoadedClass(name);                if (loadedClass != null) return loadedClass;                String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";                InputStream resourceAsStream = getClass().getResourceAsStream(fileName);                if (resourceAsStream == null) {                    return super.loadClass(name);                }                byte[] bytes = new byte[resourceAsStream.available()];                resourceAsStream.read(bytes);                return defineClass(name, bytes, 0, bytes.length);            }        };        Object obj = myClassLoader.loadClass("ClassLoaderTest").newInstance();        System.out.println(obj.getClass().getClassLoader());        System.out.println(com.ppphuang.demo.classloader.ClassLoaderTest.class.getClassLoader());        System.out.println(obj instanceof ClassLoaderTest);    }}//输出如下://com.ppphuang.demo.classloader.ClassLoaderTest$1@7a07c5b4//sun.misc.Launcher$AppClassLoader@18b4aac2//false
复制代码


上述代码自定义了一个类加载器 myClassLoader,用 myClassLoader 加载的 ClassLoaderTest 类实例化出的对象与 AppClassLoader 加载的 ClassLoaderTest 类做 instanceof 运算,最终输出的接口是 false。由此可以判断出不同加载器加载同一个类,这两个类也是不相同的。


因为不同类加载器的加载的类是不同的,所以我们可以在一个 JVM 里通过自定义类加载器来将一个单例类实例化两次。

ClassLoader 传递性

程序在运行过程中,遇到了一个未知的类,它会选择哪个 ClassLoader 来加载它呢?


虚拟机的策略是使用调用者 Class 对象的 ClassLoader 来加载当前未知的类。就是在遇到这个未知的类时,虚拟机肯定正在运行一个方法调用(静态方法或者实例方法),这个方法写在哪个类,那这个类就是调用者 Class 对象。前面我们提到每个 Class 对象里面都有一个 classLoader 属性记录了当前的类是由谁来加载的。


因为 ClassLoader 的传递性,所有延迟加载的类都会由初始调用 main 方法的这个 ClassLoader 全权负责,它就是 AppClassLoader。


ClassLoaderTest classLoaderTest = new ClassLoaderTest();System.out.println(classLoaderTest.getClass().getClassLoader());//sun.misc.Launcher$AppClassLoader@18b4aac2
复制代码


如果我们使用一个自定义类加载器加载一个类,那么这个类里依赖的类也会由这个类加载来负责加载:


Object obj = myClassLoader.loadClass("com.ppphuang.demo.classloader.ClassLoaderTest").newInstance();
复制代码


因为类加载器的传递性,依赖类的加载器也会使用当前类的加载器,当我们利用自定义类加载器来将一个单例类实例化两次的时候,能保证两个单例对象是完全隔离。

双亲委派模型

当一个类加载器需要加载一个类时,自己并不会立即去加载,而是首先委派给父类加载器去加载,父类加载器加载不了再给父类的父类去加载,一层一层向上委托,直到顶层加载器(BootstrapClassLoader),如果父类加载器无法加载那么类加器才会自己去加载。

findLoadedClass

当一个类被父加载器加载了,子加载器再次加载这个类的时候,还需要向父加载器委托吗?


我们先把问题细化一下:


  1. AClassLoader 的父加载器为 BClassLoader,BClassLoader 的父加载器为 CClassLoader,当 AClassLoader 调用 loadClass() 加载类,并最终由 CClassLoader 加载的类,到底算谁加载的?

  2. 后续 AClassLoader 再加载相同类时,是否能直接从 AClassLoader 的 findLoadedClass0() 中找到该类并返回,还是说再走一次双亲委派最终从 CClassLoader 的 findLoadedClass0() 中找到该类并返回?


JVM 里有一个数据结构叫做 SystemDictonary,这个结构主要就是用来检索我们常说的类信息,其实也就是 private native final Class<?> findLoadedClass0(String name) 方法的逻辑。


这些类信息对应的结构是 klass,对 SystemDictonary 的理解,可以理解为一个哈希表,key 是类加载器对象 + 类的名字,value 是指向 klass 的地址。当我们任意一个类加载器去正常加载类的时候,就会到这个 SystemDictonary 中去查找,看是否有这么一个 klass 可以返回,如果有就返回它,否则就会去创建一个新的并放到结构里。


这里面还涉及两个小概念,初始类加载器、定义类加载器。


上述类加载问题中,AClassLoader 加载类的时候会委托给 BClassLoader 来加载,BClassLoader 加载类的时候会委托给 CClassLoader 来加载,当 AClassLoader 调用 loadClass() 加载类,并最终由 CClassLoader 加载,那么我们称 CClassLoader 为该类的定义类加载器,AClassLoader 和 BClassLoader 为该类的初始类加载器。在这个过程中,AClassLoader、BClassLoader 和 CClassLoader 都会在 SystemDictonary 生成记录。那么后续 C 的子加载器(AClassLoader 和 BClassLoader)加载相同类时,就能在自己 findLoadedClass0() 中找到该类,不必再向上委托。

双亲委派的目的

  1. 防止重复加载类。在 JVM 中,要唯一确定一个对象,是由类加载器和全类名两者共同确定的,考虑到各层级的类加载器之间仍然由重叠的类资源加载区域,通过向上抛的方式可以避免一个类被多个不同的类加载器加载,从而形成重复加载。

  2. 防止系统 API 被篡改。例如读者定义了一个名为 java.lang.Integer 的类,而该类在核心库中也存在,借用双亲委派的机制,我们就能有效防止该自定义的同名类被加载,从而保护了平台的安全性。


JDK 1.2 之后引入双亲委派的方式来实现类加载器的层次调用,以尽可能保证 JDK 的系统 API 不会被用户定义的类加载器所破坏,但一些使用场景会打破这个惯例来实现必要的功能。

破坏双亲委派模型

Thread Context ClassLoader

在介绍破坏双亲委派模型之前,我们先了解一下 Thread Context ClassLoader(线程上下文类加载器)。


JVM 中经常需要调用由其他厂商实现并部署在应用程序的 ClassPath 下的 JNDI 服务提供者接口 (Servicepovider iotertace, SPD) 的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?为了解决这个困境,Java 的设计团队只好引入了一个不太优雅的设计:线程上下文类加裁器 ( Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是 AppClassLoader。有了线程上下文类加载器,程序就可以做一些 “舞弊”的事情了。JNDI 服务使用这个线程上下文类加载器去加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java 中涉及 SPI 的加载基本上都采用这种方式来完成的。


可以通过如下的代码来获取当前线程的 ContextClassLoader :


ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
复制代码


我们在前面测试代码中将 Thread Context ClassLoader 也设置为自定义加载器,目的是避免自定义加载器加载的类里面使用了 Thread Context ClassLoader(默认是 AppClassLoader),导致对象没有完全完全隔离,这也是自定义加载器的常用原则之一。在自定义加载器加载完成之后也要将 Thread Context ClassLoader 复原:


//实例化自定义类加载器MyClassLoader myClassLoader = new MyClassLoader();//获取当前线程的 ContextClassLoader 备用ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();//设置当前线程的 ContextClassLoader 为实例化的自定义类加载器(这么做的原因下文会补充)Thread.currentThread().setContextClassLoader(myClassLoader);//通过自定义类加载器加载 RPCClientClass<?> rpcClientCls = myClassLoader.loadClass("com.ppphuang.demo.classloader.single.RPCClient");//将当前线程的 ContextClassLoader 还原为初始的 contextClassLoaderThread.currentThread().setContextClassLoader(contextClassLoader);
复制代码

Tomcat 类加载模型

提到破坏双亲委派模型就必须要提到 Tomcat,部署在一个 Tomcat 中的每个应用程序都会有一个独一无二的 webapp classloader,他们互相隔离不受彼此的影响。除了互相隔离的类加载器,Tomcat 中还有共享的类加载器,大家可以去查看一下相关的文档,还是很值得我们借鉴学习的。


看到这里再回头来理解上文自定义类加载器实例化单例类的代码,应该就很好理解了。

总结

本文通过如何将一个单例类实例化两次的案例,用代码实践来引入 Java 类加载器相关的概念与工作机制。理解并熟练掌握相关知识之后可以扩宽解决问题的思路,另辟蹊径,达到目的。

参考

https://blog.csdn.net/qq_43369986/article/details/117048340


https://blog.csdn.net/qq_40378034/article/details/119973663


https://blog.csdn.net/J080624/article/details/84835493


公众号:DailyHappy 一位后端写码师,一位黑暗料理制造者。

发布于: 2023-05-15阅读数: 34
用户头像

PPPHUANG

关注

普通的改变,将改变普通。 2018-11-15 加入

一位后端写码师,一位黑暗料理制造者。

评论

发布
暂无评论
不是单例的单例——巧用ClassLoader_单例模式_PPPHUANG_InfoQ写作社区