现在的位置: 首页 > 程序设计> 正文
Java深度解析之字节代码操控与类的加载链接初始化
2011年08月02日 程序设计 暂无评论 ⁄ 被围观 4,976+

本系列的文章转载自infoq专栏合集《Java深度历险》,10篇文章分别由浅到深描述了Java的多个细节,具体包括
1. Java 字节代码的操纵    2. Java类的加载、链接和初始化  3. Java线程:基本概念、可见性与同步
4. Java垃圾回收机制与引用类型  5. Java泛型   6. Java注解  7. Java反射与动态代理
8. Java I/O   9. Java安全  10. Java对象序列化与RMI,
相信通过十个主题的学习,读者能对Java的这几个方面有较深入的理解。enjoy it!

Java 字节代码的操纵

在一般的Java应用开发过程中,开发人员使用Java的方式比较简单。打开惯用的IDE,编写Java源代码,再利用IDE提供的功能直接运行Java  程序就可以了。这种开发模式背后的过程是:开发人员编写的是Java源代码文件(.java),IDE会负责调用Java的编译器把Java源代码编译成平台无关的字节代码(byte code),以类文件的形式保存在磁盘上(.class)。Java虚拟机(JVM)会负责把Java字节代码加载并执行。Java通过这种方式来实现其 “编写一次,到处运行(Write once, run anywhere)”  的目标。Java类文件中包含的字节代码可以被不同平台上的JVM所使用。Java字节代码不仅可以以文件形式存在于磁盘上,也可以通过网络方式来下载,还可以只存在于内存中。JVM中的类加载器会负责从包含字节代码的字节数组(byte[])中定义出Java类。在某些情况下,可能会需要动态的生成 Java字节代码,或是对已有的Java字节代码进行修改。这个时候就需要用到本文中将要介绍的相关技术。首先介绍一下如何动态编译Java源文件。

动态编译 Java 源文件

在一般情况下,开发人员都是在程序运行之前就编写完成了全部的Java源代码并且成功编译。对有些应用来说,Java源代码的内容在运行时刻才能确定。这个时候就需要动态编译源代码来生成Java字节代码,再由JVM来加载执行。典型的场景是很多算法竞赛的在线评测系统(如 PKU JudgeOnline),允许用户上传Java代码,由系统在后台编译、运行并进行判定。在动态编译Java源文件时,使用的做法是直接在程序中调用Java编译器。
JSR 199引入了Java编译器API。如果使用JDK 6 的话,可以通过此API来动态编译Java代码。比如下面的代码用来动态编译最简单的Hello World类。该Java类的代码是保存在一个字符串中的。

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
public class CompilerTest {
  public static void main(String[] args) throws Exception {
    String source = "public class Main { public static void main(String[] args){System.out.println("Hello World!");}}";
    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler()
    StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);     
    StringSourceJavaObject sourceObject = new CompilerTest.StringSourceJavaObject("Main", source);     
    Iterable< extends JavaFileObject> fileObjects = Arrays.asList(sourceObject);
    CompilationTask task = compiler.getTask(null, fileManager, nullnull, null, fileObjects);
    boolean result = task.call();
    if (result) {
      System.out.println("编译成功。");
    }
}
 
static class StringSourceJavaObject extends SimpleJavaFileObject {
  private String content = null;
  public StringSourceJavaObject(String name, String content) throws URISyntaxException {
    super(URI.create("string:///" + name.replace('.','/') + Kind.SOURCE.extension), Kind.SOURCE);
    this.content = content;
  }
  public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
     return content;
  }
 }
}
public class CompilerTest {
  public static void main(String[] args) throws Exception {
    String source = "public class Main { public static void main(String[] args){System.out.println("Hello World!");}}";
    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler()
    StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);     
    StringSourceJavaObject sourceObject = new CompilerTest.StringSourceJavaObject("Main", source);     
    Iterable< extends JavaFileObject> fileObjects = Arrays.asList(sourceObject);
    CompilationTask task = compiler.getTask(null, fileManager, null, null, null, fileObjects);
    boolean result = task.call();
    if (result) {
      System.out.println("编译成功。");
    }
}

static class StringSourceJavaObject extends SimpleJavaFileObject {
  private String content = null;
  public StringSourceJavaObject(String name, String content) throws URISyntaxException {
    super(URI.create("string:///" + name.replace('.','/') + Kind.SOURCE.extension), Kind.SOURCE);
    this.content = content;
  }
  public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
     return content;
  }
 }
}

