Core Java

Three Metaprogramming Models in Java: Annotation Processors vs Bytecode Agents vs Reflection

Java code can write, rewrite, or inspect other Java code. The real question is when it happens, and who pays for it.

Three Moments, Three Trade-Offs

Java developers meet metaprogramming daily, often without noticing. A @Getter annotation, a mapper that appears from nowhere, or a framework that finds your fields on its own all rely on it. However, these tools work at very different moments. Some act while you compile, some while classes load, and some while the program runs.

That timing decides almost everything else: the cost, the safety, and the debugging experience. This article walks through the three models in that order.

Model One: Annotation Processors

Generating Code at Compile Time

An annotation processor runs inside the compiler. It reads your annotations and then produces new code before the program is even packaged. MapStruct is the clean example. According to its README, it generates a mapper implementation at compile time, using plain method invocations instead of reflection, and it has no runtime dependencies. As a result, the generated code is easy to read and to debug.

Lombok Bends the Rules

Lombok looks similar, yet it works differently. The standard annotation API (JSR 269) does not allow a processor to modify existing classes. Lombok therefore reaches into the compiler’s internal classes and edits the syntax tree, as a BellSoft talk explains. In addition, Baeldung’s tutorial notes that Lombok hid its internal API from version 1.16.0, which makes custom annotations hard to write. In practice, this is why Lombok needs IDE plugins, while MapStruct output is ordinary source code.

Model Two: Bytecode Agents

A Java agent runs even later. It receives each class as bytes while the JVM loads it, and it may return changed bytes. That power makes it ideal for profilers, tracing tools, and mocking libraries. Nothing in your source code needs to change, which is both its charm and its risk.

Here is a complete agent you can run. Save it as Agent.java. The transformer counts class loads and returns null, which means “leave the class unchanged”.

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import java.util.concurrent.atomic.AtomicInteger;

public class Agent {
    static final AtomicInteger seen = new AtomicInteger();

    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader, String name, Class<?> cls,
                                    ProtectionDomain pd, byte[] bytes) {
                seen.incrementAndGet();
                return null; // null means: leave the class unchanged
            }
        });
        Runtime.getRuntime().addShutdownHook(new Thread(() ->
            System.out.println("agent saw " + seen.get() + " class loads")));
    }
}

Next, save a tiny Hello.java with a main method that prints one line. Then compile, package, and run everything with these commands. I tested them on JDK 21.

javac Agent.java Hello.java
printf 'Premain-Class: Agent\n' > manifest.txt
jar cfm agent.jar manifest.txt Agent*.class
java -javaagent:agent.jar Hello

On my machine the program printed its own line, followed by “agent saw 17 class loads”. Your count may differ. It is small because the agent only sees classes loaded after it starts.

What It Costs

I also measured the hook itself. A test program loaded 3,000 java.base classes, once without the agent and once with this no-op agent, seven runs each.

Median of 7 runs on one single-core machine, OpenJDK 21. Run-to-run spread (86 to 118 ms without, 102 to 123 ms with) was larger than the gap, so read this as “no visible cost”, not as a precise overhead.

The lesson is nuanced. An empty hook is nearly free. Nevertheless, a real agent parses and rewrites every class, so its cost depends entirely on its work. Also, JEP 451 matters here. In JDK 21, dynamic loading of agents into a running JVM triggers a warning, and a future release will disallow it by default. Agents loaded at startup with -javaagent remain allowed, as one summary of the JEP notes. I could not confirm whether the default has changed in newer releases, so check your JDK.

Model Three: Reflection

Reflection is the most familiar model. Code inspects classes and calls methods by name while running. Serialization and dependency injection libraries depend on it, because they cannot know your types in advance. The trade-off is speed and safety. Errors appear at runtime, and each call costs more than a direct one.

To see how much more, I timed one tiny method called 20 million times per round. I used a hand-written loop, not JMH, and I took the best of five warm rounds. The whole test ran three times with similar results.

Median of 3 runs on one single-core machine, OpenJDK 21. Hand-rolled timing loop, so treat the values as relative, not absolute. The 0.31 ns figures show the JIT compiler optimizing the loop aggressively.

The picture is clear. A plain Method.invoke cost about 3 ns per call, roughly ten times the optimized direct call. A MethodHandle stored in a static final field matched the direct call, while the same handle in an ordinary field cost about as much as reflection. Therefore, how you store the handle matters.

Choosing Between Them

ModelRuns whenMain strengthMain cost
Annotation processorCompile timeFast, type-safe, readable output (MapStruct)Tool and IDE support; Lombok relies on internals
Bytecode agentClass load timeChanges behavior without touching sourceRisky, hard to debug, JEP 451 limits dynamic loading
ReflectionRun timeFully flexible, no build stepSlower calls, errors only at run time

Quick checklist

  • Prefer compile-time generation when the types are known in advance.
  • Use reflection when you must discover types at run time.
  • Reserve agents for cross-cutting tools such as profilers and tracers.
  • Keep method handles in static final fields if speed matters.
  • Test with the newest JDK, since agent rules are tightening.

What We Have Learned

Java offers three moments for metaprogramming. Annotation processors work at compile time. MapStruct generates plain source with no runtime dependencies, whereas Lombok edits the compiler’s syntax tree through internal classes. Bytecode agents work at class-load time and can change any class, yet JEP 451 already warns about dynamic loading in JDK 21. Reflection works at run time. In my measurements a plain reflective call cost about 3 ns, versus 0.31 ns for the direct call, and a static final method handle matched the direct call. In short, move work to compile time when you can, and keep the flexible tools for the cases that truly need them.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button