Loading content...
When you write object.method(), a remarkable journey begins. The name "method" must be translated into an actual memory address—the location of executable code. This translation can happen at different times: during compilation (static binding), during program loading (link-time binding), or during execution (dynamic binding).
Understanding how this resolution happens reveals why some code can be optimized aggressively while other code pays runtime costs for flexibility. It explains mysterious errors, unexpected behavior, and the performance characteristics of object-oriented systems.
This page traces the complete path from source code to executed call, covering the compilation phases for static resolution and the runtime mechanisms for dynamic dispatch.
By the end of this page, you will understand: how compilers resolve overloaded method calls, how virtual method tables enable runtime dispatch, the role of static vs dynamic type in binding decisions, and how JIT compilers optimize virtual calls. You'll see the complete picture of method resolution.
Binding is the process of associating a name (method name, function name) with the code that should execute. Different bindings occur at different points in the program's lifecycle:
1. Compile-Time Binding (Static/Early Binding)
The compiler determines exactly which function will be called. The call instruction in the generated code contains the actual address (or a placeholder that the linker fills in). No runtime decisions are needed.
static, final, or private methods2. Link-Time Binding
The linker resolves references between separately compiled modules. Function addresses are fixed when the executable is created or when libraries are loaded.
3. Runtime Binding (Dynamic/Late Binding)
The actual function is determined during execution, based on information available only at that moment (typically the object's actual type).
| Binding Time | Information Used | Performance | Flexibility |
|---|---|---|---|
| Compile-time | Static types, signatures | Best (inlining possible) | None (fixed at compile) |
| Link-time | Symbol resolution | Excellent (direct calls) | Minimal (library swapping) |
| Runtime | Actual object type | Overhead (indirection) | Maximum (true polymorphism) |
Earlier binding means more optimization opportunities but less flexibility. Later binding means more flexibility but runtime costs. The art of system design is choosing the right binding time for each interaction: static where performance matters and types are known, dynamic where flexibility and extensibility are essential.
When the compiler encounters a method call, it performs a sophisticated resolution process to determine which method implementation to bind. This process uses only static information—the declared types of variables, not the actual objects that might exist at runtime.
The complete overload resolution algorithm:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
public class OverloadResolution { // Overload set for demonstration void process(int x) { System.out.println("int: " + x); } void process(long x) { System.out.println("long: " + x); } void process(double x) { System.out.println("double: " + x); } void process(Integer x) { System.out.println("Integer: " + x); } void process(Number x) { System.out.println("Number: " + x); } void process(Object x) { System.out.println("Object: " + x); } void process(int... x) { System.out.println("varargs: " + Arrays.toString(x)); } public static void main(String[] args) { OverloadResolution demo = new OverloadResolution(); // Resolution trace for each call: demo.process(42); // Candidates: all 7 methods (42 can convert to each) // Best: process(int) - exact match // Output: "int: 42" demo.process(42L); // Candidates: long, double, Integer(no), Number, Object, varargs // Best: process(long) - exact match for long literal // Output: "long: 42" demo.process((short) 42); // Candidates: int, long, double, Integer(no), Number, Object, varargs // Best: process(int) - short widens to int, closest primitive // Output: "int: 42" demo.process(Integer.valueOf(42)); // Candidates: int, long, double, Integer, Number, Object, varargs // Best: process(Integer) - exact match for Integer object // Output: "Integer: 42" demo.process(3.14f); // Candidates: double, Number, Object // Best: process(double) - float widens to double // Output: "double: 3.14" demo.process("hello"); // Candidates: Object only (String is not Number or primitive) // Best: process(Object) - only viable option // Output: "Object: hello" demo.process(1, 2, 3); // Candidates: varargs only (3 arguments) // Best: process(int...) - only viable for 3 args // Output: "varargs: [1, 2, 3]" // Ambiguity example (if we had both): // void process(int x, double y) { } // void process(double x, int y) { } // demo.process(1, 2); // AMBIGUOUS! Both equally good. }}The conversion ranking hierarchy (Java):
| Priority | Conversion Category | Examples | Notes |
|---|---|---|---|
| 1 | Identity (exact match) | int → int | Perfect match, always preferred |
| 2 | Widening primitive | byte → short → int → long → float → double | No information loss |
| 3 | Widening reference | String → Object, ArrayList → List | Subtype to supertype |
| 4 | Boxing | int → Integer, boolean → Boolean | Primitive to wrapper |
| 5 | Unboxing | Integer → int | Wrapper to primitive |
| 6 | Widening + unboxing | Byte → int (unbox then widen) | Combined conversion |
| 7 | Varargs | int, int, int → int... | Last resort |
Crucially, overload resolution uses only the declared types of expressions. If you write Object obj = "hello"; process(obj);, the compiler sees Object, not String. It selects process(Object) even though the actual runtime object is a String. Static polymorphism cannot see runtime types.
When a method call requires runtime binding (virtual methods, interface methods), the compiler cannot emit a direct call instruction. Instead, it emits code that performs a runtime lookup based on the actual object type.
The two-step compiled code for virtual calls:
Fetch the vtable pointer — Every object with virtual methods contains a hidden pointer (vptr) to its class's vtable. The compiled code loads this pointer from the object.
Index into the vtable — Each virtual method has a fixed index in the vtable. The call fetches the function pointer at that index and calls through it.
This is remarkably efficient—just two memory loads and an indirect call—but it's still more expensive than a direct call and prevents inlining.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Source codeclass Shape {public: virtual void draw() = 0; // vtable index 0 virtual double area() = 0; // vtable index 1 virtual ~Shape() {} // vtable index 2}; class Circle : public Shape { double radius;public: Circle(double r) : radius(r) {} void draw() override { /*...*/ } // Overrides at index 0 double area() override { return 3.14159 * radius * radius; } // Index 1}; void render(Shape* shape) { shape->draw(); // Virtual call} // What the compiler generates (conceptual)://// Circle object layout:// +------------------------+// | vptr (8 bytes) | ---> Points to Circle_vtable// | radius (8 bytes) |// +------------------------+//// Circle_vtable layout:// +------------------------+// | [0]: Circle::draw |// | [1]: Circle::area |// | [2]: Circle::~Circle |// +------------------------+//// Compiled 'render' function (x86-64 assembly, simplified)://// render(Shape* shape):// mov rax, [rdi] ; Load vptr from object (rdi = this pointer)// call [rax] ; Call function at vtable[0] (draw is index 0)// ret//// The key insight: the address called is determined by the OBJECT's vptr,// which points to the vtable of the object's ACTUAL class (Circle, not Shape). // Interface dispatch in Java (similar concept, different mechanism):// Java uses an "itable" (interface table) or method handle dispatch// for interface calls, which is slightly more expensive than class vtables.Interface method dispatch:
Interface methods add complexity because a class can implement multiple interfaces, and the same interface method might appear at different vtable indices in different implementing classes.
Solutions used by different runtimes:
| Platform | Interface Dispatch Strategy | Performance |
|---|---|---|
| C++ | No native interfaces; use abstract classes with vtables | Same as virtual |
| Java HotSpot | Interface method tables (itables) + inline caching | Slightly slower than class vtables |
| .NET CLR | Interface dispatch slots with hashmap fallback | Comparable to Java |
| Objective-C/Swift | Selector-based message dispatch | More flexible, more overhead |
JIT compilers use inline caching to speed up virtual calls. At each call site, the runtime caches the last type seen and its method address. If the same type appears again (common in practice), it can skip the vtable lookup and call directly. Monomorphic sites (always the same type) become as fast as direct calls. Polymorphic sites (multiple types) use polymorphic inline caches (PICs).
The behavior of method calls depends on understanding two different notions of "type":
Static type (declared type, compile-time type):
Dynamic type (actual type, runtime type):
The interaction:
For a call variable.method(args), the compiler uses the static type to determine which method signature applies (overload resolution). If that method is virtual, the runtime uses the dynamic type to determine which implementation to call.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
class Animal { public void speak() { System.out.println("Animal sound"); } public void speak(String message) { System.out.println("Animal says: " + message); }} class Dog extends Animal { @Override public void speak() { System.out.println("Woof!"); } @Override public void speak(String message) { System.out.println("Dog barks: " + message); } public void fetch() { System.out.println("Fetching..."); }} public class TypeDistinction { public static void main(String[] args) { // Static type: Animal, Dynamic type: Dog Animal animal = new Dog(); // ----- Overload Resolution (uses STATIC type: Animal) ----- // Compiler sees: Animal has speak() and speak(String) // For call speak("hello"), compiler selects speak(String) animal.speak(); // Resolved to speak() at compile time animal.speak("hello"); // Resolved to speak(String) at compile time // ----- Virtual Dispatch (uses DYNAMIC type: Dog) ----- // At runtime, actual object is Dog // speak() -> Dog.speak() -> "Woof!" // speak(String) -> Dog.speak(String) -> "Dog barks: hello" // Output: // Woof! // Dog barks: hello // ----- Static type limits visibility ----- // animal.fetch(); // COMPILE ERROR! // Static type is Animal, which doesn't have fetch() // Even though dynamic type (Dog) has fetch(), compiler doesn't know // Cast reveals methods of static type ((Dog) animal).fetch(); // Works: static type is now Dog } // Method that demonstrates overload selection with polymorphism public static void demonstrate(Animal a) { a.speak(); // Virtual call to speak() } public static void demonstrate(Dog d) { d.speak(); // Virtual call to speak() d.fetch(); // Also available because static type is Dog }} // The two-phase process summarized:// 1. COMPILE TIME: Compiler uses STATIC type for:// - Determining visible methods// - Overload resolution// - Access control checks//// 2. RUNTIME: JVM uses DYNAMIC type for:// - Virtual method dispatch// - Finding actual implementation to executeWhen you get a compile error saying a method doesn't exist—even though you "know" the object has that method—you're witnessing the static type limitation. The compiler only sees the declared type. If Animal doesn't have fetch(), you can't call it through an Animal variable, period. Cast or change the declared type.
Two mechanisms can make a derived class method "replace" a base class method: overriding and hiding. They look similar but behave very differently.
Overriding (dynamic dispatch):
Hiding/Shadowing (static dispatch):
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
class Parent { // Instance method - can be overridden public void instanceMethod() { System.out.println("Parent instance method"); } // Static method - can only be hidden, not overridden public static void staticMethod() { System.out.println("Parent static method"); } // Field - can only be hidden, not overridden public String name = "Parent";} class Child extends Parent { // OVERRIDING: Replaces parent's instance method @Override public void instanceMethod() { System.out.println("Child instance method"); } // HIDING: Hides parent's static method (NOT override!) // Note: @Override would cause compile error here public static void staticMethod() { System.out.println("Child static method"); } // HIDING: Hides parent's field (NOT override!) public String name = "Child";} public class HidingDemo { public static void main(String[] args) { Child child = new Child(); Parent parentRef = child; // Same object, different static types // OVERRIDING: Behavior based on DYNAMIC type (Child) child.instanceMethod(); // "Child instance method" parentRef.instanceMethod(); // "Child instance method" - SAME! // Both calls invoke Child.instanceMethod() because: // - Method is virtual (overridden) // - Dynamic type is Child // HIDING: Behavior based on STATIC type Child.staticMethod(); // "Child static method" Parent.staticMethod(); // "Parent static method" - DIFFERENT! child.staticMethod(); // "Child static method" (static type Child) parentRef.staticMethod(); // "Parent static method" (static type Parent) // The last two differ because: // - Static methods use static type for resolution // - parentRef's static type is Parent // FIELDS: Also hiding, based on STATIC type System.out.println(child.name); // "Child" System.out.println(parentRef.name); // "Parent" - DIFFERENT! // Fields are never overridden, always hidden // This asymmetry is a common source of bugs! }}Method hiding is almost always unintended and a source of bugs. If you define a method in a derived class with the same signature as a base class non-virtual method, you've hidden it. Calls through base type references will still invoke the base version. Always use virtual (C++) or understand that Java instance methods are virtual by default. C# requires new keyword to explicitly acknowledge hiding.
Modern runtime environments employ Just-In-Time (JIT) compilation to optimize code during execution. One of the most important JIT optimizations is devirtualization—converting virtual calls to direct calls when the runtime can prove which method will be called.
Why devirtualization matters:
Virtual calls prevent inlining. If the JIT can't inline small methods, performance suffers. Devirtualization enables:
Devirtualization techniques:
final cannot be overridden. Direct call is always safe.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// The JIT optimizer in action interface Processor { int process(int value);} // Only implementation of Processor in the applicationclass Doubler implements Processor { @Override public int process(int value) { return value * 2; }} public class ProcessorClient { private final Processor processor; public ProcessorClient(Processor processor) { this.processor = processor; } public int processMany(int[] values) { int sum = 0; for (int v : values) { // Virtual call: processor.process(v) sum += processor.process(v); } return sum; }} // When this code runs:// 1. JIT profiles the processMany() method// 2. Observes that 'processor' is always Doubler// 3. Applies guarded devirtualization: // Optimized version (conceptual):// public int processMany_optimized(int[] values) {// int sum = 0;// for (int v : values) {// if (processor.getClass() == Doubler.class) { // Type guard// sum += v * 2; // Inlined Doubler.process()!// } else {// sum += processor.process(v); // Virtual fallback// }// }// return sum;// } // With Class Hierarchy Analysis, if Doubler is the ONLY Processor:// public int processMany_cha(int[] values) {// int sum = 0;// for (int v : values) {// sum += v * 2; // No guard needed, Doubler is the only option// }// return sum;// } // Tips to help the JIT devirtualize: // 1. Use 'final' classes/methods when possiblefinal class FinalDoubler implements Processor { @Override public int process(int value) { return value * 2; }} // 2. Keep implementations monomorphic at call sites// Avoid mixing types in the same collection if possible // 3. Avoid unnecessary interface abstraction// If only one implementation exists, consider concrete typeUse final on classes and methods that shouldn't be overridden. Don't create unnecessary abstract layers. Keep call sites monomorphic when possible (same type at each call site). These practices help the JIT devirtualize, leading to significant performance improvements in hot paths.
Let's trace a complete method call through the entire resolution process, from source code to executed instruction.
Walking through both paths:
Non-Virtual Method (Static Binding):
CALL <function_address> with placeholderVirtual Method (Dynamic Binding):
draw)The vtable mechanism is beautifully simple: every object carries a pointer to its class's function table. The overhead is one pointer per object plus a table per class. Method dispatch is two memory accesses. This design, used since the 1980s, remains the foundation of object-oriented runtime systems because it's hard to beat in efficiency while preserving full polymorphism.
We've traced the complete journey from method call to executed code. Here are the essential insights:
What's next:
The final page of this module examines performance considerations in depth. We'll explore when polymorphism costs matter, how to measure dispatch overhead, and strategies for optimizing hot polymorphic code paths.
You now understand the complete mechanics of method resolution—from overload resolution at compile time to vtable dispatch at runtime, and how JIT compilers optimize both. This knowledge enables you to predict behavior, debug unexpected calls, and make informed design decisions.