如 果 不 能 使  用 JDK 6 提  供 的 Java 编  译 器 API 的 话 , 可 以 使  用 JDK 中 的 工 具类 com.sun.tools.javac.Main,不过该工具类只能编译存放在磁盘上的文件,类似于直接使用javac命令。

另外一个可用的工具是 Eclipse JDT Core提供的编译器。这是Eclipse Java开发环境使用的增量式Java编译器,支持运行和调试有错误的代码。该编译器也可以单独使用。Play框架 在内部使用了JDT的编译器来动态编译Java源代码。在开发模式下,Play框架会定期扫描项目中的Java源代码文件,一旦发现有修改,会自动编译Java源代码。因此在修改代码之后,刷新页面就可以看到变化。使用这些动态编译的方式的时候,需要确保JDK中的tools.jar在应用的 CLASSPATH中。

下面介绍一个例子,是关于如何在Java里面做四则运算,比如求出来(3+4)*7-10 的值。

一般的做法是分析输入的运算表达式,自己来模拟计算过程。考虑到括号的存在和运算符的优先级等问题,这样的计算过程会比较复杂,而且容易出错。另外一种做法是可以用 JSR 223引入的脚本语言支持,直接把输入的表达式当做JavaScript或是JavaFX脚本来执行,得到结果。下面的代码使用的做法是动态生成Java源代码并编译,接着加载Java类来执行并获取结果。这种做法完全使用Java来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static double calculate(String expr) throws CalculationException  {
  String className = "CalculatorMain";
  String methodName = "calculate";
  String source = "public class " + className       + " { public static double " + methodName + "() { return " + expr + "; } }";
  //省略动态编译Java源代码的相关代码,参见上一节
  boolean result = task.call();
  if (result) {
     ClassLoader loader = Calculator.class.getClassLoader();
     try {
         Class<?> clazz = loader.loadClass(className);
         Method method = clazz.getMethod(methodName, new Class<?>[] {});
         Object value = method.invoke(null, new Object[] {});
         return (Double) value;
     } catch (Exception e) {
         throw new CalculationException("内部错误。");
   }
 } else {
    throw new CalculationException("错误的表达式。");
}
}
private static double calculate(String expr) throws CalculationException  {
  String className = "CalculatorMain";
  String methodName = "calculate";
  String source = "public class " + className       + " { public static double " + methodName + "() { return " + expr + "; } }";
  //省略动态编译Java源代码的相关代码,参见上一节
  boolean result = task.call();
  if (result) {
     ClassLoader loader = Calculator.class.getClassLoader();
     try {
         Class<?> clazz = loader.loadClass(className);
         Method method = clazz.getMethod(methodName, new Class<?>[] {});
         Object value = method.invoke(null, new Object[] {});
         return (Double) value;
     } catch (Exception e) {
         throw new CalculationException("内部错误。");
   }
 } else {
    throw new CalculationException("错误的表达式。");
}
}

上面的代码给出了使用动态生成的 Java 字节代码的基本模式,即通过类加载器来加载字节代码,创建 Java 类的对象的实例,再通过 Java 反射 API 来调用对象中的方法。

Java 字节代码增强

Java  字节代码增强指的是在Java字节代码生成之后,对其进行修改,增强其功能。这种做法相当于对应用程序的二进制文件进行修改。在很多Java框架中都可以见到这种实现方式。Java字节代码增强通常与Java源文件中的注解(annotation)一块使用。注解在Java源代码中声明了需要增强的行为及 相关的元数据,由框架在运行时刻完成对字节代码的增强。Java字节代码增强应用的场景比较多,一般都集中在减少冗余代码和对开发人员屏蔽底层的实现细节 上。用过 JavaBeans的人可能对其中那些必须添加的getter/setter方法感到很繁琐,并且难以维护。而通过字节代码增强,开发人员只需要声明Bean中的属性即可,getter/setter方法可以通过修改字节代码来自动添加。用过 JPA的人,在调试程序的时候,会发现 实体类 中被添加了一些额外的 域和方法。这些域和方法是在运行时刻由JPA的实现动态添加的。字节代码增强在 面向方面编程(AOP)的一些实现中也有使用。

在讨论如何进行字节代码增强之前,首先介绍一下表示一个 Java 类或接口的字节代码的组织形式。

类文件 {    0xCAFEBABE,小版本号,大版本号,常量池大小,常量池数组,    访问控制标记,当前类信息,父类信息,实现的接口个数,实现的接口信息数组,域个数,域信息数组,方法个数,方法信息数组,属性个数,属性信息数组}

