深入理解Java虚拟机(第二版) 第九章:类加载及执行子系统的案例与实战

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

  • 9.1 概述
  • 9.2 Tomcat: 正统的类加载器架构
  • 9.3 OSGi:灵活的类加载器架构
  • 9.4 字节码生成技术与动态代理的实现
  • 9.5 Retrotranslator:跨越 JDK 版本

9.1 概述

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

9.2 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 自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如图所示。
深入理解Java虚拟机(第二版) 第九章:类加载及执行子系统的案例与实战_第1张图片
灰色背景的 3 个类加载器是 JDK 默认提供的类加载器,这 3 个加载器的作用前面已经介绍过了。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebAppClassLoader 则是 Tomcat 自己定义的类加载器,它们分别加载 /common/、/server/、/shared/和 /WebApp/WEB-INF/ 中的 Java 类库。其中 WebApp 类加载器和 Jsp 类加载器通常会存在多个实例,每一个 Web 应用程序对应一个 WebApp 类加载器,每一个 JSP 文件对应一个 Jsp 类加载器。

从上图的委派关系中可以看出,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 如何访问并不在其加载范围的用户程序呢?

9.3 OSGi:灵活的类加载器架构

Java 程序社区中流传着这么一个观点:“学习 JavaEE 规范,去看 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 所示。
深入理解Java虚拟机(第二版) 第九章:类加载及执行子系统的案例与实战_第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 在提供强大功能的同时,也引入了额外的复杂度,带来了线程死锁和内存泄露的风险。

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

“字节码生成” 并不是什么高深的技术,读者在看到 “字节码生成” 这个标题时也不必去向诸如 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 是如何做到的。

//动态代理的简单示例
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");

深入理解Java虚拟机(第二版) 第九章:类加载及执行子系统的案例与实战_第3张图片
加入这句代码后再次运行程序,磁盘中将会产生一个名为 “$Proxy0.class” 的代理类 Class 文件(注:应该先在【项目目录】非【ClassPath 目录】下,建立和包名对应的文件夹,如图 a 所示),反编译后可以看见如代码清单 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 的源码。

9.5 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 转换处理后的字节码的对比图。

你可能感兴趣的:(JVM)