深入理解Java虚拟机-第九章 类加载及执行子系统的案例与实践

第九章 类加载及执行子系统的案例与实践

9.1 概述

9.2 案例分析

9.2.1 Tomcat:正统的类加载器架构

在本栏文章《深入理解Java虚拟机-第七章 虚拟机类加载机制》最后曾简单介绍过 Tomcat 是如何破坏双亲委派模型的,此处详细说明下,还是从主流 Java Web 服务器需要解决的问题开始讲起:

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

由于存在这些问题,在部署 Web 应用时,单独的一个 ClassPath 就无法满足要求了,所以各种 Web 服务器都 “不约而同” 的提供了好几个 ClassPath 路径供用户存放第三方类库,这些路径一般都以 lib 或 classes 命名。每个路径的类库具备不同的访问范围和服务对象,话不多说我们来看 Tomcat 是怎么规划用户类库结构和类加载器的。
在 Tomcat 目录结构中,有 3 组目录(“/common/*”、“/server/*”、“/shared/*”)可以存放 Java 类库,另外还可以加上 Web 应用程序自身的目录 “/WEB-INF/*”,一共 4 组,把 Java 类库放在这些目录中的含义是:

  • /common 目录,列库可以被 Tomcat 和所有的 Web 应用程序共同使用。
  • /server目录,列库可以被 Tomcat 使用,对所有的 Web 应用程序不可见。
  • /shared 目录,列库可以被所有的 Web 应用程序共同使用,Tomcat 不可见。
  • /WebApp/WEB-INF 目录,仅能被此 Web 应用程序使用,对 Tomcat 和其他 Web 应用程序都不可见

为了支持这套目录结构并对目录里面的类库进行加载和隔离,Tomcat 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,如图:
Tomcat 服务器的类加载结构
CommonClassloader 用来加载 /common 目录,CatalinaClassLoader 负责加载 /server 目录,SharedClassLoader 负责加载 /shared 目录,WebAPPClassLoader 则是负责 /WebApp/WEB-INF 目录中的 Java 类库。WebAppClassLoader 和 JsperLoader 会有多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 JsperLoader 。
从图中的委派关系中可以看出,CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class,它出现的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperIoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。
对于Tomcat的6.x版本,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader项后才会真正建立CatalinaClassLoader和SharedClassLoader的实例,否则会用到这两个类加载器的地方都会用CommonClassLoader的实例代替,而默认的配置文件中没有设置这两个loader项,所以Tomcat6.x顺理成章地把/common、/server和/shared三个目录默认合并到一起变成一个/ib目录,这个目录里的类库相当于以前/common目录中类库的作用。这是Tomcat设计团队为了简化大多数的部署场景所做的-项改进,如果默认设置不能满足需要,用户可以通过修改配置文件指定server.loader和share.loader的方式重新启用Tomcat5.x的加载器架构。
对于书中提出的问题:

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

我思考的答案是,使用线程上下文类加载器,使类加载器可以使用子类加载器加载的类。这也是 Tomcat 破坏双亲委派模型的现象。

9.2.2 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 之间的关系如图所示:
ABC 三者的关系
类加载时可能进行的查找规则如下:

  • 以 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 进行类加载的时候,就很容易发生死锁。

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

提到字节码生成,脑子里面第一个想到的就是动态代理。不管是 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 等。

9.2.4 Retrotranslator:跨越 JDK 版本

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

本章我最喜欢的部分来了。实战实战实战~

9.3.1 目标

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

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

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

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

我的想法是:

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

书上的观点与我基本一致,只有最后一个问题不太一致。书上把在执行类中堆 System.out 的符号引用替换为我们准备的 PrintStream 的符号引用。

9.3.3 实现

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

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

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

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

}

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

/**
 * @Author Simon
 * @Date 2020/2/18 16:15
 */
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 文件,另一个是执行类。分别如下:

package com.simon.remote;

import java.sql.SQLOutput;

/**
 * @Author Simon
 * @Date 2020/2/18 18:24
 */
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);
        }
    }
}

package com.simon.remote;

import java.io.*;

/**
 * @Author Simon
 * @Date 2020/2/18 18:27
 */
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 文件,内容为:
执行结果
至此全面成功!

读书越多越发现自己的无知,Keep Fighting!

本文仅是在自我学习 《深入理解Java虚拟机》这本书后进行的自我总结,有错欢迎友善指正。

欢迎友善交流,不喜勿喷~
Hope can help~

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值