如上所示,一个类或接口的字节代码使用的是一种松散的组织结构,其中所包含的内容依次排列。对于可能包含多个条目的内容,如所实现的接口、域、方法和属性等,是以数组来表示的。而在数组之前的是该数组中条目的个数。不同的内容类型,有其不同的内部结构。对于开发人员来说,直接操纵包含字节代码的字节数组的话,开发效率比较低,而且容易出错。已经有不少的开源库可以对字节代码进行修改或是从头开始创建新的Java类的字节代码内容。这些类库包括 ASMcglibserpBCEL等。使用这些类库可以在一定程度上降低增强字节代码的复杂度。比如考虑下面一个简单的需求,在一个Java类的所有方法执行之前输出相应的日志。熟悉AOP的人都知道,可以用一个前增强(before advice)来解决这个问题。如果使用ASM的话,相关的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ClassReader cr = new ClassReader(is);
ClassNode cn = new ClassNode();
cr.accept(cn, 0);
for (Object object : cn.methods) {
MethodNode mn = (MethodNode) object;
if ("<init>".equals(mn.name) || "<clinit>".equals(mn.name)) {
     continue;
}
 
InsnList insns = mn.instructions;
InsnList il = new InsnList();
il.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out","Ljava/io/PrintStream;"));
il.add(new LdcInsnNode("Enter method -> " + mn.name));
il.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream""println", "(Ljava/lang/String;)V"));
insns.insert(il);
mn.maxStack += 3;
}
 
ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
byte[] b = cw.toByteArray();
ClassReader cr = new ClassReader(is);
ClassNode cn = new ClassNode();
cr.accept(cn, 0);
for (Object object : cn.methods) {
MethodNode mn = (MethodNode) object;
if ("<init>".equals(mn.name) || "<clinit>".equals(mn.name)) {
     continue;
}

InsnList insns = mn.instructions;
InsnList il = new InsnList();
il.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out","Ljava/io/PrintStream;"));
il.add(new LdcInsnNode("Enter method -> " + mn.name));
il.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"));
insns.insert(il);
mn.maxStack += 3;
}

ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
byte[] b = cw.toByteArray();

ClassWriter就 可以获取到包含增强之后的字节代码的字节数组,可以把字节代码写回磁盘或是由类加载器直接使用。上述示例中,增强部分的逻辑比较简单,只是遍历Java类中的所有方法并添加对System.out.println方法的调用。在字节代码中,Java方法体是由一系列的指令组成的。而要做的是生成调用 System.out.println方法的指令,并把这些指令插入到指令集合的最前面。ASM对这些指令做了抽象,不过熟悉全部的指令比较困难。ASM提供了一个工具类 ASMifierClassVisitor,可以打印出Java类的字节代码的结构信息。当需要增强某个类的时候,可以先在源代码上做出修改,再通过此工具类来比较修改前后的字节代码的差异,从而确定该如何编写增强的代码。

对类文件进行增强的时机是需要在 Java 源代码编译之后,在 JVM 执行之前。比较常见的做法有:

  • 由IDE在完成编译操作之后执行。如Google App Engine的Eclipse插件会在编译之后运行 DataNucleus来对实体类进行增强。
  • 在构建过程中完成,比如通过 Ant 或 Maven 来执行相关的操作。
  • 实现自己的 Java 类加载器。当获取到 Java 类的字节代码之后,先进行增强处理,再从修改过的字节代码中定义出 Java 类。
  • 通过 JDK 5 引入的 java.lang.instrument 包来完成。

java.lang.instrument

