All publications
Systems & Internals

Dynamic Code Execution

Proxies and Class Loading in Java and Android

That's the world of dynamic code execution — a realm where advanced features like reflection, custom class loaders, runtime proxies, and bytecode manipulators come together to unleash a whole new level of flexibility.

AmirHossein Aghajari · Jun 2, 2025 · 30 min read
JavaAndroidJVMRuntimeReflectionDEX

Hello everyone!

In most programming tutorials, Java and Android development seem rigid and straightforward: you write your code, compile it, and that's what the app or program does. But beneath the surface, both platforms offer powerful ways to make your applications much more dynamic and adaptable. Imagine being able to generate classes at runtime, intercept method calls on the fly, load entire new modules or plugins while your app is running, or even alter the behavior of existing code without changing its source files.

That's the world of dynamic code execution — a realm where advanced features like reflection, custom class loaders, runtime proxies, and bytecode manipulators come together to unleash a whole new level of flexibility. These aren't just academic tricks; they're the backbone of the most popular libraries in the Java and Android ecosystems. Tools like Retrofit make seamless network APIs, Mockito enables robust unit testing, and dynamic plugin architectures are possible — all thanks to these mechanisms.

In this article, we're going to open the black box and see how it all works. You'll discover:

  • How Java leverages proxies and class loaders for runtime power,
  • Why Android needs DEX bytecode and its own unique loading strategies,
  • What's really happening inside libraries like Retrofit and Mockito,
  • And tricks and caveats from low-level APIs like Unsafe and DexMaker.

Whether you're an Android developer curious about loading plugins, a Java backend engineer exploring dynamic APIs, or just someone who loves to see what's possible at runtime, this article will help you understand the magic behind it all. Let's get started!

01 — Introspection

Reflection

Reflection is the start of everything when it comes to dynamic code execution. Whenever we talk about dynamic loading, we're absolutely talking about reflection. As you know, both Java and Kotlin support reflection. By reflection, we mean the ability to access any class, method, field, or even constructor — just by knowing their names, even if they are not visible in the current context. Reflection is a powerful feature, allowing us to inspect or modify code behavior at runtime.

Another important concept closely related to reflection is the ClassLoader. In Java, a ClassLoader is responsible for loading classes into memory when your application runs. This means you can even load classes that were not available during the initial compilation of your app — making truly dynamic behavior possible! For instance, on Android, DexClassLoader lets you load additional APKs or DEXs during runtime, enabling features like plugin systems or on-demand modules.

Things to Watch Out For

Before you decide to use reflection or dynamic loading in your project, it's important to consider some potential downsides, risks and policy restrictions:

  • Performance Overhead: Reflection and dynamic class loading are powerful, but they can be significantly slower than regular, statically compiled code. Use them thoughtfully, especially in performance-critical parts of your application.
  • Maintainability: While dynamic techniques enable flexible designs, over-reliance on them can make your codebase harder to understand, debug, and maintain — especially for new team members.
  • Security Risks: Be especially cautious about the security implications. Dynamically loading or modifying code opens up serious risks, particularly if any of the code or data being loaded comes from untrusted sources. Always validate and tightly control what you load or modify at runtime.
  • Google Play Policy: Keep in mind that the Google Play Store has strict rules against downloading and executing code from the internet at runtime. We'll highlight this important rule again at the end of the article.

Example: Accessing a Private Field Using Reflection

Let's look at a simple example where we use reflection to access a private field in a class:

Java
class Test {
    private final String abc = "This is a test";
    private Test() {}
}

try {
    Class<?> testClass = Class.forName("Test");
    Object testInstance = testClass.getDeclaredConstructor().newInstance();
    java.lang.reflect.Field abcField = testClass.getDeclaredField("abc");
    abcField.setAccessible(true); // allow access to private field
    String abcValue = (String) abcField.get(testInstance);
    System.out.println(abcValue); // Output: This is a test
} catch (Exception e) {
    e.printStackTrace();
}
Reflection inspector simulated
class Test {
    private final String abc = "This is a test";
    private Test() {}
}
  • Test.class
  • ├── Fields · abc
  • ├── Methods
  • ├── Constructors
  • └── Modifiers
  1. getDeclaredField("abc")
  2. setAccessible(true)
  3. field.get(instance)
  4. → "This is a test"

In this code, even though the abc field and the constructor of the Test class are both private, we are still able to create an instance and read the private field's value using reflection. This demonstrates how reflection lets us break through normal access controls and work dynamically with classes and objects — just by knowing their structure and member names.

02 — Sharp edges

Reflection Tricks: Modifying Private Fields and Exploring the String Pool

Reflection in Java unlocks the ability to do extraordinary — and sometimes quite unexpected — things. By using reflection, you can access and even change the private internals of any object, including core Java classes. Below are two fun examples that demonstrate both the power and the risks of reflection. do not do this in production!

Heads-up: both tricks below are JDK-8-era. On modern JDKs the internal fields are laid out differently and reflective access to core classes is blocked (see the note at the end of this section).

Danger zone

These tricks reach into String and Boolean and rewrite state that is supposed to be immutable and shared across the whole program.

⚠ INTERNAL STATE MODIFIED

Example 1: Modifying the String Pool

Java
try {
    java.lang.reflect.Field valueField = String.class.getDeclaredField("value");
    valueField.setAccessible(true);
    valueField.set("Hello World!", valueField.get(":)"));
} catch (Exception e) {
    e.printStackTrace();
}

System.out.println("Hello World!"); // Output: :)

In this example, we use reflection to access the private internal value field of the String class, which stores the actual characters of the string. By copying the characters from ":)" into "Hello World!", the code changes the output of "Hello World!" to look like just a smiley face.

This works because Java stores string literals in a special section of memory called the String Pool. All references to the literal "Hello World!" will share the same object, so when the value of that object is changed, it affects every place that uses the same literal.

Example 2: Changing Boolean Values

Java
try {
    java.lang.reflect.Field valueField = Boolean.class.getDeclaredField("value");
    valueField.setAccessible(true);
    valueField.set(true, valueField.get(false));
} catch (Exception e) {
    e.printStackTrace();
}

System.out.printf("%b\n", true); // Output: false

In this trick, reflection is used to reach into the Boolean class and swap the internal value of the constant true with that of false. After making this change, printing true actually results in false.

This works because Java's wrapper classes, like Boolean, convert literals true to Boolean.TRUE and false to Boolean.FALSE, so all usages of these values share the same instance. When you change the internal value of Boolean.TRUE using reflection, every occurrence of true that is autoboxed or referenced as a Boolean object will now behave differently — demonstrating just how far-reaching and risky such reflection tricks can be.

