JVM第八卷---类加载与执行子系统的案例与实战

x33g5p2x  于2022-02-28 转载在 Java  
字(16.4k)|赞(0)|评价(0)|浏览(201)

类加载器

JVM第六卷—类加载机制中已经讲述了类加载器的相关知识,这里简单回顾一些重点

Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名来获取描述类的二进制字节流",这个动作放到Java虚拟机外部实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码被称为"类加载器"

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下,才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。

这里的相等,包括Class对象的equals方法,isAssignableFrom方法,isInstance方法的返回结果,也包括了使用instanceOf关键字做对象所属关系判定等各种情况。

服务器困境

主流的服务器一般都需要实现自定义的类加载器,而且一般还不止一个,因为一个功能健全的服务器,需要解决如下问题:

  • 部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离。两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只能有一份。服务器应当保证两个应用程序的类库可以互相独立使用。
  • 部署在同一台服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互共享。怎么刚说完隔离又说共享呢,其实这个也很常见。比如用户可能有 10 个使用 Spring 架构的应用程序部署在同一个服务器上,那么在内存中加载 10 份Spring 类库进去显然是不理智不优雅的,虚拟机的方法区会很容易出现过度膨胀的风险。
  • 服务器需要尽量保证自身安全不受部署的 Web 引用程序影响。这其实很好理解,不能说我服务器用的类库和应用的类库混为一谈,部署个应用把我服务器部署崩了,这也是不合适的。
  • 支持JSP应用的服务器,都需要支持HOTSWAP功能。HOTSWAP技术是通过类加载器实现的,后文会讲到。

由于存在这些问题,在部署 Web 应用时,单独的一个 ClassPath 就无法满足要求了,所以各种 Web 服务器都 “不约而同” 的提供了好几个 ClassPath 路径供用户存放第三方类库,这些路径一般都以 lib 或 classes 命名。每个路径的类库具备不同的访问范围和服务对象,话不多说我们来看 Tomcat 是怎么规划用户类库结构和类加载器的。

Tomcat: 正统的类加载架构

在 Tomcat 目录结构中,有 3 组目录(“/common/”、“/server/”、“/shared/”)可以存放 Java 类库,另外还可以加上 Web 应用程序自身的目录 “/WEB-INF/”,一共 4 组,把 Java 类库放在这些目录中的含义是:

但默认不一定是开放的,可能只有/lib/目录存在,用于存放java类库,另外还需要加上应用程序自身的"/WEB-INF/"目录,一共四组。

把java类库放置在这四组类库中,每一组都有其特殊含义:

  • 放置在/commons目录中,类库可被Tomcat和所有的Web应用程序共同使用
  • 放置在/server目录中,类库可被Tomcat使用,对所有Web应用程序不可见
  • 放置在/shared目录中,类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见
  • 放置在/WebApp/WEB-INF目录中,类库仅对该Web应用程序使用,对Tomcat和其他Web应用程序都不可见。

为了支持这套目录结构并对目录里面的类库进行加载和隔离,Tomcat 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,如图:

CommonClassloader 用来加载 /common 目录,CatalinaClassLoader 负责加载 /server 目录,SharedClassLoader 负责加载 /shared 目录,WebAPPClassLoader 则是负责 /WebApp/WEB-INF 目录中的 Java 类库。

WebApp类加载器和JSP类加载器通过会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个JasperLoader类加载器。

Commons类加载器加载的类都可以被Catalina类加载器和Shared类加载器使用,而Catalina类加载器和Shared类加载器自己可以加载的类与对方相互隔离。

WebAPP类加载器可以使用Shared类加载器加载到的类,但各个WebAPP类加载器实例之间相互隔离。

而JasperLoader的加载范围仅仅只是这个JSP文件被修改的时候,会替换掉目前的JasperLoader实例,并通过再建立一个新的JSP类加载器来实现JSP文件的HotSwap功能。

Tomcat 如果使用默认的类加载机制行不行?

答案是不行的。

为什么?

我们看,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的累加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。

我们想我们要怎么实现jsp文件的热修改,jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?

我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

Tomcat 6以后简化了版本的默认目录结构,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loade项后才会真正建立Catalina类加载器和Shared类加载器的实例,否则用到这两个类加载器的地方都会使用Common类加载器替换,而默认的配置文件中没有这两个loader项。

Tomcat 6之后,将/common,/server,/shared这3个目录默认合并到一起编程一个/lib目录,这个目录里面的类库相当于以前/common目录中类库的作用。

