CodeNewbie Community 🌱

DeveloperTom404
DeveloperTom404

Posted on

Method Handles faster reflection (sometimes)

Method Handles faster reflection (sometimes)

How does Java support dynamic calls? From slow reflection to the optimized MethodHandle and invokedynamic—let's explore the evolution of dynamism on the JVM and dive into how MethodHandle works under the hood, along with the roles played by CallSite and invokedynamic.

Why Java needs dynamic calls?

Let's start with a brief retrospective: just a year after Java 1.0 was released, the next version brought the first version of the reflection mechanism. The Java code got the opportunity to obtain class data (their fields, methods, and constructors) and dynamically manipulate objects at runtime, even if their types were unknown at compile time. Then, JPython, one of the first well-known dynamically typed languages on the JVM, was released and started to extensively leverage reflection.

However, why does Java need these calls? Just imagine that:

  • You need to analyze annotations in a class.
  • You're developing a framework that must work with classes unknown at compile time.
  • You need to invoke a method whose name is specified in a configuration file.

Reflection comes to the rescue! It gives the flexibility needed for such tasks. However, this convenience doesn't come for free, as it comes with a performance cost.

Overheads and reflection limitations

When developers used reflection to implement dynamic method call on the JVM, they faced several key issues that still remain relevant today.

First, all reflective operations handle the Object type, which prevents compile-time type checking and delays all potential errors to runtime.

Second, each reflective call requires access and type verification that significantly degrade performance.

Third, the JIT compiler can't optimize these calls because they can't be inlined, compiled to machine code, or analyzed during profiling.

Inlining is an optimization that replaces a method call with its body to eliminate overheads and boost performance. Once inlined, the JIT compiler can optimize further, for example, by eliminating dead code. This practice works best with small methods.

Unfortunately, reflection can't solve these problems because its core is dynamic type handling at runtime. That's why deferred errors, performance, and a lack of optimizations aren't just compromises and drawbacks. They're the inevitable price of the flexibility it offers.

What and how MethodHandle solves

JSR 292, which introduced the MethodHandle API to Java, addressed many of reflection's issues—though it didn't fully replace it. Unlike reflection, which focuses on introspecting metadata, the MethodHandle API encapsulates method calls in a way that allows the JVM to perform optimizations via inlining and speculative compilation.

Speculative compilation (or speculation) is a JIT optimization where the code is compiled specifically for the execution paths based on statistical assumptions (about types, values, etc.) to minimize call delays. If assumptions turn out wrong, the optimization is rolled back and turns into deoptimization.

To create a MethodHandle, use MethodHandles.Lookup, a factory with access control.

The main methods for handling MethodHandle are #invokeExact and #invoke.

Look at them:

@IntrinsicCandidate
public final native @PolymorphicSignature Object invokeExact(Object... args)
    throws Throwable;

@IntrinsicCandidate
public final native @PolymorphicSignature Object invoke(Object... args)
    throws Throwable;
Enter fullscreen mode Exit fullscreen mode

Although the methods are declared as accepting and returning Object, the @PolyMorphicSignature annotation makes the compiler to ignore this declaration. Instead, the actual types of arguments and return values are determined at compile time based on the types of the passed arguments and the return values.

This means that methods handle specific types in their code, so devs can't use MethodHandle with Object.

Let's look at example—we create a MethodHandle that compares numbers using Integer#compare:

MethodHandle Integer_compare = MethodHandles.lookup().findStatic(
        Integer.class, "compare",
        MethodType.methodType(int.class, int.class, int.class)
);
Enter fullscreen mode Exit fullscreen mode

We can't call #invokeExact without specifying int, as a result, that leads to the crash at runtime:

Object result = Integer_compare.invokeExact(1, 2); // WrongMethodTypeException!
Enter fullscreen mode Exit fullscreen mode

The code will only work with explicit conversion to int:

int result = (int) Integer_compare.invokeExact(1, 2);
Enter fullscreen mode Exit fullscreen mode

The call to #invokeExact or #invoke is not direct. They're intrinsics; the virtual machine replaces them with optimized code.

Intrinsic is a method that the JVM treats in a special way and replaces with highly optimized native code or machine instructions at runtime.

#invokeExact requires exact signature matching, while #invoke allows type conversions as in a normal method call. However, if the call signature is unknown, or developers need more flexibility, there's another way to work with MethodHandle:

