Widening Beats Boxing Beats Varargs

The Three Phases of Overload Resolution in Java

 · 13 min

Three method overloads… which one runs?

java
static String f(long x)    { return "f(long)"; }
static String f(Integer x) { return "f(Integer)"; }
static String f(int... x)  { return "f(int...)"; }

f(1);

Ask three Java developers, and most will say f(Integer). The argument makes sense, as 1 is a primitive, and an Integer is what an int is, just boxed. A long is a different type at a different width, and int... wasn’t written with a single argument in mind.

Well, it’s f(long).

And it’s not close.

The compiler never weighed those three candidates against each other. Instead, it runs up to three passes in a fixed order.

Everything here sticks to plain arguments: primitives, boxes, and references. Lambdas and method references add their own wrinkle, as an implicitly typed lambda isn’t even considered when checking applicability, but that’s a story for another time.


Three Phases, in Order

Overload resolution happens in three phases, and each one permits strictly more than the last. Resolution stops at the first phase that matches:

First comes strict invocation (JLS §15.12.2.2). Here, boxing, unboxing, and varargs are off the table. The argument has to arrive as something the parameter already accepts, either the exact type or a wider one.

java
static String f(long x)    { ... }
static String f(Integer x) { ... }
static String f(int... x)  { ... }

f(1);   // f(long): widening reaches it in phase 1, the other two never compete

Second is loose invocation (JLS §15.12.2.3). Boxing and unboxing are allowed now, each optionally followed by a widening. That’s how an int reaches Object, boxed to Integer and then widened. Varargs are still forbidden.

java
static String g(Integer x) { ... }
static String g(int... x)  { ... }

g(1);   // g(Integer): nothing matches in phase 1, so boxing gets its turn

Third, and last, variable arity invocation (JLS §15.12.2.4). Varargs are allowed now. It only runs when nothing earlier could take the arguments at all.

java
static String j(String x)    { ... }
static String j(String... x) { ... }

j("a");        // j(String):    phase 1 gets there first
j("a", "b");   // j(String...): the only overload that takes two arguments

static String h(Object x)  { ... }
static String h(int... x)  { ... }

h(1);   // h(Object), from phase 2, so h(int...) never gets a turn

j("a", "b") reaches phase 3 only because nothing earlier could take two arguments. h(int...) is written specifically for ints, and it never gets that far.

What makes the order matter is what’s left by the time each phase runs. A method that could have matched without boxing would have matched in phase 1:

text
phase 1  strict           all candidates
phase 2  loose            only the ones phase 1 couldn't take
phase 3  variable arity   only the ones phases 1 and 2 couldn't take

Only within a phase does the “most specific method” rule (JLS §15.12.2.5) apply. That’s the rule most of us assume is running the whole show.

When There’s More Than One Parameter

Every overload so far takes only a single parameter. But applicability is decided for the whole signature, never one argument at a time.

A method reaches phase 1 only if every argument converts without boxing, so one boxed parameter keeps the whole method out until phase 2, regardless of how exact the others are.

The “most specific” rule is also for the whole signature, and that’s where it gets interesting:

java
static String p(long a, Integer b) { ... }
static String p(Integer a, long b) { ... }

p(1, 1);

Here, we have widening and boxing in both signatures. Each method fits one parameter better, and neither fits both, so neither is more specific. That’s why the compiler can’t decide:

text
error: reference to p is ambiguous
  both method p(long,Integer) in Amb and method p(Integer,long) in Amb match

JLS §15.12.2.5 states: one method is more specific than another if any invocation it handles could be passed on to the other without a compile error. As p(1L, 1) can’t be passed to p(Integer, long), and p(1, 1L) can’t be passed to p(long, Integer) either, no method is more specific.

The Object Overload Is Not Free

The phases don’t just decide which method runs. They also decide what the argument costs on its way in.

A primitive falling through to a reference-typed overload, like h(1) reaching h(Object), isn’t free. There are two costs associated with the call, and the first one is sitting right there in the bytecode:

text
40: invokestatic  #43   // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
43: invokestatic  #54   // Method h:(Ljava/lang/Object;)Ljava/lang/String;

The first cost is the parameter getting boxed. But the second cost doesn’t show up at this call site at all.

invokestatic h:(Ljava/lang/Object;) is statically bound, so the int becomes an Integer, and there’s nothing left to dispatch at this call site. What we’ve actually done is move the problem one level in.

Whatever h(Object) does with that parameter, like calling toString() or equals(), now dispatches on the runtime type of whatever each caller happened to hand it, impacting JIT optimization. HotSpot profiles those receiver types per call site, and the C2 compiler inlines through at most TypeProfileWidth of them, which defaults to 2:

