关于java:不是单例的单例巧用ClassLoader

40次阅读

共计 10118 个字符,预计需要花费 26 分钟才能阅读完成。

本文通过如何将一个单例类实例化两次的案例,用代码实际来引入 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);
// 通过自定义类加载器加载 RPCClient
Class<?> rpcClientCls = myClassLoader.loadClass("com.ppphuang.demo.classloader.single.RPCClient");
// 将以后线程的 ContextClassLoader 还原为初始的 contextClassLoader
Thread.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);
// 通过自定义类加载器加载 RPCClient
Class<?> rpcClientCls = myClassLoader.loadClass("com.ppphuang.demo.classloader.single.RPCClient");
// 将以后线程的 ContextClassLoader 还原为初始的 contextClassLoader
Thread.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 一位后端写码师,一位光明操持制造者。

正文完
 0