由于存在着大量对Java字节代码进行修改的需求,JDK 5引入了java.lang.instrument包并在 JDK 6中 得到了进一步的增强。基本的思路是在JVM启动的时候添加一些代理(agent)。每个代理是一个jar包,其清单(manifest)文件中会指定一个 代理类。这个类会包含一个premain方法。JVM在启动的时候会首先执行代理类的premain方法,再执行Java程序本身的main方法。在 premain方法中就可以对程序本身的字节代码进行修改。JDK 6 中还允许在JVM启动之后动态添加代理。java.lang.instrument包支持两种修改的场景,一种是重定义一个Java类,即完全替换一个 Java类的字节代码;另外一种是转换已有的Java类,相当于前面提到的类字节代码增强。还是以前 面 提 到 的 输 出 方 法 执 行 日 志  的 场 景 为 例 , 首 先 需 要 实现 java.lang.instrument.ClassFileTransformer接口来完成对已有Java类的转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static class MethodEntryTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className,     Class<?> classBeingRedefined, ?ProtectionDomain protectionDomain, byte[] classfileBuffer) throws  IllegalClassFormatException {
 try {
       ClassReader cr = new ClassReader(classfileBuffer);
       ClassNode cn = new ClassNode();
       //省略使用ASM进行字节代码转换的代码
       ClassWriter cw = new ClassWriter(0);
       cn.accept(cw);
       return cw.toByteArray();
    } catch (Exception e){
      return null;
    }
 }
}
static class MethodEntryTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className,     Class<?> classBeingRedefined, ?ProtectionDomain protectionDomain, byte[] classfileBuffer) throws  IllegalClassFormatException {
 try {
       ClassReader cr = new ClassReader(classfileBuffer);
       ClassNode cn = new ClassNode();
       //省略使用ASM进行字节代码转换的代码
       ClassWriter cw = new ClassWriter(0);
       cn.accept(cw);
       return cw.toByteArray();
    } catch (Exception e){
      return null;
    }
 }
}

有了这个转换类之后,就可以在代理的 premain 方法中使用它。

1
2
3
public static void premain(String args, Instrumentation inst) {
   inst.addTransformer(new MethodEntryTransformer());
}
public static void premain(String args, Instrumentation inst) {
   inst.addTransformer(new MethodEntryTransformer());
}

把该代理类打成一个 jar 包 ,并在 jar 包的清单文件中通过 Premain-Class 声明代理类的名称。运行 Java 程序的时候,添加 JVM 启动参数-javaagent:myagent.jar。这样的话,JVM 会在加载 Java 类的字节代码之前,完成相关的转换操作。

总结

操纵 Java 字节代码是一件很有趣的事情。通过它,可以很容易的对二进制分发的 Java程序进行修改,非常适合于性能分析、调试跟踪和日志记录等任务。另外一个非常重要的作用是把开发人员从繁琐的 Java 语法中解放出来。开发人员应该只需要负责编写与业务逻辑相关的重要代码。对于那些只是因为语法要求而添加的,或是模式固定的代码,完全可以将其字节代码动态生成出来。字节代码增强和源代码生成是不同的概念。源代码生成之后,就已经成为了程序的一部分,开发人员需要去维护它:要么手工修改生成出来的源代码,要么重新生成。而字节代码的增强过程,对于开发人员是完全透明的。妥善使用 Java 字节代码的操纵技术,可以更好的解决某一类开发问题。

参考资料

Java 类的加载、链接和初始化

在前面的文章中介绍了Java字节代码的操纵,其中提到了利用Java类加载器来加载修改过后的字节代码并在JVM上执行。本文接着上一篇的话题,讨论Java类的加载、链接和初始化。Java字节代码的表现形式是字节数组(byte[]),而Java类在JVM中的表现形式是java.lang.Class类 的对象。一个Java类从字节代码到能够在JVM中被使用,需要经过加载、链接和初始化这三个步骤。这三个步骤中,对开发人员直接可见的是Java类的加 载,通过使用Java类加载器(class loader)可以在运行时刻动态的加载一个Java类;而链接和初始化则是在使用Java类之前会发生的动作。本文会详细介绍Java类的加载、链接和 初始化的过程。

Java 类的加载

Java类的加载是由类加载器来完成的。一般来说,类加载器分成两类:启动类加载器(bootstrap)和用户自定义的类加载器(user-defined)。两者的区别在于启动类加载器是由JVM的原生代码实现的,而用户自定义的类加载器都继承自Java中的java.lang.ClassLoader类。在用户自定义类加载器的部分,一般JVM都会提供一些基本实现。应用程序的开发人员也可以根据需要编写自己的类加载器。JVM中最常使用的是系统类加载器(system),它用来启动Java应用程序的加载。通过java.lang.ClassLoader的getSystemClassLoader()方法可以获取到该类加载器对象。

类加载器需要完成的最终功能是定义一个Java类,即把Java字节代码转换成JVM中的java.lang.Class类的对象。但是类加载的过程并不 是这么简单。Java类加载器有两个比较重要的特征:层次组织结构和代理模式。层次组织结构指的是每个类加载器都有一个父类加载器,通过getParent()方法可以获取到。类加载器通过这种父亲-后代的方式组织在一起,形成树状层次结构。代理模式则指的是一个类加载器既可以自己完成Java类的定义工作,也可 以代理给其它的类加载器来完成。由于代理模式的存在,启动一个类的加载过程的类加载器和最终定义这个类的类加载器可能并不是一个。前者称为初始类加载器, 而后者称为定义类加载器。两者的关联在于:一个Java类的定义类加载器是该类所导入的其它Java类的初始类加载器。比如类A通过import导入了类 B,那么由类A的定义类加载器负责启动类B的加载过程。