Note: Starting with JDK 9, attempting to use reflection to access private fields in core Java classes produces a warning like: WARNING: An illegal reflective access operation has occurred. Beginning with JDK 16 and later, these operations are blocked by default, and a InaccessibleObjectException is thrown. This change protects the integrity of the platform and prevents dangerous modifications to core library classes.

03 — Loading

Understanding ClassLoader: The Gatekeeper of Dynamic Loading

While reflection gives us the ability to inspect and interact with the structure of classes at runtime, the ClassLoader is the mechanism that actually brings classes into the Java runtime environment. When your program starts, the Java Virtual Machine (JVM) uses different class loaders to load classes from disk, os, or even dynamically-generated bytecode.

Every class in Java is loaded by a specific ClassLoader. This means that two classes with the same name and the same code, if loaded by different class loaders, are considered completely distinct by the JVM.

With custom class loaders, we can simply define our own classes directly into a specific class loader. This allows us to load classes at runtime from any source — such as files, networks, or even by generating bytecode on the fly.

Here's an example of a simple custom class loader that lets us define a new class from a byte array:

Java · custom loader
class CustomClassLoader extends ClassLoader {
    public Class<?> loadClassFromBytes(String className, byte[] bytes) {
        return defineClass(className, bytes, 0, bytes.length);
    }
}

The method defineClass is what actually takes the raw bytecode (as an array of bytes) and turns it into a usable Java class in the JVM. It is a protected method inside ClassLoader, which means it can only be called from subclasses or within the same package. By creating our own class loader, we're able to use this powerful method to introduce any valid bytecode as a new class, giving us a lot of control over how and when classes are created.

Let's say we have a class called HelloWorld:

Java
public class HelloWorld {
    public void sayHello() {
        System.out.println("Hello from dynamically defined class!");
    }
}

If we compile this class (using javac) and read its .class file into a byte array, we would have something like this:

HELLO_WORLD_CLASS_BYTE_CODES — expand HelloWorld.class bytes
Java · bytecode
static final byte[] HELLO_WORLD_CLASS_BYTE_CODES = new byte[] {
    // ... bytes from HelloWorld.class ...
    (byte) 0xCA,(byte) 0xFE,(byte) 0xBA,(byte) 0xBE,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x34,(byte) 0x00,(byte) 0x1F,(byte) 0x0A,(byte) 0x00,(byte) 0x06,(byte) 0x00,(byte) 0x11,(byte) 0x09,(byte) 0x00,(byte) 0x12,(byte) 0x00,(byte) 0x13,(byte) 0x08,(byte) 0x00,(byte) 0x14,(byte) 0x0A,(byte) 0x00,(byte) 0x15,(byte) 0x00,(byte) 0x16,(byte) 0x07,(byte) 0x00,(byte) 0x17,(byte) 0x07,(byte) 0x00,(byte) 0x18,(byte) 0x01,(byte) 0x00,(byte) 0x06,(byte) 0x3C,(byte) 0x69,(byte) 0x6E,(byte) 0x69,(byte) 0x74,(byte) 0x3E,(byte) 0x01,(byte) 0x00,(byte) 0x03,(byte) 0x28,(byte) 0x29,(byte) 0x56,(byte) 0x01,(byte) 0x00,(byte) 0x04,(byte) 0x43,(byte) 0x6F,(byte) 0x64,(byte) 0x65,(byte) 0x01,(byte) 0x00,(byte) 0x0F,(byte) 0x4C,(byte) 0x69,(byte) 0x6E,(byte) 0x65,(byte) 0x4E,(byte) 0x75,(byte) 0x6D,(byte) 0x62,(byte) 0x65,(byte) 0x72,(byte) 0x54,(byte) 0x61,(byte) 0x62,(byte) 0x6C,(byte) 0x65,(byte) 0x01,(byte) 0x00,(byte) 0x12,(byte) 0x4C,(byte) 0x6F,(byte) 0x63,(byte) 0x61,(byte) 0x6C,(byte) 0x56,(byte) 0x61,(byte) 0x72,(byte) 0x69,(byte) 0x61,(byte) 0x62,(byte) 0x6C,(byte) 0x65,(byte) 0x54,(byte) 0x61,(byte) 0x62,(byte) 0x6C,(byte) 0x65,(byte) 0x01,(byte) 0x00,(byte) 0x04,(byte) 0x74,(byte) 0x68,(byte) 0x69,(byte) 0x73,(byte) 0x01,(byte) 0x00,(byte) 0x0C,(byte) 0x4C,(byte) 0x48,(byte) 0x65,(byte) 0x6C,(byte) 0x6C,(byte) 0x6F,(byte) 0x57,(byte) 0x6F,(byte) 0x72,(byte) 0x6C,(byte) 0x64,(byte) 0x3B,(byte) 0x01,(byte) 0x00,(byte) 0x08,(byte) 0x73,(byte) 0x61,(byte) 0x79,(byte) 0x48,(byte) 0x65,(byte) 0x6C,(byte) 0x6C,(byte) 0x6F,(byte) 0x01,(byte) 0x00,(byte) 0x0A,(byte) 0x53,(byte) 0x6F,(byte) 0x75,(byte) 0x72,(byte) 0x63,(byte) 0x65,(byte) 0x46,(byte) 0x69,(byte) 0x6C,(byte) 0x65,(byte) 0x01,(byte) 0x00,(byte) 0x0F,(byte) 0x48,(byte) 0x65,(byte) 0x6C,(byte) 0x6C,(byte) 0x6F,(byte) 0x57,(byte) 0x6F,(byte) 0x72,(byte) 0x6C,(byte) 0x64,(byte) 0x2E,(byte) 0x6A,(byte) 0x61,(byte) 0x76,(byte) 0x61,(byte) 0x0C,(byte) 0x00,(byte) 0x07,(byte) 0x00,(byte) 0x08,(byte) 0x07,(byte) 0x00,(byte) 0x19,(byte) 0x0C,(byte) 0x00,(byte) 0x1A,(byte) 0x00,(byte) 0x1B,(byte) 0x01,(byte) 0x00,(byte) 0x25,(byte) 0x48,(byte) 0x65,(byte) 0x6C,(byte) 0x6C,(byte) 0x6F,(byte) 0x20,(byte) 0x66,(byte) 0x72,(byte) 0x6F,(byte) 0x6D,(byte) 0x20,(byte) 0x64,(byte) 0x79,(byte) 0x6E,(byte) 0x61,(byte) 0x6D,(byte) 0x69,(byte) 0x63,(byte) 0x61,(byte) 0x6C,(byte) 0x6C,(byte) 0x79,(byte) 0x20,(byte) 0x64,(byte) 0x65,(byte) 0x66,(byte) 0x69,(byte) 0x6E,(byte) 0x65,(byte) 0x64,(byte) 0x20,(byte) 0x63,(byte) 0x6C,(byte) 0x61,(byte) 0x73,(byte) 0x73,(byte) 0x21,(byte) 0x07,(byte) 0x00,(byte) 0x1C,(byte) 0x0C,(byte) 0x00,(byte) 0x1D,(byte) 0x00,(byte) 0x1E,(byte) 0x01,(byte) 0x00,(byte) 0x0A,(byte) 0x48,(byte) 0x65,(byte) 0x6C,(byte) 0x6C,(byte) 0x6F,(byte) 0x57,(byte) 0x6F,(byte) 0x72,(byte) 0x6C,(byte) 0x64,(byte) 0x01,(byte) 0x00,(byte) 0x10,(byte) 0x6A,(byte) 0x61,(byte) 0x76,(byte) 0x61,(byte) 0x2F,(byte) 0x6C,(byte) 0x61,(byte) 0x6E,(byte) 0x67,(byte) 0x2F,(byte) 0x4F,(byte) 0x62,(byte) 0x6A,(byte) 0x65,(byte) 0x63,(byte) 0x74,(byte) 0x01,(byte) 0x00,(byte) 0x10,(byte) 0x6A,(byte) 0x61,(byte) 0x76,(byte) 0x61,(byte) 0x2F,(byte) 0x6C,(byte) 0x61,(byte) 0x6E,(byte) 0x67,(byte) 0x2F,(byte) 0x53,(byte) 0x79,(byte) 0x73,(byte) 0x74,(byte) 0x65,(byte) 0x6D,(byte) 0x01,(byte) 0x00,(byte) 0x03,(byte) 0x6F,(byte) 0x75,(byte) 0x74,(byte) 0x01,(byte) 0x00,(byte) 0x15,(byte) 0x4C,(byte) 0x6A,(byte) 0x61,(byte) 0x76,(byte) 0x61,(byte) 0x2F,(byte) 0x69,(byte) 0x6F,(byte) 0x2F,(byte) 0x50,(byte) 0x72,(byte) 0x69,(byte) 0x6E,(byte) 0x74,(byte) 0x53,(byte) 0x74,(byte) 0x72,(byte) 0x65,(byte) 0x61,(byte) 0x6D,(byte) 0x3B,(byte) 0x01,(byte) 0x00,(byte) 0x13,(byte) 0x6A,(byte) 0x61,(byte) 0x76,(byte) 0x61,(byte) 0x2F,(byte) 0x69,(byte) 0x6F,(byte) 0x2F,(byte) 0x50,(byte) 0x72,(byte) 0x69,(byte) 0x6E,(byte) 0x74,(byte) 0x53,(byte) 0x74,(byte) 0x72,(byte) 0x65,(byte) 0x61,(byte) 0x6D,(byte) 0x01,(byte) 0x00,(byte) 0x07,(byte) 0x70,(byte) 0x72,(byte) 0x69,(byte) 0x6E,(byte) 0x74,(byte) 0x6C,(byte) 0x6E,(byte) 0x01,(byte) 0x00,(byte) 0x15,(byte) 0x28,(byte) 0x4C,(byte) 0x6A,(byte) 0x61,(byte) 0x76,(byte) 0x61,(byte) 0x2F,(byte) 0x6C,(byte) 0x61,(byte) 0x6E,(byte) 0x67,(byte) 0x2F,(byte) 0x53,(byte) 0x74,(byte) 0x72,(byte) 0x69,(byte) 0x6E,(byte) 0x67,(byte) 0x3B,(byte) 0x29,(byte) 0x56,(byte) 0x00,(byte) 0x21,(byte) 0x00,(byte) 0x05,(byte) 0x00,(byte) 0x06,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x02,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x07,(byte) 0x00,(byte) 0x08,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x09,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x2F,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x05,(byte) 0x2A,(byte) 0xB7,(byte) 0x00,(byte) 0x01,(byte) 0xB1,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x02,(byte) 0x00,(byte) 0x0A,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x06,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x0B,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x0C,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x05,(byte) 0x00,(byte) 0x0C,(byte) 0x00,(byte) 0x0D,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x0E,(byte) 0x00,(byte) 0x08,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x09,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x37,(byte) 0x00,(byte) 0x02,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x09,(byte) 0xB2,(byte) 0x00,(byte) 0x02,(byte) 0x12,(byte) 0x03,(byte) 0xB6,(byte) 0x00,(byte) 0x04,(byte) 0xB1,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x02,(byte) 0x00,(byte) 0x0A,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x0A,(byte) 0x00,(byte) 0x02,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x03,(byte) 0x00,(byte) 0x08,(byte) 0x00,(byte) 0x04,(byte) 0x00,(byte) 0x0B,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x0C,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x09,(byte) 0x00,(byte) 0x0C,(byte) 0x00,(byte) 0x0D,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x01,(byte) 0x00,(byte) 0x0F,(byte) 0x00,(byte) 0x00,(byte) 0x00,(byte) 0x02,(byte) 0x00,(byte) 0x10,
};

