May 10, 2022

    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 CallResolvermethod 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.classfile can be disassembled usingjavap -c Testto reveal the bytecode of ourcalculatemethod:

      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 methodcalculatedoes 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,calculatorin 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 value22is 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.

    FIG-1_Stack layout before executing calculator.add(22, 33)Figure 1: Stack layout before executingCalculator.add(22,33)

    // #2 = Method Calculator.add:(II)I
    5: invokevirtual #2
    

    Theinvokevirtualinstruction callsCalculator.add. To tell the JVM which method is to be invoked, the instruction’s operand#2is 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-verboseflag tojavap). In our case, as indicated by the comment above, this constant is a Methodref constant referencing a method of classCalculatorwith nameaddand 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)Ifor 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_2instruction.

    FIG-2_Stack layout after the call

    Figure 2: Stack layout after the call

    9: return
    

    At the end, thecalculatemethod exits using thereturninstruction.

    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 theinvokestaticinstruction. Its operand is a MethodRef constant specifying the method to be invoked. Intestoffset 0, this constant references the methodstaticMethodwith descriptor()V(no arguments and void return type) of classStaticCall. For static calls we can stop the resolving process right here and declarestaticMethodas 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 imaginarydrivemethod). 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 classVehiclethat provides adrivemethod, which bothCarandBikeimplement.

    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 theuseVehiclemethod receives an arbitraryVehicleobject, it can then call itsdrivemethod 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 theinvokevirtualinstruction, 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:

    1. 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 is Car.

      In ProGuardCORE, this approximation is done by using thePartialEvaluatorthat is able to track all potential types we see assigned to the called object.

    2. 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 isCar, we see that this class already contains an implementation fordriveand 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 theCabrioletclass, 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.

    3. What if we don’t know the actual type? In our example above, the type of argumentvehicleis simply specified to beVehicle, not restricted to any particular subclass. In such a case, we can’t find the appropriate target method, asVehicleitself doesn’t contain an implementation fordrivesince 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 thatuseVehiclehas target candidatesCar.driveandBike.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 (handleVirtualMethodsand resolveVirtualin 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 foruseVehiclealso 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 thatinvokeinterfacereeplacesinvokevirtual. 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 thatinvokevirtualis able to use some performance optimizations in selecting the target method, whileinvokeinterfacecan’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,SubClassdoesn’t fully replace the implementation ofsayHi, but it rather extends it by further functionality. As such,SubClass.sayHineeds to perform a call toSuperClass.sayHi. Using onlyinvokevirtualhere is impossible though; the JVM would see that the called object (thisinSubClass.sayHi) is of typeSubClass. The lookup process would then identifySubClass.sayHias 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 theinvokespecialinstruction at offset 1. As can be seen in theinvokespecialsection of §6.5 of the JVM specification, the main difference between it andinvokevirtualis the fact that the starting class of the lookup process can be specified. If the direct parent class (SuperClassin 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 <= 3lambda expression into the private methodinvokedynamicat offset 6 intest. The exact details of the process that is kicked off throughinvokedynamicare 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 aboutinvokedynamicin an Android context. The short summary is that instead of generating an anonymous inner class at compile time implementing thePredicate.testmethod 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 ofinvokedynamiccalls. You can take a look at the implementation inhandleInvokeDynamicthat 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.

     

    Samuel Hopstock - Software Engineer

    Discover how Guardsquare provides industry-leading protection for mobile apps.

    Request Pricing

    Other posts you might be interested in