Back to Blog
Java

Java Overload Resolution: How the Compiler Chooses

java overload resolution: Understand how Java selects among overloaded methods at compile time: the three-phase resolution process, ambiguity with null, varargs, and p...

method overloadingcompile-time dispatchJava Language Specificationambiguous callsvarargs
Illustration of a Java compiler selecting among several overloaded method signatures, with one highlighted as the chosen overload.

Every time a Java program calls a method with arguments, the compiler must decide which overloaded method signature matches. This decision happens at compile time, not at runtime, and it follows a fixed three-phase process defined by the Java Language Specification. Understanding that process is what makes java overload resolution predictable instead of mysterious.

The key point: overload resolution is a compile-time activity. The compiler examines the argument types and selects the most specific applicable method. If no method is applicable, or if more than one method is equally specific, the compiler reports an error.

The Three Phases of Method Selection

Java's compiler works through three phases when resolving an overloaded method call. Each phase considers a different set of candidate methods, and the first phase that produces an applicable method wins.

Phase 1: Strict Invocation

In the first phase, the compiler looks for methods that are applicable without allowing boxing, unboxing, or varargs expansion. The argument types must match the parameter types exactly, or match through widening primitive conversion or widening reference conversion.

public class Calculator { public int add(int a, int b) { return a + b; } public long add(long a, long b) { return a + b; } }

When you call calculator.add(3, 4), both arguments are int. The compiler first checks whether either method is applicable in phase 1. The add(int, int) method matches exactly. The add(long, long) method would require widening int to long, which is also allowed in phase 1. When multiple methods are applicable, the compiler picks the most specific one. add(int, int) is more specific than add(long, long) because an int argument can be passed to a long parameter via widening, so the call resolves to add(int, int).

Phase 2: Boxing and Unboxing

If no method is applicable in phase 1, the compiler moves to phase 2. This phase allows boxing and unboxing conversions in addition to the conversions allowed in phase 1.

public class Printer { public void print(Integer value) { System.out.println("Integer: " + value); } public void print(Object value) { System.out.println("Object: " + value); } }

Calling printer.print(42) passes an int. In phase 1, neither method is applicable because int cannot be assigned to Integer without boxing, and int cannot be assigned to Object without boxing followed by widening. In phase 2, boxing is allowed, so int boxes to Integer, which can be assigned to both Integer and Object. Both methods are applicable in phase 2. The compiler picks print(Integer) because Integer is more specific than Object.

Phase 3: Varargs

If phases 1 and 2 produce no applicable method, the compiler tries phase 3, which allows varargs expansion.

public class Formatter { public String format(String pattern, Object... args) { return String.format(pattern, args); } }

A call like formatter.format("%d-%s", 42, "hello") is only applicable in phase 3, because the second and third arguments must be packed into an Object[].

The following table summarizes what each phase permits:

PhaseAllowed conversionsExample
1Identity, widening primitive, widening referenceint to long, String to Object
2Phase 1 plus boxing and unboxingint to Integer, Integer to long
3Phase 2 plus varargs expansionint to int...

Why Ambiguity Happens

Ambiguity occurs when two or more methods are equally specific, meaning neither is more specific than the other. The compiler cannot choose, so it reports a compilation error.

public class Ambiguous { public void handle(String value) { System.out.println("String"); } public void handle(Integer value) { System.out.println("Integer"); } }

Calling handle(null) is ambiguous. null is assignable to both String and Integer, and neither type is more specific than the other. The compiler reports an error because no method is more specific.

This is one of the most common java overload resolution failures developers hit. The fix is usually to cast the argument or to add an overload that accepts a common supertype.

Overload Resolution with Null Arguments

null is assignable to every reference type, which makes it a frequent source of ambiguity. When you pass null to an overloaded method, the compiler checks which parameter types accept null. If multiple methods accept it and none is more specific, the call fails.

public class Handler { public void process(String s) { } public void process(List<String> list) { } }

process(null) is ambiguous because String and List<String> are unrelated reference types. Neither is more specific. The compiler cannot decide.

A cast resolves the ambiguity:

handler.process((String) null);

This tells the compiler which overload you mean. The cast is a compile-time hint; the runtime behavior is still a null reference.

Widening, Boxing, and Varargs Interaction

The three phases are ordered deliberately. Widening is preferred over boxing, and boxing is preferred over varargs. This ordering prevents surprising behavior.

public class Resolver { public void accept(long value) { System.out.println("long"); } public void accept(Integer value) { System.out.println("Integer"); } public void accept(int... values) { System.out.println("varargs"); } }

Calling accept(5) with an int:

  • Phase 1: accept(long) is applicable via widening. accept(int...) is not considered because phase 1 does not allow varargs.
  • The compiler picks accept(long).

The int is widened to long rather than boxed to Integer or packed into a varargs array. This matches the phase ordering.

Method Overriding vs. Overloading

Overload resolution selects which method signature to call at compile time. Overriding determines which implementation runs at runtime. These are separate mechanisms, and confusing them leads to subtle bugs.

public class Parent { public void display(String value) { System.out.println("Parent String"); } } public class Child extends Parent { public void display(Object value) { System.out.println("Child Object"); } }

Given Parent p = new Child(); and a call p.display("hello"), the compiler resolves the overload using the static type Parent. Only display(String) exists on Parent, so that is the selected method. At runtime, the JVM dispatches to the overridden implementation. Since Child does not override display(String) (it overloads it with display(Object)), the Parent implementation runs.

The result is Parent String, not Child Object. This is a common surprise for developers who assume runtime dispatch also affects overload selection.

Choosing Between Overloads and Distinct Method Names

Overloading is useful when the same logical operation accepts different input types. But it adds resolution complexity, especially when the parameter types are related by inheritance or when null is a valid argument.

Consider whether distinct method names would be clearer. For example, readInt(), readLong(), and readString() are unambiguous. A single read() method overloaded for each type forces the caller to think about resolution rules.

A practical guideline: use overloading when the parameter types are clearly distinct and the operation is genuinely the same. Avoid overloading when the types are closely related, such as int and long, or String and Object, because the resolution behavior may surprise the caller.

Performance and Maintainability Considerations

Overload resolution itself has no runtime cost. The compiler performs the selection at compile time, and the generated bytecode contains a direct reference to the chosen method. There is no dispatch overhead beyond a normal method invocation.

The maintainability cost is different. Every new overload changes the resolution space for existing call sites. Adding an overload that is more specific than an existing one can silently change which method existing code calls. This is especially dangerous when the new parameter type is a subtype of an existing parameter type.

public class EventBus { public void publish(Object event) { // generic handling } public void publish(UserCreatedEvent event) { // specialized handling } }

Before the second method existed, publish(userCreatedEvent) resolved to publish(Object). After adding publish(UserCreatedEvent), the same call resolves to the new method. If the specialized handling has different side effects, existing behavior changes without a compile error.

This is the real operational risk in java overload resolution: adding an overload is a source-compatible change that can alter behavior. Code review should treat new overloads as behavioral changes, not just additions.

When Overload Resolution Breaks in Production

The most common production issue is not a crash but a silent behavior change after a dependency update. A library adds a more specific overload, and your call site starts resolving to it. The new method may validate input differently, throw different exceptions, or handle null differently.

Another failure mode is the ambiguous call that appears only after a refactor. When you change a parameter type from String to Object, existing call sites that passed null may become ambiguous if another overload accepts a type that null also fits.

The defensive approach is to keep overload parameter types as far apart as possible, avoid overloading on types where one is assignable to the other, and document the resolution behavior for callers who pass null or use type hierarchies.

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