Who You Gonna Call? Behind the Scenes of JVM Method Invocations
One of Java’s key benefits is its ability to produce platform-independent programs that don’t need to be recompiled for the desired target architecture. This is achieved by compiling programs to bytecode, which are then executed by the Java Virtual Machine (JVM) as opposed to the CPU directly.
While this process is abstracted away from you as the programmer, allowing you to solely focus on the high-level language, you might still wonder how your program works behind the scenes. There are many bits and pieces that are interesting about the JVM, but we can’t cover everything at once. In this post, we’ll take a look at one of them, namely how the JVM finds out what the target of a method call is.
Refresher: Analysis of JVM Bytecode
JVM bytecode provides a rich amount of information about JVM-based programs, such as Java programs and Android apps. To perform more complex analyses on it (like call graph generation, which we will cover in a later post), you need the right tooling to help you parse and understand the instructions.
For this reason, Guardsquare created ProGuardCORE, which aims to be a versatile and powerful JVM bytecode analysis and manipulation framework. Things like call resolution, which we will explore in this post, are already fully implemented in ProGuardCORE. Each of the following sections covers specific invocation instructions, and contains links to the corresponding CallResolver
method in ProGuardCORE to allow you to dig deeper into the practicalities of call resolution.
TL;DR: What Does JVM Bytecode Look Like Again?
If you are already familiar with JVM bytecode basics, you can skip ahead to the next section (Static Methods). If not, however, this section will provide you with a quick introduction. If you are interested in more details, I recommend you to have a look at chapter 6 of Oracle’s JVM specification document.
Consider the following basic Java snippet that contains a method call toCalculator.add
:
public class Test
{
public void calculate(Calculator calculator)
{
int result = calculator.add(22, 33);
}
}
After compiling this code usingjavac Test.java
, the resultingTest.class
file can be disassembled usingjavap -c Test
to reveal the bytecode of ourcalculate
method:
public void calculate(Calculator);
Code:
0: aload_1
1: bipush 22
3: bipush 33
// #2 = Method Calculator.add:(II)I
5: invokevirtual #2
8: istore_2
9: return
Each instruction has a number in front, which is its offset; this specifies how many bytes are between the instruction and the start of the method, allowing us to uniquely identify a location in the bytecode.
Finding out what the bytecode for methodcalculate
does is not too difficult when comparing it to the original Java code and looking at the comments added byjavap
, so let’s break it down:
0: aload_1
Local variable number 1 (first argument of the method,calculator
in this case) is loaded and pushed onto the stack as the instance object that will later be used to calladd
.
1: bipush 22
The integer value22
is pushed onto the stack, which corresponds to the first argument value that will be passed toadd
.
3: bipush 33
The second argument,33
, is pushed onto the stack. Here we can already see what the full calling convention in the JVM looks like. First, you push the instance object whose method you want to call (not the case for static calls), followed by the call arguments in declaration order, leading to the stack layout shown in Figure 1.
Figure 1: Stack layout before executingCalculator.add(22,33)
// #2 = Method Calculator.add:(II)I
5: invokevirtual #2
Theinvokevirtual
instruction callsCalculator.add
. To tell the JVM which method is to be invoked, the instruction’s operand#2
is used. It specifies that the constant at position 2 (this may change depending on your compiler) in the constant pool is to be evaluated (you can also look at the constant pool of any class by adding the-verbose
flag tojavap
). In our case, as indicated by the comment above, this constant is a Methodref
constant referencing a method of classCalculator
with nameadd
and descriptor(II)I
.
While the first two parts of the reference are self-explanatory, the descriptor might need some further explanation: It specifies the argument types and the return type of a method in the JVM through the format(<argument-types>)<return-type>)
, e.g. in our case(II)I
for a method taking two integers and returning an integer.
Knowing the descriptor of a called method is necessary for identification due to potential overloading.
8: istore_2
The method call consumes all the previously pushed arguments and the instance pointer and instead places the return value on top of the stack, resulting in the stack layout shown in Figure 2. This return value is stored in the local variable number 2 by theistore_2
instruction.
Figure 2: Stack layout after the call
9: return
At the end, thecalculate
method exits using thereturn
instruction.
There are many more instructions the JVM can use, but for our purposes, we won’t need much more than the ones shown in this snippet. Our goal is to resolve call targets of any type ofinvoke…
instruction.
The following sections will summarize the key aspects of this resolution process, including static calls, virtual calls, and calls to dynamic methods.
Static Methods: invokestatic
Static methods in Java belong to the class itself, instead of any of its instances. Thus, a call to such a method can already be resolved at compile time.
public class StaticCall
{
public void test()
{
staticMethod();
}
public static void staticMethod()
{
}
}
In the bytecode, static calls are performed using theinvokestatic
instruction. Its operand is a MethodRef constant specifying the method to be invoked. Intest
offset 0, this constant references the methodstaticMethod
with descriptor()V
(no arguments and void return type) of classStaticCall
. For static calls we can stop the resolving process right here and declarestaticMethod
as the call target, because we don’t have to deal with any runtime target class resolution.
public void test();
Code:
// #2 = Method staticMethod:()V
0: invokestatic #2
3: return
public static void staticMethod();
Code:
0: return
Virtual Methods
In the previous section about static methods, we only cared about the invocation instruction itself. But now, we need to take its context into account. All other types of calls in the JVM are performed on an instance object, making them virtual method calls. So how do virtual methods differ from static ones?
One of the key principles of object oriented programming (perhaps even the most important one) is polymorphism. This means that several objects of different types can be accessed through the same methods, where each type can have a different implementation for them. As a result, we now need to resolve the actual type of the called object instead of only the method referenced by the invocation instruction.
A regularly used example of this concept are different types of vehicles, e.g. cars and bicycles, where each of them provides the ability to drive on a street (through some imaginarydrive
method). How this works under the hood, however, is completely different depending on the type of vehicle; while a car has a motor, bicycles need to be pedaled.
Invokevirtual
Let’s consider how this example translates to Java. In the snippet below, you see that we have an abstract base classVehicle
that provides adrive
method, which bothCar
andBike
implement.
public abstract class Vehicle
{
public abstract void drive();
}
public class Car extends Vehicle
{
@Override
public void drive()
{
// Start the motor and let's go!
}
}
public class Cabriolet extends Car
{
// This drives exactly like a normal car
}
public class Bike extends Vehicle
{
@Override
public void drive()
{
// You'll have to do the work yourself now!
}
}
public class Main
{
public void useVehicle(Vehicle vehicle)
{
vehicle.drive();
}
}
public void useVehicle(Vehicle);
Code:
0: aload_1
// #2 = Method Vehicle.drive:()V
1: invokevirtual #2
4: return
As we can see in the bytecode, when theuseVehicle
method receives an arbitraryVehicle
object, it can then call itsdrive
method without having to know which exact driving implementation should be used. All it cares about is the invocation of the abstractly defined base method, and the JVM needs to handle the task of finding which method it actually needs to call at runtime.
Such calls are handled by theinvokevirtual
instruction, which references the method name, descriptor and the most specific type we know of the called object. In our case, we really only know that we have an object of typeVehicle
, which is why this is the class referenced in offset 1 ofuseVehicle
, as the baseline for the resolution process. In essence, resolving such a call requires you to do the following steps:
-
Find out the actual runtime type of the called object. This information can’t always be resolved statically, e.g. in this example, we can’t uncover any concrete information about the runtime type of
vehicle
. But in other cases, we have enough information to approximate this, like when a local variable is created similar toVehicle vehicle = new Car();
, for example. In those situations, we can deduce that the actual type of this object isCar
.In ProGuardCORE, this approximation is done by using the
PartialEvaluator
that is able to track all potential types we see assigned to the called object. -
Find the most specific implementation. Starting from the runtime type of the called object, we need to search for a class in the inheritance hierarchy that implements the desired method. In case we know the called object’s type is
Car
, we see that this class already contains an implementation fordrive
and we can directly deduce that this is the call target.In other scenarios, the inheritance hierarchy might go through several levels and the class belonging to the actual type of the called object doesn’t have an implementation for the method. An example for this is the
Cabriolet
class, in which case we go to its direct parent class (Car
) and continue the lookup process there until we reach some transitive parent that finally contains an implementation. - What if we don’t know the actual type? In our example above, the type of argument
vehicle
is simply specified to beVehicle
, not restricted to any particular subclass. In such a case, we can’t find the appropriate target method, asVehicle
itself doesn’t contain an implementation fordrive
since it’s declared as an abstract method. To model all potential situations that can occur during runtime, static call resolution can only over approximate the actual behavior by looking up all known subclasses that contain an implementation of the target method and mark them as potential targets for this call. In our example, this would mean thatuseVehicle
has target candidatesCar.drive
andBike.drive
.
The detailed process of call lookups is slightly more complicated in reality. If you would like to dive deeper into this topic, you can look at §5.4.6 of the JVM specification and also explore the related code in ProGuardCORE (handleVirtualMethods
and resolveVirtual
in particular). Another very interesting resource is this blog post by John Vilk that describes how the introduction of default implementations for interface methods suddenly introduced more complexity and even ambiguities (!) into the call resolution process.
This shows once more that while some language features might be nice for the programmer, they can produce difficulties on a lower level, which doesn’t make the lives of code analyzer programs much easier.
Invokeinterface
Java doesn’t only provide polymorphism through subclassing; there is also the possibility to define interfaces that can be implemented. While a class can only have a single direct parent class, it can implement as many interfaces as it wants. We can rewrite the above example to use this alternative style of inheritance:
public interface Vehicle
{
void drive();
}
public class Car implements Vehicle
{
@Override
public void drive()
{
// Start the motor and let's go!
}
}
public class Bike implements Vehicle
{
@Override
public void drive()
{
// You'll have to do the work yourself now!
}
}
public class Main
{
public void useVehicle(Vehicle vehicle)
{
vehicle.drive();
}
}
In Java code, this looks nearly identical to the traditional subclassing example, and the bytecode foruseVehicle
also looks very similar:
public void useVehicle(Vehicle);
Code:
0: aload_1
// #2 = InterfaceMethod Vehicle.drive:()V
1: invokeinterface #2, 1
6: return
The only thing that changed here is thatinvokeinterface
reeplacesinvokevirtual
. Luckily for us, resolving this type of call (described in detail in §5.4.3.4 of the JVM specification) works in the same way as forinvokevirtual
. The only difference between those instructions is thatinvokevirtual
is able to use some performance optimizations in selecting the target method, whileinvokeinterface
can’t. But this has no semantic impact on the resolution process itself. This is explained in more detail in this excellent StackOverflow answer.
Invokespecial
This type of call instruction behaves likeinvokevirtual
, but takes care of some special situations, as the name implies. Those include invocation of constructors, explicit calls to parent class methods in the form ofsuper.someMethod()
or private method invocation.
The following example is an intuitive use-case to explain the need for this instruction:
public class SuperClass
{
public void sayHi()
{
System.out.println("Hi from SuperClass");
}
}
public class SubClass extends SuperClass
{
@Override
public void sayHi()
{
super.sayHi();
System.out.println("Hi from SubClass");
}
}
Here,SubClass
doesn’t fully replace the implementation ofsayHi
, but it rather extends it by further functionality. As such,SubClass.sayHi
needs to perform a call toSuperClass.sayHi
. Using onlyinvokevirtual
here is impossible though; the JVM would see that the called object (this
inSubClass.sayHi
) is of typeSubClass
. The lookup process would then identifySubClass.sayHi
as the desired implementation and provoke an endless recursive loop.
public void sayHi();
Code:
0: aload_0
// #2 = Method SuperClass.sayHi:()V
1: invokespecial #2
// #3 = Field java/lang/System.out:Ljava/io/PrintStream;
4: getstatic #3
// #4 = String Hi from SubClass
7: ldc #4
// #5 = Method java/io/PrintStream.println:(Ljava/lang/String;)V
9: invokevirtual #5
12: return
Instead, the compiler produces the bytecode seen above, namely theinvokespecial
instruction at offset 1. As can be seen in theinvokespecial
section of §6.5 of the JVM specification, the main difference between it andinvokevirtual
is the fact that the starting class of the lookup process can be specified. If the direct parent class (SuperClass
in this example) is referenced, the lookup process is forced to skip the actual type of the called object and instead start at the referenced class. Like this, the first method the JVM finds in our example isSuper.sayHi
, resulting in the desired behavior of calling the actually overridden parent class method.
Dynamic Methods: invokedynamic
This invocation instruction has been introduced for languages that run in the JVM but are dynamically typed, e.g. JRuby. Dynamically typed languages don’t require you to specify the type of variables at compile time, opposed to statically typed languages like Java. All type checking happens during runtime, which is why call resolution can’t work the same way as it does with other JVM calls.
Java itself also usesinvokedynamic
, though mostly for lambda expressions. In the case of lambda expressions, dynamic methods can replace anonymous inner classes that would first need to be instantiated and then passed to the correct caller.
Additionally, a few language features of newer Java versions, like records or more efficient string concatenation, use dynamic methods under the hood, usually as an optimization to require less bytecode instructions. In the snippet below, you can see how a lambda expression used in the Java stream API is compiled to rather complex bytecode:
import java.util.Set;
import java.util.stream.Collectors;
public class Streams
{
public void test(Set numbers)
{
numbers.stream().filter(x -> x <= 3).collect(Collectors.toSet());
}
}
public void test(java.util.Set);
Code:
0: aload_1
// #2 = InterfaceMethod java/util/Set.stream:
// ()Ljava/util/stream/Stream;
1: invokeinterface #2, 1
// #3 = InvokeDynamic #0:test:()Ljava/util/function/Predicate;
6: invokedynamic #3, 0
// #4 = InterfaceMethod java/util/stream/Stream.filter:
// (Ljava/util/function/Predicate;)Ljava/util/stream/Stream;
11: invokeinterface #4, 2
// #5 = Method java/util/stream/Collectors.toSet:
// ()Ljava/util/stream/Collector;
16: invokestatic #5
// #6 = InterfaceMethod java/util/stream/Stream.collect:
// (Ljava/util/stream/Collector;)Ljava/lang/Object;
19: invokeinterface #6, 2
24: pop
25: return
private static boolean lambda$test$0(java.lang.Integer);
Code:
0: aload_0
// #7 = Method java/lang/Integer.intValue:()I
1: invokevirtual #7
4: iconst_3
5: if_icmpgt 12
8: iconst_1
9: goto 13
12: iconst_0
13: ireturn
Here, the compiler extracted thex -> x <= 3
lambda expression into the private methodinvokedynamic
at offset 6 intest
. The exact details of the process that is kicked off throughinvokedynamic
are quite complex and not in scope of this post. If you would like to learn more about it, you should check out this in-depth explanation by Ali Dehghani and our related blog post that talked aboutinvokedynamic
in an Android context. The short summary is that instead of generating an anonymous inner class at compile time implementing thePredicate.test
method required byStream.filter
, this is deferred to the program’s runtime. Such a behavior is used to optimize bytecode size and execution speed.
ProGuardCORE is of course also able to resolve target methods ofinvokedynamic
calls. You can take a look at the implementation inhandleInvokeDynamic
that benefits from the capabilities of ourLambdaExpressionCollector
.
Conclusion
In this blog, we illustrated that the difficulty of resolving method calls in JVM bytecode greatly depends on the actual invocation instruction. Static calls are very easy to resolve, while virtual and dynamic calls require more work and might not even be able to be resolved without ambiguity solely by static analysis of the bytecode.
In a future post, we will build on the call resolution capabilities of ProGuardCORE to allow further analysis of JVM programs by looking at their call graph. Stay tuned to find out more!
In the meantime, you can find out more about JVM call resolution by checking the corresponding parts in the JVM specification and our implementation of theCallResolver
.