Java · Java Essentials

Java: Objects, Equality, Immutability

equals, hashCode and compareTo; records, final and unmodifiable views; overload resolution, static hiding, default methods, constructors that call overridable methods, sealed types - and what a proxy can and can't intercept.

  • 10 min read
  • 23 practice questions
  • 2 graded problems
On this page
  1. Description
  2. Identity and equality
  3. Writing equals and hashCode
  4. What final really freezes
  5. Which method runs?
  6. Constructors, sealed types, arrays and generics
  7. What a proxy can intercept
  8. Common mistakes
  9. Check yourself
  10. Key takeaways
  11. Quick revise
  12. Practice

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:

  1. Identity versus equality: == and equals.
  2. Writing equals and hashCode correctly, and letting records do it for you.
  3. Immutability: what final really freezes, and copies versus read-only views.
  4. Which method runs: overloading, overriding, static methods, default methods.
  5. Constructors, sealed types, and arrays versus generics.
  6. 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?
  • equals checks 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:

  1. Override both equals and hashCode, always together. A HashSet first uses hashCode to pick a bucket and only then calls equals. If two equal points have different hash codes, they land in different buckets and equals never even runs. That's the second reason the set above can fail.
  2. 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.
  3. Keep compareTo consistent with equals. TreeSet and TreeMap never call equals; they treat compareTo(...) == 0 as "the same". BigDecimal is the famous example where the two disagree: 1.0 and 1.00 aren't equals, but compareTo says 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:

Your call: printer.print(x)

Compile time: choose the overload
from the DECLARED types of the arguments

Run time: choose the override
from the ACTUAL class of the object

Your call: printer.print(x)

Compile time: choose the overload
from the DECLARED types of the arguments

Run time: choose the override
from the ACTUAL class of the object

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 @Transactional on saveOrder does nothing for that call. Put the method on another bean, or put the annotation on the method that's called from outside.
  • final methods. When a class has no interface, the proxy is a generated subclass that overrides each public method. A final method 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

MistakeWhat happensDo this instead
equals(Point p) instead of equals(Object o)collections never call it@Override public boolean equals(Object o)
equals without hashCodeequal objects land in different bucketsoverride both, or use a record
A key field changes after putthe entry can't be foundimmutable keys
compareTo that disagrees with equalsTreeSet and HashSet disagreemake them consistent
final around the caller's listthe caller can still change itList.copyOf in the constructor
Collections.unmodifiableList for immutabilitychanges to the source show throughList.copyOf
Expecting the run-time type to pick an overloadthe declared type decidesoverride, or check the type yourself
Overridable method called from a constructorruns before the subclass is set upcall only private or final methods
this.method() on a proxied beanthe annotation is ignoredcall 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; equals is value. Override equals(Object) with @Override, and always override hashCode with it.
  • Keys must not change, and compareTo should agree with equals.
  • Records give you correct equals, hashCode and toString for free.
  • final and records are shallow: copy mutable inputs with List.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.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Equality in one picture

QuestionToolNotes
Same object?==identity
Same value?equalsthe class decides; default is ==
Which bucket?hashCodeequal objects must have equal hash codes
Which comes first?compareTo / ComparatorTreeSet/TreeMap treat 0 as "the same"

The correct pattern

@Override
public boolean equals(Object o) {                       // Object, not Point
    if (this == o) return true;
    if (!(o instanceof Point p)) return false;          // handles null too
    return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }   // always with equals

record Point(int x, int y) {}                           // or let a record do all of it

record Basket(List<String> items) {
    Basket {                                            // compact constructor:
        items = List.copyOf(items);                     //   copy mutable input
        if (items.isEmpty()) throw new IllegalArgumentException("empty");   //   validate
    }
}

Which method runs

KindChosenBy
Overloadcompile timedeclared argument types (no boxing, then boxing, then varargs)
Overriderun timethe object's actual class
Static methodcompile timethe declared type: hidden, never overridden
Default methodrun timea conflict between two interfaces must be overridden (A.super.m())

The traps

  • equals(Point) overloads instead of overriding: add @Override.
  • equals without hashCode: equal objects never meet in a HashSet.
  • A key that changes after put is lost.
  • BigDecimal 1.0 vs 1.00: equals false, compareTo 0.
  • final is shallow: copy lists in, never hand out a modifiable one.
  • Collections.unmodifiableList is a live view; List.copyOf is a snapshot.
  • A constructor calling an overridable method runs the subclass's code before its fields are set.
  • Object[] a = new String[1]; a[0] = 1; throws ArrayStoreException; List<Object> = new ArrayList<String>() doesn't compile.
  • Proxies miss this.method() calls and final methods.

Staff-level points, one line each

  • equals contract: reflexive, symmetric, transitive, consistent, false for null. Subclass + new value field can't keep it: make value classes final, or compose.
  • Objects.hash allocates; records' hash formula is unspecified; identity hash codes live in the object header.
  • Records: canonical constructor; deserialization runs it (validation included); byte[] components compare by identity.
  • final fields are seen fully initialized by every thread (JLS 17.5) unless this escapes the constructor.
  • List.copyOf skips the copy for an already unmodifiable List.of list, but copies a view.
  • Overload phases: widening beats boxing beats varargs: m(long) over m(Integer) for m(5).
  • The JIT inlines monomorphic and bimorphic call sites; megamorphic ones (3+ classes) stay virtual.
  • Erasure: bridge methods, heap pollution, no new T[], type tokens to recover types.
  • JDK proxies implement interfaces; CGLIB/ByteBuddy proxies subclass (no final, no private). Self-invocation skips both.

Say it in an interview

  • "I'd make this a record, so equals and hashCode stay consistent with its fields."
  • "The field is final, but the list isn't, so I copy it in the constructor with List.copyOf."
  • "Overloads bind at compile time, so the declared type decides which one runs."
  • "The proxy can't see this.saveOrder(), so I'd move it to another bean."

Practice

This skill comes with 23 multiple-choice questions that tell you which misconception a wrong answer reveals and 2 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java: Objects, Equality, Immutability free

Count Unique Sign-Ups

MediumProCoding problemJava

A newsletter for a London startup collects sign-ups from a web form. People type their email address in all sorts of ways: with stray spaces, or in different capitals. " Ana@Example.com" and "ana@example.com" are the same person.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java: Objects, Equality, Immutability free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime