类加载及执行子系统的案例与实战

时间:2022-01-11 10:19:42

摘自《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》(第二版)

概述

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

案例分析

        在案例分析部分,笔者准备了 4 个例子,关于类加载器和字节码的案例各有两个。并且这两个领域的案例中各有一个案例是大多数 Java 开发人员都使用过的工具或技术,另外一个案例虽然不一定每个人都使用过,但却特别精彩地演绎出这个领域中的技术特性。希望这些案例能引起读者的思考,并给读者的日常工作带来灵感。

Tomcat:正统的类加载器架构

        主流的 Java Web 服务器,如 Tomcat、Jetty、WebLogic、WebSphere 或其他笔者没有列举的服务器,都实现了自己定义的类加载器(一般都不止一个)。因为一个功能健全的 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 生成类的热替换,当然也有 “非主流” 的,如运行在生产模式(Producation Mode)下的 WebLogic 服务器默认就不会处理 JSP 文件的变化。

        由于存在上述问题,在部署 Web 应用时,单独的一个 ClassPath 就无法满足需求了,所以各种 Web 服务器都 “不约而同” 地提供了好几个 ClassPath 路径供用户存放第三方类库,这些路径一般都以 “lib” 或 “classes” 命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相对应的自定义类加载器去加载放置在里面的 Java 类库。现在,笔者就以 Tomcat 服务器(注:本案例中选用的是 Tomcat 5.x 服务器的目录和类加载器结构,在 Tomcat 6.x 的默认配置下,/common、/server 和 /shared 三个目录已经合并到一起了)为例,看一看 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 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如图 9-1 所示。