Now, we can use our custom class loader to load the class and even create instances or call its methods:

Java
Class<?> helloWorldClass = new CustomClassLoader()
    .loadClassFromBytes("HelloWorld", HELLO_WORLD_CLASS_BYTE_CODES);

System.out.println("Class '" + helloWorldClass.getName() + "' defined successfully!");
// Create an instance of HelloWorld and invoke sayHello()
Object helloWorldInstance = helloWorldClass.getDeclaredConstructor().newInstance();
java.lang.reflect.Method sayHelloMethod = helloWorldClass.getMethod("sayHello");
sayHelloMethod.invoke(helloWorldInstance);

Output will be:

Output
Class 'HelloWorld' defined successfully!
Hello from dynamically defined class!

This example shows how, by using a custom class loader, we gain the flexibility to load classes at runtime from any source.

Example: Same Class Name, Different ClassLoaders

To illustrate another important detail, consider what happens when the same class name is loaded by two different class loaders. Despite having the same name and bytecode, the JVM treats each version as a completely distinct class. Below is a simple demonstration that shows how two different class loaders produce two different instances of what appears to be "the same" class:

Java
CustomClassLoader loader1 = new CustomClassLoader();
CustomClassLoader loader2 = new CustomClassLoader();

Class<?> classFromLoader1 = loader1.loadClassFromBytes("HelloWorld", HELLO_WORLD_CLASS_BYTE_CODES);
Class<?> classFromLoader2 = loader2.loadClassFromBytes("HelloWorld", HELLO_WORLD_CLASS_BYTE_CODES);

// Even though names and bytecode are the same:
System.out.println(classFromLoader1.equals(classFromLoader2)); // Output: false

// Instances cannot be cast to each other's type!
Object obj1 = classFromLoader1.getDeclaredConstructor().newInstance();
System.out.println(classFromLoader2.isInstance(obj1)); // Output: false
ClassLoader identity simulated
ClassLoader A ↓ com.example.Hello
ClassLoader B ↓ com.example.Hello