shell
java -XX:+PrintFlagsFinal -version | grep -E 'TypeProfileWidth|TypeProfileMajorReceiverPercent'

If more than two types show up at a compiled call site, it goes megamorphic: dispatch needs to go through the vtable, and the callee stops being inlined. Any code already compiled against the previous profile gets de-optimized and recompiled.

That costs something, but it’s measured in nanoseconds: the same article puts a megamorphic call at about 3ns against 1ns, and concludes you don’t have to worry about call performance.

Megamorphism is a property of the method dispatch, not of overload resolution. h(Object) would have had the same problem if it had won in phase 1.

These are all JIT compiler details, with quite a bit more nuance behind them. C2 still inlines a dominating type once it clears TypeProfileMajorReceiverPercent (default 90), for instance. But let’s go back to method overloading.

This is one reason StringBuilder carries thirteen append overloads, six of them for scalar primitives. Originally, they weren’t optional at all: StringBuffer had them since 1.0, long before boxing existed. Today, append(Object) would happily catch all six in phase 2. Keeping the variants separate, though, skips boxing entirely. The value goes into the buffer as text, not through a toString() call site shared by every type in the program.

List.of arrived in JDK 9 (JEP 269) and is a more modern example. It provides twelve overloads, one for each arity from zero to ten, plus a varargs fallback, so the common case never allocates an array just to reach phase 3.

An extra nanosecond is beneath the notice of any single call site, yours included. The JDK doesn’t get to optimize for one call site, though, and two extra nanoseconds is two seconds over a billion calls.

The API surface grows with every one of those overloads, and that’s deliberate. It buys the common paths a faster route through the phases.


Why The Order Is What It Is

When Java 1.0 arrived, there were no auto-boxing or varargs, only widening. That made overload resolution simple, but introduced a constraint when the other features finally arrived in Java 5.

The constraint was narrow, but necessary: nothing in Java 5 was allowed to make a pre-Java-5 call ambiguous. If boxing had been allowed to compete with widening on equal footing, every f(1) ever written would suddenly have been up for renegotiation.

So the new mechanisms weren’t mixed into the existing rules. They were appended after them, each in its own phase, reachable only once everything before it came up empty.

The JLS states it clearly (§15.12.2):

“This guarantees that any calls that were valid in the Java programming language before Java SE 5.0 are not considered ambiguous as the result of the introduction of variable arity methods, implicit boxing and/or unboxing.”

This makes the phase order not a ranking of best fit, but a historical artifact that needed to adapt. It might feel “wrong,” but it was the cost of source compatibility, and it was almost certainly worth paying.

What “Compatible” Didn’t Cover

The guarantee has an exception, and the JLS names it in the very next sentence: a variable arity method is treated as a fixed arity method in the first phase, so m(Object...) is actually competing as a plain m(Object[]):

java
static void m(Object obj)    { ... }
static void m(Object... objs) { ... }   // treated as m(Object[]) in phase 1

m(null);   // now picks m(Object...), with v == null

Object[] is more specific than Object, so phase 1 hands m(null) to the new method.

javac does warn here (non-varargs call of varargs method with inexact argument type for last parameter), which is more than the changes further down this article get.

So “compatible” meant not ambiguous. It never meant unchanged.


The One We’ve All Already Hit

List has both remove(int index) and remove(Object o). So if we have a List<Integer>, what happens if we use int arguments?

java
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));

list.remove(1);   // [1, 3]

With a literal 1, phase 1 finds remove(int) by identity conversion and stops right there. It removes the element at index 1, not the element equal to 1.

If we want to remove an actual entry, we need to provide an Integer:

java
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));

list.remove(Integer.valueOf(1));   // [2, 3]

This way, we hand the compiler an argument that’s already a reference type, so phase 1 can’t match remove(int) at all. A cast like (Integer) 1, or any variable typed Integer, does the same thing.

Why didn’t the compiler warn us?
Because nothing is wrong.

Both methods exist, one of them is applicable in phase 1, and the rules were followed exactly. A warning here would have to be a warning about the language itself.

javac does have -Xlint:overloads, but it only flags overloads that could make a lambda ambiguous:

text
warning: [overloads] run(Function<String,String>) in L is potentially ambiguous with run(Consumer<String>) in L

remove(int) next to remove(Object) doesn’t make it even blink.


Adding an Overload Is Not a Safe Change

Let’s say we maintain a small library, and it has exactly one entry point:

