skip to content

At compile time, how does Java choose among overloaded methods, and why can overloading combined with overriding produce surprising results?

level: seniorimportance: should knowfreq 52%

answer

  1. Two decisions: signature (compile, static type) then body (runtime, object)
  2. 3 phases: widen → box → varargs; stop at first match
  3. Most specific wins; tie = compile error
  4. Object o = "hi"; print(o) -> print(Object)
  5. null matches most-specific ref type; unrelated types = ambiguous

basics

~20 s

The compiler picks an overload using the declared (static) types of the arguments, in phases: exact match, then widening, then autoboxing, then varargs. Because this uses declared types, a value's real runtime type does not change which overload is selected, which can surprise people.

solid answer

~50 s

Overload resolution happens entirely at compile time using the static types of the arguments. Java applies three phases in order, stopping at the first that finds an applicable method: phase 1 without boxing/varargs (exact and widening primitive/reference conversions), phase 2 allowing autoboxing/unboxing, phase 3 allowing varargs. Within a phase, if several methods apply, the most specific one wins; genuine ties are a compile error. The trap is that the *declared* type drives selection, so `Object o = "hi"; print(o);` calls `print(Object)` even though the object is a String. Overriding does not interact with this choice: the compiler first fixes WHICH overload (its signature) using static types, then at runtime dynamic dispatch may pick a different override of that same signature. So you can get the overload of one type but the overridden body of another. null also surprises people: it matches the most specific reference parameter, which can be ambiguous.

code

java · 11 lines
java
class Base { void who(Base b) { System.out.println("Base.who(Base)"); } }
class Sub extends Base {
    @Override void who(Base b) { System.out.println("Sub.who(Base)"); } // override
    void who(Sub s)           { System.out.println("Sub.who(Sub)"); }   // overload
}

Base b = new Sub();
b.who(new Sub());
// Step 1 (compile time, static type Base): only who(Base) is visible -> signature = who(Base)
// Step 2 (runtime, object is Sub): dynamic dispatch picks Sub's override
// OUTPUT: "Sub.who(Base)"   (NOT Sub.who(Sub))

go deeper

for a junior

Knows overload choice happens at compile time and uses the declared argument types; can recognize the Object o = "hi"; print(o) surprise even if not the phase details.

for a middle

Explains the static-type selection and most-specific rule, and that null can be ambiguous; aware that overriding does not change the chosen overload.

for a senior

Lays out the three resolution phases (widen/box/varargs), most-specific tie-breaking, and walks the combined overload+override trap correctly, plus null/casting disambiguation.

for a principal

Turns this into API-design and review guidance: avoids fragile overload sets (wrapper-vs-primitive, sibling types), knows when to replace overloading with visitor/pattern-matching, and can reason about source/binary compatibility when adding overloads.

