【JAVA】不会有人不知道 Java 类能够在运行时动态生成吧?

简介: 探讨了更加深入的类加载和字节码操作方面技术。为了理解底层的原理,选取的例子是比较偏底层的、能力全面的类库,如果实际项目中需要进行基础的字节码操作,可以考虑使用更加高层次视角的类库。

前言

在阅读本文之前,可以先回看一下这篇博文:【JAVA】动态代理基于什么原理?

本篇博文的重点是,有哪些方法可以在运行时动态生成一个 Java 类?
 

概述

我们可以从常见的 Java 类来源分析,通常的开发过程是,开发者编写 Java 代码,调用 javac 编译成 class 文件,然后通过类加载机制载入 JVM,就成为应用运行时可以使用的 Java 类了。

从上面过程得到启发,其中一个直接的方式是从源码入手,可以利用 Java 程序生成一段源码,然后保存到文件等,下面就只需要解决编译问题了。

有一种笨办法,直接用 ProcessBuilder 之类启动 javac 进程,并指定上面生成的文件作为输入,进行编译。最后,再利用类加载器,在运行时加载即可。

前面的方法,本质上还是在当前程序进程之外编译的,那么还有没有不这么 low 的办法呢?

你可以考虑使用 Java Compiler API,这是 JDK 提供的标准 API,里面提供了与 javac 对等的编译器功能,具体请参考 java.compiler 相关文档。

进一步思考,我们一直围绕 Java 源码编译成为 JVM 可以理解的字节码,换句话说,只要是符合 JVM 规范的字节码,不管它是如何生成的,是不是都可以被 JVM 加载呢?我们能不能直接生成相应的字节码,然后交给类加载器去加载呢?当然也可以,不过直接去写字节码难度太大,通常我们可以利用 Java 字节码操纵工具和类库来实现.
 

正文

首先来理解一下,类从字节码到 Class 对象的转换,在类加载过程中,这一步是通过下面的方法提供的功能,或者 defineClass 的其他本地对等实现。

protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                   ProtectionDomain protectionDomain)
protected final Class<?> defineClass(String name, java.nio.ByteBuffer b,
                                   ProtectionDomain protectionDomain)

这里只选取了最基础的两个典型的 defineClass 实现,Java 重载了几个不同的方法。

可以看出,只要能够生成出规范的字节码,不管是作为 byte 数组的形式,还是放到 ByteBuffer 里,都可以平滑地完成字节码到 Java 对象的转换过程。

JDK 提供的 defineClass 方法,最终都是本地代码实现的。

static native Class<?> defineClass1(ClassLoader loader, String name, byte[] b, int off, int len,
                                  ProtectionDomain pd, String source);

static native Class<?> defineClass2(ClassLoader loader, String name, java.nio.ByteBuffer b,
                                  int off, int len, ProtectionDomain pd, String source);

更进一步,我们来看看 JDK dynamic proxy 的实现代码。你会发现,对应逻辑是实现在 ProxyBuilder 这个静态内部类中,ProxyGenerator 生成字节码,并以 byte 数组的形式保存,然后通过调用 Unsafe 提供的 defineClass 入口。

byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
      proxyName, interfaces.toArray(EMPTY_CLASS_ARRAY), accessFlags);
try {
  Class<?> pc = UNSAFE.defineClass(proxyName, proxyClassFile,
                                   0, proxyClassFile.length,
                                   loader, null);
  reverseProxyCache.sub(pc).putIfAbsent(loader, Boolean.TRUE);
  return pc;
} catch (ClassFormatError e) {.
// 如果出现ClassFormatError,很可能是输入参数有问题,比如,ProxyGenerator 有 bug
}

前面理顺了二进制的字节码信息到 Class 对象的转换过程,似乎我们还没有分析如何生成自己需要的字节码,接下来一起来看看相关的字节码操纵逻辑。

JDK 内部动态代理的逻辑,可以参考 java.lang.reflect.ProxyGenerator 的内部实现。我觉得可以认为这是种另类的字节码操纵技术,其利用了 DataOutputStrem 提供的能力,配合 hard-coded 的各种 JVM 指令实现方法,生成所需的字节码数组。

