CH09-案例与实战

9.1 概述

在Class文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不太多,Class文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路,这些思路后来成为了许多常用功能和程序实现的基础。

9.2 案例分析

四个分别关于类加载器和字节码的案例。

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

主流的 Java Web 服务器都实现了自定义加载器,因为一个功能健全的 Web 服务器要解决如下几个问题。

部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离。这是最基本的要求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应该保证两个应用程序的类库可以互相独立使用。

部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以互相共享。这个需求也很常见,如,用户可能有 10 个使用 Spring 组织的应用程序部署在同一台服务器上,如果把 10 份 Spring 分别存放在各个应用程序的隔离目录中,将会有很大的资源浪费——主要不是浪费磁盘的问题,而是指类库在使用时要被加载器服务器内存,如果类库不能共享,虚拟机的方法区很容易出现过度膨胀的风险。

服务器要尽可能的保证自身的安全,而不受所部署的 Web 应用程序的影响。目前,很多主流的 Java Web 服务器自身也是使用 Java 语言实现的。因此,服务器本身也有类库依赖的问题,一般基于安全因素考虑,服务器所使用的类库应该与应用程序使用的类库相互独立。

支持 JSP 应用的 Web 服务器,大多都需要支持 HotSwap 功能。我们知道,JSP 文件最终要编译成 Java Class 才能由虚拟机执行,但 JSP 文件由于其纯文本存储的特性,运行时修改的概率要远远大于第三方类库或程序自身的 Class 文件。而且 ASP、PHP、JSP 这些网页应用也把修改后无需重启作为一个很大的优势来看待,因此“主流”的 Web 服务器都会支持 JSP 生成类的热替换,当然也有非主流的,如运行在生产模式下的 WebLogic 服务器默认就不会处理 JSP 文件的变化。

由于上述问题的存在,在部署 Web 应用时,单独的一个 ClassPath 就无法满足需求了,所以各种 Web 服务器都“不约而同”的提供了好几个 ClassPath 路径来供用户存放第三方类库,这些路径一般都以 lib 或 classes 命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每个目录都会有一个相应的自定义类加载器来加载放置在其中的 Java 类库。以下以 Tomcat 服务器为例来介绍它是如何规划用户类库结构和类加载器的。