一般的类加载器在尝试自己去加载某个Java类之前,会首先代理给其父类加载器。当父类加载器找不到的时候, 才会尝试自己加载。这个逻辑是封装在java.lang.ClassLoader类的loadClass()方法中的。一般来说,父类优先的策略就足够好了。在某些情况下,可能需要采取相反的策略,即先尝试自己加载,找不到的时候再代理给父类加载器。这种做法在Java的Web容器中比较常见,也是Servlet规范推荐的做法。比如,Apache Tomcat为每个Web应用都提供一个独立的类加载器,使用的就是自己优先加载的策略。IBM WebSphere Application Server则允许Web应用选择类加载器使用的策略。

类加载器的一个重要用途是在JVM中为相同名称的Java类创建隔离空间。在JVM中,判断两个类是否相同,不仅是根据该类的二进制名称,还需要根据两个类的定义类加载器。只有两者完全一样,才认为两个类的是相同的。因此,即便是同样的Java字节代码,被两个不同的类加载器定义之后,所得到的Java类也是不同的。如果试图在两个类的对象之间进行赋值操作,会抛出java.lang.ClassCastException。这个特性为同样名称的Java类在JVM中共存创造了条件。在实际的应用中,可能会要求同一名称的Java类的不同版本在JVM中可以同时存在。通过类加载器就可以满足这种需求。这种技术在OSGi中得到了广泛的应用。

Java 类的链接

Java类的链接指的是将Java类的二进制代码合并到JVM的运行状态之中的过程。在链接之前,这个类必须被成功加载。类的链接包括验证、准备和解析等几个步骤。验证是用来确保Java类的二进制表示在结构上是完全正确的。如果验证过程出现错误的话,会抛出java.lang.VerifyError错误。准备过程则是创建Java类中的静态域,并将这些域的值设为默认值。准备过程并不会执行代码。在一个Java类中会包含对其它类或接口的形式引用,包括它的父类、所实现的接口、方法的形式参数和返回值的Java类等。解析的过程就是确保这些被引用的类能被正确的找到。解析的过程可能会导致其它的 Java类被加载。

不同的JVM 实现可能选择不同的解析策略。一种做法是在链接的时候,就递归的把所有依赖的形式引用都进行解析。而另外的做法则可能是只在一个形式引用真正需要的时候才进行解析。也就是说如果一个Java 类只是被引用了,但是并没有被真正用到,那么这个类有可能就不会被解析。考虑下面的代码:

1
2
3
4
5
6
public class LinkTest {
    public static void main(String[] args) {
         ToBeLinked toBeLinked = null;
         System.out.println("Test link.");
    }
}
public class LinkTest {
    public static void main(String[] args) {
         ToBeLinked toBeLinked = null;
         System.out.println("Test link.");
    }
}

类 LinkTest 引用了类ToBeLinked,但是并没有真正使用它,只是声明了一个变量,并没有创建该类的实例或是访问其中的静态域。在 Oracle 的JDK 6 中,如果把编译好的ToBeLinked 的Java 字节代码删除之后,再运行LinkTest,程序不会抛出错误。这是因为ToBeLinked 类没有被真正用到,而Oracle 的JDK 6 所采用的链接策略使得ToBeLinked 类不会被加载,因此也不会发现ToBeLinked 的Java 字节代码实际上是不存在的。如果把代码改成ToBeLinked toBeLinked = new ToBeLinked();之后,再按照相同的方法运行,就会抛出异常了。因为这个时候ToBeLinked 这个类被真正使用到了,会需要加载这个类。

Java 类的初始化

当一个Java 类第一次被真正使用到的时候,JVM 会进行该类的初始化操作。初始化过程的主要操作是执行静态代码块和初始化静态域。在一个类被初始化之前,它的直接父类也需要被初始化。但是,一个接口的初始化,不会引起其父接口的初始化。在初始化的时候,会按照源代码中从上到下的顺序依次执行静态代码块和初始化静态域。考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
public class StaticTest {
     public static int X = 10;
     public static void main(String[] args) {
          System.out.println(Y); //输出60
     }
    static {
        X = 30;
    }
    public static int Y = X * 2;
}
public class StaticTest {
     public static int X = 10;
     public static void main(String[] args) {
          System.out.println(Y); //输出60
     }
    static {
        X = 30;
    }
    public static int Y = X * 2;
}