private void codeLocalLoadStore(int lvar, int opcode, int opcode_0,
                              DataOutputStream out)
  throws IOException
{
  assert lvar >= 0 && lvar <= 0xFFFF;
  // 根据变量数值,以不同格式,dump操作码
    if (lvar <= 3) {
      out.writeByte(opcode_0 + lvar);
  } else if (lvar <= 0xFF) {
      out.writeByte(opcode);
      out.writeByte(lvar & 0xFF);
  } else {
      // 使用宽指令修饰符,如果变量索引不能用无符号byte
      out.writeByte(opc_wide);
      out.writeByte(opcode);
      out.writeShort(lvar & 0xFFFF);
  }
}

这种实现方式的好处是没有太多依赖关系,简单实用,但是前提是你需要懂各种 JVM 指令,知道怎么处理那些偏移地址等,实际门槛非常高,所以并不适合大多数的普通开发场景。

幸好,Java 社区专家提供了各种从底层到更高抽象水平的字节码操作类库,我们不需要什么都自己从头做。JDK 内部就集成了 ASM 类库,虽然并未作为公共 API 暴露出来,但是它广泛应用在,如 java.lang.instrumentation API 底层实现,或者 Lambda Call Site 生成的内部逻辑中,这些代码的实现我就不在这里展开了,如果你确实有兴趣或有需要,可以参考类似 LamdaForm 的字节码生成逻辑:java.lang.invoke.InvokerBytecodeGenerator。

从相对实用的角度思考一下,实现一个简单的动态代理,都要做什么?如何使用字节码操纵技术,走通这个过程呢?

对于一个普通的 Java 动态代理,其实现过程可以简化成为:

  • 提供一个基础的接口,作为被调用类型(com.mycorp.HelloImpl)和代理类之间的统一入口,如 com.mycorp.Hello。
  • 实现 InvocationHandler,对代理对象方法的调用,会被分派到其 invoke 方法来真正实现动作。
  • 通过 Proxy 类,调用其 newProxyInstance 方法,生成一个实现了相应基础接口的代理类实例,可以看下面的方法签名。
public static Object newProxyInstance(ClassLoader loader,
                                    Class<?>[] interfaces,
                                    InvocationHandler h)

我们分析一下,动态代码生成是具体发生在什么阶段呢?

不错,就是在 newProxyInstance 生成代理类实例的时候。我选取了 JDK 自己采用的 ASM 作为示例,一起来看看用 ASM 实现的简要过程,请参考下面的示例代码片段。

第一步,生成对应的类,其实和我们去写 Java 代码很类似,只不过改为用 ASM 方法和指定参数,代替了我们书写的源码。

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

cw.visit(V1_8,                      // 指定Java版本
      ACC_PUBLIC,                   // 说明是public类型
      "com/mycorp/HelloProxy",      // 指定包和类的名称
      null,                         // 签名,null表示不是泛型
      "java/lang/Object",                    // 指定父类
      new String[]{ "com/mycorp/Hello" });   // 指定需要实现的接口

更进一步,我们可以按照需要为代理对象实例,生成需要的方法和逻辑。

MethodVisitor mv = cw.visitMethod(
      ACC_PUBLIC,                 // 声明公共方法
      "sayHello",                 // 方法名称
      "()Ljava/lang/Object;",     // 描述符
      null,                       // 签名,null表示不是泛型
      null);                      // 可能抛出的异常,如果有,则指定字符串数组

mv.visitCode();
// 省略代码逻辑实现细节
cw.visitEnd();                      // 结束类字节码生成

上面的代码虽然有些晦涩,但总体还是能多少理解其用意,不同的 visitX 方法提供了创建类型,创建各种方法等逻辑。ASM API,广泛的使用了 Visitor 模式,如果你熟悉这个模式,就会知道它所针对的场景是将算法和对象结构解耦,非常适合字节码操纵的场合,因为我们大部分情况都是依赖于特定结构修改或者添加新的方法、变量或者类型等。

按照前面的分析,字节码操作最后大都应该是生成 byte 数组,ClassWriter 提供了一个简便的方法。

