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

这篇具有很好参考价值的文章主要介绍了不是单例的单例——巧用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);
//通过自定义类加载器加载 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 一位后端写码师,一位黑暗料理制造者。文章来源地址https://www.toymoban.com/news/detail-445234.html

到了这里,关于不是单例的单例——巧用ClassLoader的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 【设计模式】单例模式、“多例模式”的实现以及对单例的一些思考

    单例模式是设计模式中最简单的一种,对于很多人来说,单例模式也是其接触的第一种设计模式,当然,我也不例外。这种设计模式在学习、面试、工作的过程中广泛传播,相信不少人在面试时遇到过这样的问题:“说说你最熟悉的集中设计模式”,第一个脱口而出的就是单

    2024年02月07日
    浏览(50)
  • C++的单例模式

    忘记之前有没有写过单例模式了。 再记录一下: 我使用的代码: 双锁单例: 单例模式的不同实现方式各有优缺点 双检锁(Double Checked Locking): 优点: 线程安全。 在实例已经被创建之后,直接返回实例,避免了每次获取实例时都需要获取锁的开销。 缺点: 代码相对复杂

    2024年02月10日
    浏览(46)
  • 委托的单例模式

     在项目中我们经常会使用到委托,委托是多播的,如果控制不好反复注册就会多次触发,可以使用委托的单例模式去注册,这样可以避免多次触发问题。 下面是几种委托实例代码: 带参数委托管理: 调用方法: ActionManager参数.Removal(\\\" 注册Key \\\"); ActionManager 参数 .Register(\\\" 注册

    2024年02月08日
    浏览(54)
  • Swift中的单例

    在Swift中实现单例模式可以通过使用静态常量或静态变量来实现。下面是一个示例: 在上面的示例中,通过使用 static let 来创建一个静态常量 sharedInstance ,它被赋值为一个 Singleton 类的实例。这个静态常量可以保证在整个应用程序中只有一个唯一的实例。 通过将初始化方法

    2024年04月28日
    浏览(37)
  • 设计模式——C++11实现单例模式(饿汉模式、懒汉模式),与单例的进程

    本文将介绍单例模式,使用C++11实现多个版本的单例模式,分析各自的优缺点。最后提及如何实现一个单例的进程。 单例模式属于创建型模式,提供了一种创建对象的方式。 单例模式确保一个类只有一个实例。通过一个类统一地访问这个实例。 思想:将构造函数设置为私有

    2024年02月09日
    浏览(50)
  • 如何构造一个安全的单例?

    我们知道,单例是一种很常用的设计模式,主要作用就是节省系统资源,让对象在服务器中只有一份。但是实际开发中可能有很多人压根没有写过单例这种模式,只是看过或者为了面试去写写demo熟悉一下。那为啥说是一种常用的模式? 其实我们用的spring管理对象生命周期,

    2024年02月13日
    浏览(41)
  • 【单例模式】饿汉模式和懒汉模式的单例模式

    设计模式是一种 在软件设计中经过验证的解决问题的方案或者模版 。它们是从实践中总结出来的,可以帮助解决常见的设计问题,提高代码的重用性、维护性和扩展性。 设计模式可以分为三大类: 创建型模式(Creational Patterns) :创建型模式关注对象的实例化过程,包括如

    2024年02月16日
    浏览(68)
  • 面向对象中的单例模式

    设计模式就是前人根据实际的问题提出的问题解决方案,我们把这种就称之为设计模式。 单例模式是一种常见的设计模式! 所谓的设计模式,不是一种新的语法,而是人们在实际的应用中,面对某种特定的情形而设计出来的某种常见的有效的解决方案,所以,设计模式只是

    2024年02月05日
    浏览(47)
  • 【多线程】线程安全的单例模式

    单例模式能保证某个类在程序中只存在 唯一 一份实例, 而不会创建出多个实例,从而节约了资源并实现数据共享。 比如 JDBC 中的 DataSource 实例就只需要一个. 单例模式具体的实现方式, 分成 “饿汉” 和 “懒汉” 两种. 类加载的同时, 创建实例. 注意: 使用 static 修饰 instanc

    2024年02月09日
    浏览(46)
  • C++ 中的单例模式singleton

    在面向对象编程中,设计模式是解决常见问题的最佳实践。单例模式是其中之一,它确保一个类只有一个实例,并提供一个全局访问点来获取该实例。在本文中,我们将详细介绍 C++ 中的单例模式。 单例模式是一种设计模式,它限制一个类只能创建一个对象。这个模式通常用

    2024年02月21日
    浏览(44)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包