Skip to content

Contracts and language gotchas

Mid / Senior

The equals/hashCode/compareTo contracts, and the traps that bite in interviews and production: Integer caching, constructor virtual calls, finally, overload resolution, and collection mutation.

Created 13 October 2026

9 concepts · detail hidden

  1. 01Glossary — terms used in this page
    • Contract — behavioural rules documented in a method’s Javadoc that the compiler cannot enforce but the library depends on.
    • Reflexive / symmetric / transitive — the three properties of an equivalence relation: a=a; a=b implies b=a; a=b and b=c imply a=c.
    • Hash bucket — the slot in a hash table chosen from an object’s hash code.
    • Autoboxing — automatic conversion between a primitive (int) and its wrapper (Integer).
    • Fail-fast iterator — an iterator that throws if the collection is structurally modified during iteration.
    #c-glossary-terms-used-in-this-page
  2. 02The equals and hashCode contract — why overriding one without the other breaks hash collections

    The rules

    From Object’s documentation:

    1. If a.equals(b), then a.hashCode() == b.hashCode().
    2. hashCode() must return the same value during one execution as long as the fields used in equals do not change.
    3. If !a.equals(b), hash codes may still collide (allowed, but slows lookups).

    The default Object.equals is reference identity and the default hashCode is derived from the object’s identity. Override equals alone and two logically equal objects still have different identity hash codes, violating rule 1.

    How a HashMap uses them

    map.get(key)
     1. h = key.hashCode()                -> pick bucket index (h spread, then & (n-1))
     2. walk the bucket's entries:
           for each entry: entry.hash == h  AND  (entry.key == key || key.equals(entry.key))
     3. found -> return value
    

    hashCode selects the bucket; equals confirms the match inside it. Wrong bucket means equals is never even called.

    The bug

    class Point {
        final int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }
    
        @Override public boolean equals(Object o) {
            return o instanceof Point p && p.x == x && p.y == y;
        }
        // hashCode NOT overridden
    }
    
    Set<Point> set = new HashSet<>();
    set.add(new Point(1, 2));
    System.out.println(set.contains(new Point(1, 2)));  // false (usually!)
    

    Both points are equal, but their identity hash codes differ, so contains searches a different bucket. It sometimes “works” if two codes happen to land in the same bucket, which makes the bug intermittent and nasty.

    The fix

    @Override public int hashCode() {
        return Objects.hash(x, y);         // same fields as equals, nothing more
    }
    

    Rule of thumb: hashCode must use only fields that equals uses (a subset is legal, extra fields are not).

    Writing a correct equals

    @Override public boolean equals(Object o) {
        if (this == o) return true;                       // fast path, reflexive
        if (!(o instanceof Point p)) return false;        // also handles null
        return x == p.x && y == p.y;
    }
    
    • Compare double and float fields with Double.compare, not == (NaN != NaN, 0.0 == -0.0).
    • Compare object fields with Objects.equals (null-safe).
    • Use getClass() != o.getClass() instead of instanceof if you want subclasses to never equal parent instances; use instanceof with a final class or when equality is defined by an interface (as List does).

    Symmetry broken by inheritance

    class ColorPoint extends Point {
        final String color;
        ColorPoint(int x, int y, String c) { super(x, y); color = c; }
        @Override public boolean equals(Object o) {
            return o instanceof ColorPoint cp && super.equals(o) && cp.color.equals(color);
        }
    }
    
    Point p = new Point(1, 2);
    ColorPoint cp = new ColorPoint(1, 2, "red");
    p.equals(cp);   // true   (cp is a Point with same x, y)
    cp.equals(p);   // false  (p is not a ColorPoint)
    

    There is no way to extend an instantiable class and add a value component while preserving the equals contract. Options: composition over inheritance (ColorPoint has a Point), make the class final, or use a record.

    Records and modern shortcuts

    record Point(int x, int y) {}     // equals, hashCode, toString generated, consistent with each other
    

    Lombok’s @EqualsAndHashCode and IDE generators also keep the two in sync.

    Mutable keys

    Point p = new MutablePoint(1, 2);
    set.add(p);
    p.setX(99);                          // hashCode changes
    set.contains(p);                     // false: it sits in the old bucket
    set.remove(p);                       // also fails, the entry is leaked
    

    Never mutate fields that feed hashCode while the object is in a hash collection. Prefer immutable keys.

    JPA entities

    hashCode based on a database-generated id changes when the entity is saved (null -> value), so it breaks in HashSets. Common choices: a constant hashCode (getClass().hashCode()), or a natural/business key. Never include lazy associations: calling equals would trigger a database load.

    #c-the-equals-and-hashcode-contract-why-overriding-one-without-
  3. 03compareTo, Comparable and Comparator — consistency with equals and overflow traps

    The contract

    a.compareTo(b) returns negative, zero, or positive and must be antisymmetric (sgn(a.compareTo(b)) == -sgn(b.compareTo(a))), transitive, and strongly recommended to be consistent with equals (compareTo == 0 iff equals).

    Why consistency matters

    Set<BigDecimal> hash = new HashSet<>();
    Set<BigDecimal> tree = new TreeSet<>();
    for (var s : new String[]{"1.0", "1.00"}) {
        hash.add(new BigDecimal(s));
        tree.add(new BigDecimal(s));
    }
    System.out.println(hash.size());   // 2  (equals compares scale: 1.0 != 1.00)
    System.out.println(tree.size());   // 1  (compareTo ignores scale: they compare equal)
    

    HashSet uses equals/hashCode, TreeSet uses compareTo (or the supplied Comparator). Inconsistent ordering gives different answers from the two.

    Overflow in subtraction comparators

    // BUG: overflows when values have opposite signs and large magnitude
    Comparator<Integer> bad = (a, b) -> a - b;
    bad.compare(Integer.MAX_VALUE, -1);       // negative, wrongly says MAX < -1
    
    // Correct
    Comparator<Integer> good = Integer::compare;
    

    A comparator that violates transitivity can make Collections.sort throw IllegalArgumentException: Comparison method violates its general contract!.

    Building comparators

    people.sort(Comparator.comparing(Person::lastName)
            .thenComparing(Person::firstName)
            .thenComparingInt(Person::age)
            .reversed());
    

    Use Comparator.nullsFirst/nullsLast when fields can be null.

    #c-compareto-comparable-and-comparator-consistency-with-equals-
  4. 04Integer caching and boxing traps — why == works for 127 and fails for 128

    Integer.valueOf (used by autoboxing) caches instances for -128 to 127.

    Integer a = 127, b = 127;
    Integer c = 128, d = 128;
    System.out.println(a == b);        // true   (same cached object)
    System.out.println(c == d);        // false  (two distinct objects)
    System.out.println(c.equals(d));   // true
    

    Same story for Long, Short, Byte, Character (0-127), and Boolean. Always compare wrapper values with equals or unbox first.

    More boxing traps

    Integer count = null;
    int n = count;                      // NullPointerException (auto-unboxing)
    
    Map<String, Integer> m = new HashMap<>();
    int v = m.get("missing");           // NPE: get returns null, unboxed to int
    
    long sum = 0;
    Long boxed = 0L;                    // using Long in a hot loop allocates millions of objects
    for (long i = 0; i < 1_000_000; i++) boxed += i;
    

    Mixed ternaries unbox too: with Integer boxedNull = null;, Integer x = flag ? 1 : boxedNull; throws NPE when flag is false, because int and Integer operands make the whole expression type int, so boxedNull is unboxed. (A literal null as the other operand does not trigger this.)

    remove ambiguity

    List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
    list.remove(1);                     // removes the element at index 1 -> [10, 30]
    list.remove(Integer.valueOf(10));   // removes the VALUE 10 -> [30]
    

    Overload resolution prefers the exact primitive match remove(int index).

    #c-integer-caching-and-boxing-traps-why-works-for-127-and-fails
  5. 05Constructors calling overridable methods — seeing a half-initialised subclass

    Field initialisers of a subclass run after the superclass constructor returns. If the superclass constructor calls an overridable method, the override sees default values.

    class Base {
        Base() { init(); }                          // calls overridable method
        void init() { System.out.println("Base init"); }
    }
    
    class Child extends Base {
        private String name = "child";              // assigned after super() finishes
        @Override void init() {
            System.out.println("name = " + name);   // prints "name = null"
        }
    }
    
    new Child();    // name = null
    

    Order of execution for new Child():

    1. Memory allocated, all fields set to defaults (null, 0).
    2. Child() starts and immediately calls Base().
    3. Base() calls init(), dispatched virtually to Child.init, where name is still null.
    4. Base() returns; Child’s field initialisers run; then the body of Child().

    Rule: never call overridable methods from constructors (or readObject, or clone). Make the method private/final, or use a factory/init step after construction.

    Fields are hidden, not overridden

    class A { String name = "A"; String get() { return name; } }
    class B extends A { String name = "B"; String get() { return name; } }
    
    A ref = new B();
    ref.name;    // "A"  (field access uses the static type)
    ref.get();   // "B"  (method call uses the runtime type)
    

    Static methods are hidden the same way: they are resolved by the reference’s declared type.

    #c-constructors-calling-overridable-methods-seeing-a-half-initi
  6. 06Overload resolution happens at compile time
    static void print(Object o) { System.out.println("Object"); }
    static void print(String s) { System.out.println("String"); }
    
    Object o = "hello";
    print(o);            // "Object"  (declared type of the variable, not the runtime String)
    print("hello");      // "String"
    print(null);         // "String"  (most specific applicable overload)
    

    Overloads are selected by static types; overrides by runtime type. Resolution order is: exact/widening primitive conversion, then boxing, then varargs; the most specific candidate wins, and ambiguous calls fail to compile.

    static void f(long x) { System.out.println("long"); }
    static void f(Integer x) { System.out.println("Integer"); }
    f(5);                // "long": widening beats boxing
    

    Common surprise: List.remove(int) vs remove(Object) (above), and print(char[]) vs print(Object) for println.

    #c-overload-resolution-happens-at-compile-time
  7. 07finally, return and exceptions — what actually gets returned
    static int f() {
        try {
            return 1;
        } finally {
            return 2;          // overrides the try's return; compiler warns
        }
    }
    f();   // 2
    
    static int g() {
        int x = 1;
        try {
            return x;          // value 1 is copied for return BEFORE finally runs
        } finally {
            x = 99;            // does not change the returned value
        }
    }
    g();   // 1
    
    try {
        throw new RuntimeException("original");
    } finally {
        throw new IllegalStateException("from finally");   // "original" is silently lost
    }
    

    Rules: finally always runs (except System.exit, JVM crash, or an infinite loop/thread death). A return or throw inside finally discards the pending result or exception. Use try-with-resources instead of manual finally { close(); }; it keeps the primary exception and attaches close failures as suppressed (e.getSuppressed()), and closes resources in reverse order of declaration.

    Exception override rule

    An overriding method may throw only the same checked exceptions or narrower ones (or none), never new or broader checked exceptions. Unchecked exceptions are unrestricted.

    #c-finally-return-and-exceptions-what-actually-gets-returned
  8. 08Collections gotchas — fixed-size lists, immutability, and ConcurrentModificationException

    Modifying while iterating

    List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));
    for (Integer n : nums) {
        if (n % 2 == 0) nums.remove(n);     // ConcurrentModificationException (usually)
    }
    

    The for-each loop uses an iterator that checks a modCount counter on next(). Safe alternatives:

    nums.removeIf(n -> n % 2 == 0);                         // simplest
    for (Iterator<Integer> it = nums.iterator(); it.hasNext();)
        if (it.next() % 2 == 0) it.remove();                // iterator's own remove
    

    Quirk: removing the second-to-last element happens not to throw, because hasNext() returns false before next() checks modCount. That is why the bug passes small tests.

    Which “immutable” list is it?

    Factory Add/remove Set Nulls Writes through to the source
    new ArrayList<>(...) yes yes yes no
    Arrays.asList(arr) no (fixed size) yes yes yes, backed by the array
    List.of(...) no no no (NPE) no
    Collections.unmodifiableList(l) no no yes view: changes to l show through
    List.copyOf(l) no no no no (snapshot)
    String[] arr = {"a", "b"};
    List<String> view = Arrays.asList(arr);
    arr[0] = "z";
    view.get(0);            // "z"
    view.add("c");          // UnsupportedOperationException
    

    List.of(...).contains(null) also throws NPE. Arrays.asList(intArray) for a primitive int[] yields a List<int[]> of size 1.

    HashMap and HashSet

    • Iteration order of HashMap is unspecified and may change between versions; use LinkedHashMap for insertion order and TreeMap for sorted order.
    • HashMap permits one null key; ConcurrentHashMap and Hashtable permit none.
    • map.get(k) == null is ambiguous (missing key or null value); use containsKey or getOrDefault.
    • Mutating a TreeMap’s key so it compares differently corrupts the tree.
    #c-collections-gotchas-fixed-size-lists-immutability-and-concur
  9. 09Other contracts and conventions worth knowing
    • clone() and Cloneable — Cloneable has no methods; Object.clone() does a shallow, field-by-field copy and throws if the class is not Cloneable. Hard to get right with mutable fields and final fields; prefer a copy constructor or factory.
    • Serializable — declare serialVersionUID explicitly or compatible changes break deserialisation; mark sensitive or non-serialisable fields transient; deserialisation does not run the constructor of a serialisable class (only of its first non-serialisable superclass). Treat Java deserialisation of untrusted data as a security hole.
    • AutoCloseable.close() should be idempotent, per its Javadoc recommendation.
    • Iterator/Iterable — for-each requires Iterable; hasNext() must be side-effect free.
    • toString() — never include secrets; avoid cycles (parent -> child -> parent) that cause StackOverflowError.
    • Static initialisation order — static fields and blocks run in textual order on first use of the class; a static field referenced before its initialiser sees the default value.
    • Integer arithmetic — Math.abs(Integer.MIN_VALUE) is still negative; int overflow wraps silently; 5 / 2 == 2; (int) 3.9 == 3; -7 % 3 == -1; use Math.floorMod for non-negative remainders; 0.1 + 0.2 != 0.3 (use BigDecimal with string constructor for money).
    • char arithmetic — 'a' + 1 is an int (98); "" + 'a' + 1 is the string "a1"; char c = 'a'; c += 1; compiles (compound assignment narrows) but c = c + 1; does not.
    • String switch and == — switch on strings uses equals; == on strings compares references (see String interning).
    • Optional — don’t use as a field or parameter type; Optional.of(null) throws, use ofNullable; orElse(expensive()) always evaluates its argument, prefer orElseGet.
    • Streams — a stream can be consumed once (IllegalStateException on reuse); lambdas in a stream should not mutate external state; parallelStream() on a small or I/O-bound source is usually slower.
    #c-other-contracts-and-conventions-worth-knowing