Recently I posted a poll on LinkedIn about method selection in overloaded method calls and was rather surprised that only 20% of the answers were correct. That gave me a push to do this write-up, as I feel that same-named methods do not get the credit they deserve for the mess they can cause.
I plan to make this a three-part article. This part will look purely at method overloading within a single type definition (class, interface, or enum). Basically, we going to look into how most specific method is selected by only looking at methods arguments. Part two will cover overloading with generics. Part three will examine the complexities beyond the single-type case, such as inheritance, polymorphism, and method hiding.
Throughout this article, I am not going to get into the different cases of compilation errors, as build process and IDEs are taking a good care of those.
To start the confusion, let’s look at a short example of two classes (all overloads are defined in one class, just as we talked):
public class Example1 {
public static class Helper {
private void m(double num) { System.out.println("m(double)"); }
public void m(char... chars) { System.out.println("m(char...)"); }
public void m(Comparable c) { System.out.println("m(Comparable)"); }
public void m(Object obj) { System.out.println("m(Object)"); }
}
public static void main(String... args) {
Helper h = new Helper();
h.m('a');
}
}
import Example1.Helper;
public class Example2 {
public static void main(String... args) {
Helper h = new Helper();
h.m('a');
}
}
Execution of Example1 prints:
m(double)
Execution of Example2 prints:
m(Comparable)
Why does this happen? We’ll come back to this puzzle after covering some ground.
The biggest danger with overloading, is that a change to a method signature or addition of a new method may change which method is getting executed by an unsuspecting caller, as it may not give you any error or even a warning. For the scope of this article it is somewhat simple, though not always obvious. But once we get to the next parts it will be progressively harder to spot.
When two methods share the same name in Java, they may participate in overloading, overriding, or hiding, depending on their signatures, modifiers, and inheritance hierarchy. I will try to keep my examples short, but I have to say, the mess you can create by combining all three is incredible.
The idea of overloading is actually older than OOP itself—FORTRAN already let you write ABS for integers or floating-point numbers in the 1950s. C++ in the 1980s then brought user-defined function overloading into mainstream OOP, and Java inherited it from day one. The idea behind it is to improve readability and API consistency by using the same logical name for conceptually similar operations and to allow API growth by adding new overloads without breaking code.
So, what is overloading about?
First the formal JLS definition (JLS 8.4.9 https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.4.9):
if two methods of a class (whether both declared in the same class, or both inherited by a class, or one declared and one inherited) have the same name but signatures that are not override-equivalent, then the method name is said to be overloaded. This fact causes no difficulty and never by itself results in a compile-time error. There is no required relationship between the return types or between the throws clauses of two methods with the same name, unless their signatures are override-equivalent.
Since in this part we only going to look at methods defined within a single type, we are going to ignore “override-equivalent”, "inherited" or even not mentioned imported static functions (we will go overall of it in details in Part three) and just say:
if a type declaration contains methods with the same name those methods are overloaded.
This applies regardless of differences in return type, number of parameters, whether the methods are static or instance methods, or their access level. This also assumes there is no compile time error.
Note: if your class defines more than one constructor, they are considered overloaded just like methods. All of the overload resolution rules we discuss here apply to constructors as well.
Example:
static void m(int x) {}
private int m() { return 0; }
public String m(int x, long y) { return ""; }
Overloading is all about the compiler choosing a method to execute for you. Since it is in the compiler’s realm, this decision is made by the time the compiler has done its job, and it may or may not match your intent. It is not going to be changed at runtime, and if something changes you have to recompile. Let me show what I mean.
Example:
We have two classes, Main and Helper:
package team1;
public class Helper {
public void m(Object o) {
System.out.println("m(Object)");
}
}
import team1.Helper;
public class Main {
public static void main(String... args) {
new Helper().m("test");
}
}
Let’s assume that the team that wrote class Main doesn’t have any control over the class Helper, and it is provided by another team in the form of a JAR file.
When you build and execute main, it produces the following output:
m(Object)
Now let’s prove that if we change method m in the Helper class, the JVM will pick up the change without recompiling Main. This should work just fine as per the JLS 13.4.22: “Changes to the body of a method or constructor do not break compatibility with pre-existing binaries.”
Lets edit the Helper class and rebuild the jar:
package team1;
public class Helper {
public void m(Object o) {
System.out.println("V2 m(Object)");
}
}
Now we rerun Main, and see the new output:
V2 m(Object)
Okay, it’s obviously picked up. Now it’s time to change Helper again and repeat the process. We are going to add a new method to it, which is again fine per the JLS 13.4.12: “Adding a method to a class does not break compatibility with pre-existing binaries.”
package team1;
public class Helper {
public void m(String o) {
System.out.println("m(String)");
}
public void m(Object o) {
System.out.println("V2 m(Object)");
}
}
Just looking at the code, in theory, the call to new Helper().m("test") should print:
m(String)
But since Main is not recompiled, we will still get:
V2 m(Object)
And only after we recompiled Main with the new JAR and run it, we will get:
m(String)
This clearly shows that overload resolution happens at compile time: without recompiling Main, the JVM cannot pick the newly added m(String) overload. Note, no changes to the class Main were done. No methods were modified. A new method was added and the compiler decided it is a better fit for the name and parameters you provided.
This is what happens when compiler is picking a method for you. It is described in JLS 5 ( https://docs.oracle.com/javase/specs/jls/se24/html/jls-5.html ): Conversions and Contexts and §15.12 ( https://docs.oracle.com/javase/specs/jls/se24/html/jls-15.html#jls-15.12 ): Method Invocation Expressions. But here is the simplified set of rules, if we take inheritance and generics out.
Determine Type to Search. Figure out the name of the method to be invoked and which type to search for definitions of methods of that name.
Determine Method Signature. Using the name of the method and the argument expressions, locate methods that are both accessible and applicable, that is, declarations that can be correctly invoked on the given arguments.
Identify Potentially Applicable Methods. Keep only methods whose parameter lists could match the arguments, excluding obviously impossible.
Identify Matching Arity Methods Applicable by Loose Invocation. Try to match without using varargs. For each candidate, check if every argument can be converted to the corresponding parameter using method-invocation conversions.
Identify Methods Applicable by Variable Arity Invocation. If no fixed-arity method worked, try varargs.
Choosing the Most Specific Method. If multiple applicable overloads remain, pick the most specific:
- Exact match > widening primitive > boxing/unboxing > reference upcast > varargs.
Let’s have a small recap there:
- Widening primitive - implicit conversion of primitive types, for example int to long, or char to double. ( https://docs.oracle.com/javase/specs/jls/se24/html/jls-5.html#jls-5.1.2 )
- Boxing/Unboxing - conversions of primitive types to a corresponding reference type, for example from int to Integer or char to Character. ( https://docs.oracle.com/javase/specs/jls/se24/html/jls-5.html#jls-5.1.7 )
- Reference upcast - conversion of a Child type to the Parent type. Integer to Number or Integer to Object. ( https://docs.oracle.com/javase/specs/jls/se24/html/jls-5.html#jls-5.1.5 )
- Varargs - (variable arity parameters) let a method accept zero or more arguments of the same type. At compile time, the arguments are packaged into an array and passed to the method. ( https://docs.oracle.com/javase/specs/jls/se24/html/jls-8.html#jls-8.4.1 )
- Among reference types, the subtype parameter wins (e.g., anything > Object, Integer > Number, etc.)
- For competing varargs, compare their element types with the same priorities
- If no single winner exists → ambiguous method call error.
Is the Chosen Method Appropriate? Correct receiver kind (static vs instance), still accessible, and any required unchecked conversions/generic warnings are accounted for. If this fails, it’s a compile-time error.
Lets look at some examples:
Example 1. At this point let’s look at the puzzle from the beginning of this article.
public class Example1 {
public static class Helper {
private void m(double num) { System.out.println("m(double)"); }
public void m(char... chars) { System.out.println("m(char...)"); }
public void m(Comparable c) { System.out.println("m(Comparable)"); }
public void m(Object obj) { System.out.println("m(Object)"); }
}
public static void main(String... args) {
Helper h = new Helper();
h.m('a');
}
}
import Example.Helper;
public class Example2 {
public static void main(String... args) {
Helper h = new Helper();
h.m('a');
}
}
In the main method of Example1:
Private methods of inner class are accessible in the outer class, so all 4 methods could be applied
'a' could be converted to double through primitive widening and to Character through boxing. But primitive widening beats boxing.
Since there is fixed arity method available varargs are not looked into. Therefore m(double) is called
In the main method of Example2:
Private method is not accessible, so it is not one of the candidates
'a' could be converted to Character. Character reference could be upcasted to Comparable or to Object. Comparable is more specific
Varargs are not looked into. Therefore m(Comparable) is called.
Example 2. Let’s look into another case:
public class Example2 {
static void m(Double a) { }
static void m(int...a) {}
public static void main(String... arg) {
m(Integer.valueOf(1));
}
}
Integer.valueOf(1) is an Integer, so it cannot be converted to Double. Therefore m(int...) is the only one method available.
public class Example2 {
static void m(double a) { }
static void m(int...a) {}
public static void main(String... arg) {
m(Integer.valueOf(1));
}
}
However, Integer is unboxed to int primitive, that widens to double, so m(double) is picked int this example. A fun note, in this example I could have replaced m(Integer.valueOf(1)) with m(Character.valueOf('a')) and get the same result in both cases. Note how the second class went to Unboxing and then to widening ( Integer - > int -> double ), but Unboxing -> widening -> Boxing is not happening.
Example 3. Lets move to multiple arguments:
public class Example3 {
static void m(Object a, Object b) { }
static void m(int...a) {}
public static void main(String... arg) {
m(1,1);
}
}
m(1,1) in this case going to execute m(Object, Object). Note that m(), m(1), m(1,1,1), though, will all call m(int...)
Example 4.
public class Example4 {
static void m(float a, long b) { }
static void m(int a, Character b) {}
public static void main(String... arg) {
m(1,'a');
}
}
Obviously, m(float, long) is going to be executed here. (widening beats Boxing)
Example 5.
public class Example5 {
static void m(Serializable a, String...c) { }
static void m(String...c) { }
public static void main(String... arg) {
m();
m("a");
m("a","b");
m("a","b", "c");
m('a');
}
}
All calls except the last one are going to m(String...). Both methods declared go into a varargs category, and m(String...) is a more specific on the first 4 calls.
Example 6.
public class Example6 {
static void m(int a, float b, double c) { }
static void m(int a, float b, Integer c) { }
public static void main(String... arg) {
m(1,2,3);
}
}
m(int, float, double). In this example the method with 3 primitives wins (widening vs Boxing). But if I change the last argument to be a vararg - not anymore.
public class Example6 {
static void m(int a, float b, double... c) { }
static void m(int a, float b, Integer c) { }
public static void main(String... arg) {
m(1,2,3);
}
}
As we saw, overloading is resolved strictly at compile time. The compiler goes through the available methods, applies conversions if needed, and picks the most specific one it can find. Once the decision is made, it does not change at runtime. This explains why adding a new overload or slightly changing a signature may change which method is called without producing any errors or warnings.
Even in the simple case of a single type, the rules can lead to non-obvious results. Private visibility, static versus instance methods, varargs, boxing and unboxing, widening, and reference conversions all play a role in which overload is chosen.
This part only looked at concrete types in a single class, interface, or enum. In the next part we will move on to generics, where type inference, erasure, and unchecked conversions make the resolution rules even more subtle and much harder to predict.
Top comments (0)