Even though both classFromLoader1 and classFromLoader2 represent classes called HelloWorld loaded from the exact same bytecode, they are considered entirely separate by the JVM because they were loaded by different class loaders. As a result, not only are the class objects themselves different (equals returns false), but also, an instance created from one cannot be recognized as an instance of the other — casting between them will fail.

04 — Context

ProtectionDomain

When defining a class with ClassLoader#defineClass, you can provide an additional parameter called ProtectionDomain.

A ProtectionDomain groups together a set of permissions, information about where the class was loaded from, and any digital certificates associated with the code. In classic Java, this is used with the (now-legacy) SecurityManager to control what actions the loaded code is allowed to perform — such as file or network access — based on its origin and permissions.

However, in modern Java (JDK 17 and later), the SecurityManager is marked for removal, and applications can no longer use ProtectionDomain for practical security purposes. Likewise, on Android (as discussed later in this article), neither SecurityManager nor ProtectionDomain are supported for restricting code permissions.

Class ↓ ProtectionDomain
  • Code Source
  • Permissions
  • Certificates

In short: While ProtectionDomain is a historical aspect of Java class loading, it is no longer relevant in most current Java, and can safely be ignored for dynamic loading in modern Java and in all Android development.

05 — Defining bytes

Defining New Classes with Unsafe and MethodHandles

While using a custom ClassLoader is a common approach, you can also define and load a class directly into the System class loader on standard Java, without needing to create your own class loader. Java provides several advanced APIs for this purpose, such as sun.misc.Unsafe (in older Java versions) and MethodHandles.Lookup#defineClass (in more recent Java).

Note that this approach is not possible on Android, the Android runtime does not support injecting classes directly into the system class loader in this way. We'll discuss how dynamic class loading is handled specifically on Android later in this article.

Defining Classes with Unsafe

Before Java 11, it was possible to use the sun.misc.Unsafe class to define a class directly to the main system class loader. Here's how it works:

Java · Unsafe
Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
java.lang.reflect.Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);

java.lang.reflect.Method defineClassMethod = unsafeClass.getMethod(
    "defineClass",
    String.class, byte[].class, int.class, int.class,
    ClassLoader.class, ProtectionDomain.class
);

// Define the class dynamically to the system class loader
defineClassMethod.invoke(
    theUnsafe.get(null),
    "HelloWorld",
    HELLO_WORLD_CLASS_BYTE_CODES,
    0,
    HELLO_WORLD_CLASS_BYTE_CODES.length,
    ClassLoader.getSystemClassLoader(),
    null
);

// Now we can use the dynamically defined class
Class<?> helloWorldClass = Class.forName("HelloWorld");
System.out.println("Class '" + helloWorldClass.getName() + "' defined successfully!");

Note: sun.misc.Unsafe is an internal API, officially unsupported and not present in all Java implementations. Its use is strongly discouraged in production code and is increasingly restricted in newer versions of Java.

Defining Classes with MethodHandles.Lookup

Starting from Java 9, the recommended and supported way to define classes dynamically is MethodHandles.Lookup#defineClass, which continues to work in Java 11 and later.

Java · MethodHandles
MethodHandles.lookup().defineClass(HELLO_WORLD_CLASS_BYTE_CODES);

Class<?> helloWorldClass = Class.forName("HelloWorld");
System.out.println("Class '" + helloWorldClass.getName() + "' defined successfully!");

This method defines the class in the same context as the caller's lookup — so it lands in the caller's own package, and the class name comes from the bytecode itself. It's safer, standard, and future-proof compared to Unsafe.defineClass. On Java 15+, Lookup.defineHiddenClass goes a step further for one-off classes that aren't discoverable by name.

06 — Interception

Understanding Proxies in Java: The Secret Behind Retrofit and More

Now that we have talked about what ClassLoader and Unsafe are, and how they allow us to dynamically load or define classes while the program is running, we can turn our attention to proxies.

A proxy is another useful feature in Java that makes it possible to create objects whose behavior can be dynamically controlled or changed, without having to modify the original class.

This flexibility is very important for many well-known Java and Android libraries. For example, libraries like Retrofit, which handles HTTP APIs, and Mockito, which is used for unit testing, both rely on proxies behind the scenes to provide dynamic and flexible features. But what exactly is a proxy, and how do these libraries actually use them in practice? Let's take a closer look.

Proxy.newProxyInstance

The main way to create a dynamic proxy in Java is by using the Proxy.newProxyInstance method. This method allows you to create an object that implements one or more interfaces at runtime. Any method call made on this proxy object is passed to an InvocationHandler, which lets you decide what should actually happen when a method is called.

What does it do?

  • Creates a new object that looks like an implementation of a given interface (or set of interfaces).
  • Intercepts every method call, so you can run custom code, modify results, or add extra behavior.
  • Does not require you to write a concrete implementation class.
  • Only works with interfaces, not with regular classes.

Simple Example

Suppose you have an interface:

Java
interface Hello {
    void sayHello(String name);
}

You can create a proxy for this interface like this:

Java · dynamic proxy
Hello helloProxy = (Hello) Proxy.newProxyInstance(
    Hello.class.getClassLoader(),
    new Class<?>[] { Hello.class },
    new InvocationHandler() {
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) {
            System.out.println("Hello, " + args[0] + "!");
            return null;
        }
    }
);

helloProxy.sayHello("World"); // Output: Hello, World!
Invocation trace simulated
  1. Proxy
  2. InvocationHandler.invoke()
  3. Method
  4. "Hello, World!"

Here, instead of writing a real Hello implementation, we provide an InvocationHandler that defines what should happen every time any method from the Hello interface is called. In this case, it simply prints a message.

This is exactly how Retrofit works! When you call Retrofit.create(APIServiceInterface.class), it generates a new proxy around your APIServiceInterface. Whenever you invoke a method of that interface, Retrofit's own implementation of InvocationHandler is called. Through this handler, Retrofit can see which method was called and with what arguments.

Retrofit takes this a step further by relying on annotations. Since the invoke method receives a Method instance, Retrofit can inspect which annotations are used on the method and its parameters. Based on the method structure and the specified annotations (such as @GET, @POST, and parameter annotations), Retrofit builds the appropriate HTTP request using OkHttp behind the scenes. This dynamic approach allows Retrofit to map simple interface methods directly to network operations, making it both powerful and flexible.

07 — Under the hood

What Does newProxyInstance Do Internally?

Although creating a proxy with Proxy.newProxyInstance looks simple from the outside, there's quite a bit happening under the hood. When you call newProxyInstance, Java actually generates a new class at runtime. This new class extends java.lang.reflect.Proxy and implements all the interfaces you specify.

Proxy generation simulated

