三、ClassLoader

如果事先知道哪些类需要修改,最简单的修改类方式如下:

  • 1、 通过调用ClassPool.get()方法获取一个CtClass对象
  • 2、 修改它
  • 3、 调用CtClass对象的writeFile()或toBytecode()方法获取修改后的类文件

如果在类加载的时候要确定一个类是否被修改,用户应该让Javassist和类加载器一起工作。Javassist可以通类加载器一起工作,在加载类的时候修改类字节码。用户可以定义自己的ClassLoader,也可以使用Javassist提供的ClassLoader。

3.1 CtClass类的toClass方法

CtClass提供了一个便捷的方法toClass(), 这个方法要求使用当前线程的上下文类加载器,加载CtClass对应的类。 调用该方法, 调用者必须拥有响应的权限;否则,会抛出一个SecurityException异常。

以下代码是怎么使用toClass():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package org.demo;

public class Hello {
public void say(){
System.out.println("org.demo.Hello");
}
}

...
@Test
public void testToClass() throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("org.demo.Hello");
CtMethod m = cc.getDeclaredMethod("say");
m.insertBefore("{ System.out.println(\"org.demo.Hello.say()\");}");
Class c = cc.toClass();
Hello h = (Hello)c.newInstance();
h.say();
}
...

//输出
org.demo.Hello.say()
org.demo.Hello

Test.main()方法在Hello类的say()方法体中插入了一个println()方法的调用。然后创建从被修改过的Hello类创建一个实例对象,然后调用该对象的say()方法。

注意,上面代码的前提是Hello类在toClass()前,未被类加载器加载。否则,JVM在toClass()调用修改Hello类前,将加载原来的Hello类。不然的话,尝试加载修改后的Hello类将会报LinkageError错误。比如,下列例子Test的main方法将会抛异常:

1
2
3
4
5
6
public static void main(String[] args ) throws Exception {
Hello orig = new Hello();
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("Hello");
...
}

以上代码,原始的Hello类在main的第一行已经被加载,因为ClassLoader无法同时加载两个不同版本的Hello类,因此在调用toClass()方法时,将抛出异常。

如果以上程序运行在JBoss或Tomcat等应用服务器,toClass()方法用到的上下文类加载器可能与预期的不一致。在这种情况下,你可能会看到抛出ClassClassException异常。为了避免这种异常,你必须提供一个合适的类加载器给toClass()。
比如,如果bean是会话bean对象,那么以下代码将争取执行:

1
2
CtClass cc = ... 
Class c = cc.toClass(bean.getClass().getClassLoader());

你需要提供一个ClassLoader给toClass()方法,这个ClassLoader是用来加载你的程序的类加载器(比如上面例子,Bean对象的加载器) 。

toClass()是一个便捷方法。如果你要更加复杂的功能,你必须编写自己的ClassLoader。

3.2 Java中的类加载机制

在Java中,可以同时存在多个类加载器,每个加载器创建自己的命名空间。不同的类加载器可以以相同的类名加载不同的类文件。以这种方式加载的两个类被识别成不同的两个类。这种特性运行我们在一个JVM中运行多个程序,即使这些程序以相同的类名加载了不同的类文件。

注:JVM不允许动态重新加载一个类。一旦一个类被一个加载器加载,运行时不能够重新加载这个类的修改过的版本。
因此,类被加载后,你不能够修改类的定义。但是,JPDA(Java Platform Debugger Architecture)技术提供了受限制的在运行时加载一个类的能力。 可以参考Section 3.6

如果一个类文件被两个不同的类加载器加载,JVM创建两个名字相同、定义也相同的不同类对象。两个类被(JVM)认为是不一样的。因为两个类被被认为是不一样的,一个类的实例对象不能赋值给另外一个类的变量。两个类之间的转换操作会失败并且抛出ClassCastException异常。

如下例所示,以下代码片段抛出一个异常:

1
2
3
4
MyClassLoader myLoader = new MyClassLoader(); 
Class clazz = myLoader.loadClass("Box");
Object obj = class.newInstance();
Box b = (Box)obj;

类Box被两个ClassLoader加载。假设一个类加载器CL加载一个类包含该代码片段。这段代码引用MyClassLoader, Class, Object和Box, CL也加载这些类(除非它委托给其他ClassLoader加载)。因此,变量b的类型是Box类,被CL加载。另外一方面,myLoader也加载了Box类。obj对象是一个被myLoader加载的Box类的实例。因此,因为变量obj的类和变量b的类是两个不同版本的Box类, 最后一个语句会抛出ClassCastException异常。

多个类加载器会形成树形结构。除了bootstrap类加载器之外, 每个类加载器会有一个parent类加载器,父加载器通常用来加载子加载器。由于可以沿着类加载器的树形层次结构,委托加载类的请求, 因此可以一个类可以被一个类加载器加载,但是这个类加载器并不是你直接发起加载请求的类加载器。因此,被请求加载类C的类加载器,可能与真正加载C类的加载器是不一样的。为了区分,我们将前一个加载器叫C的起始加载器,后一个加载器叫C的真实加载器。

