Method overloading and overriding in Java
Compile-time versus runtime polymorphism: how javac picks an overload, the rules an override must follow, @Override, static hiding, bridge methods, vtable dispatch, inline caches and the null and boxing puzzles.
On this page
🎭 What are method overloading and overriding?
Overloading means one class has several methods with the same name but different parameter lists, like notify(String email) and notify(User user); the compiler picks one from the argument types. Overriding means a subclass replaces an inherited instance method with the same signature, like CardPayment.fee() replacing Payment.fee(); the JVM picks one at runtime from the object's class. Overloading is compile-time polymorphism, overriding is runtime polymorphism.
This is one of the most asked Java questions at every level, and the definitions above are only the entry ticket. The real interview is a three-line snippet where an overloaded method is called through a parent reference, a null argument, or an int that could widen or box, and you're asked what it prints. Getting those right means knowing exactly which decision the compiler makes and which one the JVM makes. Seniors then get asked about bridge methods, invokevirtual and why a call site with three receiver types is slower than one with two.
What you’ll learn
- Why overloading is decided by javac from declared types and overriding by the JVM from the receiver's class
- The overriding rules: same parameters, covariant return, access can't narrow, checked exceptions can't widen
- Why
static,privateandfinalmethods aren't overridden, and how@Overridecatches theequals(Order)bug - What bridge methods are and why they appear for covariant returns and generics
- How
invokevirtual, vtables and monomorphic, bimorphic and megamorphic inline caches affect performance - How to solve overloading puzzles with
null, widening and boxing
For this lesson, we'll focus on the split between compile-time and runtime selection, the overriding rules (signature, covariant returns, access, exceptions), @Override, why static, private and final methods aren't overridden, bridge methods, how dispatch works in the JVM and the JIT, and the classic null and boxing puzzles.
Below the line (out of scope)
- The full three-phase overload resolution algorithm and varargs: covered in Overloading and varargs
- Calling the parent's version with
super.m(): covered in The super keyword - Access levels and why overrides can't narrow them: covered in Access modifiers
The 30-second answer
Overloading is resolved by the compiler using the static types of the arguments: it picks a method signature and writes it into the bytecode. Overriding is resolved by the JVM at runtime using the class of the receiver object, through invokevirtual and the vtable. An override must have the same name and parameter types, a same or covariant return type, equal or wider access, and no new or broader checked exceptions. static methods are hidden, private methods aren't inherited, and final methods can't be overridden. I always put @Override on overrides so the compiler catches an accidental overload, like equals(Order) instead of equals(Object).
Interview radar
Asked very often: Expect overload-versus-override definitions, output puzzles with null, widening and boxing, and the @Override bug around equals(Object).
Senior rounds probe: I probe bridge methods, invokevirtual, vtable dispatch and why a hot call site changes shape when a third receiver class appears.
Production angle: This shows up as wrong overloads in APIs, duplicate business actions from accidental overloads, and polymorphic hot paths that benchmark faster than production.
01Picture it first: the front desk and the person on shift
Picture calling a company's support line. The front desk reads the form you filled in ("billing question, card payment") and routes you to a department extension. That routing is decided from what's written on the form, before anyone checks who's actually at work: that's overloading, decided from declared types. Once the extension rings, whoever is on shift in that department picks up and handles it their way: that's overriding, decided by the actual object at runtime.
The figure shows those two decisions for one call, p.process(arg), where p is declared Payment but holds a CardPayment, and arg is declared Object but holds a String.
Two-stage method selection: at compile time javac uses the declared types Payment and Object to choose the signature process(Object), and at runtime the JVM uses the receiver's class CardPayment to choose which implementation of process(Object) runs
Notice that the runtime type of the argument (String) is never consulted. Java dispatches on the receiver only; this is called single dispatch.
02How it works under the hood
Overloading picks a signature, overriding picks an implementation
Try it yourself first
Before reading on, predict both lines. Payment has two overloads; CardPayment overrides only one of them.
class Payment {
void process(Object payload) { System.out.println("Payment.process(Object)"); }
void process(String payload) { System.out.println("Payment.process(String)"); }
}
class CardPayment extends Payment {
@Override
void process(Object payload) { System.out.println("CardPayment.process(Object)"); }
}
Payment p = new CardPayment();
Object arg = "tok_4242";
p.process(arg); // prints: CardPayment.process(Object)
p.process("tok_4242"); // prints: Payment.process(String)
Notice three things:
- For
p.process(arg), the compiler sees anObjectargument, so onlyprocess(Object)applies. It emitsinvokevirtual Payment.process:(Ljava/lang/Object;)V, and at runtime the vtable slot for that signature points toCardPayment's override. - For
p.process("tok_4242"), both overloads apply andprocess(String)is more specific, so the compiler picks it.CardPaymentdidn't override that signature, so the inheritedPaymentversion runs. - The two overloads are two different methods with two different vtable slots. Overriding one does nothing to the other.
The compiler chooses among overloads in phases: first without boxing or varargs (only widening and subtyping), then with boxing and unboxing, then with varargs. Within a phase it picks the most specific applicable method, and if none is most specific the call is ambiguous. The full algorithm is in Overloading and varargs; for this lesson, remember that it all happens in javac and uses only declared types.
The diagram below is the checklist I use when an interviewer gives me a one-line call and asks what runs.
A checklist separates javac choosing the overloaded signature from the JVM choosing the overriding implementation for that signature
The overriding rules
An instance method in a subclass overrides an inherited one when it has the same name and the same parameter types (after generic erasure, JLS calls this a subsignature). If the names and parameters match, these extra rules must hold or the code doesn't compile:
| Rule | Allowed | Not allowed |
|---|---|---|
| Return type | Same, or a subtype (covariant) for reference types | A different primitive, or an unrelated type |
| Access | Same or wider (protected to public) |
Narrower (public to protected) |
| Checked exceptions | Same, narrower, fewer or none | New or broader checked exceptions |
| Unchecked exceptions | Anything | (no restriction) |
| Modifiers | Can add final, synchronized, strictfp; can drop or add abstract |
Overriding a final or static method; a static method "overriding" an instance one |
The reason behind every rule is the same: substitutability. Code written against Payment must keep working when it receives a CardPayment. If the parent promises public, a Payment return value and only IOException, the override can't hide the method, return something unrelated or throw a SQLException that callers never prepared for.
Try it yourself first
Which of the four methods in CardPayment compile?
class Payment {
Payment copy() { return new Payment(); }
public void capture() throws java.io.IOException { }
static String gateway() { return "default"; }
final long fee() { return 0; }
}
class CardPayment extends Payment {
@Override CardPayment copy() { return new CardPayment(); } // ✅ covariant return
@Override void capture() throws Exception { } // ❌ weaker access AND broader checked exception
String gateway() { return "stripe"; } // ❌ instance method can't override a static one
long fee() { return 150; } // ❌ cannot override; overridden method is final
}
Only copy() compiles. The compiler messages are worth recognising: "attempting to assign weaker access privileges; was public", "overridden method is static" and "overridden method is final". javac reports one problem per method, so once you make capture() public it moves on to "overridden method does not throw Exception".
@Override: let the compiler check your intent
@Override doesn't change behaviour. It asks the compiler to fail if the method doesn't actually override or implement something. That turns the most expensive overriding bug into a compile error:
final class OrderId {
private final String value;
OrderId(String value) { this.value = value; }
public boolean equals(OrderId other) { // overload, NOT an override
return other != null && value.equals(other.value);
}
@Override public int hashCode() { return value.hashCode(); }
}
Set<OrderId> seen = new HashSet<>();
seen.add(new OrderId("ORD-1"));
System.out.println(seen.contains(new OrderId("ORD-1"))); // prints: false
System.out.println(new OrderId("ORD-1").equals(new OrderId("ORD-1"))); // prints: true
HashSet calls equals(Object), and OrderId never overrode it, so identity comparison from Object runs. A direct call with an OrderId argument picks the overload, which is why a quick unit test "proves" it works. Adding @Override to equals(OrderId) fails with "method does not override or implement a method from a supertype", and the fix is equals(Object other).
Static, private and final: three ways not to override
staticmethods are hidden. A same-signature static method in a subclass hides the parent's; the call compiles toinvokestaticagainst the declared type, soPayment p = new CardPayment(); p.gateway()runsPayment.gateway().@Overrideon it is a compile error.privatemethods aren't inherited, so a same-signature method in the subclass is a new, unrelated method. A call to the private method from inside the parent always runs the parent's version, even though javac has emittedinvokevirtualfor it since Java 11; the JVM binds private methods directly.finalmethods can't be overridden, by definition. The JIT can call them directly, but as the inheritance lesson explains, it usually manages that withoutfinalanyway.
Covariant returns and bridge methods
The JVM is older than covariant returns. In bytecode, a method is identified by its name and full descriptor, including the return type, so copy()Payment and copy()CardPayment are different methods to the JVM. To make the override work, javac generates a hidden bridge method in CardPayment with the parent's descriptor, which delegates to the real one. javap -p -v CardPayment shows it:
CardPayment copy(); // the method you wrote
Payment copy(); // flags: ACC_BRIDGE, ACC_SYNTHETIC
0: aload_0
1: invokevirtual #51 // Method copy:()LCardPayment;
4: areturn
Generics need bridges for the same reason. Comparable<Order> erases to compareTo(Object), while you wrote compareTo(Order), so javac adds a bridge compareTo(Object) that does checkcast Order and calls yours. That's why a raw call with the wrong type fails with a ClassCastException whose stack trace points at a line in your class you never wrote a cast on:
record Order(String id, long amountPaise) implements Comparable<Order> {
@Override public int compareTo(Order other) { return Long.compare(amountPaise, other.amountPaise); }
}
Comparable raw = new Order("ORD-1", 50_000); // raw type: unchecked warning
raw.compareTo("not an order");
// ClassCastException: class java.lang.String cannot be cast to class Order
// at Order.compareTo(Order.java:1) ← the synthetic bridge's checkcast
How the JVM dispatches an override
At runtime, invokevirtual Payment.fee reads the receiver's class pointer, loads the vtable entry at fee's fixed slot and jumps. Interface calls (invokeinterface) go through an itable instead, because a class can implement many interfaces and the slot numbers can't be shared. The inheritance lesson draws the vtable; what matters here is that this lookup is all the JVM ever does for overriding. The overloading decision is already baked into the bytecode as a descriptor, so the JVM never weighs argument types.
03Real world: the duplicate refund
A refund service deduplicated requests by putting RefundKey objects into a ConcurrentHashMap.newKeySet(). RefundKey had a hand-written equals(RefundKey other) without @Override, plus a correct hashCode. Every unit test that compared two keys with equals passed. In production, a client retried a timed-out request, both keys landed in the same bucket, equals(Object) from Object said they were different, and the customer was refunded twice. The fix was one word, Object, and the lesson was a build rule: enable the MissingOverride check (Error Prone) or the IDE inspection so an unannotated override fails the build.
The same family of bug shows up with overloads and generics. A remove(int index) and remove(Object o) pair on List<Integer> means list.remove(1) removes the element at index 1, not the value 1, because the exact int match wins in phase 1 before boxing is considered. Use list.remove(Integer.valueOf(1)) when you mean the value.
04Advanced: what the JIT does, and the puzzles interviewers love
Inline caches and megamorphic call sites
A raw vtable call costs a couple of dependent loads and an indirect branch, but the bigger cost is that the JIT can't inline a call when it doesn't know the target, and inlining unlocks every other optimisation (escape analysis, constant folding, loop optimisations). So HotSpot profiles each call site in the interpreter and records which receiver classes it has seen. The figure shows the three states a call site can be in.
Three inline cache states for the call payment.fee(): monomorphic with one receiver class inlined directly, bimorphic with two classes inlined behind a type check, and megamorphic with three or more classes falling back to a vtable or itable call
- Monomorphic (one class seen): C2 inlines that class's method behind a cheap class-pointer comparison. If a different class ever shows up, the guard fails and the code deoptimises.
- Bimorphic (two classes seen): C2 inlines both with an if/else on the class.
-XX:TypeProfileWidthdefaults to 2 in C2, which is where the limit comes from. - Megamorphic (three or more): no inlining, a real vtable or itable call. If one class still accounts for at least 90% of calls, C2 inlines that one and falls back to a virtual call for the rest.
Inlining a monomorphic fee() makes the call cost effectively zero, while a megamorphic site pays roughly 1 to 5 ns per call and blocks the optimisations inside. Microbenchmarks that add a third receiver type to a hot loop commonly show a 2x to 5x slowdown for small methods. This is why a benchmark that only ever passes CardPayment can mislead you about production, where CardPayment, UpiPayment and WalletPayment all flow through the same call site. Don't design around it by default; measure with JMH and -XX:+PrintInlining when a profile shows a hot polymorphic call.
Overloading puzzles with null and boxing
Try it yourself first
Predict each line before reading the comments.
class Notifier {
void send(Object payload) { System.out.println("Object"); }
void send(String payload) { System.out.println("String"); }
void retry(long attempts) { System.out.println("long"); }
void retry(Integer attempts) { System.out.println("Integer"); }
void audit(Object event) { System.out.println("Object"); }
void audit(long event) { System.out.println("long"); }
}
Notifier n = new Notifier();
n.send(null); // prints: String (most specific: String is a subtype of Object)
n.retry(5); // prints: long (phase 1 widening beats phase 2 boxing)
n.audit(Integer.valueOf(5)); // prints: Object (subtyping in phase 1 beats unboxing in phase 2)
n.audit('x'); // prints: long (char widens to long in phase 1)
The null case flips into a compile error as soon as there are two unrelated candidates: with send(String) and send(Integer), send(null) reports "reference to send is ambiguous". Cast the null to pick one, as in send((String) null).
Two more rules round it out. Overloads can't differ only by return type, and they can't differ only by generic type arguments: void load(List<String> ids) and void load(List<Integer> ids) fail with "name clash: ... have the same erasure", because after erasure both are load(List).
Many candidates assume Java picks an overload using the runtime type of the argument, so they expect p.process(arg) above to print the String version. Java is single dispatch: only the receiver's runtime class matters, and argument types are frozen at compile time. If you need behaviour that depends on the runtime type of an argument, use polymorphism on the argument itself, the visitor pattern (double dispatch), or since Java 21 a switch with type patterns over a sealed hierarchy. Mentioning that third option shows you know modern Java.
| Overloading | Overriding | |
|---|---|---|
| Where | Same class (or inherited plus new) | Subclass of the declaring class |
| Signature | Same name, different parameters | Same name, same parameters |
| Return type | Anything | Same or covariant |
| Decided by | Compiler, from static types | JVM, from receiver's runtime class |
| Bytecode | Different descriptors | Same descriptor, different vtable entry |
| Applies to | Static and instance methods, constructors | Instance methods only |
| Polymorphism | Compile-time (static) | Runtime (dynamic) |
05How to say it in the interview
When I ask about overloading versus overriding, I'm listening for who decides and when. A strong answer says the compiler picks the signature from declared types and the JVM picks the implementation from the receiver's class, then walks through a puzzle without guessing. For senior roles, I want covariant returns explained with bridge methods, and some awareness that megamorphic call sites cost real performance.
In the interview
"Overloading is same name, different parameters, and the compiler resolves it from the static types of the arguments, preferring widening over boxing over varargs and the most specific match. The chosen signature is written into the bytecode. Overriding is a subclass replacing an inherited instance method with the same signature; the JVM resolves it at runtime from the receiver's class via invokevirtual and the vtable. An override needs the same parameters, a same or covariant return, equal or wider access and no broader checked exceptions. Static methods are hidden, private ones aren't inherited and final ones can't be overridden. Covariant returns and generics work through synthetic bridge methods. Java is single dispatch, so the argument's runtime type never picks an overload, which is why I always use @Override: it turns an accidental equals(Order) overload into a compile error."
On the whiteboard
overload same name, diff params javac, static types compile-time
phases: widen/subtype → box/unbox → varargs; most specific
null: most specific subtype; unrelated → ambiguous
override same sig, subclass JVM, receiver class runtime
return same|covariant access ≥ checked exc ≤
static → hidden private → not inherited final → error
@Override catches equals(Order) overload bug
bridge ACC_BRIDGE: covariant return, generic erasure (+checkcast)
dispatch invokevirtual → vtable[slot]; invokeinterface → itable
JIT mono (inline) | bi (2 inlined) | mega (≥3 → vtable call)
single dispatch → visitor / sealed + switch patterns for args
Further reading (9)
- JLS §8.4.8 Inheritance, Overriding, and Hiding
- JLS §8.4.9 Overloading
- JLS §15.12.2 Compile-Time Step 2: Determine Method Signature
- JLS §15.12.4.4 Locate Method to Invoke
- JVMS §6.5 invokevirtual
- The Java Tutorials: Overriding and Hiding Methods
- The Java Tutorials: Effects of Type Erasure and Bridge Methods
- Effective Java, 3rd ed., Items 40 and 52: Consistently use the Override annotation; use overloading judiciously
- Baeldung: Method Overloading and Overriding in Java