public Object invokeWithArguments(Object... arguments) throws Throwable {
    ....
}
Enter fullscreen mode Exit fullscreen mode

Unlike the other methods, #invokeWithArguments really uses Object and dynamically checks the types of arguments at runtime. That's versatile, sure, but it impacts performance.

Even though #invokeExact and #invoke are intrinsics, we can't just replace them with method calls. The JVM first rewrites the call to LambdaForm, an internal intermediate representation that breaks the call into more primitive operations.

LambdaForm within MethodHandle

Before exploring LambdaForm, it's important to note that it's a non-public implementation detail specific to OpenJDK. In other JDKs, LambdaForm may significantly differ, or might not exist at all, as in the case with ART.

So, LambdaForm represents a sequence of low-level operations that can be changed using methods in the MethodHandles class.

Let's look at MethodHandle in the following code snippet:

MethodHandle Integer_compare = MethodHandles.lookup().findStatic(
        Integer.class, "compare",
        MethodType.methodType(int.class, int.class, int.class)
);
Enter fullscreen mode Exit fullscreen mode

The simplified form of its LambdaForm looks like this:

(a0:int, a1:int) => {
    return Integer.compare(a0, a1);
}
Enter fullscreen mode Exit fullscreen mode

Then we use the MethodHandles#permuteArguments decorator, which swaps the arguments:

Integer_compare = MethodHandles.permuteArguments(
        Integer_compare,
        MethodType.methodType(int.class, int.class, int.class),
        1, 0
);
Enter fullscreen mode Exit fullscreen mode

Now the simplified version of LambdaForm looks like this:

(a0:int, a1:int) => {
    swap_a0 = a1
    swap_a1 = a0
    return Integer.compare(swap_a0, swap_a1);
}
Enter fullscreen mode Exit fullscreen mode

The MethodHandles adapters create lightweight LambdaForm chains that help the JIT compiler to inline call logic, specialize machine code, and remove intermediate steps. That's why #invokeExact and #invoke outperform reflection after warm-up: they work with primitives that are transparent to JVM optimizations, unlike reflection, which remains "a black box" to the compiler.

JVM warm-up is an optimization process at runtime where the JVM dynamically adapts to the actual load by applying JIT optimizations, collecting profiling data, and adjusting garbage collection.

CallSite over MethodHandle

Imagine that MethodHandle is a multi-tool like a screwdriver with multiple bits. At first glance, it seems perfect for all kinds of jobs. But there's a catch: each adapter like bits for different slots can only be fixed once during creation. It's like we solder the bit to the handle. This is convenient, but only if the task never changes.

Why is this a disaster for dynamic calls?

Let's dive even deeper into the abstract world: we're working on a conveyor belt (its metaphor for script engine), where:

  • Details (the obj object) are constantly changing: today they're square, tomorrow they're round.
  • The required operations (the doSomething method) are different for each part.
  • The processing tools (the x and y arguments) are also different.

What happens to MethodHandle in this chaos?

Our screwdriver with a soldered bit works perfectly—but only for a single, specific part and operation. The moment a different part shows up (a new type of obj) or a new task is required (a different doSomething signature), the screwdriver becomes useless. We have to toss it out, make a new one again, solder on a different bit, and put it into every spot on the conveyor belt where it's needed—in other words, we create a new MethodHandle with the right adapters.

That's where CallSite comes in: a smart screwdriver holder with a quick-swap mechanism: we mount it onto the conveyor and leave it be. When the system launches for the first time, we call a bootstrap method like a service engineer: it inspects the incoming part and the required operation, then inserts the appropriate screwdriver into the holder. If a differently shaped part arrives, the conveyor halts and an emergency engineer—a rebinder we've prepared in advance—quickly reconfigures the tool. The conveyor resumes without missing a beat.

Wait, why we always solder on the bit?

In most cases, the tool is already perfectly suited and the JVM optimistically assumes that once the tool is installed, it won't need to be changed—which lets the JIT compiler optimize it heavily and execute repeated operations at blazing speed.

Like any good tool, CallSite comes in different modifications:

  • ConstantCallSite is the simplest option: a fixed holder soldered in place. Once set, it can't be changed. It's perfect when you know that the parts and operations won't ever vary.
  • MutableCallSite is like a quick-swap holder with #setTarget. But there's a catch: the system doesn't guarantee synchronization, so multiple engineers (threads) might try changing the tool at the same time.
  • VolatileCallSite is an improved version of the previous one. When engineers change a screwdriver, they always activate the lock (volatile semantics). This ensures all workers (threads) see a new tool instead of an old one lingering in memory (cache). You can read more about volatile semantics in another one of our articles.

