The last two chapters showed subclasses replacing parent methods to print different messages or compute different totals, without naming what was happening. The mechanism is called method overriding, and the rules around it are stricter than they look. This lesson lays out exactly when a method actually overrides its parent, when it accidentally creates a new method instead, what the compiler enforces, and why @Override is the cheapest bug-prevention tool in Java.
Method overriding happens when a subclass defines a method with the same signature as a method it inherits from its parent, and replaces the parent's implementation with its own. The subclass version runs when the method is called on an instance of the subclass, even when the instance is held through a parent-typed variable.
A small product hierarchy to make that concrete. Product knows how to describe itself in a generic way. Book is a Product that wants a richer description.
Two things matter here. First, Book.describe() replaces Product.describe() entirely for Book instances. Second, the variable book is declared as Product, but the call resolves to Book.describe() because Java looks at the actual object, not the variable's declared type. That last point is the heart of runtime polymorphism, covered at the end of this lesson.
The declared type of the variable picks which methods are visible to the compiler. The actual object picks which version actually runs. This distinction comes back several times in this lesson.
These two words sound similar and are easy to confuse. They are not related concepts. Overloading happens inside one class. Overriding happens across a parent-child pair.
| Aspect | Overloading | Overriding |
|---|---|---|
| Where it happens | Same class | Subclass replacing parent's method |
| Same method name? | Yes | Yes |
| Same parameter list? | No (must differ) | Yes (must match exactly) |
| Return type | Can be anything | Same or covariant subtype |
| Access modifier | Independent | Same or more permissive |
| Resolved when? | Compile time (static types of args) | Runtime (actual object type) |
| What it's for | Multiple call shapes for one action | Specialized behavior in a subclass |
Overloading is about giving callers different ways to invoke the same operation. Overriding is about a subclass saying "for this exact operation, do it my way instead." Changing the parameter list in a subclass while expecting to override actually overloads the method, which means the parent's version still runs for any inherited call. That's one of the bugs @Override exists to catch.
For a subclass method to actually override a parent method, five conditions must all be true. The compiler enforces every one of them.
Describe and describe are different methods.(int) and (Integer) are different signatures.protected method in the parent can be overridden as protected or public, but not private or package-private. Visibility cannot be reduced.RuntimeException) are unrestricted.The decision tree for "is this an override?" looks like this.
The next few subsections walk through each rule with code that compiles and code that doesn't.
These two are the obvious ones, but they're also where most accidental "non-overrides" happen. The parent method silently keeps running when the parameter list doesn't quite match.
The author of DiscountedProduct probably intended p.describe() to print "Discounted product...". It didn't. The new method took an extra int, so the compiler treated it as a different method altogether. The original Product.describe() is still inherited and still runs. No error, no warning, just wrong behavior.
The fix is to match the signature exactly, or to add @Override so the compiler refuses to compile the mismatch in the first place.
If the parent returns String, the child can return String. The child can also return a subtype of String, but String is final, so in practice that just means String. With non-final parent return types, the child has more options. Covariants get their own section because they're useful enough to deserve real coverage.
A widened return type, on the other hand, is not allowed. If the parent returns Book and the child tries to return Product, the compiler rejects it.
The compiler reports something like:
Why this restriction? Callers of Catalog.findFeatured() are entitled to a Book based on the parent's contract. If a subclass could return any Product, those callers would break. Returning a narrower type is safe, returning a wider one is not.
If the parent method is public, the override must be public. Not protected, not package-private, not private. The principle is that any caller able to legally call the parent method must also be able to call the override, because they might be holding a child instance through a parent-typed variable without knowing it.
The compiler error reads:
The other direction is allowed. Overriding a protected parent method with a public child method is fine, because that widens access rather than narrowing it.
| Parent's access | Child override can be |
|---|---|
public | public only |
protected | protected or public |
| package-private (default) | package-private, protected, or public |
private | Not inherited at all, so not overridable. See "What Cannot Be Overridden". |
A child override is allowed to throw the same checked exceptions as the parent, narrower ones (subclasses), fewer of them, or none. It cannot add new checked exceptions or replace an existing one with a broader type.
The last one fails with:
The reasoning is the same shape as the access rule. Callers of OrderStore.save() only catch IOException. If a child slipped in a broader Exception, those callers would suddenly have unhandled checked exceptions in code that the compiler had previously approved. To keep the parent's contract honest, children can only throw the same or less.
Unchecked exceptions (RuntimeException and its subclasses) escape this rule entirely. A child can throw IllegalArgumentException from an override even if the parent declared no exceptions. The compiler doesn't enforce throws clauses for unchecked exceptions, so it doesn't check them on overrides either.
@Override Annotation@Override is an annotation placed right above a method intended to override a parent. It tells the compiler: "this method overrides something in a parent class or interface. If it doesn't, please refuse to compile." It changes no behavior at runtime. Its entire job is to catch the silent-failure cases just shown.
Without the @Override annotation, this still works. So why bother?
Consider a typo in the method name during a refactor.
The compiler error reads:
Without @Override, this typo would compile cleanly. The class would have two methods, the inherited describe() from Product and a brand-new descripe() defined here. Calls to book.describe() would silently run the parent's version. A reader scanning the code would see the override and assume it ran. Bugs like this are surprisingly hard to find because nothing crashes, the output is just subtly wrong.
@Override catches every shape of accidental non-override: typos in the name, wrong parameter types, wrong number of parameters, wrong order of parameters. Any of those means the method doesn't actually match a parent method, and @Override turns that mismatch into a compile error.
The rule of thumb is simple: put @Override on every method intended as an override. There's no downside. It costs nothing at runtime, makes the intent obvious to readers, and traps the entire class of bugs where a "missed" override silently falls back to the parent.
Rule 3 said the override's return type can be the same as the parent's or a covariant subtype. Covariant return types let a subclass return a more specific type than the parent declared. This is useful in practice, not just an edge case.
The example: a Catalog has a method featuredItem() that returns a Product. A subclass BookCatalog only ever stocks books, so its featuredItem() should return a Book. Java lets the return type be tightened.
The line doing the real work is Book b = books.featuredItem();. Without covariant returns, the override would have to return Product, and the caller would have to cast: Book b = (Book) books.featuredItem();. Every cast is a place where the type system stops helping, and a place where a future change can introduce a ClassCastException. Covariant returns push that knowledge into the type system so the cast disappears.
Covariant returns came in with Java 5. Before that, every override had to return exactly the parent's type. The feature is the reason patterns like clone() can return a precise type instead of bare Object.
Covariant returns add zero runtime cost. The compiler inserts a small bridge method internally so the parent's signature still works for callers holding a parent reference.
The only constraint is the direction. The return type can only get narrower, not wider. A child returning Product when the parent returns Book would fail to compile, as the Rule 3 section showed.
Not every method in a parent class is up for grabs. Four kinds of "methods" in the parent are off-limits for overriding, and each one is off-limits for a different reason.
final MethodsA method marked final says "this implementation is the one, and no subclass gets to change it." Trying to override it is a compile error.
The error reads:
Authors mark methods final when the method's behavior is part of a guarantee the class wants to preserve, regardless of what subclasses do. Hash-key calculations, identity checks, security gates, anything where a misbehaving override would break invariants. final shows up on fields and parameters more often than on methods, but on methods it's a way to lock down behavior that shouldn't shift across the hierarchy.
static Methodsstatic methods belong to the class, not to any instance. They aren't part of an object's behavior, so the runtime polymorphism that powers overriding doesn't apply to them. If a subclass defines a static method with the same signature as a static method in the parent, what happens isn't overriding. It's a separate feature called method hiding, covered in its own section below.
private Methodsprivate methods aren't inherited by the subclass at all. The subclass can't see them, can't call them, can't override them. If the subclass happens to define a method with the same name and signature as a parent's private method, it's just a brand-new method that happens to share a name. There's no relationship between the two, and @Override on it fails to compile.
The first secretSauce in Book compiles fine, but it has no connection to the parent's. Calls inside Product's methods that hit secretSauce() will still run the parent's version, because the parent doesn't even know the child has a method by that name.
Constructors aren't methods. They have no return type (not even void), their name is fixed by the class name, and they don't get inherited. There's no concept of "overriding a constructor" because there's nothing to override. A child class writes its own constructors that, by chaining, invoke a parent constructor. That's a different mechanism.
A summary table of what each modifier means for overriding.
| Parent method has | Inherited? | Can subclass override? | What happens if subclass declares same signature? |
|---|---|---|---|
Default behavior (instance method, non-final) | Yes | Yes | Overrides |
final | Yes | No | Compile error |
static | Yes (as a class member) | No | Hides, doesn't override (see next section) |
private | No | No | Creates a new, unrelated method |
| Constructor | N/A | N/A | Subclass writes its own, chained via super(...) |
static Methods Look Overridden but Aren'tThis is the most confusing corner of the lesson, so it gets its own section.
When a subclass defines a static method with the same signature as a static method in the parent, it doesn't override the parent's method. It hides it. The difference is which version gets called: hiding picks based on the declared (compile-time) type of the variable, while overriding picks based on the actual (runtime) object.
The example shows the difference clearly.
The variable p is declared Product but holds a Book. The instance method call p.describe() runs Book.describe() because Java looks at the actual object. The static method call p.category() runs Product.category() because Java looks at the declared type of p. Same variable, two different rules, because describe is an instance method and category is a static method.
Putting @Override on Book.category() causes the compiler to reject it:
That's because @Override only applies to overriding, and static methods don't override. They hide.
The takeaway: don't write static methods expecting subclass polymorphism. For behavior that varies by subclass, use instance methods. static methods are for class-level utilities where the type of the variable, not the type of the object, is what matters.
The phrase "Java looks at the actual object" has appeared three times now. This is the defining feature of overriding, and it has a name: runtime polymorphism (also called dynamic dispatch or late binding).
The compiler doesn't decide which override runs. It can't, because it only knows the declared type of the variable, and that type might just be a parent. The decision is made when the program runs and Java looks up the actual class of the object on the heap.
The variable p in the loop is typed Product. The compiler only knows the declared type, but it generates code that, at runtime, asks each actual object which version of describe to run. The Book object answers with Book.describe. The other two answer with Product.describe. One loop, three call sites, two different methods, all because of runtime polymorphism.
Runtime dispatch adds a small indirection (one pointer hop through the object's method table) compared to a static call. In normal code this is invisible. In hot code paths, the JIT compiler often inlines virtual calls when it can prove the type is fixed, so the cost effectively disappears.
The takeaway is that overriding is what makes runtime polymorphism work, and runtime polymorphism is what makes overriding worth having.
8 quizzes