除此之外,如果一个类加载器接收请求加载类C(C的起始加载器),将请求委托给父加载器PL,那么类加载器CL将不会被请求去加载任何类C定义中引用到的类。CL不是这些类的起始加载器。相反的,CL的父类加载器将作为他们(C类定义中引用的类)的起始加载器,并且加载他们。类C定义中引用的其他类,将会被C的真实加载器加载。

为了理解这种机制,让我们看看以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public  class  Point { // loaded by PL 
private int x, y;
public int getX() { return x; }
}

public class Box { // the initiator is L but the real loader is PL
private Point upperLeft, size;
public int getBaseX() { return upperLeft.getX(); }
}

public class Window { // loaded by a class loader L
private Box box;
public int getBaseX() { return box.getBaseX(); }
}

假设类Window被一个类加载器L加载。类Window的起始加载器和真实加载器都是L。由于Window的定义中引用Box,JVM将请求L加载Box。这里假设L委托该任务给父加载器PL。Box的起始加载器是L,但是真实加载器是PL。在这个案例中,Point的起始加载器不是L,而是PL,因为它是Box的真实加载器。因此L并没有被请求去加载Point类。

接下来,我们考虑下一下稍微改动后的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public  class  Point { // loaded by PL 
private int x, y;
public int getX() { return x; }
}

public class Box { // the initiator is L but the real loader is PL
private Point upperLeft, size;
public Point getSize() { return size; }
}

public class Window { // loaded by a class loader L
private Box box;
public boolean widthIs(int w ){
Point p = box.getSize();
return w == p.getX();
}
}

现在,Window的类定义中也引用了Point。在这个案例中, 类加载器L也必须将Point的类加载请求委托给PL。你必须避免两个加载器重复加载相同的类。两个类加载器中,一个类加载器必须委托两位一个去加载。

如果加载器L没有委托给PL加载Point,widthIs方法将抛出一个ClassCastException异常。因为Box的真实加载器是PL,Box中引用的Point也是由PL加载。因此,getSize()返回值是一个由PL加载的Point实例对象,而widthIs()方法中p变量引用的Point是由L加载。JVM认为他们是不同的类型,因此抛出一个异常,因为类型不匹配。

这种行为比较绕但确实必要的。 如下语句:

1
Point p = box.getSize();

没有抛出异常,那么Window的代码可以破坏Point对象的封装。比如, 由PL加载的Point的属性是私有的。但是,如下定义, Window类可以直接通过由L加载器加载的Point的x属性获得值:

1
2
3
4
public class Point{
public int x, y ; //not private
public int getX() { return x ; }
}

更多的关于类加载器的详情,可以参考一下paper:

Sheng Liang and Gilad Bracha, “Dynamic Class Loading in the Java Virtual Machine”,
ACM OOPSLA’98, pp.36-44, 1998.

3.3 使用javassist.Loader

Javassist提供了一个类加载器: javassist.Loader。这个类加载器使用一个javassist.ClassPool读取类文件。

比如, javassist.Loader可以用来加载经过Javassist修改的特定类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import javassit.*; 
import test.Rectangle;

public class Main{
public static void main(String[] args) throws Throwable{
ClassPool pool = ClassPool.getDefault();
Loader cl = new Loader(pool);
CtClass ct = pool.get("test.Rectangle");
ct.setSuperclass(pool.get("test.Point"));

Class c = cl.loadClass("test.Rectangle");
Object rect = c.newInstance();
...
}
}

这段代码修改了类test.Rectangle。test.Rectangle的父类设置成test.Point类。然后程序加载修改过的类,创建test.Rectangle类的新的对象。

如果用户想要修改一个已经被加载过的类, 可以为javassist.Loader加入一个是事件监听器。事件监听器将会在Loader加载器加载一个类的时候被通知到。类加载器必须实现以下接口:

1
2
3
4
public interface Translator { 
public void start(ClassPool pool) throws NotFoundException, CannotCompileException;
public void onLoad(ClassPool pool, String className) throws NotFoundException, CannotCompileException
}

方法start()将会在一个监听器被加入到javassist.Loader对象后,由javassist.Loader的addTranslator()触发调用。onLoad()方法在javassist.Loader加载一个类前被调用。onLoad()方法可以修改已经加载的类的定义。

比如,以下监听器在类被加载前,更改所有类成public。

1
2
3
4
5
6
7
public class MyTranslator implements Translator{
void start(ClassPool pool) throws NotFoundException, CannotCompileException{}
void onLoad(ClassPool pool, String className) throws NotFoundException, CannotCompileException{
CtClass cc = pool.get(className);
cc.setModifiers(Modifier.PUBLIC);
}
}

注意,不能在onLoad()方法中调用toBytecode()、writeFile(),因为javassist.Loader在获取类文件的时候,会调用这些方法。

要运行使用MyTranslator对象的应用MyApp类,如下编写主类:

