24 What Methods Are There to Dynamically Generate a Java Class at Runtime

在Java中,有几种方法可以在运行时动态生成一个Java类:

  1. 使用Java反射API:Java的反射API允许你在运行时检查和操作类的结构。通过反射,你可以创建一个新的Class对象并通过Class对象的方法来动态生成类的各个组成部分,如字段、方法、构造函数等。

  2. 使用Java动态代理:动态代理是一种通过实现一个或多个接口来创建新的类的机制。通过创建动态代理类,你可以在运行时生成一个新的接口的实现类,该类可以拦截接口中的方法调用并添加额外的逻辑。

  3. 使用字节码操作库:字节码操作库(如ASM、CGLIB等)允许你直接操作Java字节码,并在运行时生成新的类。通过使用这些库,你可以以编程方式来生成类的字节码,并在 JVM 加载这些字节码时动态定义新的类。

以上这些方法提供了不同的灵活性和复杂性,你可以根据具体的需求选择适合的方法来动态生成Java类。不过,需要注意的是,在动态生成类时要务必小心处理类加载、命名冲突和安全性等问题。

24 What methods are there to dynamically generate a Java class at runtime #

We can analyze the common sources of Java classes. Typically, the development process involves developers writing Java code, compiling it into class files using javac, and then loading them into the JVM through the class loading mechanism for the Java classes to be used at runtime.

From the above process, one direct approach is to start with the source code. We can use a Java program to generate a piece of source code and then save it to a file. Then, we just need to resolve the compilation issue.

One clumsy solution is to directly use something like ProcessBuilder to launch the javac process and specify the generated file as input for compilation. Finally, we can use a class loader to load it at runtime.

The previous method essentially compiles outside of the current program process. Is there a less “low-level” approach?

You can consider using the Java Compiler API, which is a standard API provided by the JDK. It provides compiler functionality equivalent to javac. For more details, please refer to the documentation on java.compiler.

Further thinking, we have always focused on compiling Java source code into bytecode that the JVM can understand. In other words, as long as it is bytecode that complies with the JVM specification, can it be loaded by the JVM regardless of how it is generated? Can we directly generate the corresponding bytecode and then hand it over to the class loader for loading?

Certainly, that is also possible. However, directly writing bytecode is too difficult. Typically, we can use Java bytecode manipulation tools and libraries to achieve this, such as ASM, Javassist, cglib mentioned in the 6th column.

Analysis of Examination Points #

Although it used to be viewed as black magic, in the current complex and ever-changing development environment, it is not uncommon to dynamically generate logic at runtime. Taking a closer look at the dynamic proxy we’ve been talking about, isn’t it essentially modifying existing type implementations or creating new types at specific times?

Once we understand the basic idea, we can continue to explore around the class loading mechanism. During the interview process, the interviewer is likely to examine from the perspective of technical principles or practice:

  • How exactly does the seamless conversion between bytecode and class loading work? At which step in the entire class loading process does it occur?
  • How can bytecode manipulation techniques be utilized to implement basic dynamic proxy logic? Besides dynamic proxy, what other applications are there for bytecode manipulation techniques?
  • What are the scenarios?

Knowledge Expansion #

First, let’s understand the conversion from bytecode to Class objects. During the class loading process, this step is achieved by the following methods, provided by the functionality described in defineClass or other equivalent native implementations.

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)

I have only selected the two basic typical implementations of defineClass here, but Java overloads several different methods.

As you can see, as long as you can generate valid bytecode, whether as an array of bytes or put into a ByteBuffer, the conversion process from bytecode to Java objects can be smoothly completed.

The defineClass methods provided by the JDK are ultimately implemented in native code.

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);

Furthermore, let’s take a look at the implementation code of JDK dynamic proxy. You will find that the corresponding logic is implemented in the static inner class ProxyBuilder. ProxyGenerator generates bytecode, saves it as a byte array, and then calls the defineClass method provided by Unsafe.

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) {
    // If a ClassFormatError occurs, it is likely that there is a problem with the input parameters, such as a bug in ProxyGenerator.
}

We have clarified the process of converting binary bytecode information into Class objects. Now let’s analyze how to generate the bytecode we need. Let’s take a look at the relevant bytecode manipulation logic.

The logic of JDK internal dynamic proxy can be seen from the implementation of java.lang.reflect.ProxyGenerator. I think this can be considered an alternative bytecode manipulation technique, which utilizes the capabilities provided by DataOutputStream and various hard-coded JVM instructions to generate the required bytecode array. You can refer to the example code below.

private void codeLocalLoadStore(int lvar, int opcode, int opcode_0,
                                DataOutputStream out)
    throws IOException
{
    assert lvar >= 0 && lvar <= 0xFFFF;
    // Dump the opcode according to the variable value in different formats
    if (lvar <= 3) {
        out.writeByte(opcode_0 + lvar);
    } else if (lvar <= 0xFF) {
        out.writeByte(opcode);
        out.writeByte(lvar & 0xFF);
    } else {
        // Use wide instruction modifier if the variable index cannot be represented by an unsigned byte
        out.writeByte(opc_wide);
        out.writeByte(opcode);
        out.writeShort(lvar & 0xFFFF);
    }
}

