Java Method Overriding
Method overriding is what makes inheritance in Java actually useful for polymorphism: it lets a subclass provide its own specific implementation of a method that is already defined in its superclass. Instead of writing a new method, the subclass replaces the behavior of an existing one, while keeping the same name and signature. This is the mechanism behind almost every framework callback, every toString() customization, and every “write once, behave differently per type” pattern you’ll see in real Java code.
Overview: How Method Overriding Works
When a subclass declares a method with the exact same name, parameter list, and a compatible return type as a method in its superclass, it overrides that method. Calling the method on a subclass object executes the subclass’s version, not the superclass’s version, even if the object is referenced through a superclass-typed variable. This is called runtime polymorphism or dynamic dispatch.
Internally, the JVM does not decide which method to call based on the compile-time type of your reference variable. Every object carries a hidden pointer to its actual runtime class, and non-private, non-static, non-final instance methods are resolved through a mechanism informally called virtual method dispatch. At the bytecode level, calls to instance methods use the invokevirtual instruction, which looks up the method to run in the actual object’s class (and its ancestors, if the subclass didn’t override it) at the moment the call happens – not when the code was compiled. This is why a variable declared as the superclass type can still trigger subclass behavior: the JVM checks what the object is, not what the variable is declared as.
Overriding is different from overloading. Overloading means multiple methods share a name but differ in parameter types or count, and the compiler picks one at compile time based on the arguments. Overriding means one method signature exists across a class hierarchy, and the JVM picks the implementation at runtime based on the actual object type. Confusing the two is one of the most common bugs beginners write – covered below in Common Mistakes.
Syntax
To override a method, the subclass method must match the superclass method’s signature and follow these rules:
| Rule | Requirement |
|---|---|
| Method name | Must be identical |
| Parameters | Must match exactly in number, type, and order |
| Return type | Must be the same type, or a subtype of it (covariant return) |
| Access modifier | Cannot be more restrictive than the superclass version |
| Checked exceptions | Cannot throw new or broader checked exceptions |
| Static / private / final methods | Cannot be overridden (static methods can be redeclared, but that is hiding, not overriding) |
class SuperclassName {
returnType methodName(parameterList) {
// original behavior
}
}
class SubclassName extends SuperclassName {
@Override
returnType methodName(parameterList) {
// new behavior
}
}
@Override– an annotation that tells the compiler “I intend to override a method.” It is optional but strongly recommended, since the compiler will error out if your method doesn’t actually match a superclass method, catching typos immediately.super.methodName(...)– calls the superclass’s version of the method from inside the override, useful for extending behavior instead of fully replacing it.
Examples
Example 1: Basic Overriding
public class Main {
static class Animal {
void makeSound() {
System.out.println("The animal makes a sound");
}
}
static class Dog extends Animal {
@Override
void makeSound() {
System.out.println("The dog barks");
}
}
public static void main(String[] args) {
Animal a = new Animal();
a.makeSound();
Dog d = new Dog();
d.makeSound();
}
}
Output:
The animal makes a sound
The dog barks
Each object calls its own class’s version of makeSound(). Nothing surprising yet – but the next example shows why this matters.
Example 2: Polymorphism Through a Superclass Reference
public class Main {
static class Shape {
double area() {
return 0.0;
}
void describe() {
System.out.println("This shape has area " + area());
}
}
static class Circle extends Shape {
private double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
return Math.PI * radius * radius;
}
}
static class Square extends Shape {
private double side;
Square(double side) {
this.side = side;
}
@Override
double area() {
return side * side;
}
}
public static void main(String[] args) {
Shape[] shapes = { new Circle(2.0), new Square(3.0) };
for (Shape s : shapes) {
s.describe();
}
}
}
Output:
This shape has area 12.566370614359172
This shape has area 9.0
Both array elements are typed as Shape, but calling describe() triggers each object’s own area() override. The superclass method describe() never had to know about Circle or Square at all – this is the core value of overriding: you can write code against the general type and let each subclass supply its own specifics.
Example 3: Extending Behavior with super
public class Main {
static class Employee {
protected String name;
protected double baseSalary;
Employee(String name, double baseSalary) {
this.name = name;
this.baseSalary = baseSalary;
}
double calculatePay() {
return baseSalary;
}
@Override
public String toString() {
return name + " earns " + calculatePay();
}
}
static class Manager extends Employee {
private double bonus;
Manager(String name, double baseSalary, double bonus) {
super(name, baseSalary);
this.bonus = bonus;
}
@Override
double calculatePay() {
return super.calculatePay() + bonus;
}
}
public static void main(String[] args) {
Employee e = new Employee("Alice", 50000.0);
Employee m = new Manager("Bob", 60000.0, 15000.0);
System.out.println(e);
System.out.println(m);
}
}
Output:
Alice earns 50000.0
Bob earns 75000.0
This is the most important example to internalize. toString() is defined once, in Employee, and calls calculatePay(). When m (a Manager stored in an Employee variable) is printed, toString() runs from Employee, but the call to calculatePay() inside it still dispatches dynamically to Manager‘s override, which itself calls super.calculatePay() to reuse the base logic and add the bonus on top. This is exactly how the JVM resolves every virtual call: by the object’s actual class, at every level of the call chain, every time.
Under the Hood: What Happens at Runtime
- The compiler checks that a method call is valid based on the reference variable’s declared (compile-time) type – it must have a method with a matching signature somewhere in that type’s hierarchy, or it won’t compile.
- The compiler emits an
invokevirtualbytecode instruction (for instance methods) rather than baking in a fixed method address. - At runtime, the JVM looks at the actual object on the heap and walks its real class’s method table, starting from the object’s own class and moving up the hierarchy only if that class doesn’t override the method.
- The first matching implementation found (closest to the actual class) is executed – this is why subclass overrides always win over ancestor implementations, regardless of the variable’s declared type.
- Fields work differently: field access is resolved at compile time based on the declared type, not the runtime type. Only methods are dynamically dispatched, which is a frequent source of confusion.
Common Mistakes
Mistake 1: Reducing Visibility
An override cannot have a more restrictive access modifier than the method it overrides. Trying to narrow public to private fails to compile:
class Animal {
public void makeSound() {
System.out.println("...");
}
}
class Dog extends Animal {
@Override
private void makeSound() { // ERROR: cannot reduce visibility
System.out.println("Woof");
}
}
Fix: keep the access level the same or make it more permissive (e.g. public in both, or protected widened to public).
Mistake 2: Accidentally Overloading Instead of Overriding
Changing the parameter type creates a brand-new overload, not an override – the superclass method is still inherited unchanged, and your “override” is a separate method entirely:
class Animal {
void makeSound(String volume) {
System.out.println("Sound at " + volume);
}
}
class Dog extends Animal {
@Override
void makeSound(int volume) { // ERROR: does not override anything
System.out.println("Bark at " + volume);
}
}
Without @Override, this mistake compiles silently and produces confusing behavior, since callers using Animal makeSound(String) never reach the “overriding” method at all. Fix: match the parameter list exactly, and always use @Override so the compiler catches the mismatch immediately.
Best Practices
- Always annotate overrides with
@Override– it costs nothing and catches signature mismatches at compile time instead of at runtime. - Keep overridden methods behaviorally consistent with what the superclass documents – callers relying on the superclass’s contract shouldn’t be surprised by a subclass silently changing the meaning of a call.
- Use
super.method()to extend rather than fully duplicate logic when the subclass only needs to add behavior, not replace it entirely. - Never rely on field access for polymorphism – only methods dispatch dynamically, so put behavior you want to vary by subclass into methods, not fields.
- Avoid overriding methods to do something unrelated to their original purpose (e.g. an overridden
equals()that has side effects) – it violates the expectations of anyone using the superclass type. - Mark methods
finalin the superclass if a subclass must never override them, to make that intent explicit and enforced by the compiler.
Practice Exercises
Exercise 1: Create a Vehicle class with a method maxSpeed() returning double. Create Bicycle and SportsCar subclasses that override maxSpeed() with different values, then print all three through an array of Vehicle references.
Exercise 2: Write a Shape class with a toString() override that reports the shape’s perimeter(). Add two subclasses that override perimeter() only, and confirm that toString() still reports the correct value for each without being touched.
Exercise 3: Deliberately write a method in a subclass that you intend to override but give it a mismatched parameter type. Add @Override and observe (mentally or by compiling) the compiler error, then fix the signature so it compiles.
Summary
- Overriding lets a subclass replace a superclass method’s implementation while keeping the same signature.
- The JVM resolves overridden method calls at runtime using
invokevirtual, based on the object’s actual class – this is dynamic dispatch. - Overriding requires an identical signature and a covariant or identical return type; overloading requires a different parameter list and is resolved at compile time instead.
- An override’s access modifier can only stay the same or become more permissive, never more restrictive.
- Use
@Overrideon every intended override to let the compiler catch mistakes, andsuper.method()to build on top of existing behavior instead of discarding it. - Only instance methods are dynamically dispatched – fields and static methods are resolved based on the declared (compile-time) type.
