Contents
- Bytecode Basics & Class File Structure
- Reading Bytecode with javap
- Common Opcodes
- Writing a Java Agent (premain)
- ClassFileTransformer — Intercepting Class Loading
- Dynamic Attach (agentmain)
- Byte Buddy — High-Level Bytecode Manipulation
- Real-World Use Cases
A .class file has a fixed binary structure defined by the JVM specification:
- Magic number — 0xCAFEBABE (identifies it as a Java class file)
- Minor & major version — e.g., major 65 = Java 21
- Constant pool — all string literals, class/method/field references
- Access flags — public, final, interface, abstract, etc.
- Fields & methods — each method contains a Code attribute with bytecode instructions, max stack depth, and local variable table
The JVM is a stack-based virtual machine. Instructions push and pop operands on a per-frame operand stack rather than using registers like x86/ARM.
javap is the JDK disassembler. Use -c for bytecode and -v (verbose) for the full constant pool and metadata.
// Source code
public class Counter {
private int count = 0;
public int increment() {
return ++count;
}
}
# Compile then disassemble
javac Counter.java
javap -c Counter
// Disassembled output for increment()
public int increment();
Code:
0: aload_0 // push 'this' onto operand stack
1: dup // duplicate top of stack
2: getfield #7 // pop 'this', push this.count (int)
5: iconst_1 // push constant 1
6: iadd // pop count and 1, push count+1
7: putfield #7 // pop 'this' and new value, store to this.count
10: aload_0 // push 'this' again
11: getfield #7 // push this.count (now incremented)
14: ireturn // return int at top of stack
# Full verbose output (constant pool, line number table, stack frames)
javap -v Counter
There are ~200 opcodes. These are the most frequently encountered:
// Load/store
aload_0 load reference from local variable 0 (usually 'this')
iload_1 load int from local variable 1
astore_2 store reference to local variable 2
// Constants
iconst_0..5 push int constant 0-5
ldc #n push constant from constant pool (String, int, float, Class)
aconst_null push null
// Arithmetic
iadd / isub / imul / idiv / irem
ladd / lsub (long variants)
fadd / dadd (float/double variants)
// Object/field/method
new #n allocate new object (does NOT call constructor)
invokespecial call constructor or private/super method
invokevirtual polymorphic instance method dispatch
invokestatic static method
invokeinterface interface method call
invokedynamic lambda / string concatenation (since Java 7)
getfield / putfield instance field access
getstatic / putstatic static field access
// Control flow
if_icmpeq/ne/lt/ge/gt/le int comparison branches
goto #offset unconditional jump
tableswitch dense switch
lookupswitch sparse switch
// Return
ireturn / lreturn / freturn / dreturn / areturn / return (void)
// Exception
athrow throw exception (object on stack)
A Java agent is a JAR that the JVM loads before main(). It receives an Instrumentation handle that lets it add ClassFileTransformers.
// Agent entry point — called before main()
import java.lang.instrument.Instrumentation;
public class TimingAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] Loaded with args: " + agentArgs);
inst.addTransformer(new TimingTransformer(), /* canRetransform= */ true);
}
}
The agent JAR's MANIFEST.MF must declare the entry point:
Manifest-Version: 1.0
Premain-Class: com.example.TimingAgent
Agent-Class: com.example.TimingAgent
Can-Retransform-Classes: true
Can-Redefine-Classes: true
# Attach agent at JVM startup
java -javaagent:timing-agent.jar=verbose myapp.Main
A ClassFileTransformer receives raw bytecode bytes for every class being loaded and can return modified bytes. The example below uses ASM (the industry-standard bytecode library) to inject a timing print around every method entry.
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
public class TimingTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain pd,
byte[] classfileBuffer) {
// Skip JDK internals and the agent itself
if (className == null || className.startsWith("java/")
|| className.startsWith("sun/") || className.startsWith("com/example/agent")) {
return null; // null = no transformation
}
try {
ClassReader cr = new ClassReader(classfileBuffer);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new TimingClassVisitor(cw, className);
cr.accept(cv, ClassReader.EXPAND_FRAMES);
return cw.toByteArray();
} catch (Exception e) {
e.printStackTrace();
return null; // return null on error to keep original bytes
}
}
}
class TimingClassVisitor extends ClassVisitor {
private final String className;
TimingClassVisitor(ClassVisitor cv, String name) {
super(Opcodes.ASM9, cv);
this.className = name;
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if (name.equals("<init>") || name.equals("<clinit>")) return mv;
return new TimingMethodVisitor(mv, className + "." + name);
}
}
class TimingMethodVisitor extends MethodVisitor {
private final String methodName;
TimingMethodVisitor(MethodVisitor mv, String name) {
super(Opcodes.ASM9, mv);
this.methodName = name;
}
@Override
public void visitCode() {
// Inject: System.out.println("[ENTER] methodName");
mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("[ENTER] " + methodName);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println",
"(Ljava/lang/String;)V", false);
super.visitCode();
}
}
Always use ClassWriter.COMPUTE_FRAMES when modifying bytecode — computing stack map frames by hand is error-prone and version-dependent.
Agents can also be attached to a running JVM without restarting it — useful for profilers and diagnostic tools.
// In the agent JAR — agentmain is called for dynamic attach
public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] Dynamically attached!");
// retransform already-loaded classes
for (Class<?> clazz : inst.getAllLoadedClasses()) {
if (inst.isModifiableClass(clazz) && clazz.getName().startsWith("com.myapp")) {
try {
inst.retransformClasses(clazz);
} catch (UnmodifiableClassException e) {
// some JDK-internal classes cannot be retransformed
}
}
}
}
// Attach from another JVM process (requires tools.jar / JDK)
import com.sun.tools.attach.VirtualMachine;
VirtualMachine vm = VirtualMachine.attach("12345"); // target PID
vm.loadAgent("/path/to/agent.jar", "optionalArgs");
vm.detach();
Working directly with ASM opcodes is verbose. Byte Buddy provides a fluent, type-safe API for common transformation patterns, used internally by Mockito, Hibernate, and many APM agents.
// Maven: net.bytebuddy:byte-buddy:1.14.x
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
// 1. Subclass & method interception (no agent needed)
Class<? extends Greeter> dynamic = new ByteBuddy()
.subclass(Greeter.class)
.method(ElementMatchers.named("sayHello"))
.intercept(Advice.to(LoggingAdvice.class))
.make()
.load(Greeter.class.getClassLoader())
.getLoaded();
Greeter instance = dynamic.getDeclaredConstructor().newInstance();
instance.sayHello("World");
// 2. The @Advice class — defines enter/exit interceptors
class LoggingAdvice {
@Advice.OnMethodEnter
static long enter(@Advice.Origin String method) {
System.out.println("[ENTER] " + method);
return System.nanoTime();
}
@Advice.OnMethodExit(onThrowable = Throwable.class)
static void exit(@Advice.Origin String method,
@Advice.Enter long startNs,
@Advice.Thrown Throwable thrown) {
long elapsedMs = (System.nanoTime() - startNs) / 1_000_000;
System.out.printf("[EXIT] %s — %d ms%n", method, elapsedMs);
if (thrown != null) System.out.println("[THROW] " + thrown);
}
}
// 3. Use Byte Buddy inside a Java agent for load-time weaving
public static void premain(String args, Instrumentation inst) {
new AgentBuilder.Default()
.type(ElementMatchers.nameStartsWith("com.myapp"))
.transform((builder, type, loader, module, pd) ->
builder.method(ElementMatchers.any())
.intercept(Advice.to(LoggingAdvice.class)))
.installOn(inst);
}
- APM agents (New Relic, Datadog, OpenTelemetry Java) — inject tracing spans into HTTP handlers, DB calls, and message consumers at load time.
- Code coverage (JaCoCo) — insert probe instructions at every branch to count which lines are executed.
- Mocking (Mockito) — subclass or redefine classes to intercept method calls and return configured answers.
- ORM lazy loading (Hibernate) — replace entity classes with proxy subclasses that trigger DB fetches on first field access.
- Hotswap / hot reload — redefine method bodies in a running JVM during development (JRebel, Spring Boot DevTools).
- Security hardening — deny-list dangerous API calls (e.g., Runtime.exec()) by transforming them to throw at load time.
For production instrumentation, prefer Byte Buddy's AgentBuilder over raw ASM. It handles retransformation, module access, and Java version compatibility automatically.