java
// v1
public static String handle(Integer id) {
  return "Integer: " + id;
}

Callers write Lib.handle(42). It boxes, it works, and nobody has given it a second thought in three years.

Now a second system starts sending us IDs as long, so we add an overload for them. It expands the accepted values and removes the need for boxing for both int and long:

java
// v2
public static String handle(Integer id) {
  return "Integer: " + id;
}

public static String handle(long id) {   // NEW
  return "long: " + id;
}

This has the same shape as StringBuilder’s primitive append overloads: a primitive method sitting next to a reference-typed one that could handle the same arguments, one phase later. It still compiles, and it’s binary-compatible. Nothing is deprecated, nothing is removed, and the old method still does precisely what it did yesterday.

The int callers never asked for any of this, and nothing on their side changed. And that’s exactly what makes it so treacherous:

text
1. App.java compiled against v1, run against v1
   javap:  invokestatic Lib.handle:(Ljava/lang/Integer;)Ljava/lang/String;
   $ java -cp v1:app-v1 App
   Integer: 42

2. Same compiled App.class, run against v2
   the call site recorded a descriptor, and that descriptor still resolves
   $ java -cp v2:app-v1 App
   Integer: 42        <== unchanged

3. Same App.java, recompiled against v2
   javap:  invokestatic Lib.handle:(J)Ljava/lang/String;
   $ java -cp v2:app-v2 App
   long: 42           <== silently switched

That’s where the binary compatibility comes from: which overload to use is chosen at compile time and baked directly into the constant pool as a descriptor.

It might look like a weird quirk, but the JLS spells it out (§13.4.23):

“[…] newly added methods or constructors will not be used, even if their signatures are both applicable and more specific than the signature originally chosen.”

That ensures a deployed artifact never changes its mind. But rebuilding it does, creating a debugging puzzle:

  • Nobody edited the call site:
    The commit that changed the behavior doesn’t touch the file where the behavior changed.

  • Our declared dependencies may not have moved either:
    A transitive version bump two levels down is enough to pull in the new overload.

  • It’s reproducible with a fresh build, not with the deployed jar:
    Which is the exact inverse of where we instinctively go looking.

  • The symptom can arrive months later:
    If the two overloads differ at all (in rounding, null handling, or an id == null branch that a primitive can never reach), the bug lands with whatever release happens to rebuild first.

There’s a rule hiding in here:

An overload that can win for existing callers is a behavior change, not an addition. If it can’t do exactly what the old one did, give it a new name.

Boxing and width are only the obvious cases. Adding handle(CharSequence) next to an existing handle(Object) retargets every String caller without ever leaving phase 1, because the most specific method wins. And adding handle(String) next to handle(Integer) breaks handle(null) outright.

Thankfully, that last one is the “best-case scenario” for us:

text
error: reference to handle is ambiguous
  both method handle(Integer) in Lib and method handle(String) in Lib match

javac detects it and fails loudly. Everything else in this section compiled without so much as a warning.

Still, StringBuilder gets away with its primitive append overloads because each one produces exactly the text append(Object) would have. If overloads agree on behavior, it doesn’t matter which one the compiler picks.


Ask the Class File

None of what we discussed so far is an argument against overloading.

The JDK overloads heavily, and as we saw with StringBuilder, usually for very good reasons. It isn’t an argument for sprinkling Integer.valueOf() or casts across every call site either.

What actually goes wrong is subtler.

We read a call site and reason about which overload should match, as if the compiler were weighing all of them on some scale of closeness, which it isn’t. It’s walking a short list in a fixed order, and that order was frozen in place to protect code that had already been written.

When the answer matters, though, we don’t have to guess:

shell
javap -c MyClass.class

The descriptor on the invokestatic line tells us which method the compiler actually picked, and checking it takes about ten seconds.

Our App from the overloading section, compiled against v1:

text
  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: bipush        42
       5: invokestatic  #13                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       8: invokestatic  #19                 // Method Lib.handle:(Ljava/lang/Integer;)Ljava/lang/String;
      11: invokevirtual #25                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      14: return

The same source, recompiled against v2, same command:

text
  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc2_w        #13                 // long 42l
       6: invokestatic  #15                 // Method Lib.handle:(J)Ljava/lang/String;
       9: invokevirtual #21                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: return

No boxing call, a different descriptor, and one instruction fewer.

The compiler already made the decision. We just have to go and read what it wrote down.


A Functional Approach to Java Cover Image
Interested in using functional concepts and techniques in your Java code?
Check out my book!
Available in English, Polish, and Korean.

Resources