Now, imagine our conveyor belt is comparing numbers.

We mount the holder with the initial screwdriver (a standard comparison):

CallSite callSite = new MutableCallSite(Integer_compare);
Enter fullscreen mode Exit fullscreen mode

Receive a control panel for the current screwdriver in the holder:

MethodHandle dynamic_Integer_compare = callSite.dynamicInvoker();
Enter fullscreen mode Exit fullscreen mode

Then handle the detail:

result = (int) dynamic_Integer_compare.invokeExact(1, 2);
// result = -1
Enter fullscreen mode Exit fullscreen mode

Suddenly, an order comes down from above: numbers must now be compared in reverse order. Without stopping the conveyor belt, we change the comparison logic on the fly:

callSite.setTarget(
    MethodHandles.permuteArguments(
        Integer_compare,
        MethodType.methodType(int.class, int.class, int.class),
        1, 0
    )
);
Enter fullscreen mode Exit fullscreen mode

The new detail is processed using the new logic:

result = (int) dynamic_Integer_compare.invokeExact(1, 2);
// result = 1
Enter fullscreen mode Exit fullscreen mode

The main entry point for this mechanism is the bytecode instruction, invokedynamic. When it is executed for the first time, it calls a bootstrap method to obtain CallSite, and then refers to CallSite.

MethodHandle is faster (and no)

Although MethodHandle APIs are promoted as a high-performance alternative to reflection, their speed greatly depends on the usage context.

On the first call, MethodHandle performs similarly to reflection—or sometimes even slower—due to the overhead of compiling LambdaForm and initializing internal JVM structures. However, after basic JIT optimizations, MethodHandle gains advantages due to inlining and specialized machine code generation.

To prove it, let's use a JMH benchmark on Oracle OpenJDK 23 to compare direct and reflective calls, #invoke, #invokeExact, and #invokeWithArguments of the Integer#compare(int, int) method.

Here are results of the "hot" run after JIT compiler optimizations:

Method Operations per µs Average execution time (ns) Relative speed
Direct call 3123.7 ± 64.8 1 1.0x
MethodHandle#invokeExact 424.6 ± 7.7 2 ± 1 7.4x slower
MethodHandle#invoke 422.0 ± 12.1 2 ± 1 7.4x slower
Method#invoke 195.9 ± 11.1 5 ± 1 15.9x slower
MethodHandle#invokeWithArguments 11.1 ± 0.4 88 ± 2 281.4x slower

The key points:

#invokeExact or #invoke is 7 times slower due to type checks, passing through a native adapter, and processing a polymorphic signature.

#invokeWithArguments is significantly slower—it's 17.6 times slower than reflection because each call requires type checks, argument boxing, and signature analysis, while reflection caches some of its checks after the first call.

Below are the results of the percentile analysis, measured in microseconds per operation.

Percentile is the value below which a certain percentage of observations fall. For example, the 90th percentile (p0.90) at 10 ms, means that 90% of measurements were less than or equal to 10 ms.

Metrics Direct call (µs) MethodHandle#invokeExact (µs) MethodHandle#invoke (µs) Method#invoke (µs) MethodHandle#invokeWithArguments (µs)
p0.50 ≈ 0 ≈ 0 ≈ 0 ≈ 0 ≈ 0
p0.90 0.1 0.1 0.1 0.1 0.1
p0.99 0.1 0.1 0.1 0.1 0.2
p0.9999 12.0 9.7 12.5 7.4 15.8
p1.0 83.584 87.3 85.7 848.9 1,943.6

Even if the typical performance of the methods is similar up to the 99th percentile, the tail latencies radically differ. Reflection experiences spikes reaching 848 microseconds—about 10 times slower than invokeExact. Meanwhile, invokeWithArguments is disastrous: its maximum execution time is 1,943 microseconds, which is 22 times slower than invokeExact! This makes it a bad choice for high-load systems.

Tail latency is latency values in the worst-case scenarios of system operation, measured by high percentiles (p0.99–p0.9999). It shows how slow even the longest operations can be.

How Java uses MethodHandle: lambdas, concatenation, and VarHandles