The advantage of this implementation approach is that it has few dependencies, is simple and practical. However, the premise is that you need to understand various JVM instructions and know how to handle offset addresses and other details. The actual barrier is very high, so it is not suitable for most ordinary development scenarios.

Fortunately, the Java community provides various bytecode manipulation libraries at various abstraction levels, ranging from low level to higher level. We don’t need to start from scratch. The JDK itself integrates the ASM library, although it is not exposed as a public API, it is widely used in the underlying implementation of java.lang.instrumentation API or the logic generated by Lambda Call Site. I won’t go into the details of these implementations here. If you are interested or have a need, you can refer to the bytecode generation logic of similar classes such as LamdaForm: java.lang.invoke.InvokerBytecodeGenerator.

From a relatively practical perspective, what do you need to do to implement a simple dynamic proxy? How do you use bytecode manipulation techniques to achieve this process? For a regular Java dynamic proxy, its implementation process can be simplified as follows:

  • Provide a base interface, which serves as a unified entry point between the invoked type (com.mycorp.HelloImpl) and the proxy class, such as com.mycorp.Hello.

  • Implement InvocationHandler, where method invocations on the proxy object will be dispatched to its invoke method to perform the actual actions.

  • Use the Proxy class and its newProxyInstance method to generate an instance of the proxy class that implements the corresponding base interface. Here is the method signature:

    public static Object newProxyInstance(ClassLoader loader, Class[] interfaces, InvocationHandler h)

Let’s analyze at which stage dynamic code generation occurs.

Indeed, it happens when newProxyInstance generates an instance of the proxy class. As an example, I chose ASM, which is used by the JDK itself. Let’s take a look at a simplified process implemented using ASM. Please refer to the code snippet below.

Step 1: Generate the corresponding class. It is similar to writing Java code, but instead of writing the source code, we use ASM methods with specified parameters.

ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);

cw.visit(V1_8,                      // specifies the Java version
      ACC_PUBLIC,               // indicates it is a public type
        "com/mycorp/HelloProxy",  // specifies the package and class name
      null,                     // signature (null means it is not generic)
      "java/lang/Object",               // specifies the super class
      new String[]{ "com/mycorp/Hello" }); // specifies the implemented interfaces

Step further, we can generate the required methods and logic for the proxy object instance as needed.

MethodVisitor mv = cw.visitMethod(
      ACC_PUBLIC,               // declares a public method
      "sayHello",               // method name
      "()Ljava/lang/Object;",   // descriptor
      null,                     // signature (null means it is not generic)
      null);                      // possible exceptions, if any, specified as a string array

mv.visitCode();
// omit code logic implementation details
cw.visitEnd();                      // ends class bytecode generation

While the above code may seem a bit convoluted, the general idea can be understood. Different visitX methods provide the functionality to create types, create various methods, and so on. The ASM API widely utilizes the Visitor pattern, which decouples algorithms from object structures. This pattern is very suitable for bytecode manipulation as we often need to modify or add new methods, variables, or types based on specific structures.

Based on the earlier analysis, bytecode manipulation typically ends with the generation of a byte array. ClassWriter provides a convenient method for this.

cw.toByteArray();

Then, we move to the well-known class loading phase. I won’t go into details here, assuming you are already familiar with the class loading process. If you are interested in the specifics of using ASM, you can refer to this tutorial.

Now, let’s address the last question: besides dynamic proxies, where else can bytecode manipulation techniques be applied?

Although this technique may seem distant from our everyday development, it is actually deeply integrated into various aspects. Many frameworks and tools you are currently using may apply this technique. Here are a few common areas where bytecode manipulation is employed.

  • Various mocking frameworks
  • ORM frameworks
  • IOC containers
  • Some profiler tools or runtime diagnostics tools, and more
  • Tools that generate formalized code

One could even argue that bytecode manipulation is an indispensable part of tooling and foundational frameworks, greatly reducing the burden on developers.

Today we explored more in-depth techniques related to class loading and bytecode manipulation. I chose a library that is relatively low-level and provides comprehensive capabilities to help you understand the underlying principles. However, if you need to perform basic bytecode operations in actual projects, you may consider using libraries with a higher-level perspective, such as Byte Buddy.

Practice #

Have you grasped the topic we discussed today? Imagine if we have a requirement to add a certain functionality, such as statistics on the consumption of a certain type of resource like network communication. The key requirement is that, when it’s not enabled, it must be zero cost, not low cost. Can we implement this using the techniques we talked about today or related technologies?

Please write your thoughts on this question in the comment section. I will select well-thought-out comments and give you a study reward coupon. I welcome you to discuss with me.

Are your friends also preparing for interviews? You can “invite friends to read” and share today’s topic with them. Maybe you can help them.