Method Overloading in Java
Understand compile-time polymorphism through method overloading — parameter types, varargs, autoboxing pitfalls, and real-world builder patterns.
Method overloading is compile-time polymorphism — defining multiple methods with the same name but different parameter lists in the same class. The compiler selects the right version at compile time based on the arguments you pass. This lets you offer a clean, consistent API where the same operation — like format or send — works naturally with different input types, instead of forcing callers to use awkward names like formatInt or formatDouble.
Basic Overloading
The simplest use of overloading is providing a shorter convenience version of a method alongside the full version. Callers who only need the common case get a simpler call; those who need full control use the longer signature. The convenience version typically delegates to the full version to avoid duplicating logic.
public class StringUtils {
// Different number of parameters
public String repeat(String s, int times) {
return s.repeat(times);
}
// Convenience version — delegates to the full version with a sensible default
public String repeat(String s) {
return repeat(s, 2);
}
// Different parameter types — same operation, different input formats
public String format(int value) {
return String.format("%d", value);
}
public String format(double value) {
return String.format("%.2f", value);
}
public String format(boolean value) {
return value ? "Yes" : "No";
}
public String format(String value) {
return value == null ? "(none)" : value.trim();
}
}
StringUtils utils = new StringUtils();
System.out.println(utils.repeat("ab")); // abab
System.out.println(utils.repeat("ab", 3)); // ababab
System.out.println(utils.format(42)); // 42
System.out.println(utils.format(3.14159)); // 3.14
System.out.println(utils.format(true)); // Yes
System.out.println(utils.format(" hello ")); // hello
Overloading with Varargs
Varargs (T...) let one method accept zero or more arguments of a type. Use varargs as the fallback overload — Java’s resolution rules prefer exact-match overloads first, then varargs. This means you can provide optimised two-arg and three-arg versions without breaking the general case.
public class Calculator {
// Exact overload for two args — Java picks this when you pass exactly 2 ints
public int sum(int a, int b) {
System.out.println("two-arg version");
return a + b;
}
// Exact overload for three args
public int sum(int a, int b, int c) {
System.out.println("three-arg version");
return a + b + c;
}
// Varargs fallback: catches everything else (0, 4, 5, ... args)
public int sum(int... values) {
System.out.println("varargs version (" + values.length + " args)");
int total = 0;
for (int v : values) total += v;
return total;
}
}
Calculator calc = new Calculator();
System.out.println(calc.sum(1, 2)); // two-arg version → 3
System.out.println(calc.sum(1, 2, 3)); // three-arg version → 6
System.out.println(calc.sum(1, 2, 3, 4)); // varargs version (4 args) → 10
System.out.println(calc.sum()); // varargs version (0 args) → 0
Autoboxing Resolution — A Pitfall
Java resolves overloads in a specific order: exact match → widening → autoboxing → varargs. This means widening a primitive (int → long) takes priority over boxing it (int → Integer). If you’re not aware of this, the wrong overload can be called silently. Knowing the resolution order lets you predict and control which version gets picked.
public class OverloadPitfall {
public void process(int value) {
System.out.println("primitive int: " + value);
}
public void process(long value) {
System.out.println("primitive long: " + value);
}
public void process(Integer value) {
System.out.println("Integer (boxed): " + value);
}
}
OverloadPitfall obj = new OverloadPitfall();
obj.process(42); // exact match: "primitive int: 42"
Integer boxed = 42;
obj.process(boxed); // exact match on Integer: "Integer (boxed): 42"
// If process(int) didn't exist, passing a literal 42 would call process(long)
// — widening beats autoboxing, so process(Integer) would NOT be chosen
Builder-Style Overloading
A common real-world pattern is to combine overloaded static factory methods with a full builder. The factory methods cover the 80% case with minimal syntax; the builder handles the remaining 20% with full control. This gives callers a clean API without forcing everyone to use the verbose builder for simple requests.
public class HttpRequest {
private final String method;
private final String url;
private final Map<String, String> headers;
private final String body;
private final int timeoutMs;
private HttpRequest(Builder builder) {
this.method = builder.method;
this.url = builder.url;
this.headers = Map.copyOf(builder.headers);
this.body = builder.body;
this.timeoutMs = builder.timeoutMs;
}
// Overloaded static factory methods — simple, readable shortcuts for common cases
public static HttpRequest get(String url) {
return new Builder("GET", url).build();
}
public static HttpRequest post(String url, String body) {
return new Builder("POST", url).body(body).build();
}
// Overload for POST with custom headers — same name, different signature
public static HttpRequest post(String url, String body, Map<String, String> headers) {
return new Builder("POST", url).body(body).headers(headers).build();
}
public static HttpRequest delete(String url) {
return new Builder("DELETE", url).build();
}
@Override
public String toString() {
return String.format("%s %s (timeout=%dms, body=%s)", method, url, timeoutMs, body);
}
// Full builder for complex cases that the factory methods don't cover
public static class Builder {
private final String method;
private final String url;
private Map<String, String> headers = new HashMap<>();
private String body = null;
private int timeoutMs = 5000;
public Builder(String method, String url) {
this.method = method;
this.url = url;
}
public Builder body(String body) { this.body = body; return this; }
public Builder headers(Map<String, String> headers) { this.headers = headers; return this; }
public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
// Simple cases use the clean overloaded factory methods
HttpRequest getReq = HttpRequest.get("https://api.example.com/users");
HttpRequest postReq = HttpRequest.post("https://api.example.com/users", "{\"name\":\"Alice\"}");
// Complex case uses the full builder
HttpRequest complexReq = new HttpRequest.Builder("PUT", "https://api.example.com/users/1")
.body("{\"name\":\"Alice\"}")
.headers(Map.of("Authorization", "Bearer token123"))
.timeout(10000)
.build();
System.out.println(getReq); // GET https://api.example.com/users (timeout=5000ms, body=null)
System.out.println(postReq); // POST https://api.example.com/users (timeout=5000ms, body={"name":"Alice"})
Type Promotion Rules
When no exact-match overload exists, Java widens the argument type rather than narrowing it. Understanding the widening chain helps you predict which overload gets called when you pass a value whose type doesn’t exactly match any signature.
byte → short → int → long → float → double
public class WidenDemo {
public void show(long value) { System.out.println("long: " + value); }
public void show(double value) { System.out.println("double: " + value); }
}
WidenDemo d = new WidenDemo();
d.show(42); // int widens to long (closer in the chain): "long: 42"
d.show(42L); // exact match: "long: 42"
d.show(3.14f); // float widens to double: "double: 3.140000104904175"
d.show(3.14); // exact match: "double: 3.14"
Overloading for Null Safety
Overloaded methods can catch null explicitly in a controlled way. The key pitfall is ambiguity — if null could match multiple overloads, the compiler will refuse to compile. The cleanest fix is an Object overload that acts as the null-safe dispatcher.
public class MessageSender {
public void send(String message) {
System.out.println("Sending string: " + message);
}
public void send(byte[] data) {
System.out.println("Sending binary: " + data.length + " bytes");
}
// Object overload handles null and dispatches to the correct specific overload.
// Without this, send(null) would be a compile error — ambiguous between String and byte[].
public void send(Object message) {
if (message == null) {
System.out.println("Sending null message — ignored.");
return;
}
// Pattern matching routes to the right specific overload
if (message instanceof String s) send(s);
else if (message instanceof byte[] b) send(b);
}
}