Click a stage to see what happens at runtime.

The base class, java.lang.reflect.Proxy, isn't very complicated itself. Its main job is to hold a reference to an InvocationHandler, the object that controls what happens whenever a method is called on your proxy. You can imagine the core of this class being as simple as:

Java
public class Proxy {
    protected InvocationHandler h;

    protected Proxy(@NonNull InvocationHandler h) {
        this.h = h;
    }
}

When you create a proxy, the JVM generates a new subclass of Proxy that implements your interfaces. Every method call on your proxy object is automatically forwarded to the InvocationHandler instance via the invoke method.

For example, for the Hello interface we used earlier, a proxy class similar to the following would be dynamically generated and defined by the JVM:

Java · generated proxy
class $HelloProxy0 extends Proxy implements Hello {

    public $HelloProxy0(InvocationHandler h) {
        super(h);
    }

    @Override
    public void sayHello(String name) {
        try {
            // Get Method object for Hello.sayHello
            Method m = Hello.class.getMethod("sayHello", String.class);
            // Forward the call to the InvocationHandler
            h.invoke(this, m, new Object[] { name });
        } catch (Throwable t) {
            throw new UndeclaredThrowableException(t);
        }
    }

    @Override
    public boolean equals(Object obj) {
        try {
            Method m = Object.class.getMethod("equals", Object.class);
            return (Boolean) h.invoke(this, m, new Object[] { obj });
        } catch (Throwable t) {
            throw new UndeclaredThrowableException(t);
        }
    }

    @Override
    public String toString() {
        try {
            Method m = Object.class.getMethod("toString");
            return (String) h.invoke(this, m, null);
        } catch (Throwable t) {
            throw new UndeclaredThrowableException(t);
        }
    }

    @Override
    public int hashCode() {
        try {
            Method m = Object.class.getMethod("hashCode");
            return (Integer) h.invoke(this, m, null);
        } catch (Throwable t) {
            throw new UndeclaredThrowableException(t);
        }
    }
}

Java will generate the bytecode of a class like the example I showed you, with the bytecode itself being produced by the internal java.lang.reflect.ProxyGenerator class. Then, it defines this new class using either Unsafe, MethodHandles, or custom class loaders, depending on the Java version. Finally, it returns an instance of that proxy class as an instance of the interface you need. This entire process happens automatically under the hood, so you receive a proxy object that looks and acts like your interface, while routing all method calls through your custom InvocationHandler.

08 — In the wild

Mock: How Mocking Libraries Use Proxies

Mocking, as seen in popular libraries like Mockito, is a widely used testing technique that is fundamentally similar to the proxy mechanism we just explored.

When you want to mock an interface, libraries such as Mockito typically use Proxy.newProxyInstance behind the scenes, just like other proxies.

However, when it comes to mocking classes (not just interfaces), the situation is a bit different. Proxy-based solutions only work with interfaces, so mocking libraries need to take an extra step. They generate a new proxy class that extends your concrete class — effectively a subclass — where every method can be intercepted and redirected to custom handlers, just as with interfaces. This is achieved using bytecode generation libraries such as ByteBuddy or cglib.

One of the classic challenges in mocking was dealing with final classes and methods, since, by definition, you cannot extend from final classes or override final methods to create proxy subclasses. However, since Mockito 2.x, this limitation has been overcome. Mockito can now mock final classes and methods out of the box by enabling an inline mock maker. This works by instrumenting the bytecode at runtime to temporarily remove the final flag when creating the mock, allowing even final types to be dynamically proxied and controlled in tests.

In summary, for interfaces, mocking libraries use dynamic proxies; for classes, they programmatically generate subclasses that act as proxies, overriding methods to provide the same interception and control found in interface-based mocking. This behind-the-scenes code generation is what enables powerful mocking and stubbing in modern Java unit testing.

How Does Mockito's whenever/doReturn Style Work?

You might wonder how mocking libraries like Mockito enable such elegant and readable syntaxes as whenever(...) doReturn(...), especially in Kotlin.

Think about it for a moment — how can Mockito achieve code that looks like this?

Note: This part is not typically related to the main topic of class loading or proxy generation, but consider it as a bonus :)

Let's consider the following interface:

Java
interface TestInterface {
    String testString();
    Boolean testBoolean();
}

And in Kotlin, Mockito lets you write:

Kotlin
val mockInstance: TestInterface = mock()

whenever(mockInstance.testString()) doReturn "Hello from mock!"

Then, when you call:

Kotlin
println(mockInstance.testString()) // Output: Hello from mock!
Mock simulator simulated
mock(TestInterface.class);
whenever(mock.testString())
    .doReturn("Hello from mock!");
—
  1. Proxy
  2. InvocationHandler
  3. remember method
  4. replay stored value

How is this implemented?

Behind the scenes, when you call mock(), Mockito creates a dynamic proxy (as we discussed earlier) or, for classes, a subclass. This mock object intercepts all method calls and passes them to Mockito's internal handler, which tracks not only the method invoked but also the context in which it was called.

Now, here's the clever part: when you call whenever(mockInstance.testString()), Mockito uses custom handler to record the fact that testString() was invoked on that mock, rather than immediately executing your test code. This "stubbing" phase allows Mockito to remember which method you want to intercept. The subsequent doReturn("Hello from mock!") call tells the handler what value to return whenever that specific method is called on the mock.

So the next time you call mockInstance.testString(), the mock recognizes the method and simply returns the value you specified!

SimpleMock Implementation

As a bonus, let's see how a basic mocking mechanism like Mockito's can actually be implemented for interfaces using dynamic proxies and a simple handler. This example will help demystify what's happening behind the scenes.

Java · SimpleMock
public final class SimpleMock {
    private SimpleMock() { }

    public static <T> T mock(Class<T> interfaceToMock) {
        return (T) Proxy.newProxyInstance(
            interfaceToMock.getClassLoader(),
            new Class[]{interfaceToMock},
            SimpleMockInvocationHandler.INSTANCE
        );
    }

    public static final Stubbing whenever(Object call) {
        // Here, the call sets up the ongoing method in the handler
        return Stubbing.INSTANCE;
    }

    static class Stubbing {
        private static final Stubbing INSTANCE = new Stubbing();
        private Stubbing() {}

        public void doReturn(Object returnValue) {
            SimpleMockInvocationHandler.INSTANCE.add(returnValue);
        }
    }
}

class SimpleMockInvocationHandler implements InvocationHandler {
    static final SimpleMockInvocationHandler INSTANCE = new SimpleMockInvocationHandler();

    private Map<Method, Object> map = new HashMap<>();
    private Method onGoingMethod;

    private SimpleMockInvocationHandler() { }

    void add(Object returnValue) {
        if (onGoingMethod == null)
            throw new NullPointerException();
        map.put(onGoingMethod, returnValue);
        onGoingMethod = null;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) {
        onGoingMethod = method;
        return map.getOrDefault(method, null);
    }
}