在上面的代码中,在初始化的时候,静态域的初始化和静态代码块的执行会从上到下依次执行。因此变量X 的值首先初始化成10,后来又被赋值成30;而变量Y 的值则被初始化成60。

Java 类和接口的初始化只有在特定的时机才会发生,这些时机包括:

  • 创建一个 Java 类的实例。如
    1
    
    MyClass obj = new MyClass()
    MyClass obj = new MyClass()
  • 调用一个 Java 类中的静态方法。如
    1
    
    MyClass.sayHello()
    MyClass.sayHello()
  • 给 Java 类或接口中声明的静态域赋值。如
    1
    
    MyClass.value = 10
    MyClass.value = 10
  • 访问 Java 类或接口中声明的静态域,并且该域不是常值变量。如
    1
    
    int value = MyClass.value
    int value = MyClass.value
  • 在顶层 Java 类中执行assert 语句。

通过Java 反射API 也可能造成类和接口的初始化。需要注意的是,当访问一个Java类或接口中的静态域的时候,只有真正声明这个域的类或接口才会被初始化。考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class B {
   static int value = 100;
   static {
         System.out.println("Class B is initialized."); //输出
   }
 }
class A extends B {
    static {
         System.out.println("Class A is initialized."); //不会输出
    }
}
public class InitTest {
    public static void main(String[] args) {
        System.out.println(A.value); //输出100
    }
}
class B {
   static int value = 100;
   static {
         System.out.println("Class B is initialized."); //输出
   }
 }
class A extends B {
    static {
         System.out.println("Class A is initialized."); //不会输出
    }
}
public class InitTest {
    public static void main(String[] args) {
        System.out.println(A.value); //输出100
    }
}

在上述代码中,类InitTest 通过A.value 引用了类B 中声明的静态域value。由于value是在类B 中声明的,只有类B 会被初始化,而类A 则不会被初始化。

创建自己的类加载器

在Java 应用开发过程中,可能会需要创建应用自己的类加载器。典型的场景包括实现特定的Java 字节代码查找方式、对字节代码进行加密/解密以及实现同名 Java 类的隔离等。创建自己的类加载器并不是一件复杂的事情, 只需要继承自java.lang.ClassLoader 类并覆写对应的方法即可。java.lang.ClassLoader 中提供的方法有不少,下面介绍几个创建类加载器时需要考虑的:

  • defineClass():这个方法用来完成从Java字节代码的字节数组到java.lang.Class的转换。这个方法是不能被覆写的,一般是用原生代码来实现的。
  • findLoadedClass():这个方法用来根据名称查找已经加载过的Java类。一个类加载器不会重复加载同一名称的类。
  • findClass():这个方法用来根据名称查找并加载Java类。
  • loadClass():这个方法用来根据名称加载Java类。
  • resolveClass():这个方法用来链接一个Java类。

这里比较 容易混淆的是findClass()方法和loadClass()方法的作用。前面提到过,在Java 类的链接过程中,会需要对Java 类进行解析,而解析可能会导致当前Java 类所引用的其它Java 类被加载。在这个时候,JVM 就是通过调用当前类的定义类加载器的loadClass()方法来加载其它类的。findClass()方法则是应用创建的类加载器的扩展点。应用自己的类加载器应该覆写findClass()方法来添加自定义的类加载逻辑。loadClass()方法的默认实现会负责调用findClass()方法。

前面提到,类加载器的代理模式默认使用的是父类优先的策略。这个策略的实现是封装在loadClass()方法中的。如果希望修改此策略,就需要覆写loadClass()方法。

下面的代码给出了自定义的类加载的常见实现模式:

1
2
3
4
5
6
7
public class MyClassLoader extends ClassLoader {
   protected Class<?>
   findClass(String name) throwsClassNotFoundException {
        byte[] b = null; //查找或生成Java类的字节代码
        return defineClass(name, b, 0, b.length);
   }
}
public class MyClassLoader extends ClassLoader {
   protected Class<?>
   findClass(String name) throwsClassNotFoundException {
        byte[] b = null; //查找或生成Java类的字节代码
        return defineClass(name, b, 0, b.length);
   }
}

参考资料

给我留言

留言无头像?


×
腾讯微博