Back to Blog
Java

Java Method Resolution: Overloading, Overriding, and Virtual Dispatch

java method resolution: Understand how Java resolves method calls at compile time and runtime, including overloading, overriding, and virtual dispatch in the JVM.

method resolutionJVMoverloadingoverridingvirtual dispatchmethod invocation
Diagram illustrating Java method resolution between compile-time and runtime dispatch.

When you call a method in Java, the compiler and the JVM cooperate to decide which implementation actually runs. This process is called java method resolution, and it happens in two distinct phases: compile-time resolution for overloaded methods and runtime resolution for overridden methods. Getting these phases wrong leads to subtle bugs that are hard to trace, especially when inheritance and interfaces are involved.

What Java Method Resolution Means at Compile Time

The Java compiler resolves method calls based on the static type of the receiver and the argument types. At this stage, the compiler selects a method signature from the set of methods that are visible and applicable. This is called compile-time method resolution, and it is the foundation for overloading.

Consider this simple example:

public class Printer { public void print(String s) { System.out.println("String: " + s); } public void print(Object o) { System.out.println("Object: " + o); } }

When you write printer.print("hello"), the compiler sees the argument's static type as String and selects the print(String) overload. If you pass an Object reference, it selects print(Object). The decision is made entirely at compile time; the runtime does not re-evaluate which overload to call.

How Overloading Is Resolved by the Compiler

Overloading resolution follows a precise set of rules defined by the Java Language Specification. The compiler first looks for methods that are applicable by strict invocation, then by loose invocation, and finally by variable arity. In practice, this means the most specific applicable method wins.

A common mistake is assuming that overloading is resolved dynamically. It is not. The compiler uses the declared type of the arguments, not their runtime type. For example:

Object value = "text"; printer.print(value); // calls print(Object), not print(String)

Even though value holds a String at runtime, the compiler sees the static type Object and selects the Object overload. This behavior is often surprising to developers who expect dynamic dispatch for all method calls.

Overloading is a compile-time feature. It lets you provide multiple method signatures that share a name, but the selection is fixed when the code is compiled. This is different from overriding, which is a runtime feature.

How Overriding Changes Resolution at Runtime

Overriding occurs when a subclass provides a specific implementation of a method declared in a superclass or interface. Unlike overloading, the decision of which overridden method to call is made at runtime based on the actual type of the receiver object.

public class Animal { public void speak() { System.out.println("Generic animal sound"); } } public class Dog extends Animal { @Override public void speak() { System.out.println("Woof"); } }

If you have an Animal reference pointing to a Dog instance, calling speak() invokes Dog.speak() because the JVM uses the runtime type of the object. This is called virtual dispatch, and it is the core of polymorphism in Java.

The compiler checks that the method exists and is accessible, but it does not decide which implementation runs. That decision is deferred to the JVM.

Virtual Dispatch and the JVM Method Table

The JVM implements virtual dispatch using a method table, often called a vtable. Each class has a table that maps method signatures to actual code addresses. When a virtual method is called, the JVM looks up the method in the vtable of the object's runtime class and jumps to the corresponding implementation.

For example, when the JVM executes animal.speak(), it does not know at compile time whether animal points to an Animal or a Dog. At runtime, it fetches the vtable from the object header and finds the entry for speak(), which points to the correct implementation.

This lookup adds a small indirection compared to a direct call. In modern JVMs, the Just-In-Time (JIT) compiler often optimizes virtual calls through inline caching. If a call site consistently sees the same receiver type, the JIT can inline the method body and avoid the vtable lookup entirely. When a call site sees many different types, it becomes megamorphic, and the JIT may fall back to a slower dispatch path.

Method References and Interface Default Methods

Method references, introduced in Java 8, provide a compact syntax for referring to existing methods as functional interfaces. They also participate in method resolution, but the rules are slightly different because the target type is determined by the expected functional interface.

List<String> names = Arrays.asList("ada", "grace"); names.stream().map(String::toUpperCase).forEach(System.out::println);

Here String::toUpperCase is resolved against the Function<String, String> interface. The compiler identifies the applicable method based on the functional interface's signature. This is still compile-time resolution, but the receiver is the method reference's target.

Interface default methods add another layer. When a class implements multiple interfaces that provide default methods with the same signature, the class must override the method to resolve the conflict. The JVM uses a deterministic resolution order: the most specific default method wins, and explicit overrides in the class take precedence.

public interface A { default void hello() { System.out.println("A"); } } public interface B { default void hello() { System.out.println("B"); } } public class C implements A, B { @Override public void hello() { A.super.hello(); // explicit choice } }

Without the override, the compiler would reject the class because the inherited default methods conflict. This is a case where method resolution requires explicit intervention.

Performance and Maintainability Considerations

Virtual dispatch has a runtime cost, but it is rarely a bottleneck in well-designed applications. The JIT compiler's inline caching reduces the overhead for monomorphic call sites. However, you can still affect performance by how you structure your code.

Overloading does not incur runtime cost because it is resolved at compile time. Overriding and interface dispatch are where the vtable lookup happens. If you have a hot loop that calls a virtual method on many different receiver types, the JIT may not be able to inline the call, and you might see higher CPU usage. In such cases, consider using a design that reduces polymorphism in the hot path, such as a switch on a type tag or a dedicated strategy object.

Maintainability is also affected by resolution rules. Overloading can make APIs confusing if the overloads have similar signatures but different behavior. Overriding, on the other hand, should always honor the contract defined by the superclass method, including the @Override annotation to catch mistakes early.

The following table summarizes the key differences:

AspectOverloadingOverriding
Resolution timeCompile timeRuntime
BasisStatic types of argumentsRuntime type of receiver
Method selectionMost specific applicable methodOverride in most derived class
Use caseMultiple signaturesPolymorphic behavior
PerformanceNo runtime costVirtual dispatch, often inlined

Common Resolution Pitfalls and How to Avoid Them

One common pitfall is mixing overloading with overriding. When a subclass overloads a method that exists in the superclass, the two methods are unrelated. For example:

public class Parent { public void process(String s) { ... } } public class Child extends Parent { public void process(Object o) { ... } // overload, not override }

Calling child.process("x") resolves to Parent.process(String) because the compiler selects the most specific overload, even though Child has a method with a broader parameter type. This can lead to confusion if you expect the child's method to be called.

Another pitfall is relying on the runtime type of arguments for overload resolution. As shown earlier, overloads are chosen based on static types. If you need dynamic behavior, use overriding or a pattern like the visitor pattern.

Interface default methods can also cause subtle issues when a class inherits a default method and also has a method with the same signature from a superclass. In that case, the superclass method wins over the interface default, because classes take precedence over interfaces in Java's resolution rules. This is defined in the Java Language Specification, and it is easy to forget.

To avoid these pitfalls, follow these guidelines in your code:

  • Always annotate overriding methods with @Override so the compiler verifies the intent.
  • Avoid overloading methods that accept related types, especially Object and a specific type, unless the behavior is clearly documented.
  • When implementing multiple interfaces with default methods, explicitly override conflicting methods to make the resolution explicit.
  • Remember that overload resolution uses static types; if you need runtime dispatch, design for overriding.

Understanding java method resolution is not just an academic exercise. It directly affects how you write polymorphic code, how you structure APIs, and how the JVM executes your application. By keeping the compile-time and runtime phases separate in your mental model, you can predict which method will run in any given scenario and avoid the surprises that come from mixing overloading and overriding.

java method resolution: Practical Usage and Code Examples | RYUSLOG DEV