## Two independent decisions, made at two different times The single most important idea here: **Java makes two separate decisions about a method call.** 1. **Which overload (signature) to call** — decided by the **compiler**, using the **static (declared) types** of the arguments. 2. **Which overridden body of that signature to run** — decided by the **JVM at runtime**, using the **dynamic type** of the *receiver object*. These never mix: dynamic dispatch can only choose among overrides *of the signature the compiler already locked in*. Forgetting this produces almost every "WTF" example in this area. ## Phase 1, 2, 3: how the compiler narrows the candidates Given a call like `m(arg1, arg2)`, the compiler collects all *accessible* methods named `m` and finds the **applicable** ones in three phases, **stopping at the first phase that yields at least one**: - **Phase 1 — strict invocation:** applicable by *subtyping* and **widening primitive conversions** only. No boxing, no varargs. (`int`→`long`→`double`; `Dog`→`Animal`.) - **Phase 2 — loose invocation:** everything in phase 1 **plus autoboxing/unboxing** (`int`↔`Integer`). Still no varargs. - **Phase 3 — variable arity:** everything above **plus varargs** (`int...`). This ordering is why an `int` argument prefers `f(long)` (widening, phase 1) over `f(Integer)` (boxing, phase 2) over `f(int...)` (varargs, phase 3), even if all three exist. ## Most-specific rule and ambiguity If a phase yields multiple applicable methods, the compiler keeps the **most specific** one: method A is more specific than B if any argument set valid for A is also valid for B (loosely, A's parameter types are subtypes of B's). If neither is more specific, the call is **ambiguous** and is a **compile error** — you must cast an argument to disambiguate. ## Trap 1: static type, not runtime type, picks the overload ```java void print(Object o) { System.out.println("Object"); } void print(String s) { System.out.println("String"); } Object o = "hello"; print(o); // prints "Object" — declared type is Object print("hi");// prints "String" — declared type is String ``` Even though `o` *holds* a String at runtime, the compiler only sees `Object o`, so it binds `print(Object)`. Overloading is purely a compile-time, static-type decision. (If you genuinely want runtime-type-based selection you need the visitor pattern or `instanceof`/pattern matching, not overloading.) ## Trap 2: overloading + overriding together ```java class Base { void who(Base b) { System.out.println("Base.who(Base)"); } } class Sub extends Base { void who(Base b) { System.out.println("Sub.who(Base)"); } // OVERRIDES void who(Sub s) { System.out.println("Sub.who(Sub)"); } // OVERLOADS } Base b = new Sub(); b.who(new Sub()); ``` What prints? The compiler sees `b`'s static type `Base`, which only has `who(Base)`. So the **signature is locked to `who(Base)`**. At runtime, dynamic dispatch finds `Sub`'s override of `who(Base)`. Result: **`Sub.who(Base)`** — NOT `Sub.who(Sub)`, even though the argument is really a `Sub`. The overload was chosen statically (only `who(Base)` was visible); the override was chosen dynamically. ## Trap 3: null and ambiguity ```java void g(String s) {} void g(Integer i) {} g(null); // COMPILE ERROR: ambiguous — null fits both, neither more specific ``` `null` is assignable to every reference type. The compiler tries to pick the **most specific** reference parameter. With unrelated types (`String`, `Integer`) neither is more specific, so it is ambiguous. With related types (`Object` vs `String`), `String` wins because it is more specific. Disambiguate by casting: `g((String) null)`. ## Trap 4: widening beats boxing beats varargs (and `byte`/`short` surprises) ```java void h(long x) {} void h(Integer x) {} void h(int... x) {} h(5); // calls h(long): widening (phase 1) beats boxing (phase 2) beats varargs (phase 3) ``` ## Why this matters in practice - API designers should avoid overload sets that differ only by closely-related reference types or by primitive-vs-wrapper, because callers (and `null`) hit ambiguity or pick the "wrong" one. - Code reviewers should be suspicious when a method is *both* overloaded and overridden in a hierarchy — that is the classic source of the Trap 2 surprise. ## Deriving the answer Hold two facts and you can derive every case: **(1) overload selection is compile-time and uses declared types via the 3-phase, most-specific rule; (2) override selection is runtime and only chooses among bodies of the already-fixed signature.** Apply them in that order to any tricky example and the result falls out.

  • Why does h(5) call h(long) when h(Integer) and h(int...) also exist?
    Resolution is phased: widening conversions are tried first (phase 1), boxing second (phase 2), varargs last (phase 3). int widens to long in phase 1, so h(long) is chosen before boxing to Integer or using the varargs form is ever considered.
  • How can you force runtime-type-based method selection, since overloading won't do it?
    Use dynamic dispatch deliberately: the visitor (double-dispatch) pattern, or branch on the runtime type with instanceof / switch pattern matching. Overloading is permanently a compile-time, static-type decision.

saying these in an interview costs you the question

  • Believing the argument's runtime type changes which overload is chosen
  • Expecting overriding to 'upgrade' to a more specific overload at runtime
  • Thinking f(5) prefers Integer (boxing) over long (widening)
  • Assuming g(null) always compiles unambiguously
  • Designing APIs with overloads differing only by wrapper vs primitive or sibling reference types

context