cw.toByteArray();

然后,就可以进入我们熟知的类加载过程了;

最后一个问题,字节码操纵技术,除了动态代理,还可以应用在什么地方?

这个技术似乎离我们日常开发遥远,但其实已经深入到各个方面,也许很多你现在正在使用的框架、工具就应用该技术,下面是我能想到的几个常见领域。

  • 各种 Mock 框架
  • ORM 框架
  • IOC 容器
  • 部分 Profiler 工具,或者运行时诊断工具等
  • 生成形式化代码的工具

甚至可以认为,字节码操纵技术是工具和基础框架必不可少的部分,大大减少了开发者的负担。

后记

以上就是 【JAVA】不会有人不知道 Java 类能够在运行时动态生成吧? 的所有内容了;

探讨了更加深入的类加载和字节码操作方面技术。为了理解底层的原理,选取的例子是比较偏底层的、能力全面的类库,如果实际项目中需要进行基础的字节码操作,可以考虑使用更加高层次视角的类库。

📝 上篇精讲: 【JAVA】聊聊类加载过程
💖 我是  𝓼𝓲𝓭𝓲𝓸𝓽,期待你的关注;
👍 创作不易,请多多支持;
🔥 系列专栏:  面试精讲 JAVA
目录
相关文章
|
8天前
|
存储 SQL 小程序
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
这篇文章详细介绍了Java虚拟机(JVM)的运行时数据区域和JVM指令集,包括程序计数器、虚拟机栈、本地方法栈、直接内存、方法区和堆,以及栈帧的组成部分和执行流程。
15 2
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
|
1天前
|
算法 Java 数据处理
从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其“不重复性”要求,彻底改变了处理唯一性数据的方式。
从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其“不重复性”要求,彻底改变了处理唯一性数据的方式。HashSet基于哈希表实现,提供高效的元素操作;TreeSet则通过红黑树实现元素的自然排序,适合需要有序访问的场景。本文通过示例代码详细介绍了两者的特性和应用场景。
15 6
|
3天前
|
IDE Java 编译器
Java:如何确定编译和运行时类路径是否一致
类路径(Classpath)是JVM用于查找类文件的路径列表,对编译和运行Java程序至关重要。编译时通过`javac -classpath`指定,运行时通过`java -classpath`指定。IDE如Eclipse和IntelliJ IDEA也提供界面管理类路径。确保编译和运行时类路径一致,特别是外部库和项目内部类的路径设置。
|
2天前
|
安全 Java 测试技术
Java零基础-StringBuffer 类详解
【10月更文挑战第9天】Java零基础教学篇,手把手实践教学!
10 2
|
3天前
|
算法 Java 数据处理
从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其独特的“不重复性”要求,彻底改变了处理唯一性约束数据的方式。
【10月更文挑战第14天】从HashSet到TreeSet,Java集合框架中的Set接口及其实现类以其独特的“不重复性”要求,彻底改变了处理唯一性约束数据的方式。本文深入探讨Set的核心理念,并通过示例代码展示了HashSet和TreeSet的特点和应用场景。
9 2
|
3天前
|
存储 Java 索引
Java 中集合框架的常见接口和类
【10月更文挑战第13天】这些只是集合框架中的一部分常见接口和类,还有其他一些如 Queue、Deque 等接口以及相关的实现类。理解和掌握这些集合的特点和用法对于高效编程非常重要。
|
8天前
|
小程序 Oracle Java
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
这篇文章是关于JVM基础知识的介绍,包括JVM的跨平台和跨语言特性、Class文件格式的详细解析,以及如何使用javap和jclasslib工具来分析Class文件。
21 0
JVM知识体系学习一:JVM了解基础、java编译后class文件的类结构详解,class分析工具 javap 和 jclasslib 的使用
|
10天前
|
存储 安全 Java
Java零基础-Java类详解
【10月更文挑战第2天】Java零基础教学篇,手把手实践教学!
13 2
|
13天前
|
Java 数据安全/隐私保护
java类和对象
java类和对象
19 5
|
10天前
|
存储 安全 Java
Java基础-Collection类关系图
Java基础-Collection类关系图
11 0