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.
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:
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();
} class Test {
private final String abc = "This is a test";
private Test() {}
} - Test.class
- ├── Fields · abc
- ├── Methods
- ├── Constructors
- └── Modifiers
- getDeclaredField("abc")
- setAccessible(true)
- field.get(instance)
- → "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).
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
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
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:
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:
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
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:
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:
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:
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
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.
- 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:
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.
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:
interface Hello {
void sayHello(String name);
} You can create a proxy for this interface like this:
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! - Proxy
- InvocationHandler.invoke()
- Method
- "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.
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:
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:
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:
interface TestInterface {
String testString();
Boolean testBoolean();
} And in Kotlin, Mockito lets you write:
val mockInstance: TestInterface = mock()
whenever(mockInstance.testString()) doReturn "Hello from mock!" Then, when you call:
println(mockInstance.testString()) // Output: Hello from mock! mock(TestInterface.class);
whenever(mock.testString())
.doReturn("Hello from mock!"); - Proxy
- InvocationHandler
- remember method
- 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.
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:
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.mockcreates 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 usingdoReturn(...). - 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
- ↓
- JVM
- .class
- ↓
- D8
- ↓
- .dex
- ↓
- ART / Dalvik
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:
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 usecontext.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 withcontext.getClassLoader().
Example Usage:
Let's say we have a class called HelloWorld:
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
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:
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); - DEX
- DexClassLoader
- loadClass()
- Class
- Constructor
- Instance
- sayHello() → "Hello, World"
Output will be:
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:
DexClassLoader apkClassLoader = new DexClassLoader(
apkFilePath,
context.getCodeCacheDir().getAbsolutePath(),
null,
context.getClassLoader()
); - 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:
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:
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.
- Java / Kotlin
- .class
- JAR
- D8
- classes.dex
d8 <INPUT-PATH> --output=<OUTPUT-PATH>
Here's a sample build.gradle.kts script to automate the process
for any build variant:
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:
./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.
.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:
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.
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); 14 — The caveat that matters
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!
- DEX
- ↓
- DexClassLoader
- ↓
- Runtime code
- Internet
- ↓
- Download executable code
- ↓
- Change app behavior
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.
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.