Overloading and varargs
What makes two methods different, the three phases javac uses to pick an overload, and why varargs is an array that can pollute the heap.
On this page
🎛️ What are overloading and varargs?
Overloading means one class has several methods with the same name but different parameter lists, like Math.max(int, int) and Math.max(double, double). The compiler picks one at compile time by looking at the argument types. Varargs (String... names) lets a single method accept zero or more arguments of one type; under the hood it is an array parameter.
For this lesson, we'll go from the rules you can recite (what makes a signature) to the rules most engineers can't: the exact order in which javac tries conversions, why null breaks overloads, and why generic varargs come with a compiler warning about "heap pollution". Interviewers like this topic because one tiny snippet can test widening, boxing, varargs and compile-time binding at once.
Below the line (out of scope)
- Overriding and runtime dispatch (
invokevirtual, vtables) are in the OOP module - Overload resolution with lambdas and method references is left for the functional programming module
The 30-second answer
A method's signature is its name plus parameter types; the return type is not part of it. Overloads are resolved at compile time from the arguments' static types, in three phases (JLS §15.12.2): first without boxing or varargs (widening allowed), then with boxing, then with varargs. Within the first phase that finds a match, the most specific method wins, and a tie is a compile error. Varargs compiles to an array parameter, so with generics it can cause heap pollution, which @SafeVarargs asserts you've avoided.
What you’ll learn
- What a method signature is, and why return type and
throwsare not part of it - The three phases of overload resolution: widening, then boxing, then varargs
- How the most specific method rule works and why
m(null)can be ambiguous - Why varargs is an array, and what
@SafeVarargspromises about heap pollution
01Signature rules: what counts as "different"
Two methods in the same class can share a name only if their signatures differ (JLS §8.4.2). The signature is the name plus the number, order and types of parameters. Everything else is decoration as far as overloading is concerned.
class Printer {
void print(int x) { }
void print(long x) { } // OK: different parameter type
void print(int x, String s) { } // OK: different count
void print(String s, int x) { } // OK: different order
// int print(int x) { } // error: already defined (return type ignored)
// void print(int count) { } // error: parameter names are ignored
// void print(int x) throws IOException { } // error: throws clause ignored
// void f(List<String> a) { }
// void f(List<Integer> a) { } // error: name clash, same erasure f(List)
}
Notice the last one. Generics are erased to List in the class file, so both methods would have the descriptor (Ljava/util/List;)V. The JVM can't tell them apart, and javac refuses to compile them.
Why isn't the return type part of the signature? Because a call can ignore the result. Given print(5); on its own line, there's nothing to tell the compiler which return type you meant.
02Picture it first: a three-phase funnel
Think of overload resolution as a funnel with three filters, each one more permissive than the last. javac gathers every accessible method with the right name, then tries phase 1. Only if nothing fits does it relax the rules and try phase 2, then phase 3. The first phase with a winner ends the search, even if a "better looking" overload would match in a later phase.
Overload resolution as a funnel: strict (widening only), then loose (adds boxing), then variable arity (adds varargs); the first phase with an applicable method stops and the most specific one wins
This ordering exists for backward compatibility. Boxing and varargs arrived in Java 5. If they had been mixed into the existing rules, recompiling old code could silently switch which overload got called. Putting them in later phases guarantees that any call that compiled under Java 1.4 still binds to the same method.
03Under the hood: watching javac choose
Try it yourself first
Before reading on, predict what each line prints. Each pair of overloads differs in only one conversion.
public class Pick {
static String a(long x) { return "long"; }
static String a(Integer x) { return "Integer"; }
static String v(Object x) { return "Object"; }
static String v(Object... x) { return "Object..."; }
static String c(long... x) { return "long..."; }
static String c(int... x) { return "int..."; }
static String d(Object o) { return "Object"; }
static String d(String s) { return "String"; }
public static void main(String[] args) {
System.out.println(a(5)); // long
System.out.println(v(1)); // Object
System.out.println(c(1)); // int...
System.out.println(d(null)); // String
Object o = "hi";
System.out.println(d(o)); // Object
}
}
Notice what each line reveals:
a(5):int → longis a widening primitive conversion, legal in phase 1.int → Integerneeds boxing (phase 2), soa(Integer)is never even considered. Widening beats boxing. (Boxing itself is covered in Wrapper classes and autoboxing.)v(1): phase 1 fails (anintisn't anObjectwithout boxing). Phase 2 boxes toIntegerand widens toObject, sov(Object)wins before varargs is tried. Boxing beats varargs.c(1): both need phase 3. Then the most specific rule applies (JLS §15.12.2.5):int...is more specific thanlong...becauseintwidens tolongand not the other way round.d(null):nullfits both.Stringis a subtype ofObject, sod(String)is more specific.d(o): the static type ofoisObject, sod(Object)is chosen. The runtime class beingStringdoesn't matter. Overloading is compile-time; only overriding looks at the runtime class.
You can see the decision frozen into the class file. javap -c Pick shows invokestatic Pick.a:(J)Ljava/lang/String;, where J is the descriptor for long. The JVM never re-runs overload resolution; it calls exactly the descriptor javac wrote.
04Ambiguity: when the compiler gives up
The most specific rule needs one candidate that beats all the others. When two candidates each win on a different parameter, or neither type is a subtype of the other, you get error: reference to m is ambiguous.
static void m(String s) { }
static void m(Integer i) { }
static void g(int x, double y) { }
static void g(double x, int y) { }
m(null); // ambiguous: String and Integer are unrelated
g(1, 2); // ambiguous: each overload is better on one argument
m((String) null); // fixed: a cast picks the overload
The null case is the classic interview trap. null converts to every reference type, so it matches both overloads, and there's no subtype relationship to break the tie. A cast is the fix, and it's also good documentation for the reader.
05Varargs is an array in disguise
static int count(Object... items) compiles to a method taking Object[], with the ACC_VARARGS flag set in the class file. At each call site, javac allocates the array for you: count(1, 2) becomes count(new Object[]{1, 2}). A few rules follow directly:
- Only one varargs parameter is allowed, and it must be last.
count()passes an empty array (length 0), nevernull.- Passing an existing array passes it as-is:
count(new Object[]{"a", "b"})sees length 2, not 1 (pass aString[]and you get the same result plus an "inexact argument type" warning). count(null)compiles with a warning and passes a null array;count((Object) null)passes an array holding onenull.
Inside the method you treat it as an array: items.length, items[0], a for-each loop. One subtlety: in phases 1 and 2, a varargs method still competes as if its last parameter were a plain array. That's why passing an existing array or null binds without ever reaching phase 3.
Overloads you'll meet in real code: List.of has fixed-arity versions for 0 to 10 elements plus a varargs fallback, and logging APIs offer debug(String, Object) and debug(String, Object, Object) alongside debug(String, Object...). Both exist to avoid allocating a varargs array on hot paths. The costly incident I've seen most is List<Integer>.remove(1): phase 1 matches remove(int index) exactly, so it removes the element at index 1 rather than the value 1. Use remove(Integer.valueOf(1)) when you mean the value.
06Advanced: varargs pitfalls, @SafeVarargs and heap pollution
Mixing varargs with overloading gets ugly fast. Both of these are compile errors in Java 21:
m(int...)andm(Integer...)withm(1): phase 3 finds both, and neitherintnorIntegeris a subtype of the other.w(int, int...)andw(int...)withw(1): neither is more specific.
Many candidates assume boxing beats widening because Integer "looks closer" to int than long does. This is frequently a mistake: phase 1 runs first and allows only widening, so a(long) wins and a(Integer) is never examined. The same reasoning tells you char passed to e(double) and e(Object) picks double.
Now the senior part. A varargs parameter of a generic type, like T... or List<String>..., can't create a real T[] at runtime because of erasure. The compiler creates an Object[] or List[] instead and warns: possible heap pollution. Heap pollution (JLS §4.12.2) means a variable of a parameterized type points to an object that isn't of that type.
static <T> T[] toArray(T... items) { return items; } // leaks the array
static <T> T[] pickTwo(T a, T b, T c) { return toArray(a, b); }
String[] s = pickTwo("x", "y", "z");
// ClassCastException: [Ljava.lang.Object; cannot be cast to [Ljava.lang.String;
Inside pickTwo, T is erased, so the varargs array is an Object[]. Returning it to a caller that expects String[] fails at the cast javac inserted. Nothing looked wrong at the call site; the pollution happened two methods away.
@SafeVarargs is your promise that the method only reads the elements and never stores into the array or lets it escape. It silences the warning for callers. It's allowed on constructors, static methods, final instance methods and, since Java 9, private instance methods: places where an override can't break the promise.
@SafeVarargs
static <T> List<T> listOf(T... items) {
List<T> out = new ArrayList<>(items.length);
for (T t : items) out.add(t); // read-only use: safe
return out;
}
Each varargs call allocates an array: 16 bytes of header plus 4 bytes per reference with compressed oops, so 24 bytes for two arguments. Escape analysis often removes it once the JIT inlines the callee, but not reliably across megamorphic calls. That's why high-throughput APIs add fixed-arity overloads for the common 1 to 3 argument cases.
| Rule | Example | Result |
|---|---|---|
| Widening beats boxing | a(long) vs a(Integer), call a(5) |
a(long) |
| Boxing beats varargs | v(Object) vs v(Object...), call v(1) |
v(Object) |
| Subtype is more specific | d(Object) vs d(String), call d(null) |
d(String) |
| Unrelated types with null | m(String) vs m(Integer), call m(null) |
compile error |
| Static type decides | Object o = "hi"; d(o) |
d(Object) |
My default: avoid overloads that differ only by boxed vs primitive or by varargs. Give them different names (removeAt, removeValue) unless the overloads do the same thing and the choice can never surprise a caller.
07How to say it in the interview
When I ask this, I'm listening for whether you know overloading is decided at compile time from static types, whether you can name the three phases in order, and whether you can explain an ambiguity instead of guessing. Mentioning heap pollution and @SafeVarargs without prompting is a strong senior signal.
In the interview
"A signature is the method name plus parameter types, so return type and throws don't count. The compiler resolves overloads statically in three phases: first only identity and widening, then it allows boxing, and only then varargs. That's why long beats Integer for an int argument. In the phase that matches, the most specific method wins; if there isn't one, such as m(null) with String and Integer overloads, it's a compile error. Varargs is an array parameter that the call site allocates, and with generic element types that array can pollute the heap, which is what @SafeVarargs promises you avoid."
Here's how this might look if you sketch it on the whiteboard:
On the whiteboard
Overload resolution (compile time)
- signature = name + param types (not return, not throws)
- phase 1: widening only
- phase 2: + boxing/unboxing
- phase 3: + varargs
- first phase with a match wins; most specific inside it
- tie / null with unrelated types: ambiguous
- varargs = array, last param,
()gives length 0 - generic varargs: heap pollution,
@SafeVarargs