One of the key applications of the MethodHandle API in Java has been the implementation of lambda expressions.

Let's take the following expression:

UnaryOperator<String> quote = str -> '"' + str + '"';
Enter fullscreen mode Exit fullscreen mode

In the byte code, it looks like this:

invokedynamic #7,  0
    // InvokeDynamic #0:apply:()Ljava/util/function/UnaryOperator;

....

private static java.lang.String lambda$main$0(java.lang.String);

....

BootstrapMethods:
  0: #36 REF_invokeStatic java/lang/invoke/LambdaMetafactory
             .metafactory:(....)Ljava/lang/invoke/CallSite;
    Method arguments:
      #28 (Ljava/lang/Object;)Ljava/lang/Object;
      #30 REF_invokeStatic MethodHandleSandbox
              .lambda$main$0:(Ljava/lang/String;)Ljava/lang/String;
      #33 (Ljava/lang/String;)Ljava/lang/String;
Enter fullscreen mode Exit fullscreen mode

The entry point is the invokedynamic call, which refers to the bootstrap method specified below. LambdaMetaFactory#metafactory creates a lambda factory that calls the generated #lambda$main$0 method, where the concatenation happens.

Dynamic calls are used in concatenation: since Java 9, it also works via invokedynamic where bootstrap methods are located in java.lang.invoke.StringConcatFactory. These calls replace the cumbersome StringBuilder and allow the JIT compiler to optimize the process for specific data.

Another important application of the MethodHandle API is VarHandle, which addresses the fundamental issues of sun.misc.Unsafe. With Unsafe, developers had to calculate memory offsets manually, risking memory corruption or JVM crashes. VarHandle offers a type-safe and flexible way to access fields, array elements, or off-heap memory. It uses MethodHandle to generate specialized operation adapters like #get, #set, #compareAndSet, and so on. This lets the JVM optimize access patterns while ensuring type and boundary safety.

Here's an example of an atomic Compare-And-Set operation using Unsafe:

class MyClass {
    int value;
}

Field Unsafe_theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
Unsafe_theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) Unsafe_theUnsafe.get(null);

long MyClass_value_offset =
    unsafe.objectFieldOffset(MyClass.class.getDeclaredField("value"));

MyClass instance = new MyClass();
boolean result = unsafe.compareAndSwapInt(instance, MyClass_value_offset, 0, 1);
Enter fullscreen mode Exit fullscreen mode

Here is the same case with VarHandle:

class MyClass {
    int value;
}

VarHandle MyClass_value = MethodHandles.privateLookupIn(
                MyClass.class, MethodHandles.lookup()
        )
        .findVarHandle(MyClass.class, "value", int.class);

MyClass instance = new MyClass();
boolean result = MyClass_value.compareAndSet(instance, 0, 1);
Enter fullscreen mode Exit fullscreen mode

First, VarHandle eliminates dangerous manual offset calculations in Unsafe, which can lead to corrupt memory or cause other critical errors.

Secondly, strict type safety guarantees that operations comply with the declared field type. For example, #compareAndSet for int restricts arguments of long, whereas Unsafe has such issues.

Thirdly, the code on VarHandle remains portable between different JVM implementations, whereas Unsafe has always been platform-dependent.

When should we use MethodHandle?

Dynamic calls in Java have traditionally relied on reflection. Its key weakness is performance because JIT can't optimize non-transparent calls.

Addressing the reflection issues, the MethodHandle API became a good alternative that optimizes dynamic operations. Its main pros for calls are the integration with JVM via invokedynamic that applies optimizations. After warming up, #invokeExact and #invoke work approximately twice as fast as reflection, although they remain seven times slower than direct calls.

The perfect scenario for using MethodHandle is the dynamic execution of predefined operations in hot code. For example, we can implement specialized proxies using Java's built-in Proxy mechanism. In cases where the call signature may also change, we can use CallSite to switch implementations and maintain performance.

Despite all the pros, the MethodHandle API does not allow us to introspect classes and their members, and it may perform worse than reflection when call signatures are frequently changing. So, whether to use reflection or the MethodHandle API depends on the specific task, and there's no versatile answer.

This evolution—from reflection to MethodHandle and CallSite—illustrates one of the key Java trends: the desire to mix the dynamic language flexibility, the type safety, and the compiled code performance. Thanks to this trio, Java remains a strictly typed language that can adapt to a wide range of execution scenarios.

Top comments (0)