Two things used to puzzle me about Java. First: I printed an object and got something like Dog@1b6d3586 instead of anything useful. Second: I knew Animal a = new Dog(); was legal, but I had no idea when I was allowed to turn it back into a Dog — and when doing so would crash my program.
Both puzzles live in the same neighborhood: polymorphism, Java's Object class, and casting. This post walks through all of them, one at a time, and connects the dots.
What is polymorphism?
Polymorphism means "one name, many forms." You write code against a general type, and the actual behavior depends on the specific object you're holding at run time.
The classic setup: a parent reference pointing at a child object.
class Animal {
void sound() {
System.out.println("Some sound");
}
}
class Dog extends Animal {
@Override
void sound() {
System.out.println("Bark");
}
}
class Cat extends Animal {
@Override
void sound() {
System.out.println("Meow");
}
}
public class Main {
public static void main(String[] args) {
Animal a = new Dog();
a.sound(); // Bark
Animal b = new Cat();
b.sound(); // Meow
}
}
Output:
Bark
Meow
Same line of code, a.sound(), different results. That's the magic — and Java gives you two different ways to achieve it.
Compile-time vs run-time polymorphism
Compile-time: method overloading
Overloading is several methods with the same name but different parameter lists in one class:
class Calculator {
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
}
public class Main {
public static void main(String[] args) {
Calculator calc = new Calculator();
System.out.println(calc.add(2, 3)); // int version
System.out.println(calc.add(2.5, 3.5)); // double version
}
}
Output:
5
6.0
The compiler looks at the argument types and picks the matching version before the program runs. Hence "compile-time."
Run-time: method overriding
Overriding is a child class redefining an inherited method with the same name and same parameters — exactly what Dog and Cat did with sound() above. The compiler can't know which object a reference will hold, so the choice is postponed until the program runs and the JVM looks at the real object.
Overloading Overriding
who decides compiler JVM at run time
relation same class parent and child
parameters must be DIFFERENT must be the SAME
binding early (compile time) late (run time)
keyword none @Override recommended
Memory trick: overloading = "more options loaded into one name." Overriding = "written over the parent's version."
Dynamic method dispatch: how the JVM picks
The mechanism behind run-time polymorphism is called dynamic method dispatch. When you write Animal a = new Dog();, two types are involved:
- The reference type (
Animal) decides what you're allowed to call. - The object type (
Dog) decides which version runs.
Every object carries a hidden pointer to its class, and each class keeps a method table — a lookup list of its methods. On each call, the JVM follows the pointer to the object's actual class:
a: Animal reference ----> Dog object in memory
|
v
Dog's method table
+------------------------+
| sound() -> Dog's code | <- overridden,
| toString() -> inherited | so Dog's
| from Animal | version wins
+------------------------+
One trap to remember: only instance methods dispatch. Fields do not — they're chosen by the reference type at compile time. If Animal and Dog both declare a name field, a.name gives you Animal's version even when a holds a Dog. This "field hiding" is a classic bug; the fix is to keep fields private and go through methods, which do dispatch.
The Object class: the ancestor of everything
Here's something Java does silently. Every class you write — every single one — automatically extends Object. These two declarations are identical:
class Dog { }
class Dog extends Object { } // same thing, written explicitly
That means this is legal:
Object obj = new Dog(); // Object reference, Dog object
Object text = "hello"; // a String IS an Object
Object num = 42; // autoboxed into Integer, which extends Object
One type can hold anything. That's why Java's collections, before generics, could store any object at all — an ArrayList is really a list of Objects.
Object gives every class a handful of methods. The ones you'll meet daily:
-
toString()— called when you print the object or concatenate it with a string. Default output is the class name plus a hash code:Dog@1b6d3586. Override it to get something readable:
class Dog {
String name = "Bruno";
@Override
public String toString() {
return "Dog named " + name;
}
}
public class Main {
public static void main(String[] args) {
Object obj = new Dog();
System.out.println(obj); // println calls toString()
System.out.println("Hi " + obj);
}
}
Output:
Dog named Bruno
Hi Dog named Bruno
-
equals(Object other)— compares this object with another. This is where the==trap comes in:==compares references (do both variables point to the same object?), whileequals()is meant to compare content:
public class Main {
public static void main(String[] args) {
String a = new String("hi");
String b = new String("hi");
System.out.println(a == b); // false: different objects
System.out.println(a.equals(b)); // true: same characters
}
}
Output:
false
true
-
hashCode()— a number derived from the object, used by hash-based collections likeHashMap. If you overrideequals(), overridehashCode()too, or those collections misbehave. -
getClass()— returns the object's actual class. Notice something? It's afinalmethod, so no one can override it to lie about their type. Even polymorphism has rules.
Here's the family picture:
Object
/ | \
String Animal Integer ...
/ \
Dog Cat
Object sits at the root of every class tree. Polymorphism, upcasting, and toString() all build on this single inheritance fact.
Upcasting and downcasting
Casting is how you move along those tree branches. There are only two directions.
Upcasting: child → parent (safe, automatic)
Animal a = new Dog(); // upcasting happens implicitly
a.sound(); // Bark — polymorphism still works
You write nothing extra. A Dog is an Animal, so the assignment is always safe. The price: you temporarily lose access to child-specific members. a.fetch() won't compile, because the compiler only sees Animal.
Upcasting is everywhere once you look: passing a Dog to a method that takes Animal, storing different objects in one Object variable, adding a Dog to a List<Animal>.
Downcasting: parent → child (risky, explicit)
To get the child-specific features back, you cast explicitly:
Animal a = new Dog();
Dog d = (Dog) a; // explicit downcast
d.fetch(); // Fetching...
The picture:
Dog ------> Animal upcast: implicit, always safe
Animal ------> Dog downcast: explicit, can fail
So when does it fail? The compiler trusts your cast — it's just a promise. But the JVM checks the actual object at run time:
class Cat extends Animal {
@Override
void sound() {
System.out.println("Meow");
}
}
public class Main {
public static void main(String[] args) {
Animal a = new Cat();
Dog d = (Dog) a; // compiles fine...
d.fetch(); // ...but crashes here
}
}
Output:
Exception in thread "main" java.lang.ClassCastException: Cat cannot be cast to Dog
A Cat is not a Dog, and no cast can make it one. The fix is to check before you cast, using instanceof:
Animal a = new Cat();
if (a instanceof Dog) {
Dog d = (Dog) a;
d.fetch();
} else {
System.out.println("Not a Dog, skipping fetch");
}
Output:
Not a Dog, skipping fetch
Since Java 16, you can combine the check and the cast in one line — the cast happens automatically if the test passes:
if (a instanceof Dog d) { // Java 16+
d.fetch();
}
When do you actually need downcasting? Mostly when code receives a broad type and must recover a specific one. The equals(Object obj) method is the everyday example: it arrives as Object, so implementations check instanceof and cast before comparing fields. And in pre-generics code, everything came out of collections as Object, so casts were constant. Generics have quietly removed most everyday casting — which is a good thing.
The final keyword
final is the brake pedal for all of this. It means "this can't be changed or extended," in three places.
A final variable can't be reassigned:
final int MAX_USERS = 100;
// MAX_USERS = 200; // ERROR: cannot assign a value to final variable
A final method can't be overridden — this is final directly switching off run-time polymorphism for that one method:
class Config {
final void loadDefaults() {
System.out.println("Loading defaults...");
}
}
class AppConfig extends Config {
// void loadDefaults() { } // ERROR: cannot override the final method
}
A final class can't be extended at all. Java's String is final, deliberately — strings are used in security-sensitive places, and arbitrary subclasses could break the JVM's assumptions.
One warning: final is not the same as immutable. It stops the variable from being reassigned, not the object from changing:
import java.util.ArrayList;
import java.util.List;
final List<String> names = new ArrayList<>();
names.add("Asha"); // fine: the object can change
// names = new ArrayList<>(); // ERROR: the reference cannot change
Which one should I use?
My decision process looks like this:
-
Overloading when one idea takes different inputs —
add(int, int)andadd(double, double). It's caller convenience. -
Overriding + parent references when behavior differs by type. This is the real design win, and it's how
toString(),equals(), and every framework callback work. - Upcasting freely — it's free and safe. Design methods to accept parent types.
-
Downcasting sparingly, and only behind
instanceof. If you find yourself casting a lot, that's a design smell; generics or polymorphic methods usually remove the need. -
Objectas a type almost never, in modern code. Prefer generics (List<String>over a rawListofObject). Reach forObjectwhen a method genuinely accepts anything, likeprintln. -
finalon variables by default (unless you must reassign), and on methods or classes only with a concrete reason — over-eagerfinalon classes makes code hard to extend and test.
Common mistakes
- Mixing up overloading and overriding. Same name + different parameters = overloading (compile time). Same name + same parameters in parent and child = overriding (run time). Change a parameter "by accident" and you've silently overloaded instead of overridden.
- Expecting fields or static methods to dispatch. They follow the reference type, not the object. Only instance methods are polymorphic — field hiding bites beginners constantly.
-
Blind downcasting.
(Dog) acompiles even whenaholds aCat. Always guard withinstanceofunless you're 100% sure of the object type. -
Comparing objects with
==. It compares references, not content. Useequals()— and remember, the other object can benull, soa.equals(b)can throw; the null-safe habit is"expected".equals(actual)for strings. -
Thinking
finalmakes objects immutable. It only prevents reassigning the variable. Afinal Liststill grows and shrinks. -
Not overriding
toString()and being puzzled byDog@1b6d3586. Any class you print should have a friendlytoString(). And if you overrideequals(), overridehashCode()too —HashMapandHashSetdepend on them agreeing.
Quick recap
- Polymorphism = one name, many forms: overloading (compiler picks) and overriding (JVM picks).
- Dynamic method dispatch = the JVM follows the object to its class's method table. Reference type decides what you can call; object type decides what runs.
- Every class silently extends
Object, gainingtoString(),equals(),hashCode(), and more. OneObjectreference can hold anything. -
Upcasting (child → parent) is implicit and safe; downcasting (parent → child) is explicit and can throw
ClassCastException— always check withinstanceof. -
finallocks a variable (no reassignment), a method (no overriding), or a class (no extending). It's the anti-polymorphism keyword — but it doesn't make objects immutable.
Try it yourself
Here's a small practice idea: create the Animal, Dog, and Cat classes, then an array of type Animal[] holding a Dog, a Cat, and another Dog. Loop through it, call sound() on each (watch the dispatch happen), and inside the loop use instanceof to call fetch() only on the dogs.
Then two experiments: remove toString() from Dog and print it; and override equals() without hashCode() and put your objects in a HashSet. Observe what surprises you — those surprises are the lessons.
If this helped, or if I got something wrong, tell me in the comments. Happy coding!
Top comments (0)