在 Tomcat 目录结构中,有 3 组目录(/common/*, /server/*, /shared/*)可以用来存放 Java 类库,另外还可以加上 Web 应用程序自身目录的 /WEB-INF/*,一共 4 组,把 Java 类库放置在这些目录中的含义分别如下。

  • common:类库可以被 Tomcat 和所有 Web 应用共同使用。
  • server:类库可以被 Tomcat 使用,对所有的 Web 应用都不可见。
  • shared:类库可以被所有 Web 应用使用,但对 Tomcat 自己不可见。
  • WEB-INF:尽可以被指定 Web 应用可见,但对 Tomcat 和其他所有 Web 应用都不可见。

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

NAME

灰色部分为 JDK 默认提供的类加载器,这里不再赘述。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebappClassLoader 则是 Tomcat 自己定义的类加载器,它们分别用于加载上面介绍的 4 个目录中的类库。通常 WebApp 和 JSP 类加载器会存在多个实例,每个 Web 应用对应一个 WebApp 类加载器,每个 JSP 文件对应一个 JSP 类加载器。

从上图中的委派模型可以看出,CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 自己能加载的类则与对方相互隔离。WebappClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebappClassLoader 实例之间相互隔离。而 JasperLoader 的加载范围仅仅是该 JSP 文件所编译出来的那个 Class,它出现的目的就是为了被丢弃:当服务器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过在建立一个新的 JSP 类加载器来实现 JSP 文件的 HotSwap 功能。

9.2.2 OSGI:灵活的类加载器架构

OSGI 中的每个模块(Bundle)与普通的 Java 类库区别并不大,两者一般都以 Jar 格式进行封装,并且内部存储的都是 Java Package 和 Class。但是一个 Bundle 可以声明它所依赖的 Java Package,也可以声明它允许导出发布的 Java Package。在 OSGI 里面,Bundle 之间的依赖关系从传统的上层模块依赖底层模块转换为平级别模块之间的依赖(之上表面上看是如此),而且类库的可见性能得到非常精确的控制,一个模块里只有被 Export 过的 Package 才能被外界访问,其他的 Package 和 Class 会隐藏起来。除了更加精确的模块划分和可见性控制之外,引入 OSGI 的另外一个重要的理由是,基于 OSGI 的程序很可能(并不一定会)可以实现模块级的热插拔功能,当程序升级或者调试除错时,可以只停用、重新安装然后启用程序的其中一部分,这对企业级程序开发来说是一个非常有诱惑力的特性。

OSGI 之所以能有上述优点,要归功于它灵活的类加载器架构。OSGI 和 Bundle 类加载器之间只有规则,没有固定的委派关系。如,某个 Bundle 声明了一个它依赖的 Package,如果有其它 Bundle 声明发布了这个 Package,那么所有对这个 Package 的类加载动作都会委派给发布它的 Bundle 类加载器来完成。当不涉及摸个具体的 Package 时,各个 Bundle 加载器都是平级关系,只有具体使用某个 Package 和 Class 时,才会根据 Package 的导入导出定义来构造 Bundle 之间的委派和依赖关系。

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

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

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

那么,这三个 Bundle 之间的类加载器以及父类加载器之间的关系如图所示:

NAME

由于没有牵扯到具体 OSGI 实现,上图中没有指明具体的类加载器实现,只是一个体现了加载器之间关系的概念模型,并且只是体现了 OSGI 中最简单的加载器委派关系。一般来说,在 OSGI 中,加载一个类可能发生的查找行为和委派发生关系会比上图中显示的过程复杂的多,类加载时可能进行的查找规则如下。

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

从上图中可以看出,在 OSGI 中,加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成了一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更好的灵活性的同时,也可能会产生很多新的隐患。

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

“字节码生成”并不是什么高深的技术,读者在看到“字节码生成”这个标题时也先不必去想诸如 Javassist、CGLib、ASM 之类的字节码类库,因为 JDK 里面的 javac 命令就是字节码生成技术的“老祖宗”,并且 javac 也是一个由 Java 语言写成的程序,它的代码存放在 OpenJDK 的 langtools/src/share/classes/com/sun/tools/javac 目录中。要深入了解字节码生成,阅读 javac 的源码是个很好的途径,不过 javac 对于我们这个例子来说太过庞大了。在 Java 里面除了 javac 和字节码类库外,使用字节码生成的例子还有很多,如 Web 服务器中的 JSP 编译器,编译时植入的 AOP 框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。

相信许多Java开发人员都使用过动态代理,即使没有直接使用过 java.lang.reflect.Proxy 或实现过 java.lang.reflect.InvocationHandler 接口,应该也用过 Spring 来做过 Bean 的组织管理。如果使用过 Spring,那大多数情况都会用过动态代理,因为如果 Bean 是面向接口编程,那么在 Spring 内部都是通过动态代理的方式来对 Bean 进行增强的。动态代理中所谓的“动态”,是针对使用 Java 代码实际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点工作量,而是 实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。

下面展示了一个简单的动态代理用法,原始的逻辑是打印一句"hello world",代理类的逻辑是在原始类方法执行前打印一句"welcome"。

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){
    IHello hello=(IHello)new DynamicProxy().bind(new Hello())
    hello.sayHello()
  }
}

上述代码里,唯一的“黑匣子”就是 Proxy.newProxyInstance() 方法,除此之外再没有任何特殊之处。该方法返回一个实现了 IHello 接口、且代理了 new Hello() 实例的行为的对象。跟踪该方法的源码,可以看到程序进行了验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤并不是我们关注的重点,而在最后它调用了 sun.misc.ProxyGenerator.generateProxyClass() 方法来完成字节码的生成动作,该方法可以在运行时产生一个描述代理类的字节码 byte[] 数组。如果想看一看这个在运行时产生的代理类的内容,可以在 main 方法中添加如下代码:

System.getProperties().put(
  "sun.misc.ProxyGenerator.saveGeneratedFiles", "true")

加入该代码后再次运行,磁盘中将会产生一个名为 $Proxy().class 的代理类 Class 文件,反编译后可以看见如下清单中的内容:

package org.fenixsoft.bytecode;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;

public final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello{
  private static Method m3;
    private static Method m1;
    private static Method m0;
    private static Method m2;
    public $Proxy0(InvocationHandler paramInvocationHandler)
    throws
  {
    super(paramInvocationHandler);
  }
  public final void sayHello()
    throws
  {
    try
    {
      this.h.invoke(this,m3, null);
      return;
    }
    catch(RuntimeException localRuntimeException)
    {
      throw localRuntimeException;
    }
    catch(Throwable localThrowable)
    {
      throw new UndeclaredThrowableException(localThrowable);
    }
  }
  //此处由于版面原因, 省略equals()、hashCode()、toString()三个方法的代码
  //这3个方法的内容与sayHello()非常相似。
  static
  {
    try
    {
      m3=Class.forName("org.fenixsoft.bytecode.DynamicProxyTest $IHello").getMethod("sayHello", new Class[0]);
        m1=Class.forName("java.lang.Object").getMethod("equals", new Class[]{Class.forName("java.lang.Object")});
        m0=Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
        m2=Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
        return;
    }
    catch(NoSuchMethodException localNoSuchMethodException)
    {
      throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
    }
    catch(ClassNotFoundException localClassNotFoundException)
    {
      throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
    }
  }
}

该代理类的实现很简单,它为传入接口中的每个方法,以及从 java.lang.Object 中继承来的 equals/hashCode/toString 方法都生成了对应的实现,并且统一调用了 InvocationHandler 对象的 invoke 方法(代码中的 this.h 即使父类 Proxy 中保存的 InvocationHandler 实例变量)来实现这些方法的内容,各个方法的区别不过是传入的参数和 Method 对象有所不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行 InvocationHandler.invoke 中的代理逻辑。

这个例子中并没有讲到 generateProxyClass() 方法具体是如何产生代理类 “$Proxy0.class” 的字节码的,大致的生成过程其实就是根据 Class 文件的格式规范去拼装字节码,但在实际开发中,以 byte 为单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果读者对动态代理的字节码拼装过程很感兴趣,可以在 OpenJDK 的 jdk/src/share/classes/sun/misc 目录下找到 sun.misc.ProxyGenerator 的源码。

9.2.4 Retrotanslator:跨越 JDK 版本

Retrotranslator 的作用是将 JDK 1.5 编译出来的 Class 文件转变为可以在 JDK 1.4 或 1.3 上部署的版本,它可以很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持 JDK 1.5 中新增的集合改进、并发包以及对泛型、注解等的反射操作。了解了 Retrotranslator 这种逆向移植工具可以做什么以后,现在关心的是它是怎样做到的?

要想知道 Retrotranslator 如何在旧版本JDK中模拟新版本JDK的功能,首先要弄清楚 JDK 升级中会提供哪些新的功能。JDK 每次升级新增的功能大致可以分为以下 4 类:

在编译器层面做的改进。如自动装箱拆箱,实际上就是编译器在程序中使用到包装对象的地方自动插入了很多 Integer.valueOf()、Float.valueOf() 之类的代码;变长参数在编译之后就自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经擦除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。

对 Java API 的代码增强。譬如 JDK 1.2 时代引入的 java.util.Collections 等一系列集合类,在 JDK 1.5 时代引入的 java.util.concurrent 并发包等。

需要在字节码中进行支持的改动。如 JDK 1.7 里面新加入的语法特性:动态语言支持,就需要在虚拟机中新增一条 invokedynamic 字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对比较稳定的状态,这种需要在字节码层面直接进行的改动是比较少见的。

虚拟机内部的改进。如 JDK5 中实现的 JSR-133 规范重新定义的 Java 内存模型、CMS 收集器之类的改动。这类改动对于程序员编写的代码来说基本是透明的,但会对程序运行时产生影响。

上述 4 种功能中,Retrotranslator 只能模拟前两类,对于后面两类直接在虚拟机内部实现的改进,一般所有的逆向移植工具都是无能为力的,至少不能完整的或者在可接收的效率上完成全部模拟,否则虚拟机设计团队也没有必要舍近求远的改动处于 JDK 底层的虚拟机。在可以模拟的两类功能中,第二类模拟相对容易实现一些,如 JDK5 引入 JUC 包,实际上是由多线程大师 Doug Lea 开发的一套并发包,在 JDK5 出现之前就已经存在,所以要在旧 JDK 中支持这部分功能,以独立类库的方式便可实现。Retrotranslator 中附带了一个名为 “backport-util-concurrent.jar” 的类库来代替 JDK5 并发包。

至于JDK在编译阶段进行处理的那些改进,Retrotranslator 则是使用 ASM 框架直接对字节码进行处理。由于组成 Class 文件的字节码指令数量并没有改变,所以无论是 JDK 1.3、JDK 1.4 还是 JDK 1.5,能用字节码表达的语义范围应该是一致的。当然,肯定不可能简单地把 Class 的文件版本号从 49.0 改回 48.0 就能解决问题了,虽然字节码指令的数量没有变化,但是元数据信息和一些语法支持的内容还是要做相应的修改。以枚举为例,在 JDK 1.5 中增加了 enum 关键字,但是 Class 文件常量池的 CONSTANT_Class_info 类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增加过 “CONSTANT_Enum_info” 之类的“枚举符号引用”常量。所以使用 enum 关键字定义常量,虽然从 Java 语法上看起来与使用 class 关键字定义类、使用 interface 关键字定义接口是同一层次的,但实际上这是由 Javac 编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于 java.lang.Enum、自动生成了 values() 和 valueOf() 方法的普通 Java 类而已。

Retrotranslator 对枚举所做的主要处理就是把枚举类的父类从 java.lang.Enum 替换为它运行时类库中包含的 “net.sf.retrotranslator.runtime.java.lang.Enum_",然后再在类和字段的访问标志中抹去 ACC_ENUM 标志位。当然,这只是处理的总体思路,具体的实现要复杂得多。可以想象既然两个父类实现都不一样,values 和 valueOf 方法自然需要重写,常量池需要引入大量新的来自父类的符号应用,这些都是实现细节。下图使用 JDK5 编译的枚举类与被 Retrotranslator 转换处理后的字节码进行对比。

NAME

9.3 实战:实现远程执行功能

不知道读者在编写程序时是否遇到过如下情景:在排查问题过程中,想查看内存中的一些参数值,却又没有方法把这些值输出到界面或日志中,又或者定位到某个缓存数据有问题,但缺少缓存的统一万里界面,不得不重启服务才能清理该缓存。类似的需求有一个共同的特点,就是只要在服务中执行一段程序代码,就可以定位或排除问题,但就是找不到可以让服务器执行代码的途径,这时候就会希望 Java 服务器中也有提供类似 Groovy Console 的功能。

JDK6 之后提供了 Compiler API,可以动态的编译 Java 程序,虽然这样达不到动态语言的灵活度,但让服务器执行临时代码的需求就可以得到解决了。在 JDK6 之前,也可以通过其他方式实现,比如编写一个 JSP 文件上传到服务器,然后在浏览器中运行,或者在服务器程序中插入一个额 BeanShell Script、JavaScript 等语言的执行引擎来执行动态脚本。

9.3.1 目标

首先,在实现“在服务端执行临时代码”这个需求之前,先来明确一下本次实战的具体目标,我们希望最终的产品是这样的:

  • 不依赖具体的 JDK 版本,能在目前普遍使用的 JDK 中部署,也能在 JDK 4~7 中部署。
  • 不改变原有服务端程序的部署方式,不依赖任何三方类库。
  • 不侵入原有程序,即无需改动原有程序的任何代码,也不会对原有程序的运行带来任何影响。
  • 考虑到 BeanShell Script 或 JavaScript 等脚本编写起来不太方便,“临时代码”需要直接支持 Java 语言。
  • “临时代码”应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里写的是“不需要”而不是“不可以”,当“临时代码”需要引用其他类库的时也没有限制,只要服务端程序能够使用,临时代码应当都能被直接引用。
  • “临时代码”的执行结果能够返回客户端,执行结果可以包括程序中输出的信息以及抛出的异常等等。

9.3.2 思路

在程序实现的过程中,我们需要解决如下 3 个问题:

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

对于第一个问题,我们有两种思路可供选择,一种是使用 tools.jar 中的 com.sun.tools.javac.Main 类来编译 Java 文件,这其实和使用 javac 命令编译一行。这种思路的缺点是引入了额外的 Jar 包,而且把程序“绑死”在了 Sun 的 JDK 上,要部署到其他公司的 JDK 中还得把这个 tools.jar 带上。另外一种思路是直接在客户端编译好,把字节码而不是 Java 源代码直接传到服务端,这听起来有点投机取巧,一般来说确实不应该假设客户端一定具有编译代码的能力,但是然程序员会写 Java 代码给服务端来排查问题,那么很难想象他的机器上会连编译 Java 代码的环境都没有。

对于第二个问题,简单的一想:要执行编译后的 Java 代码,让类加载器加载这个类生成一个 Class 对象,然后反射代用某个方法就可以了。但我们还应该考虑的周全一点:一段程序往往不是编写、运行一次就能达到效果,同一个类可能要反复的修改、提交、执行。提交上去的类还有可能需要访问其他的类库。此外,既然提交的是临时代码,那么提交的类应该在执行完成后卸载并回收。

最后一个问题,我们想把程序往标准输出和标准错误输出中但因的信息收集起来,但标准输出是整个虚拟机进程全局共享的资源,如果使用 System.setout、System.setErr 方法把输出流重定向到自定义的 PrintStream 对象上固然可以收集到输出信息,但也会对原有程序产生影响:会把其他线程输出的信息也一并收集。虽然这并不是不能解决问题,不过为了达到完全不影响原有程序的目的,我们可以采用另外一种办法,即直接在执行的类中把 System.out 的符号引用替换为我们准备好的 PrintStream 符号引用,基于前面学习的知识,做到这一点并不难。

9.3.3 实现

首先看看实现过程中需要用到的 4 个支持类。第一个类用于实现“同一个类的代码可以被加载多次”这个需求:

/**
*为了多次载入执行类而加入的加载器<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)
  }
}

HotSwapClassLoader 完成的工作仅仅是公开父类中的 protected 方法 defineClass,我们将使用该方法来把提交执行的 Java 类的字节数组转换为 Class 对象。HotSwapClassLoader 中并没有重写 loadClass 或 findClass 方法,因此如果不算外部手动调用 loadByte 的话,该类加载器的类查找范围与其父类加载器是完全一致的,在被虚拟机调用时,它会按照双亲委派模型交给父类加载。构造函数中指定为加载 HotSwapClassLoader 类的类加载器作为父类加载器,这一步是实现提交的执行代码可以访问服务端引用类库的关键。

第二个类是实现 java.lang.System 替换为自定义的 PrintStream 类的过程,它直接修改符合 Class 文件格式的字节数组中的常量池部分,将常量池中指定内容的 CONSTANT_Utf8_info 常量替换为新的字符串,具体代码如代码清单所示。ClassModifier 中涉及对字节数组操作的部分,主要是将字节数组与 int 和 String 互相转换,以及把对字节数组的替换操作封装在代码清单所示的 ByteUtils 中。

/**
*修改Class文件, 暂时只提供修改常量池常量的功能
*@author zzm
*/
public class ClassModifier{
  /**
  *Class文件中常量池的起始偏移
  */
  private static final int CONSTANT_POOL_COUNT_INDEX=8
  /**
  *CONSTANT_Utf8_info常量的tag标志
  */
  private static final int CONSTANT_Utf8_info=1
  /**
  *常量池中11种常量所占的长度, CONSTANT_Utf8_info型常量除外, 因为它不是定长的
  */
  private static final int[]CONSTANT_ITEM_LENGTH={-1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5}
  private static final int u1=1
  private static final int u2=2
  private byte[]classByte
  public ClassModifier(byte[]classByte){
    this.classByte=classByte
  }
  /**
  *修改常量池中CONSTANT_Utf8_info常量的内容
  *@param oldStr修改前的字符串
  *@param newStr修改后的字符串
  *@return修改结果
  */
  public byte[]modifyUTF8Constant(String oldStr,String newStr){
    int cpc=getConstantPoolCount()
      int offset=CONSTANT_POOL_COUNT_INDEX+u2
      for(int i=0i<cpci++){
      int tag=ByteUtils.bytes2Int(classByte,offset,u1)
        if(tag==CONSTANT_Utf8_info){
        int len=ByteUtils.bytes2Int(classByte,offset+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{
        offset+=CONSTANT_ITEM_LENGTH[tag]
      }
    }
    return classByte
  }
  /**
  *获取常量池中常量的数量
  *@return常量池数量
  */
  public int getConstantPoolCount(){
    return ByteUtils.bytes2Int(classByte,CONSTANT_POOL_COUNT_INDEX,u2)
  }
}
/**
*Bytes数组处理工具
*@author
*/
public class ByteUtils{
  public static int bytes2Int(byte[]b,int start,int len){
    int sum=0
      int end=start+len
      for(int i=starti<endi++){
      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=0i<leni++){
      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
  }
}

经过 ClassModifer 处理后的字节数组才会传给 HotSwapClassLoader.loadByte 方法来执行类加载,字节数组在这里替换符号引用之后,与客户端直接在 Java 代码中引用 HackSystem 类在编译生成的 Class 完全一样。这样的实现避免了客户端编写临时代码时要依赖特定的类,又避免了服务端修改标准输出后影响其他程序的输出。

最后一个类是前面提到过的用来替换 java.lang.System 的 HackSystem,该类中的方法看起来不少,但其实除了把 out 和 err 两个静态变量替换为使用 ByteArrrayOutputStream 作为打印目标的同一个 PrintStream 对象,以及增加了读取、清理 ByteArrayOutputStream 中内容的 getBufferString 和 clearBuffer 方法外,就再没有其他新鲜的内容了。其余的方法全部都来自于 System 类的 public 方法,方法名称、参数、返回值都完全一样,并且实现也是直接调转了 System 类的对应方法而已。保留这些方法的目的是为了在 System 在被替换为 HackSystem 之后,执行代码中调用的 System 的其余方法仍然可以继续使用,HackSystem 的实现如下所示。

/**
*为JavaClass劫持java.lang.System提供支持
*除了out和err外, 其余的都直接转发给System处理
*
*@author zzm
*/
public class HackSystem{
  public final static InputStream in=System.in
    private static ByteArrayOutputStream buffer=new ByteArrayOutputStream()
    public final static PrintStream out=new PrintStream(buffer)
    public final static PrintStream err=out
    public static String getBufferString(){
    return buffer.toString()
  }
  public static void clearBuffer(){
    buffer.reset()
  }
  public static void setSecurityManager(final SecurityManager s){
    System.setSecurityManager(s)
  }
  public static SecurityManager getSecurityManager(){
    return System.getSecurityManager()
  }
  public static long currentTimeMillis(){
    return System.currentTimeMillis()
  }
  public static void arraycopy(Object src,int srcPos,Object dest,int destPos,int length){
    System.arraycopy(src,srcPos,dest,destPos,length)
  }
  public static int identityHashCode(Object x){
    return System.identityHashCode(x)
  }
  //下面所有的方法都与java.lang.System的名称一样
  //实现都是字节转调System的对应方法
  //因版面原因, 省略了其他方法
}

我们来看看最后一个类 JavaClassExecutor,它是提供给外部调用的入口,调用前面几个直接类组装逻辑,完成类加载工作。JavaClassExecutor 只有一个 execute 方法,用输入的符合 Class 文件格式的字节数组替换 java.lang.System 的符号引用后,使用 HotSwapClassLoader 加载生成一个 Class 对象,由于每次执行 execute 方法都会生成一个新的类加载器实例,因此同一个类可以实现重复加载。然后,发射调用这个 Class 对象的 main 方法,如果期间出现任何异常,将异常信息打印到 HackSystem.out 中,最后把缓冲区中的信息作为方法的结果返回。JavaClassExecutor 的实现代码如代码清单所示。

/**
*JavaClass执行工具
*
*@author zzm
*/
public class JavaClassExecuter{
  /**
  *执行外部传过来的代表一个Java类的byte数组<br>
  *将输入类的byte数组中代表java.lang.System的CONSTANT_Utf8_info常量修改为劫持后的HackSystem类
  *执行方法为该类的static main(String[]args)方法, 输出结果为该类向System.out/err输出的信息
  *@param classByte代表一个Java类的byte数组
  *@return执行结果
  */
  public static String execute(byte[]classByte){
    HackSystem.clearBuffer()
      ClassModifier cm=new ClassModifier(classByte)
      byte[]modiBytes=cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem")
      HotSwapClassLoader loader=new HotSwapClassLoader()
      Class clazz=loader.loadByte(modiBytes)
      try{
      Method method=clazz.getMethod("main", new Class[]{String[].class})
        method.invoke(null,new String[]{null})
    }catch(Throwable e){
      e.printStackTrace(HackSystem.out)
    }
    return HackSystem.getBufferString()
  }
}

9.3.4 验证

如果只是测试的话,那么可以任意下一个 Java 类,内容无所谓,只要向 System.out 输出信息即可,取名为 TestClass,同时放到服务器 C 盘的根目录中,然后建立一个 JSP 文件并加入如下清单中的代码,就可以在浏览器中看到这个类的运行效果了。

<%@page import="java.lang.*"%>
  <%@page import="java.io.*"%>
  <%@page import="org.fenixsoft.classloading.execute.*"%>
  <%
  InputStream is=new FileInputStream("c:/TestClass.class");
  byte[]b=new byte[is.available()];
  is.read(b);
  is.close();
  out.println("<textarea style='width:1000;height=800'>");
  out.println(JavaClassExecuter.execute(b));
  out.println("</textarea>");
  %>

当然,上面的做法只是用于测试和演示,实际使用这个 JavaExecutor 执行器的时候,如果还要手工复制一个 Class 文件到服务器上就没有什么意义了。笔者给这个执行器写了一个外壳,是一个 Eclipse 插件,可以把 Java 文件编译后传输到服务器中,然后把执行器的返回结果输出到 Eclipse 的 Console 窗口中,这样就可以在有灵感的时候随时写几行调试代码,当道测试环境的服务器上立即运行了。虽然实现简单,但效果很不错,对调试问题也非常有帮助,如下图所示:

NAME

9.4 本章小结

本书 6~9 章介绍了 Class 文件格式、类加载和虚拟机执行引擎,这些内容是虚拟机中必不可少的组成部分,只有了解了虚拟机如何执行程序,才能更好的理解怎样才能写出优秀的代码。