Usage Example:

Java · usage
import static SimpleMock.*;

interface TestInterface {
    String testString();
    Boolean testBoolean();
}

TestInterface testInterfaceMock = mock(TestInterface.class);

whenever(testInterfaceMock.testString()).doReturn("Hello from mock!");
whenever(testInterfaceMock.testBoolean()).doReturn(true);

System.out.println(testInterfaceMock.testString());    // Output: Hello from mock!
System.out.println(testInterfaceMock.testBoolean());   // Output: true

How does it work?

  • Mock Creation: SimpleMock.mock creates a proxy instance for your interface.
  • Stubbing: When you call whenever(mock.testString()), the handler remembers which method was called last and lets you specify what to return next using doReturn(...).
  • Invocation: When the proxy's method is called, it checks the map and returns the value you set, just like a real mocking framework!

This example is, of course, a simple demonstration. Real libraries like Mockito support argument matching, verification, stubbing exceptions, and much more — but the underlying principle is the same: proxies, handlers, and a bit of clever behind-the-scenes tracking!

09 — Platform shift

How Does Android Let Us Load Classes Dynamically?

Almost everything we discussed earlier about dynamic class loading and proxies works in a similar way on Android — but with one very important difference: Android doesn't run Java bytecode directly like the standard JVM. Instead, Android applications run on a custom runtime environment called Dalvik (in older versions) or ART (Android Runtime) in modern versions.

On Android, Java source code is compiled to Java bytecode (class files) first, and then all bytecode for an app is further compiled and bundled into a special format called DEX (Dalvik Executable). The Dalvik/ART runtime executes these .dex files instead of the standard .class files.

This means that if you want to define or load classes dynamically on Android, you can't just use Java bytecode — you need to work with DEX files instead. Android provides its own class loading system specifically for DEX, most notably using DexClassLoader.

.class → .dex
  1. .class
  2. ↓
  3. JVM

10 — Android loading

DexClassLoader

On Android, DexClassLoader is the key to loading new classes dynamically at runtime. As we've seen before, a ClassLoader is responsible for bringing classes into your app, but the default Java ClassLoader only works with Java bytecode (.class files) and not with Android's DEX format.

Android's runtime (Dalvik or ART) relies on DEX files, so if you want to load or define classes dynamically on Android — such as loading a plugin, module, or feature — you must use DexClassLoader. This specialized loader is designed to read DEX files and make their classes available to your app.

DexClassLoader Constructor

To use DexClassLoader, you'll need to create an instance of it by calling its constructor. Here's what the constructor looks like:

Java · DexClassLoader
DexClassLoader(
    String dexPath,
    String optimizedDirectory,
    String librarySearchPath,
    ClassLoader parent
)

Let's break down each parameter:

  • dexPath : The file system path to the .dex file, a .jar file containing DEX files, or even an .apk that you want to load classes from. This is where your dynamically generated or downloaded code lives.
  • optimizedDirectory : The directory where optimized DEX files should be written. Android will generate optimized versions of your code in this directory as it loads and runs them. Usually, you can use context.getCodeCacheDir().getAbsolutePath() for this. Note: this parameter is deprecated and has no effect since API level 26.
  • librarySearchPath : (Optional) The directory for native libraries used by the loaded code. If you're not using any C/C++ libraries, you can set this to null.
  • parent : The parent ClassLoader to use for delegation. In most cases, you can pass your app's main class loader here with context.getClassLoader().

Example Usage:

Let's say we have a class called HelloWorld:

Java
package com.aghajari.test;

public class HelloWorld {
    public void sayHello() {
        System.out.println("Hello from dynamically defined class!");
    }
}

If we compile this class with javac (to produce a .class file) and then use the Android SDK's d8 tool to convert the .class file into a .dex file, we end up with Dalvik bytecode. Next, we can read the .dex file into a byte array, resulting in something like:

HELLO_WORLD_CLASS_DEX_BYTES — expand HelloWorld.dex bytes
Java · DEX bytes
static final byte[] HELLO_WORLD_CLASS_DEX_BYTES = new byte[] {
    // ... bytes from HelloWorld.dex ...
    (byte) 0x64,(byte) 0x65,(byte) 0x78,(byte) 0x0A,(byte) 0x30,(byte) 0x33,(byte) 0x35,(byte) 0x00, ...
};

Now, we can use DexClassLoader to load this class at runtime like this:

Java · DexClassLoader
File dexFile = new File(context.getCodeCacheDir(), "hello_world.dex");
if (!dexFile.exists()) {
    try (OutputStream out = new BufferedOutputStream(new FileOutputStream(dexFile))) {
        out.write(HELLO_WORLD_CLASS_DEX_BYTES);
    }
}
dexFile.setReadOnly();

// Now load the class using DexClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(
    dexFile.getAbsolutePath(),       // The path to the .dex file
    context.getCodeCacheDir().getAbsolutePath(), // Optimized output directory
    null,                            // No native library
    context.getClassLoader()         // Parent class loader
);

Class<?> helloWorldClass = dexClassLoader.loadClass("com.aghajari.test.HelloWorld");
System.out.println("Class '" + helloWorldClass.getName() + "' defined successfully!");
// Create an instance of HelloWorld and invoke sayHello()
Object helloWorldInstance = helloWorldClass.getDeclaredConstructor().newInstance();
Method sayHelloMethod = helloWorldClass.getMethod("sayHello");
sayHelloMethod.invoke(helloWorldInstance);
DexClassLoader run simulated
  1. DEX
  2. DexClassLoader
  3. loadClass()
  4. Class
  5. Constructor
  6. Instance
  7. sayHello() → "Hello, World"

Output will be:

Output
Class 'com.aghajari.test.HelloWorld' defined successfully!
Hello from dynamically defined class!

With this approach, you can load new features, plugins, or scripts as needed — simply by generating or downloading DEX files and loading them with a DexClassLoader.

It's interesting to note that, under the hood, when you use DexClassLoader to load a class, its defineClass implementation actually calls a native function within the Android runtime called dvmDefineClass (the Dalvik-era name; on modern ART the equivalent work goes through ClassLinker). If you're curious to see the actual source code, you can find the dvmDefineClass implementation here.

Proxy.newProxyInstance in Android

The same logic we explained for how proxies work in the standard JVM also applies on Android. When you call Proxy.newProxyInstance on Android, the platform creates a proxy class at runtime that implements your specified interfaces and forwards calls to the provided InvocationHandler.

The key difference is that, instead of generating Java bytecode for the proxy class, Android generates DEX bytecode. The proxy mechanism is fully integrated into the Dalvik/ART runtime, so Android can generate, define, and load proxy classes as DEX files on the fly.