类加载及执行子系统的案例与实战

        灰色背景的 3 个类加载器是 JDK 默认提供的类加载器,这 3 个加载器的作用前面已经介绍过了。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebAppClassLoader 则是 Tomcat 自己定义的类加载器,它们分别加载 /common/*、/server/*、/shared/* 和 /WebApp/WEB-INF/* 中的 Java 类库。其中 WebApp 类加载器和 Jsp 类加载器通常会存在多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 Jsp 类加载器。

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

        对于 Tomcat 的 6.x 版本,只有指定了 tomcat/conf/catalina.properties 配置文件的 server.loader 和 share.loader 项后才会真正建立 CatalinaClassLoader 和 SharedClassLoader 的实例,否则会用到这两个类加载器的地方都会用 CommonClassLoader 的实例代替,而默认的配置文件中没有设置这两个 loader 项,所以 Tomcat 6.x 顺理成章地把 /common、/server 和 /shared 三个目录默认合并到一起变成一个 /lib 目录,这个目录里的类库相当于以前 /common 目录中类库的作用。这是 Tomcat 设计团队为了简化大多数的部署场景所做的一项改进,如果默认设置不能满足需要,用户可以通过修改配置文件指定 server.loader 和 share.loader 的方式重新启用 Tomcat 5.x 的加载器架构。

        Tomcat 加载器的实现清晰易懂,并且采用了官方推荐的 “正统” 的使用类加载器的方式。如果读者阅读完上面的案例后,能完全理解 Tomcat 设计团队这样布置加载器架构的用意,那说明已经大致掌握了类加载器 “主流”
 的使用方式,那么笔者不妨再提一个问题来让读者思考一下:前面曾经提到过一个场景,如果有 10 个 Web 应用程序都是用 Spring 来进行组织和管理的话,可以把 Spring 放到 common 或 shared 目录下让这些程序共享。Spring 要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在 /WebApp/WEB-INF 目录中的,那么被 CommonClassLoader 或 SharedClassLoader 加载的 Spring 如何访问并不在其加载范围的用户程序呢?

OSGi:灵活的类加载器架构

        Java 程序社区中流传着这么一个观点:“学习 JEE 规范,去看 JBoss 源码;学习类加载器,就去看 OSGi 源码”。尽管 “JEE 规范” 和 “类加载器的知识” 并不是一个对等的概念,不过,既然这个观点能在程序员中流传开来,也从侧面说明了 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 描述)。在 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、Bundle B、Bundle C 三个模块,并且这三个 Bundle 定义的依赖关系如下。

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

        那么,这三个 Bundle 之间的类加载器及父类加载器之间的关系如图 9-2 所示。

类加载及执行子系统的案例与实战

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

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

        从图 9-2 中还可以看出,在 OSGi 里面,加载器之间的关系不再是双亲委派模型的属性结构,而是已经进一步发展成了一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更好的灵活性的同时,也可能会产生许多新的隐患。笔者曾经参与过将一个非 OSGi 的大型系统向 Equinox OSGi 平台迁移的项目,由于历史原因,代码模块之间的的依赖关系错综复杂,勉强分离出各个模块的 Bundle 后,发现在高并发环境下经常出现死锁。我们很容易就找到了死锁的原因:如果出现了 Bundle A 依赖于 Bundle B 的 Package B,而 Bundle B 又依赖了 Bundle A 的 Package A,这两个 Bundle 进行类加载时就很容易发生死锁。具体情况是当 Bundle A 加载 Package B 的类时,首先需要锁定当前类加载器的实例对象(java.lang.ClassLoader.loadClass() 是一个 synchronized 方法),然后把请求委派给 Bundle B 的加载器处理,但如果这时候 Bundle B 也正好想加载 Package A 的类,它也先锁定自己的加载器再去请求 Bundle A 的加载器处理,这样,两个加载器都在等待对方处理自己的请求,而对方处理完之前自己又一直处于同步锁定的状态,因此它们就互相死锁,永远无法完成加载请求了。Equinox 的 Bug List 中有关于这类问题的 Bug,也提供了一个以牺牲性能为代价的解决方案——用户可以启用 osgi.classloader.singleThreadLoads 参数来按单线程串行化的方式强制进行类加载器动作。在 JDK 1.7 中,为非树状继承关系下的类加载器架构进行了一次专门的升级,目的是从底层避免这类死锁出现的可能。

        总体来说,OSGi 描绘了一个很美好的模块化开发的目标,而且定义了实现这个目标所需要的各种服务,同时也有成熟框架对其提供实现支持。对于单个虚拟机下的应用,从开发初期就建立在 OSGi 是一个很不错的选择,这样便于约束依赖。但并非所有的应用都适合采用 OSGi 作为基础架构,OSGi 在提供强大功能的同时,也引入了额外的复杂度,带来了线程死锁内存泄露风险

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

        “字节码生成” 并不是什么高深的技术,读者在看到 “字节码生成” 这个标题时也不必去向诸如 Javassit、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 代码实际编写了代理类的 “静态” 代理而言的,它的优势不在于省去了编写代理类哪一点工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中

        代码清单 9-1  演示了一个最简单的动态代理的用法,原始的逻辑是打印一句 “hello world”,代理类的逻辑是在原始类方法执行前打印一句 “welcome”。我们先看一下代码,然后再分析 JDK 是如何做到的。

代码清单 9-1  动态代理的简单示例

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

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

        运行结果如下:

welcome
hello world

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

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

类加载及执行子系统的案例与实战

图 a

        加入这句代码后再次运行程序,磁盘中将会产生一个名为 “$Proxy0.class” 的代理类 Class 文件(注:应该先在【项目目录】非【ClassPath 目录】下,建立和包名对应的文件夹,如图 a 所示),反编译后可以看见如代码清单 9-2 所示的内容。

代码清单 9-2  反编译的动态代理类的代码

package org.fenixsoft.def;

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)
{
super(paramInvocationHandler);
}

public final void sayHello()
{
try
{
this.h.invoke(this, m3, null);
return;
}
catch (Error|RuntimeException localError)
{
throw localError;
}
catch (Throwable localThrowable)
{
throw new UndeclaredThrowableException(localThrowable);
}
}

// 此处由于版面原因,省略 equals()、hashCode()、toString() 三个方法的代码
// 这 3 个方法的内容与 sayHello() 非常相似

 static
{
try
{
m3 = Class.forName("org.fenixsoft.def.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 的源码。

Retrotranslator:跨越 JDK 版本

        一般来说,以 “做项目” 为主的软件公司比较容易更新技术,在下一个项目中换一个技术框架、升级到最新的 JDK 版本,甚至把 Java 换成 C#、C++ 来开发程序都是由可能的。但当公司发展壮大,技术有所积累,逐渐成为 “做产品” 为主的软件公司后,自主选择技术的权利就会丧失掉,因为之前所积累的代码和技术都是用真金白银换来的,一个稳健的团队也不会随意地改变底层的技术。然而在飞速发展的程序设计领域,新技术总是日新月异、层出不穷,偏偏这些新技术又如鲜花之于蜜蜂一样,对程序员散发着天然的吸引力。

        在 Java 世界里,每一次 JDK 大版本的发布,都伴随着一场大规模的技术革新,而对 Java 程序编写习惯改变最大的,无疑是 JDK 1.5 的发布。自动装箱、泛型、动态注解、枚举、变长参数、遍历循环(foreach 循环)……事实上,在没有这些语法特性的年代,Java 程序也照样能写,但是现在看来,上述每一种语法的改进几乎都是 “必不可少” 的。就如同习惯了 24 寸液晶显示器的程序员,很难习惯在 15 寸平显示器上编写代码。但假如 “不幸” 因为要保护现有投资、维持程序结构稳定等,必须使用 1.5 以前版本的 JDK 呢?我们没有办法把 15 寸显示器变成 24 寸的,但却可以跨越 JDK 版本之间的沟壑,把 JDK 1.5 中编写的代码放到 JDK 1.4 或 1.3 的环境去部署使用。为了解决这个问题,一种名为 “Java 逆向移植” 的工具(Java Backporting Tools)应运而生,Retrotranslator 是这类工具中较出色的一个。

        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 字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对比较稳定的状态,这种需要在字节码层面直接进行的改动是比较少见的。
  • 虚拟机内部的改进。如 JDK 1.5 中实现的 JSR-133 规范重新定义的 Java 内存模型(Java Memory Model,JMM)、CMS 收集器之类的改动,这类改动对于程序员编写代码基本是透明的,但会对程序运行时产生影响。

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

        至于 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() 的方法自然需要重写,常量池需要引入大量新的来自父类的符号引用,这些都是实现细节。图 9-3 是一个使用 JDK 1.5 编译的枚举类与被 Retrotranslator 转换处理后的字节码的对比图。

类加载及执行子系统的案例与实战

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

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

        JDK 1.6 之后提供了 Compiler API,可以动态地编译 Java 程序,虽然这样达不到动态语言的灵活度,但让服务器执行临时代码的需求就可以得到解决了。在 JDK 1.6 之前,也可以通过其他方式来做到,譬如写一个 JSP 文件上传到服务器,然后在浏览器中运行它,或者在服务器端程序中加入一个BeanShell ScriptJavaScript 等的执行引擎(如Mozilla Rhino)去执行动态脚本。在本章的实战部分,我们将使用前面学到的关于类加载及虚拟机执行子系统的知识去实现在服务端执行临时代码的功能。

目标

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

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

        看完上面列出的目标,你觉得完成这个需求需要做多少工作呢?也许答案比大多数人所想的都要简单一些:5 个类,250 行代码(含注释),大约一个半小时左右的开发时间久可以了,现在就开始编写程序吧!

思路

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

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

        对于第一个问题,我们有两种思路可以选择,一种是使用 tools.jar 包(在 Sun JDK/lib 目录下)中的 com.sun.tools.javac.Main 类来编译 Java 文件,这其实和使用javac 命令编译是一样的。这种思路的缺点的引入了额外的 JAR 包,而且把程序 “绑死” 在 Sun 的 JDK 上了,要部署到其他公司的 JDK 中还得把 tools.jar 带上(虽然 JRockit 和 J9 虚拟机也有这个 JAR 包,但它总不是标准所规定必须存在的)。另外一种思路是直接在客户端编译好,把字节码而不是 Java 代码传到服务端,这听起来好像有点投机取巧,一般来说确实不应该假定客户端一定具有编译代码的能力,但是既然程序员会写 Java 代码去给服务端排查问题,那么很难想象他的机器上会连编译 Java 程序的环境都没有。

        对于第二个问题,简单地一想:要执行编译后的 Java 代码,让类加载器加载这个类生成一个 Class 对象,然后反射调用一下某个方法就可以了(因为不实现任何接口,我们可以借用一下 Java 中人人皆知的 “main()” 方法)。但我们还应该考虑得更周全些:一段程序往往不是编写、运行一次就能达到效果,同一个类可能要反复地修改、提交、执行。另外,提交上去的类要能访问服务端的其他类库才行。还有,既然提交的是临时代码,那提交的 Java 类在执行完成后就应当能卸载和回收。

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

实现

        在程序实现部分,我们主要看一下代码及其注释。首先看看实现过程中需要用到的 4 个支持类。第一个类用于实现 “同一个类的代码可以被多次加载” 这个需求,具体程序如代码清单 9-3 所示。

代码清单 9-3  HotSwapClassLoader 的实现

/**
* 为了多次载入执行类而加入的加载器 <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 所做的事情仅仅是公开父类(即 java.lang.ClassLoader) 中的 protected 方法 defineClass(),我们将会使用这个方法把提交执行的 Java 类的 byte[] 数组转变为 Class 对象。HotSwapClassLoader 中并没有重写 loadClass() 或 findClass() 方法,因此如果不算外部手工调用 loadByte() 方法的话,这个类加载器的类查找范围与它的父类加载器是完全一致的,在被虚拟机调用时,它会按照双亲委派模型交给父类加载。构造函数中指定为加载 HotSwapClassLoader 类的类加载器作为父类加载器,这一步是实现提交的执行代码可以访问服务端引用类库的关键,下面我们来看看代码清单 9-3。

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

代码清单 9-4  ClassModifier 的实现

/**
* 修改 Class 文件,暂时只提供修改常量池常量的功能
*
*/
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[] CONSTATN_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 = 0; i < cpc; i++) {
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 += CONSTATN_ITEM_LENGTH[tag];
}
}
return classByte;
}