1
2
3
4
5
6
7
8
9
10
11
import javassist.*; 

public class Main2{
public static void main(String[] args) throws Throwable {
Translator t = new MyTranslator();
ClassPool pool = ClassPool.getDefault();
Loader cl = new Loader();
cl.addTranslator(pool, t);
cl.run("MyApp",args);
}
}

运行改程序,需要如下命令:

1
% java Main2 arg1 arg2 ...

类MyApp和其他应用类将被MyTranslator转换。

注意,类似MyApp的应用类无法访问加载类,如果Main2、MyTranslator和ClassPool, 因为他们是另外的类加载器加载的。这些应用类是由javassist.Loader加载器加载的, 而其他类如Main2等,是由默认加载器加载的。

javassist.Loader类查找类的顺序与java.lang.ClassLoader不一样。ClassLoader首先委托加载操作给父加载器,无法加载时,才尝试自己加载类。另外一方面,javassist.Loader先尝试加载类,无法加载再委托父加载器加载。在以下情况下, 它委托父加载器加载:

  • 调用ClassPool对象的get()方法时,无法找到要加载的类
  • 通过delegateLoadingOf()方法指定加载的

将被委托给父类加载器加载。

这个查找顺序允许加载已经被Javassist修改过的类。但是,它在因为某些原因无法找到已经被修改过的类是,会委托父类加载器加载。一旦一个类被父加载器加载,其他类定义中引用的类也将由父类加载器加载,因此他们不会被修改。
之前的例子中,所有C类应用的类会由C的真实加载器加载。如果你的程序无法加载一个修改过的类,你应该确认下是否类所引用的所有类是否都是由javassist.Loader加载。

3.4 编写一个类加载器

编写一个使用Javassit的类加载器如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package org.demo;

import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.NotFoundException;

import java.io.IOException;

public class SampleLoader extends ClassLoader{

public static void main(String[] args ) throws Throwable{
SampleLoader s = new SampleLoader();
Class c = s.loadClass("MyApp");
c.getDeclaredMethod("main", new Class[]{String[].class})
.invoke(null, new Object[]{args});
}

private ClassPool pool;

public SampleLoader() throws NotFoundException{
pool = new ClassPool();
pool.insertClassPath("./test-classes");
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
CtClass cc = pool.get(name);
byte[] b = cc.toBytecode();
return defineClass(name, b, 0, b.length);
} catch (NotFoundException e) {
throw new ClassNotFoundException();
}catch (IOException e) {
throw new ClassNotFoundException();
} catch (CannotCompileException e) {
throw new ClassNotFoundException();
}
}
}

类MyApp是一个应用程序入口。执行该程序, 首选要先将类放在./class目录下,该目录不是类搜索路径中的目录。否则,MyApp.class将会被默认的系统类加载器加载,该加载器是SamplerLoader的父加载器。将指定的路径./class在构造方法中通过insertClassPath()方法加入到搜索路径中。你可以指定其他路径名字替代./class。 然后执行以下命令:

1
% java SampleLoader

类加载器加载类MyApp(./class/MyApp.class)并且调用MyApp.main()方法,将命令行参数传给该方法。

3.5 修改系统类

系统类如java.lang.String不能被其他类加载器加载,除了系统类加载器之外。因此, SampleLoader或者javassist.Loader,如上所示, 不能在加载时修改系统类。

如果你的应用需要做到这一点,系统类必须被静态修改。比如,以下程序增加一个新的属性hiddenValue到java.lang.String类中:

1
2
3
4
5
6
ClassPool pool = ClassPool.getDefault(); 
CtClass cc = pool.get("java.lang.String");
CtField f = new CtField(CtClass.intType, "hiddenValue", cc);
f.setModifiers(Modifier.PUBLIC);
cc.addField(f);
cc.writeFile(".");

这段程序产生一个类”./java/lang/Stirng.class”。运行这段程序MyApp,使用修改后的String类,需使用如下方法:

1
% java -Xbootclasspath/p:.  MyApp arg1 arg2 ...

假设MyApp的定义如下:

1
2
3
4
5
public class MyApp {
public static void main(String[] args ) throws Exception {
System.out.println(Stirng.class.getField("hiddenValue").getName());
}
}

如果被修改的String类被正确加载, MyApp将打印hiddenValue。

注意:用该技术修改rt.jar的系统类的应用程序,不应该被部署,这违反了Java 2 Runtime Environment binary code license

3.6 运行时重新加载类

如果启动JVM启用了JPDA(Java平台调试架构-Java Platform Debugger Architechure)功能,一个类可以动态重新加载。在JVM加载一个类后,老版本的类定义将被卸载,新版本的类将被重新加载。因此,类定义可以在运行时动态修改。但是,新类定义必须兼容老的类。JVM不允许两个类版本的模型被改变。他们必须拥有相同的方法和属性集合。

Javassist提供了方便的类在运行时重新加载类。更多信息,查看API文档, 关于javassist.tools.HotSwapper部分。