If you're interested in how this is implemented under the hood, you can find the core code responsible for Android's proxy creation in the Android Open Source Project here.

Aside from the difference in bytecode format, the proxy logic and behavior remain essentially the same as on the standard JVM, giving Android developers dynamic interface implementation capabilities just like in classic Java.

11 — Beyond code

Loading APK Files with Resources and Assets in Android

If your dynamic module needs more than just code — such as resources (layouts, strings, drawables) or assets (images, raw files) — you can't simply load a .dex file. Instead, you'll need to dynamically load an entire .apk file that contains both the compiled classes and the resources/assets.

Loading Classes from an APK

You can use DexClassLoader to load classes from an APK file in exactly the same way as you would load from a .dex file:

Java · DexClassLoader
DexClassLoader apkClassLoader = new DexClassLoader(
    apkFilePath,
    context.getCodeCacheDir().getAbsolutePath(),
    null,
    context.getClassLoader()
);
my-plugin.apk simulated
  • assets/

Select an entry to see how it's accessed at runtime.

Loading Assets from the APK

To access assets in the APK, you'll need to attach them to your app's AssetManager. This can be tricky and may require reflection to support different Android versions. Here's a sample approach:

Java
String apkFilePath = "<APK-PATH>";
AssetManager assetManager = context.getAssets();
try {
    Class<?> apkAssetsClass = Class.forName("android.content.res.ApkAssets");
    Method loadFromPath = apkAssetsClass.getMethod("loadFromPath", String.class);
    Object apkAsset = loadFromPath.invoke(null, apkFilePath);
    Object apkAssetsArray = Array.newInstance(apkAssetsClass, 1);
    Array.set(apkAssetsArray, 0, apkAsset);
    Method setApkAssets = AssetManager.class.getMethod("setApkAssets", apkAssetsArray.getClass());
    setApkAssets.invoke(assetManager, apkAssetsArray);
} catch (Exception e) {
    try {
        Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
        addAssetPath.invoke(assetManager, apkFilePath);
    } catch (Exception e2) {
        throw new RuntimeException(e2);
    }
}

Accessing Resources from the APK

With the assets loaded, you can access resources by their type and name using reflection like this:

Java
int resId = getResources().getIdentifier(
    "<RES-NAME>",    // resource name (e.g., "my_image")
    "<RES-TYPE>",    // resource type (e.g., "drawable" or "string")
    "<YOUR-APK-PACKAGE>" // the package name of the APK
);

You can then use resId with standard Resources methods to load drawables, strings, layouts, etc.

12 — The build

How to Generate a DEX File from My Module?

If you want to dynamically load your own classes on Android, you need to generate a .dex file from your module. The easiest way is to use a Gradle build task that first jars your compiled classes and then uses the d8 tool (provided by the Android SDK Build Tools) to convert them to DEX format.

Build pipeline simulated
  1. Java / Kotlin
  2. .class
  3. JAR
  4. D8
  5. classes.dex
Shell · d8
d8 <INPUT-PATH> --output=<OUTPUT-PATH>

Here's a sample build.gradle.kts script to automate the process for any build variant:

Kotlin · build.gradle.kts
androidComponents {
    onVariants { variant ->
        val variantName = variant.name.capitalize()
        val jarClassesTaskName = "jar${variantName}Classes"
        tasks.register<Jar>(jarClassesTaskName) {
            group = "build"
            description = "Jar all compiled classes for the ${variant.name} variant."
            val compiledJavaClassesDir = layout.buildDirectory.dir("intermediates/javac/${variant.name}/compile${variantName}JavaWithJavac")
            val kotlinClassesDir = layout.buildDirectory.dir("tmp/kotlin-classes/${variant.name}")
            from(compiledJavaClassesDir)
            from(kotlinClassesDir)
            dependsOn(tasks.named("compile${variantName}JavaWithJavac"))
            archiveFileName.set("out.jar")
            destinationDirectory.set(layout.buildDirectory.dir("dexed_output/${variant.name}/"))
            inputs.files(compiledJavaClassesDir, kotlinClassesDir)
            outputs.file(destinationDirectory.get().file(archiveFileName))
        }

        val dexClassesTaskName = "dex${variantName}Classes"
        tasks.register<Exec>(dexClassesTaskName) {
            group = "build"
            description = "Dexes all compiled classes for the ${variant.name} variant from a jar file."
            dependsOn(tasks.named(jarClassesTaskName))
            val outputDexDirectory = layout.buildDirectory.dir("dexed_output/${variant.name}")
            doFirst {
                val buildToolsDir = android.sdkDirectory.resolve("build-tools/${android.buildToolsVersion}")
                val d8Executable = if (org.gradle.internal.os.OperatingSystem.current().isWindows) {
                    buildToolsDir.resolve("d8.bat")
                } else {
                    buildToolsDir.resolve("d8")
                }
                if (!d8Executable.exists() || !d8Executable.isFile) {
                    logger.error("Error: d8 executable not found at: $d8Executable")
                    throw GradleException("d8 executable missing from Build Tools version '${android.buildToolsVersion}'. Make sure it's installed via SDK Manager.")
                }
                outputDexDirectory.get().asFile.mkdirs()
                val inputJarFile = layout.buildDirectory.file("dexed_output/${variant.name}/out.jar").get().asFile
                if (!inputJarFile.exists()) {
                    logger.error("Error: Jar file not found at: $inputJarFile")
                    throw GradleException("Jar file was not created by '$jarClassesTaskName'.")
                }
                commandLine(
                    d8Executable.absolutePath,
                    inputJarFile.absolutePath,
                    "--output", outputDexDirectory.get().asFile.absolutePath,
                )
            }
        }
    }
}

How to run these tasks:

Shell
./gradlew dexReleaseClasses
# or
./gradlew dexDebugClasses

This will generate a .dex file for your chosen variant and place it inside the build/dexed_output directory of your module. You can then use this .dex file for dynamic loading at runtime using DexClassLoader.

13 — Generation

How to Write DEX Programmatically?

If you want to generate DEX (Dalvik Executable) bytecode directly in your code — without compiling Java or Kotlin sources first — there is a library called DexMaker that allows you to do this programmatically.

Unlike standard Java compilers, DexMaker doesn't compile source code into DEX. Instead, you use DexMaker's API to create types, declare fields and methods, and build up bytecode instruction by instruction. This means you generally work at a lower level, a bit like generating three-address code, where you manage local variables, method calls, and jumps with labels, rather than working with high-level constructs like for loops.

For understanding how we can create .dex files programmatically, we first need to understand how DEX bytecode is structured. Let's start with a basic example: the structure of the simple HelloWorld class written in smali, the human-readable form of DEX bytecode.

Smali
.class public LHelloWorld;                        # public class HelloWorld
.super Ljava/lang/Object;                         # extends Object
.source "HelloWorld.java"

