Contracts and language gotchas
Mid / SeniorThe 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
01Glossary — terms used in this page
#c-glossary-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=bimpliesb=a;a=bandb=cimplya=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.
02The equals and hashCode contract — why overriding one without the other breaks hash collections
#c-the-equals-and-hashcode-contract-why-overriding-one-without-The rules
From
Object’s documentation:- If
a.equals(b), thena.hashCode() == b.hashCode(). hashCode()must return the same value during one execution as long as the fields used inequalsdo not change.- If
!a.equals(b), hash codes may still collide (allowed, but slows lookups).
The default
Object.equalsis reference identity and the defaulthashCodeis derived from the object’s identity. Overrideequalsalone and two logically equal objects still have different identity hash codes, violating rule 1.How a
HashMapuses themmap.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 valuehashCodeselects the bucket;equalsconfirms the match inside it. Wrong bucket meansequalsis 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
containssearches 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:
hashCodemust use only fields thatequalsuses (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
doubleandfloatfields withDouble.compare, not==(NaN != NaN,0.0 == -0.0). - Compare object fields with
Objects.equals(null-safe). - Use
getClass() != o.getClass()instead ofinstanceofif you want subclasses to never equal parent instances; useinstanceofwith afinalclass or when equality is defined by an interface (asListdoes).
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 otherLombok’s
@EqualsAndHashCodeand 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 leakedNever mutate fields that feed
hashCodewhile the object is in a hash collection. Prefer immutable keys.JPA entities
hashCodebased on a database-generatedidchanges when the entity is saved (null -> value), so it breaks inHashSets. Common choices: a constanthashCode(getClass().hashCode()), or a natural/business key. Never include lazy associations: callingequalswould trigger a database load.- If
03compareTo, Comparable and Comparator — consistency with equals and overflow traps
#c-compareto-comparable-and-comparator-consistency-with-equals-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 withequals(compareTo == 0iffequals).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)HashSetusesequals/hashCode,TreeSetusescompareTo(or the suppliedComparator). 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.sortthrowIllegalArgumentException: Comparison method violates its general contract!.Building comparators
people.sort(Comparator.comparing(Person::lastName) .thenComparing(Person::firstName) .thenComparingInt(Person::age) .reversed());Use
Comparator.nullsFirst/nullsLastwhen fields can be null.04Integer caching and boxing traps — why == works for 127 and fails for 128
#c-integer-caching-and-boxing-traps-why-works-for-127-and-failsInteger.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)); // trueSame story for
Long,Short,Byte,Character(0-127), andBoolean. Always compare wrapper values withequalsor 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 whenflagis false, becauseintandIntegeroperands make the whole expression typeint, soboxedNullis unboxed. (A literalnullas 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).05Constructors calling overridable methods — seeing a half-initialised subclass
#c-constructors-calling-overridable-methods-seeing-a-half-initiField 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 = nullOrder of execution for
new Child():- Memory allocated, all fields set to defaults (
null,0). Child()starts and immediately callsBase().Base()callsinit(), dispatched virtually toChild.init, wherenameis stillnull.Base()returns;Child’s field initialisers run; then the body ofChild().
Rule: never call overridable methods from constructors (or
readObject, orclone). Make the methodprivate/final, or use a factory/initstep 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.
- Memory allocated, all fields set to defaults (
06Overload resolution happens at compile time
#c-overload-resolution-happens-at-compile-timestatic 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 boxingCommon surprise:
List.remove(int)vsremove(Object)(above), andprint(char[])vsprint(Object)forprintln.07finally, return and exceptions — what actually gets returned
#c-finally-return-and-exceptions-what-actually-gets-returnedstatic int f() { try { return 1; } finally { return 2; // overrides the try's return; compiler warns } } f(); // 2static 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(); // 1try { throw new RuntimeException("original"); } finally { throw new IllegalStateException("from finally"); // "original" is silently lost }Rules:
finallyalways runs (exceptSystem.exit, JVM crash, or an infinite loop/thread death). Areturnorthrowinsidefinallydiscards the pending result or exception. Use try-with-resources instead of manualfinally { 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.
08Collections gotchas — fixed-size lists, immutability, and ConcurrentModificationException
#c-collections-gotchas-fixed-size-lists-immutability-and-concurModifying 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
modCountcounter onnext(). 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 removeQuirk: removing the second-to-last element happens not to throw, because
hasNext()returns false beforenext()checksmodCount. 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 lshow throughList.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"); // UnsupportedOperationExceptionList.of(...).contains(null)also throws NPE.Arrays.asList(intArray)for a primitiveint[]yields aList<int[]>of size 1.HashMap and HashSet
- Iteration order of
HashMapis unspecified and may change between versions; useLinkedHashMapfor insertion order andTreeMapfor sorted order. HashMappermits one null key;ConcurrentHashMapandHashtablepermit none.map.get(k) == nullis ambiguous (missing key or null value); usecontainsKeyorgetOrDefault.- Mutating a
TreeMap’s key so it compares differently corrupts the tree.
- Iteration order of
09Other contracts and conventions worth knowing
#c-other-contracts-and-conventions-worth-knowingclone()andCloneable—Cloneablehas no methods;Object.clone()does a shallow, field-by-field copy and throws if the class is notCloneable. Hard to get right with mutable fields and final fields; prefer a copy constructor or factory.Serializable— declareserialVersionUIDexplicitly or compatible changes break deserialisation; mark sensitive or non-serialisable fieldstransient; 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-eachrequiresIterable;hasNext()must be side-effect free.toString()— never include secrets; avoid cycles (parent -> child -> parent) that causeStackOverflowError.- 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;intoverflow wraps silently;5 / 2 == 2;(int) 3.9 == 3;-7 % 3 == -1; useMath.floorModfor non-negative remainders;0.1 + 0.2 != 0.3(useBigDecimalwith string constructor for money). chararithmetic —'a' + 1is anint(98);"" + 'a' + 1is the string"a1";char c = 'a'; c += 1;compiles (compound assignment narrows) butc = c + 1;does not.- String switch and
==—switchon strings usesequals;==on strings compares references (see String interning). - Optional — don’t use as a field or parameter type;
Optional.of(null)throws, useofNullable;orElse(expensive())always evaluates its argument, preferorElseGet. - Streams — a stream can be consumed once (
IllegalStateExceptionon reuse); lambdas in a stream should not mutate external state;parallelStream()on a small or I/O-bound source is usually slower.