/**
* 获取常量池中常量的数量
* @return 常量池数量
*/
public int getConstantPoolCount() {
return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2);
}
}

代码清单 9-5  ByteUtils 的实现

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;
}
}

        经过 ClassModifier 处理后的 byte[] 数组才会传给 HotSwapClassLoader.loadByte() 方法进行类加载,byte[] 数组在这里替换符号引用之后,与客户端直接在 Java 代码中引用 HackSystem 类再编译生成的 Class 是完全一样的。这样的实现既避免了客户端编写临时执行代码时要依赖特定的类(不然无法引入 HackSytem),又避免了服务端修改标准输出后影响到其他程序的输出。下面我们来看看代码清单 9-4 和代码清单 9-5。

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

/**
* 为 JavaClass 劫持 java.lang.System 提供支持
* 除了 out 和 err 外,其余的都直接转发给 System 处理
*
*/
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 long nanoTime() {
return System.nanoTime();
}

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 的对应方法
// 因版面原因,省略了其他方法
}

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

代码清单 9-7  JavaClassExecuter 的实现

/**
* JavaClass 执行工具
*
*/
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();
}
}

验证

        远程执行功能的编码到此就完成了,接下来就要检验一下我们的劳动成果了。如果只是测试的话,那么可以任意写一个 Java 类,内容无所谓,只要向 System.out 输出信息即可,取名为 TestClass,同时放到服务器 C 盘的根目录中。然后,建立一个 JSP 文件并加入如代码清单 9-8 所示的内容,就可以在浏览器中看到这个类的运行结果了。

代码清单 9-8  测试 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.excute(b));
out.println("</textarea>");
%>

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

类加载及执行子系统的案例与实战