# direct methods
.method public constructor <init>()V              #  public HelloWorld() {
    .registers 1

    .line 3                                       #   super();
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V

    return-void                                   #   return;
.end method                                       #  }

# virtual methods
.method public sayHello()V                        #  public void sayHello() {
    .registers 3

    .line 5                                       #   PrintStream v0 = System.out;
    sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream;
                                                  #   v1 = "Hello..";
    const-string v1, "Hello from dynamically defined class!"
                                                  #   v0.println(v1);
    invoke-virtual {v0, v1}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V

    .line 6
    return-void                                   #   return;
.end method                                       #  }

This is called smali bytecode. Smali is a human-readable representation of DEX bytecode, much like assembly language is for native machine code.

What's the difference between direct and virtual methods?

  • Direct methods are methods that cannot be overridden — such as constructors (<init>) and private methods. They are called directly and are unique to their class.
  • Virtual methods are normal instance methods that can be overridden in subclasses. Most public and protected instance methods fall into this category, and the runtime will choose the correct method to call based on the actual object's class.

Here's a simple example that builds our familiar HelloWorld class (with a sayHello() method that prints a message) using DexMaker:

Java · DexMaker
DexMaker dexMaker = new DexMaker();
// Declare a new class: HelloWorld
TypeId<?> helloWorld = TypeId.get("LHelloWorld;");
dexMaker.declare(helloWorld, "HelloWorld.generated", Modifier.PUBLIC, TypeId.OBJECT);

// Constructor: public HelloWorld() { super(); }
Code ctor = dexMaker.declare(helloWorld.getConstructor(), Modifier.PUBLIC);
Local<?> thisRef = ctor.getThis(helloWorld);
// Call Object.<init>()
ctor.invokeDirect(TypeId.OBJECT.getConstructor(), null, thisRef);
ctor.returnVoid();

TypeId<System> systemType = TypeId.get(System.class);
TypeId<PrintStream> printStreamType = TypeId.get(PrintStream.class);

// Identify the 'sayHello()' method
MethodId hello = helloWorld.getMethod(TypeId.VOID, "sayHello");
// Declare that method and get a builder for instructions
Code code = dexMaker.declare(hello, Modifier.PUBLIC | Modifier.STATIC);

// Declare locals
Local<String> a = code.newLocal(TypeId.STRING);
Local<PrintStream> localSystemOut = code.newLocal(printStreamType);

// String a = "Hello from dynamically defined class!";
code.loadConstant(a, "Hello from dynamically defined class!");

// PrintStream localSystemOut = System.out;
FieldId<System, PrintStream> systemOutField = systemType.getField(printStreamType, "out");
code.sget(systemOutField, localSystemOut);

// localSystemOut.println(a);
MethodId<PrintStream, Void> printlnMethod = printStreamType.getMethod(TypeId.VOID, "println", TypeId.STRING);
code.invokeVirtual(printlnMethod, null, localSystemOut, a);

// return;
code.returnVoid();

// Generate DEX bytecode and the ClassLoader
File dexOutputDir = context.getDir("dex", Context.MODE_PRIVATE);
ClassLoader loader = dexMaker.generateAndLoad(
    Test2.class.getClassLoader(),
    dexOutputDir
);

// Load the class
Class<?> helloWorldClass = loader.loadClass("HelloWorld");
Object helloWorldInstance = helloWorldClass.getDeclaredConstructor().newInstance();
Method sayHelloMethod = helloWorldClass.getMethod("sayHello");
sayHelloMethod.invoke(helloWorldInstance);

As you can see, even for a very simple Hello World example, working directly with DexMaker is quite challenging and requires a lot of manual setup. However, it is definitely possible to write a transpiler that converts standard source code into DexMaker's API syntax, which could then be used to generate .dex files automatically. To be honest, that's exactly what I'm thinking about doing right now! :D

DexMaker ProxyBuilder

DexMaker doesn't just let you generate DEX code from scratch — it also comes with a ready-made helper called ProxyBuilder that can create dynamic proxies for classes on Android, similar to how Java's Proxy works for interfaces.

With ProxyBuilder, you can generate a proxy subclass for any given class at runtime. This allows you to intercept and customize method calls on the fly, which is very useful for things like mocking. However, ProxyBuilder cannot proxy final classes or final methods at runtime.

Java · ProxyBuilder
public class HelloClass {
    public String sayHello(String name) {
        return "Hello, " + name;
    }
}

// Using DexMaker's ProxyBuilder to intercept 'sayHello' calls
HelloClass proxy = ProxyBuilder.forClass(HelloClass.class)
    .handler((proxyInstance, method, args) -> {
        if (method.getName().equals("sayHello")) {
            return "Intercepted: " + args[0];
        }
        return null;
    })
    .build();

// Usage
String result = proxy.sayHello("World"); // Returns "Intercepted: World"
System.out.println(result);
Interception toggle simulated
Original Class ↓ DexMaker ↓ Generated subclass ↓ method interception
Hello, World

14 — The caveat that matters

⚠ Dynamic code loading

Google Play and App Stores Policy Warning

While dynamic code loading is technically possible on Android using DexClassLoader and similar mechanisms, it's important for all developers to know that Google's Developer Policy strictly prohibits downloading and executing code from the internet at runtime on apps distributed through the Play Store. This includes dynamically fetching .dex, .apk, or any executable script and loading it to change your app's behavior after installation.

Violating this rule can result in your app being suspended or permanently removed from Google Play!

Technically possible
  1. DEX
  2. ↓
  3. DexClassLoader
  4. ↓
  5. Runtime code
≠
Play Store concern
  1. Internet
  2. ↓
  3. Download executable code
  4. ↓
  5. Change app behavior

Google Play Developer Policy:

An app distributed via Google Play may not modify, replace or update itself using any method other than Google Play's update mechanism. Likewise, an app may not download executable code (such as dex, JAR, .so files) from a source other than Google Play. This restriction does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs (such as JavaScript in a WebView or browser).

15 — Wrapping up

Conclusion

Dynamic code execution might sound complex, but as we've seen, it's behind a lot of the magic in Java and Android development. Tools like reflection, proxies, and custom class loaders let your apps load new features, create code on the fly, and adapt at runtime, without needing to recompile everything.

Concept map

On Java, you can use these features to build flexible APIs and testing tools like Retrofit and Mockito. On Android, you do similar things with DEX files and DexClassLoader, even making use of libraries like DexMaker for extra power.

While these techniques give you a lot of flexibility, they also come with some risks, so use them carefully and understand what's going on under the hood.

Next Steps:

If you're interested in going further, check out tools like ByteBuddy or explore dynamic code generation libraries for more advanced possibilities.

Thanks for reading, Hope you enjoyed this article.