On this page
Description
Every Java program is built from objects, and three questions about them come up again and again: when are two objects "the same"? Can an object be changed after you create it? And when you call a method, which version actually runs? Java has clear answers to all three, but they aren't always the answers people guess. Interviews for Java roles probe them on purpose, and they cause some of the most confusing real bugs, like a set that can't find something you just put in it:
Set<Point> seen = new HashSet<>();
seen.add(new Point(1, 2));
seen.contains(new Point(1, 2)); // false, even though Point has an equals method
By the end of the first section you'll know the two common reasons this happens.
Here's the path, from easiest to most technical:
- Identity versus equality:
==andequals. - Writing
equalsandhashCodecorrectly, and letting records do it for you. - Immutability: what
finalreally freezes, and copies versus read-only views. - Which method runs: overloading, overriding, static methods, default methods.
- Constructors, sealed types, and arrays versus generics.
- What a framework proxy (like Spring's
@Transactional) can and can't intercept.
Identity and equality
Java has two kinds of "same":
==checks identity: are these two variables pointing at the very same object in memory?equalschecks equality: do these two objects represent the same value? Each class decides what that means.
By default, a class inherits equals from Object, and that version just uses ==. So two separate Point(1, 2) objects aren't equal until you write an equals method that compares x and y.
Writing equals and hashCode
Here's a version of Point that looks right and isn't:
final class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
public boolean equals(Point p) { return p != null && x == p.x && y == p.y; } // the bug
@Override public int hashCode() { return Objects.hash(x, y); }
}
The parameter type is Point, but HashSet calls equals(Object). A method with a different parameter type doesn't replace the original; it sits next to it as an overload. So the set still uses the inherited identity check. Writing @Override above it would have caught this: the compiler reports that the method doesn't override anything.
The correct version:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false; // also handles null
return x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
Three rules keep hash-based collections working:
- Override both
equalsandhashCode, always together. AHashSetfirst useshashCodeto pick a bucket and only then callsequals. If two equal points have different hash codes, they land in different buckets andequalsnever even runs. That's the second reason the set above can fail. - Never change a key after adding it. The entry is filed under the hash code it had when you added it. Change a field the hash code uses and the lookup searches the wrong bucket.
- Keep
compareToconsistent withequals.TreeSetandTreeMapnever callequals; they treatcompareTo(...) == 0as "the same".BigDecimalis the famous example where the two disagree: 1.0 and 1.00 aren'tequals, butcompareTosays they're equal.
The easy way: records. A record writes equals, hashCode and toString for you, based on its components, and it can't be subclassed:
record Point(int x, int y) {}
new Point(1, 2).equals(new Point(1, 2)) // true
new Point(1, 2) // prints Point[x=1, y=2]
For a simple value type, a record is the clearest and safest choice.
What final really freezes
An immutable object never changes after it's built. Immutable objects are easy to reason about, safe to share between threads, and safe to use as map keys. The final keyword is part of how you build them, but it's a shallow guarantee:
final class Order {
private final List<String> items;
Order(List<String> items) { this.items = items; } // stores the caller's list
}
final means items will always point at the same list. It doesn't stop that list from changing. The caller still holds the same ArrayList, so every change they make shows up inside the "immutable" order. The fix is to copy on the way in and never hand out something that can be changed:
final class Order {
private final List<String> items;
Order(List<String> items) { this.items = List.copyOf(items); } // an unmodifiable copy
List<String> items() { return items; } // safe to return as is
}
Records follow the same rule. Their components are final, but a record holding a list is only as immutable as the list. A compact constructor is the place to copy and to validate:
record Basket(List<String> items) {
Basket {
items = List.copyOf(items); // copy
if (items.isEmpty()) throw new IllegalArgumentException("empty basket"); // validate
}
}
There are two ways to make a list read-only, and they behave very differently:
List<String> src = new ArrayList<>(List.of("a"));
List<String> view = Collections.unmodifiableList(src); // a read-only window onto src
List<String> copy = List.copyOf(src); // an independent snapshot
src.add("b");
// view is [a, b] (it follows src), copy is still [a]
A view is cheap and always up to date. A copy is independent. For immutability, you want the copy.
Which method runs?
Java picks a method in two steps, and each step looks at something different:
Overloads are picked at compile time, using the declared type of each argument:
class Printer {
void print(Object o) { System.out.println("object"); }
void print(String s) { System.out.println("string"); }
}
Object x = "hello";
new Printer().print(x); // "object": x is declared as Object, so print(Object) is chosen
Overrides are picked at run time, using the real class of the object. Static methods don't take part in that: they belong to the class, and a subclass's static method with the same name just hides the parent's:
class P { static String who() { return "P"; } String inst() { return "P"; } }
class Q extends P { static String who() { return "Q"; } @Override String inst() { return "Q"; } }
P p = new Q();
p.who(); // "P": static, chosen by the declared type P
p.inst(); // "Q": overridden, chosen by the actual object, a Q
Default methods are interface methods with a body. If a class inherits the same default method from two interfaces, it doesn't compile until it overrides the method itself (it can call one of them with A.super.hello()).
Constructors, sealed types, arrays and generics
Don't call overridable methods from a constructor. new Child() runs the parent's constructor first. If that constructor calls a method that Child overrides, Child's version runs before Child's own fields have been set, so it sees null and 0.
Sealed types list all their subclasses up front: sealed interface Shape permits Circle, Square. A switch that handles every permitted type doesn't need a default, and adding Triangle later turns every switch that forgot it into a compile error. That's exactly the warning you want.
Arrays and generics check types at different times:
Object[] objs = new String[1]; // compiles
objs[0] = 1; // ArrayStoreException when it runs
List<Object> list = new ArrayList<String>(); // doesn't compile at all
Arrays check what you store while the program runs. Generics check while compiling, then discard the type information (that's called type erasure), which is why you can't write new T[] or instanceof List<String>. To accept "a list of any kind of number", write List<? extends Number>.
What a proxy can intercept
Frameworks such as Spring add behavior like transactions, caching and security by wrapping your object in a proxy: another object that sits in front of yours, does its extra work, and forwards the call. A proxy only sees calls that go through it, which leads to two classic surprises:
- Calling your own method. Inside a bean,
this.saveOrder()calls your object directly, skipping the proxy, so@TransactionalonsaveOrderdoes nothing for that call. Put the method on another bean, or put the annotation on the method that's called from outside. finalmethods. When a class has no interface, the proxy is a generated subclass that overrides each public method. Afinalmethod can't be overridden, so calls to it skip the extra behavior.
The same goes for any wrapper you write yourself: it intercepts exactly the methods it overrides.
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
equals(Point p) instead of equals(Object o) | collections never call it | @Override public boolean equals(Object o) |
equals without hashCode | equal objects land in different buckets | override both, or use a record |
A key field changes after put | the entry can't be found | immutable keys |
compareTo that disagrees with equals | TreeSet and HashSet disagree | make them consistent |
final around the caller's list | the caller can still change it | List.copyOf in the constructor |
Collections.unmodifiableList for immutability | changes to the source show through | List.copyOf |
| Expecting the run-time type to pick an overload | the declared type decides | override, or check the type yourself |
| Overridable method called from a constructor | runs before the subclass is set up | call only private or final methods |
this.method() on a proxied bean | the annotation is ignored | call through another bean |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. Name two different reasons seen.contains(new Point(1, 2)) can return false right after adding an equal point.
Show answer
equals takes a Point instead of an Object, so it's an overload the set never calls. Or equals is right but hashCode isn't overridden (or doesn't match), so the two points go to different buckets and equals is never asked.
2. A class has private final List<String> items and assigns the constructor's argument to it. Why isn't it immutable?
Show answer
final only fixes which list the field points at. The caller still holds the same list and can change it. Copy it in the constructor with List.copyOf, and don't hand out a modifiable list.
3. With Object x = "hi", which runs for print(x): print(Object) or print(String)?
Show answer
print(Object). Overloads are chosen at compile time from the declared type of the argument. Only overridden methods are chosen at run time.
4. Why does @Transactional on saveOrder do nothing when placeOrder in the same class calls this.saveOrder()?
Show answer
The transaction is added by a proxy in front of the bean. this.saveOrder() is a direct call inside the object, so it never goes through the proxy.
Key takeaways
==is identity;equalsis value. Overrideequals(Object)with@Override, and always overridehashCodewith it.- Keys must not change, and
compareToshould agree withequals. - Records give you correct
equals,hashCodeandtoStringfor free. finaland records are shallow: copy mutable inputs withList.copyOf, and prefer copies to views.- Overloads are chosen at compile time, overrides at run time, and static methods are hidden, not overridden.
- Don't call overridable methods from constructors.
- Proxies only intercept calls that go through them.