思考:
如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把 Spring 放到 Common 或 Shared 目录下让这些程序共享。Spring 要对 用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在 /WebApp/WEB-INF 目录中的,那么被 CommonClassLoader 或 SharedClassLoader 加载的 Spring 如何访问并不在其加载范围内的用户程序呢?

答案:

查看Spring源码发现,spring加载类所用的classloader都是通过Thread.currentThread().getContextClassLoader()来获取的,而当线程创建时会默认 setContextClassLoader(AppClassLoader),即spring中始终可以获取到这个AppClassLoader(在tomcat里就是WebAppClassLoader)子类加载器来加载bean。因此,以后任何一个线程都可以通过getContextClassLoader()获取到WebAppClassLoader来getbean了。

大白话:

spring无法加载用户的类是因为spring的ApplicationClassLoader只能加载当前类路径下的类,所以采用线程上下文类加载器设置Tomcat的webApplicationClassLoader来加载当前web应用程序下的类
线程上下文类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器就默认是应用程序类加载器。
也就相当于父类加载器请求子类加载器去完成类加载的动作。

OSGi:灵活的类加载器结构

Java 程序社区中流传着这么一个观点:“学习 JEE 规范,去看 JBoss 源码;学习类加载器,就去看 OSGi 源码”。OSGi(Open Service Gateway Initiative)是 OSGi 联盟(OSGi Alliance)指定的一个基于 Java 语言的动态模块化规范,这个规范最初由 Sun、IBM、爱立信等公司联合发起,目的是是服务提供商通过住宅网管为各种家用智能设备提供各种服务后来这个规范在 Java 的其他技术领域也有相当不错的发展,现在已经称为 Java 世界中 “事实上” 的模块化标准,并且已经有了 Equinox、Felix 等成熟的实现。OSGi 在 Java 程序员中最著名的应用案例就是 Eclipse IDE,另外还有许多大型的软件平台和中间件服务器都给予或生命将会基于 OSGi 规范来实现,如 IBM Jazz 平台、GlassFish 服务器、jBoss OSGi 等。

OSGi 中的每个模块被称为 Bundle ,模块其实与普通的 Java 类库差不多,都是以 JAR 格式的封装,内部存储 Java Package 和 Class 。但是一个 Bundle 可以声明他所依赖的 Java Package(通过 Import-Package 描述),也可以声明它允许导出发布的 Java Package(Export-Package)。对于类库的可见性能够得到非常精确的控制,一个 Bundle 中只有被 Export 的 Package 才可能由外界访问。引入 OSGi 的另一个重要理由是,基于 OSGi 的程序很可能(只是很可能,而不是一定会)可以实现模块级的热插拔功能。

OSGi 之所以能够有以上诱人的特点,要归功于它灵活的类加载器架构。OSGi 的 Bundle 类加载器之间只有规则,没有固定的委派关系。假如 Bundle A 声明它依赖 Package B,而 Bundle B 声明了它来发布 Package B,那么所有对于 Package B 的类加载动作都会委派给发布它的 Bundle B 类加载器去完成。不涉及某个具体的 Package 时,各个 Bundle 加载器都是平级关系。

另外,一个 Bundle 类加载器为其他 Bundle 提供服务时,会根据 Export-Package 列表严格控制访问范围。如果一个类存在 Bundle 的类库中,但是没有被 Export ,那么这个 Bundle 的类加载器可以找到这个类,但是不会提供给其他的 Bundle 使用,且 OSGi 平台也不会把其他 Bundle 针对这个类的类加载请求分配给这个 Bundle 来处理。

举个更简单的例子,假设存在 Bundle A、 Bundle B、 Bundle C 三个模块,并且这三个 Bundle 定义的依赖关系如下:

  • Bundle A:声明发布了 Package A,依赖了 java.* 的包。
  • Bundle B:声明依赖了 Package A 和 Package C ,同时也依赖了 java.* 的包。
  • Bundle C:声明发布了 Package C,依赖了 Package A。

那么三个 Bundle 之间的关系如图所示:

类加载时可能进行的查找规则如下:

  • 以 java.* 开头的类,委派给父类加载器加载。
  • 否则,委派列表名单内的类,委派给父类加载器加载。
  • 否则,Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。
  • 否则,查找当前 Bundle 的 Classpath,使用自己的类加载器加载。
  • 否则,查找是否在自己的 FragmentBundle 中,如果是,则委派给 Fragment Bundle 的类加载器加载。
  • 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。
  • 否则,类查找失败。

