【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天前
|
Java 开发者
重学Java基础篇—Java类加载顺序深度解析
本文全面解析Java类的生命周期与加载顺序,涵盖从加载到卸载的七个阶段,并深入探讨初始化阶段的执行规则。通过单类、继承体系的实例分析,明确静态与实例初始化的顺序。同时,列举六种触发初始化的场景及特殊场景处理(如接口初始化)。提供类加载完整流程图与记忆口诀,助于理解复杂初始化逻辑。此外,针对空指针异常等问题提出排查方案,并给出最佳实践建议,帮助开发者优化程序设计、定位BUG及理解框架机制。最后扩展讲解类加载器层次与双亲委派机制,为深入研究奠定基础。
29 0
|
14天前
|
消息中间件 Java 应用服务中间件
JVM实战—1.Java代码的运行原理
本文介绍了Java代码的运行机制、JVM类加载机制、JVM内存区域及其作用、垃圾回收机制,并汇总了一些常见问题。
JVM实战—1.Java代码的运行原理
|
14天前
|
缓存 安全 Java
《从头开始学java,一天一个知识点》之:输入与输出:Scanner与System类
你是否也经历过这些崩溃瞬间?三天教程连`i++`和`++i`都说不清,面试时`a==b`与`equals()`区别大脑空白,代码总是莫名报NPE。这个系列就是为你打造的Java「速效救心丸」!每天1分钟,地铁通勤、午休间隙即可学习。直击高频考点和实际开发中的“坑位”,拒绝冗长概念,每篇都有可运行代码示例。涵盖输入输出基础、猜数字游戏、企业编码规范、性能优化技巧、隐藏技能等。助你快速掌握Java核心知识,提升编程能力。点赞、收藏、转发,助力更多小伙伴一起成长!
40 19
|
8天前
|
存储 监控 安全
重学Java基础篇—类的生命周期深度解析
本文全面解析了Java类的生命周期,涵盖加载、验证、准备、解析、初始化、使用及卸载七个关键阶段。通过分阶段执行机制详解(如加载阶段的触发条件与技术实现),结合方法调用机制、内存回收保护等使用阶段特性,以及卸载条件和特殊场景处理,帮助开发者深入理解JVM运作原理。同时,文章探讨了性能优化建议、典型异常处理及新一代JVM特性(如元空间与模块化系统)。总结中强调安全优先、延迟加载与动态扩展的设计思想,并提供开发建议与进阶方向,助力解决性能调优、内存泄漏排查及框架设计等问题。
27 5
|
15天前
|
缓存 安全 Java
《从头开始学java,一天一个知识点》之:字符串处理:String类的核心API
🌱 **《字符串处理:String类的核心API》一分钟速通!** 本文快速介绍Java中String类的3个高频API:`substring`、`indexOf`和`split`,并通过代码示例展示其用法。重点提示:`substring`的结束索引不包含该位置,`split`支持正则表达式。进一步探讨了String不可变性的高效设计原理及企业级编码规范,如避免使用`new String()`、拼接时使用`StringBuilder`等。最后通过互动解密游戏帮助读者巩固知识。 (上一篇:《多维数组与常见操作》 | 下一篇预告:《输入与输出:Scanner与System类》)
44 11
|
8天前
|
安全 IDE Java
重学Java基础篇—Java Object类常用方法深度解析
Java中,Object类作为所有类的超类,提供了多个核心方法以支持对象的基本行为。其中,`toString()`用于对象的字符串表示,重写时应包含关键信息;`equals()`与`hashCode()`需成对重写,确保对象等价判断的一致性;`getClass()`用于运行时类型识别;`clone()`实现对象复制,需区分浅拷贝与深拷贝;`wait()/notify()`支持线程协作。此外,`finalize()`已过时,建议使用更安全的资源管理方式。合理运用这些方法,并遵循最佳实践,可提升代码质量与健壮性。
19 1
|
21天前
|
Java
课时14:Java数据类型划分(初见String类)
课时14介绍Java数据类型,重点初见String类。通过三个范例讲解:观察String型变量、&quot;+&quot;操作符的使用问题及转义字符的应用。String不是基本数据类型而是引用类型,但使用方式类似基本类型。课程涵盖字符串连接、数学运算与字符串混合使用时的注意事项以及常用转义字符的用法。
|
21天前
|
存储 Java 编译器
课时11:综合实战:简单Java类
本次分享的主题是综合实战:简单 Java 类。主要分为两个部分: 1.简单 Java 类的含义 2.简单 Java 类的开发
|
22天前
|
传感器 监控 Java
Java代码结构解析:类、方法、主函数(1分钟解剖室)
### Java代码结构简介 掌握Java代码结构如同拥有程序世界的建筑蓝图,类、方法和主函数构成“黄金三角”。类是独立的容器,承载成员变量和方法;方法实现特定功能,参数控制输入环境;主函数是程序入口。常见错误包括类名与文件名不匹配、忘记static修饰符和花括号未闭合。通过实战案例学习电商系统、游戏角色控制和物联网设备监控,理解类的作用、方法类型和主函数任务,避免典型错误,逐步提升编程能力。 **脑图速记法**:类如太空站,方法即舱段;main是发射台,static不能换;文件名对仗,括号要成双;参数是坐标,void不返航。
45 5
|
21天前
|
Oracle Java 关系型数据库
课时37:综合实战:数据表与简单Java类映射转换
今天我分享的是数据表与简单 Java 类映射转换,主要分为以下四部分。 1. 映射关系基础 2. 映射步骤方法 3. 项目对象配置 4. 数据获取与调试