由图所示,加载器的关系已经不是双亲委派模型的树形结构了,而是已经进一步发展成了一种更复杂的运行时才能确定的网状结构。注意这种模式会发生的死锁情况:Bundle A 依赖 Bundle B 的 Package B,而 Bundle B 却依赖 Bundle A 的 Package A,这时两个 Bundle 进行类加载的时候,就很容易发生死锁。

当Bundle A加载Package B的类时,首先需要锁定当前类加载器实例对象(ClassLoader.loadClass方法是一个同步方法),然后把请求委托给Bundle B的加载器去处理

字节码生成技术与动态代理的实现

提到字节码生成,脑子里面第一个想到的就是动态代理。不管是 Java 自带的还是 CGLib 的,底层所用的都是字节码生成技术。书中以 Java 自带的动态代理技术讲解,源码为:

public class DynamicProxyTest {
    interface IHello{
        void sayHello();
    }

    static class Hello implements IHello {
        @Override
        public void sayHello() {
            System.out.println("Hello world");
        }
    }

    static class DynamicProxy implements InvocationHandler {

        Object originalObj;

        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println("Welcome");
            return method.invoke(originalObj, args);

        }
    }

    public static void main(String[] args) {
        System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
        DynamicProxy dynamicProxy = new DynamicProxy();
        IHello bind = (IHello) dynamicProxy.bind(new Hello());
        bind.sayHello();
    }

}

加入 System.getProperties().put(“sun.misc.ProxyGenerator.saveGeneratedFiles”, “true”); 这一句后,在项目的根目录会生成一个 $Proxy0.class 文件,作为代理类的文件。反编译后会发现如下代码:

final class $Proxy0 extends Proxy implements IHello {
    private static Method m1;
    private static Method m3;
    private static Method m2;
    private static Method m0;

    public $Proxy0(InvocationHandler var1) throws  {
        super(var1);
    }

    public final boolean equals(Object var1) throws  {
        try {
            return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }

    public final void sayHello() throws  {
        try {
            super.h.invoke(this, m3, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final String toString() throws  {
        try {
            return (String)super.h.invoke(this, m2, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final int hashCode() throws  {
        try {
            return (Integer)super.h.invoke(this, m0, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
            m3 = Class.forName("com.simon.proxytest.DynamicProxyTest$IHello").getMethod("sayHello");
            m2 = Class.forName("java.lang.Object").getMethod("toString");
            m0 = Class.forName("java.lang.Object").getMethod("hashCode");
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }
}

这里大家会发现,当我们的方法 sayHello() 即 m3 执行的时候,实际上调用的是 h.invoke,这个 h 是什么呢,就是我们源码中传入的 this 即 DynamicProxy 的实例。 DynamicProxy 重写了 invoke 方法,也就是说最终执行的使我们自己写的方法。这个例子之中并没有讲到 generateproxyClass()(这个方法是在 Proxy.newProxyInstance() 方法中调用的,有兴趣的小伙伴自行去找哦,我找了老半天呢。。) 方法具体是如何产生代理类 $Proxy0.class 的,大致的生成过程就是根据 Class 文件的格式规范去拼装字节码,但在实际开发中这样以 byte 为单位直接拼装字节码的应用场合非常少,这种生成方式也只能产生一些高度模板化的代码。如果有大量操作字节码的需求,还是使用封装好的字节码类库比较合适,如 CGLib 等。

实战:自己动手实现远程执行功能

目标

本次需求为 “在服务端执行临时代码” ,具体目标如下:

  • 不依赖 JDK 版本,能在目前还普遍使用的 JDK 中部署,也就是使用 JDK 1.4 ~ JDK 1.8 都可以运行。
  • 不改变原有服务端程序的部署,不依赖任何第三方类库
  • 不侵入原有程序,无需改动源程序的任何代码也不会对原有程序的运行带来任何的影响
  • 临时代码需要直接支持 Java 语言
  • 临时代码应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。
  • 临时代码的执行结果能返回到客户端,执行结果可以包括程序中输出的信息及抛出的异常等。

思路

为实现以上程序,我们要解决三个问题:

  • 如何编译提交到服务器的 Java 代码
  • 如何执行编译后的 Java 代码
  • 如何收集 Java 代码的执行结果

我的想法是:

  • 如何编译提交到服务器的 Java 代码:直接编译好再扔上去,不信任服务器上的虚拟机版本
  • 如何执行编译后的 Java 代码:直接 main() 方法执行
  • 如何收集 Java 代码的执行结果:每个线程收集到固定的文件,以时间为分隔符每 10M 一个文件,统一观察观测。

实现

首先我们需要使用到服务器上的类,所以我们要定义一个自己的类加载器。HotSwapClassLoader 的作用仅仅是公开父类中的 protected 方法 defineClass(),因为默认使用的是父类的类加载器,所以我们除了外部手工调用 loadByte 方法,剩余的类加载器查找范围是跟他的父类加载器完全一致的。在被虚拟机调用时,它会按照双亲委派模型交给父类加载器。这一步是实现提交的执行代码可以访问服务端引用类库的关键

/**
 * 为了多次载入执行类而加入的加载器 <br>
 * 把 defineClass 方法开放出来,只有外部显示调用的时候才会使用到 loadByte 方法
 * 由虚拟机调用时,仍然按照原有的双亲委派规则使用 loadClass 方法进行类加载
 */
public class HotSwapClassLoader extends ClassLoader {

    public HotSwapClassLoader() {
        // 默认使用父类的类加载器
        super(HotSwapClassLoader.class.getClassLoader());
    }

    public Class loadByte(byte[] classByte) {
        return defineClass(null, classByte, 0, classByte.length);
    }

}

下一步我们想如何执行这个方法呢,利用反射是个不错的方法。于是有了下面的代码:

public class JavaClassExecutor {
    /**
     * 执行外部传过来的代表一个 Java 类的 byte 数组和 main 参数。
     * 
     * @param classByte
     */
    public static void execute(byte[] classByte, String[] args) {
        HotSwapClassLoader hotSwapClassLoader = new HotSwapClassLoader();
        Class aClass = hotSwapClassLoader.loadByte(classByte);
        try {
            Method main = aClass.getMethod("main", args.getClass());
            main.invoke(null, args);
        } catch (Throwable e) {
            e.printStackTrace();
        }

    }
}

但是光这样不够啊,我们只是执行了,报错和执行方法中打印的结果我们都没收到,那怎么办。不如我们劫持 System 类,把 System 类中的 out 和 err 覆盖掉,让他直接输出到我们想要的地方。于是我们有了如下的类

public class HackSystem {
	// 与书上的不同,我这里没有选择全部重写 System 类
	// 而是只覆盖了 out
    public static PrintStream out;

    static {
        try {
            out = new PrintStream(new File("D:\\00-WorkSpace\\99-OwnProject\\JavaBasicTest\\out.txt"));
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
    }
}

ok,这里我们把相应的劫持类写好了,怎么替换进去呢。那就把字节码解读开,将里面的 符号引用 一个个替换掉吧。在此之前,先写一个工具类,于是有如下类:

public class ByteUtils {
    public static int bytes2Int(byte[] b, int start, int len) {
        int sum = 0;
        int end = start + len;

        for (int i = start; i < end; i++) {
            int n = ((int) b[i]) & 0xff;
            n <<= (--len) * 8;
            sum = n + sum;
        }
        return sum;
    }

    public static byte[] int2Bytes(int value, int len) {
        byte[] b = new byte[len];
        for (int i = 0; i < len; i++) {
            b[len - i - 1] = (byte) ((value >> 8 * i) & 0xff);
        }
        return b;
    }

    public static String bytes2String(byte[] b, int start, int len) {
        return new String(b, start, len);
    }

    public static byte[] string2Bytes(String str) {
        return str.getBytes();
    }

    public static byte[] bytesReplace(byte[] originalBytes, int offset, int len, byte[] replaceBytes) {
        byte[] newBytes = new byte[originalBytes.length + (replaceBytes.length - len)];
        // 先复制前半部分
        System.arraycopy(originalBytes, 0, newBytes, 0, offset);
        // 再复制需要替换的部分
        System.arraycopy(replaceBytes, 0, newBytes, offset, replaceBytes.length);
        // 再复制最后剩下的部分
        System.arraycopy(originalBytes, offset + len, newBytes, offset + replaceBytes.length, originalBytes.length - offset - len);
        return newBytes;
    }

}

public class ClassModifier {

    /**
     * Class 文件中常量池的起始偏移
     * 头 8 个是魔法数 CAFE BABE
     */
    private static final int CONSTANT_POOL_COUNT_INDEX = 8;

    /**
     * CONSTANT_Utf8_info 常量的 tag 标志
     */
    private static final int CONSTANT_Utf8_info = 1;

    /**
     * 常量池中 14 种常量所占的长度,CONSTANT_Utf8_info 型常量除外,因为它不是定长的
     * 这里是根据 tag 去取值的,而 tag 不是连续的,没有的则取为 -1 。
     */
    private static final int[] CONSTANT_ITEM_LENGTH = {-1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5, -1, -1, 4, 3, -1, 5};

    /**
     * u1 类型数据单位
     */
    private static final int u1 = 1;

    /**
     * u2 类型数据单位
     */
    private static final int u2 = 2;

    /**
     * Class 字节码
     */
    private byte[] classByte;

    public ClassModifier(byte[] classByte) {
        this.classByte = classByte;
    }

    public int getConstantPoolCount() {
        return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2);
    }

    public byte[] modifyUTF8Constant(String oldStr, String newStr) {
        // 先获取常量池的总数
        int constantPoolCount = getConstantPoolCount();

        // 常量池总长度占 u2 类型数据
        int offset = CONSTANT_POOL_COUNT_INDEX + u2;

        for (int i = 0; i < constantPoolCount; i++) {

            int tag = ByteUtils.bytes2Int(classByte, offset, u1);

            // 如果标志位是 utf8 常量标志位则将字节码替换掉
            if (CONSTANT_Utf8_info == tag) {
                // 拿到当前字符传的长度,偏移量为 u1 ,长度为 u2
                int len = ByteUtils.bytes2Int(classByte, u1, u2);
                offset += (u1 + u2);
                // 把这个字符串拿出来作比较
                String str = ByteUtils.bytes2String(classByte, offset, len);
                if (str.equalsIgnoreCase(oldStr)) {
                    // 如果比较对了,那么就将 字符串的内容和长度替换进原来的字节码中
                    byte[] strBytes = ByteUtils.string2Bytes(newStr);
                    byte[] strLen = ByteUtils.int2Bytes(newStr.length(), u2);
                    // 先替换长度
                    classByte = ByteUtils.bytesReplace(classByte, offset - u2, u2, strLen);
                    // 再替换内容
                    classByte = ByteUtils.bytesReplace(classByte, offset, len, strBytes);
                    return classByte;
                } else {
                    offset += len;
                }

            } else {
                // 如果不是,则取对应 tag 所占的长度,加入偏移量
                offset += CONSTANT_ITEM_LENGTH[tag];
            }
        }

        return classByte;
    }

}

劫持类和方法都写好了,那么我们就把执行类替换掉吧:

/**
     * 执行外部传过来的代表一个 Java 类的 byte 数组和 main 参数。
     *
     * @param classByte
     */
    public static void execute(byte[] classByte, String[] args) {
        ClassModifier cm = new ClassModifier(classByte);

        // 替换掉类中的 java.lang.System
        byte[] modiBytes = cm.modifyUTF8Constant("java/lang/System", "com/simon/remote/HackSystem");
        HotSwapClassLoader hotSwapClassLoader = new HotSwapClassLoader();
        Class aClass = hotSwapClassLoader.loadByte(modiBytes);
        try {
            // 执行方法
            Method main = aClass.getMethod("main", args.getClass());
            main.invoke(null, new Object[]{args});
        } catch (Throwable e) {
            e.printStackTrace(HackSystem.out);
        }
    }

然后我们写两个类试一下,一个是被加载的 class 文件,另一个是执行类。分别如下:

public class DemoClass {
    public static void main(String[] args) {
        System.out.println("this is Demo");
        for (String arg : args) {
            System.out.println("this is args: " + arg);
        }
    }
}

public class ExecuteDemo {
    public static void main(String[] args) throws IOException {
        InputStream is = new FileInputStream("D:\\00-WorkSpace\\99-OwnProject\\JavaBasicTest\\target\\classes\\com\\simon\\remote\\DemoClass.class");
        byte[] classBytes = new byte[is.available()];
        is.read(classBytes);
        is.close();
		// 这里模拟带参访问
        JavaClassExecutor.execute(classBytes, new String[]{"Hello", "World"});
    }
}

执行完成后生成了一个 out.txt 文件,内容为:

困惑

通常情况下,在JSP,OSGI及其他一些支持热替换的库,都是需要进行类的卸载回收的,否则类在替换后,老的类就没用了但是还在内存中,就会造成内存泄漏。

我们知道类的卸载需要满足以下三个条件:

  • 该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例。
  • 加载该类的ClassLoader已经被GC。
  • 该类的java.lang.Class 对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法。

所以在自定义类加载器时,就要注意这一点,如果你是希望其使用完成后就被卸载,那么就需要特别留意类加载器及类的作用域了。

举例演示:

@Test
public void test0() throws Exception {
    test4();

    System.gc();

    TimeUnit.SECONDS.sleep(5);
}

public void test4() throws Exception {
    System.out.println(this.getClass().getClassLoader());

    URLClassLoader diskLoader = new URLClassLoader(new URL[]{new URL("file:/D:/liubenlong/a/")});//最后面的斜杠需要添加
    URLClassLoader diskLoader1 = new URLClassLoader(new URL[]{new URL("file:/D:/liubenlong/b/")});

    //加载class文件
    Class clz = diskLoader.loadClass("Hello");
    Constructor constructor = clz.getConstructor(String.class);
    Object obj = constructor.newInstance("tom");

    /**
     * 类Hello引用了类Dog,类加载器会主动加载被引用的类。
     * 注意一般是我们使用 URLClassLoader 实现自定义的类加载器。如果使用classLoader,则需要重写findClass方法来实现类字节码的加载
     */
    Method method = clz.getMethod("sayHello", null);
    //通过反射调用Test类的say方法
    method.invoke(obj, null);

    Class clz1 = diskLoader1.loadClass("Hello");
    Constructor constructor1 = clz1.getConstructor(String.class);
    Object obj1 = constructor1.newInstance("cat");

    Method method1 = clz1.getMethod("sayHello", null);
    //通过反射调用Test类的say方法
    method1.invoke(obj1, null);
}

这里System.gc();是为了主动触发GC进行类卸载。后面的sleep只是为了等待程序执行完成,输出结果。

然后需要添加启动参数-verbose:class来打印出类加载及类卸载的日志信息。

运行test0,输出

//省略部分日志
[0.482s][info][class,load] Hello source: file:/D:/liubenlong/a/
[0.483s][info][class,load] Dog source: file:/D:/liubenlong/a/
[0.484s][info][class,load] java.lang.invoke.StringConcatFactory source: jrt:/java.base
//省略部分日志
[0.508s][info][class,load] java.io.DataInputStream source: jrt:/java.base
a hi ...  java.net.URLClassLoader@3891771e
//省略部分日志
[0.549s][info][class,load] java.lang.invoke.LambdaForm$MH/0x0000000100101c40 source: java.lang.invoke.LambdaForm
a hello tom  java.net.URLClassLoader@3891771e
[0.552s][info][class,load] Hello source: file:/D:/liubenlong/b/
[0.556s][info][class,load] Dog source: file:/D:/liubenlong/b/
b hi ...  java.net.URLClassLoader@78ac1102
b hello cat  java.net.URLClassLoader@78ac1102
[0.560s][info][class,unload] unloading class Dog 0x0000000100102258
[0.560s][info][class,unload] unloading class Hello 0x0000000100102040
[0.560s][info][class,unload] unloading class Dog 0x00000001000a1a58
[0.560s][info][class,unload] unloading class Hello 0x00000001000a1840
[5.581s][info][class,load  ] java.lang.Shutdown source: jrt:/java.base
[5.581s][info][class,load  ] java.lang.Shutdown$Lock source: jrt:/java.base

Process finished with exit code 0

日志中可以看出class load 和 unload 的信息,以及Hello和Dog类的类加载器。

注意,我这里时是加了一个test4方法来进行操作类及处理业务逻辑,test0调用test4方法并且调用System.gc();。

如果System.gc();直接写道test4方法的末尾,是无法实现类的卸载的。读者可以自己实验。

原因是如果放在一起的话,都是同一个方法内,方法是虚拟机执行的最小单元,调用test4方法时会生成一个栈帧放到栈顶。

test4方法的局部变量就会存在与栈帧中的局部变量表中,这里就有URLClassLoader类加载器及Hello/Dog类实例的引用,还包括一些动态链接,所以在GC时,由于栈帧中的内容是作为GC ROOT的,所以肯定不会被回收,故而不会进行类的卸载。

注意在实际开发中一定要保证类的实例, 该类的ClassLoader都被回收,并且没该类不可以被反射调用,才可以类卸载。

相关文章