diff --git a/README.md b/README.md
index a8493d7..a84db9f 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ article; each module's own README has that article's version table, quickstart,
| [`reflection`](reflection/) | Java Reflection and MethodHandles in 2026: How They Work and What They Cost |
| [`jmh-basics`](jmh-basics/) | JMH Tutorial: Writing Java Microbenchmarks That Don't Lie |
| [`string-format`](string-format/) | String.format, formatted() and printf in Java: The Complete Cheat Sheet |
+| [`equals-hashcode`](equals-hashcode/) | equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It |
## License
diff --git a/equals-hashcode/README.md b/equals-hashcode/README.md
new file mode 100644
index 0000000..23e5cbf
--- /dev/null
+++ b/equals-hashcode/README.md
@@ -0,0 +1,38 @@
+# equals-hashcode
+
+Companion code for the ankurm.com post *"equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It."*
+Module in `java-core-examples`, the Java-core series. There is no `docs/` folder: the deeper material lives in the post itself.
+
+## Versions this was built and tested against
+
+| Component | Version | Notes |
+|---|---|---|
+| JDK | 25.0.5+8 (Temurin, LTS) | Records (final since 16) generate `equals`/`hashCode`. |
+| JUnit Jupiter | 5.11.0 | |
+| Maven | 3.9.11 | |
+
+## Quickstart
+
+```bash
+export JAVA_HOME=/path/to/jdk-25
+mvn compile
+scripts/run.sh MutableKeyDemo # one demo
+scripts/run-all.sh # regenerate every file in output/
+```
+
+## What's in here
+
+| File | What it shows |
+|---|---|
+| `ContractDemo.java` | The five rules, each broken on purpose: symmetry (`instanceof` + extra field), transitivity (the "lenient" fix), consistency. |
+| `MissingHashCodeDemo.java` | `equals()` without `hashCode()`; a constant `hashCode()` and the 2,002,999 `equals()` calls it costs for 2,000 lookups. |
+| `MutableKeyDemo.java` | A key mutated after insertion: `get` returns null, `remove` fails, iteration still sees it. |
+| `SymmetryDemo.java` | `java.util.Date` vs `java.sql.Timestamp`: the JDK breaks symmetry itself. |
+| `RecordsDemo.java` | What records generate; array components, mutable lists, `NaN` and `-0.0`; overriding `equals`. |
+| `EntityIdDemo.java` | The JPA entity trap simulated without a database (id null until "saved"). No Hibernate involved. |
+| `HashCodeRecipeDemo.java` | What `String`, `List`, `Set`, `Map`, `Objects.hash`, `Arrays.hashCode` actually compute. |
+| `EqualsHashCodeTest.java` | Pins every claim above as an assertion. |
+| `output/01-08` | Captured runs of everything above. |
+
+## License
+MIT - see [LICENSE](../LICENSE).
diff --git a/equals-hashcode/output/01-contract.txt b/equals-hashcode/output/01-contract.txt
new file mode 100644
index 0000000..4ce2673
--- /dev/null
+++ b/equals-hashcode/output/01-contract.txt
@@ -0,0 +1,17 @@
+== Reflexive / null ==
+p.equals(p) = true
+p.equals(null) = false
+
+== Symmetry broken by instanceof + extra field ==
+plain.equals(red) = true
+red.equals(plain) = false
+List.of(plain).contains(red) = false
+List.of(red).contains(plain) = true
+
+== Transitivity broken by the lenient fix ==
+r.equals(plain) = true
+plain.equals(b) = true
+r.equals(b) = false
+
+== Consistency broken by a changing equals() ==
+f.equals(f2) x4 = true false true false
diff --git a/equals-hashcode/output/02-missing-hashcode.txt b/equals-hashcode/output/02-missing-hashcode.txt
new file mode 100644
index 0000000..4891a1e
--- /dev/null
+++ b/equals-hashcode/output/02-missing-hashcode.txt
@@ -0,0 +1,15 @@
+== equals() without hashCode() ==
+a.equals(b) = true
+a.hashCode() == b.hashCode() = false
+HashSet size after adding a then b = 2
+HashSet.contains(new equal key) = false
+HashMap.get(equal key) = null
+
+== with hashCode() ==
+HashSet size after adding c then d = 1
+HashSet.contains(new equal key) = true
+
+== a legal but useless hashCode(): return 42 ==
+keys = 2000
+equals() calls for 2000 gets, constant hash = 2002999
+equals() calls for 2000 gets, good hash = 2000
diff --git a/equals-hashcode/output/03-mutable-key.txt b/equals-hashcode/output/03-mutable-key.txt
new file mode 100644
index 0000000..3e658bb
--- /dev/null
+++ b/equals-hashcode/output/03-mutable-key.txt
@@ -0,0 +1,14 @@
+before: get = 100, contains = true
+
+after acc.owner = "ankur":
+map.get(acc) = null
+set.contains(acc) = false
+map.containsKey(acc) = false
+map.size() = 1
+iteration still sees it = [Account(ankur)]
+map.remove(acc) = null
+map.size() after remove = 1
+
+set.add(acc) again -> size = 2 [Account(ankur), Account(ankur)] (one object, two entries)
+
+immutable key: map.get("asha") = 1
diff --git a/equals-hashcode/output/04-jdk-symmetry.txt b/equals-hashcode/output/04-jdk-symmetry.txt
new file mode 100644
index 0000000..b17eb49
--- /dev/null
+++ b/equals-hashcode/output/04-jdk-symmetry.txt
@@ -0,0 +1,3 @@
+date.equals(ts) = true
+ts.equals(date) = false
+date.getTime() == ts.getTime() = true
diff --git a/equals-hashcode/output/05-records.txt b/equals-hashcode/output/05-records.txt
new file mode 100644
index 0000000..24c8d85
--- /dev/null
+++ b/equals-hashcode/output/05-records.txt
@@ -0,0 +1,23 @@
+== generated equals/hashCode ==
+a.equals(b) = true
+a.hashCode() == b.hashCode() = true
+new HashSet of both, size = 1
+a == b = false
+
+== array component: reference equality ==
+w1.equals(w2) = false
+same array shared: true
+
+== mutable List component: equals works, hashCode can change ==
+hashCode before = 4714, after list.add(3) = 34537
+record in HashSet, list mutated -> contains = false
+
+== doubles compare with Double.compare semantics ==
+NaN record equals NaN record = true
+Double.NaN == Double.NaN = false
+0.0 record equals -0.0 record = false
+0.0 == -0.0 = true
+
+== overriding equals in a record ==
+t1.equals(t2) = true
+toString still shows both fields: Tagged[isbn=1, title=Effective Java 3e]
diff --git a/equals-hashcode/output/06-entity-id.txt b/equals-hashcode/output/06-entity-id.txt
new file mode 100644
index 0000000..cd06e04
--- /dev/null
+++ b/equals-hashcode/output/06-entity-id.txt
@@ -0,0 +1,10 @@
+== id-based equals/hashCode, id assigned on save ==
+two unsaved entities equal? true (both ids null)
+set size after adding two different unsaved entities = 1
+add, then save(): set.contains(c) = false
+
+== constant hashCode ==
+add, then save(): set.contains(d) = true
+
+== natural key, immutable ==
+add, then save(): set.contains(e) = true
diff --git a/equals-hashcode/output/07-hashcode-recipes.txt b/equals-hashcode/output/07-hashcode-recipes.txt
new file mode 100644
index 0000000..c978f0f
--- /dev/null
+++ b/equals-hashcode/output/07-hashcode-recipes.txt
@@ -0,0 +1,22 @@
+== what the JDK uses ==
+"hello".hashCode() = 99162322
+31*h + c over "hello" = 99162322
+List.of(1,2,3).hashCode() = 30817
+Arrays.hashCode({1,2,3}) = 30817
+Objects.hash(1,2,3) = 30817
+Set.of(1,2,3).hashCode() = 6 (sum of elements)
+Map.of(1,2).hashCode() = 3 (key^value per entry, summed)
+
+== Objects.hash(...) traps ==
+Objects.hash("a") vs "a".hashCode() = 128 vs 97
+Objects.hash(1, 2) == Objects.hash(2, 1) ? false
+Objects.hash() (no args) = 1
+Objects.hash((Object) null) = 31
+
+== Arrays as fields ==
+x.equals(y) = false, Arrays.equals = true
+x.hashCode() == y.hashCode() ? false
+Arrays.hashCode equal ? true
+
+== equal hashCodes do not mean equal objects ==
+"Aa".hashCode() = 2112, "BB".hashCode() = 2112, equal strings? false
diff --git a/equals-hashcode/output/08-tests.txt b/equals-hashcode/output/08-tests.txt
new file mode 100644
index 0000000..440cdc6
--- /dev/null
+++ b/equals-hashcode/output/08-tests.txt
@@ -0,0 +1,3 @@
+[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.205 s -- in com.ankurm.equalshashcode.EqualsHashCodeTest
+[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
diff --git a/equals-hashcode/pom.xml b/equals-hashcode/pom.xml
new file mode 100644
index 0000000..98a419c
--- /dev/null
+++ b/equals-hashcode/pom.xml
@@ -0,0 +1,46 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ java-core-examples
+ 1.0
+
+
+ equals-hashcode
+ equals-hashcode
+ equals() and hashCode() in Java: the contract, the bugs it produces in HashMap and HashSet, mutable keys, records, and the JPA entity trap.
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.0
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 25
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+ -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8
+
+
+
+
+
diff --git a/equals-hashcode/scripts/run-all.sh b/equals-hashcode/scripts/run-all.sh
new file mode 100755
index 0000000..5c915aa
--- /dev/null
+++ b/equals-hashcode/scripts/run-all.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Regenerates every file in output/. Hash-collision counts are deterministic; nothing here is timing-based.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mvn -q -B compile
+run() { java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.equalshashcode.$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' > "output/$2.txt"; }
+run ContractDemo 01-contract
+run MissingHashCodeDemo 02-missing-hashcode
+run MutableKeyDemo 03-mutable-key
+run SymmetryDemo 04-jdk-symmetry
+run RecordsDemo 05-records
+run EntityIdDemo 06-entity-id
+run HashCodeRecipeDemo 07-hashcode-recipes
+mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/08-tests.txt
+echo "regenerated:"; ls output
diff --git a/equals-hashcode/scripts/run.sh b/equals-hashcode/scripts/run.sh
new file mode 100755
index 0000000..dc4b307
--- /dev/null
+++ b/equals-hashcode/scripts/run.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# Usage: scripts/run.sh MutableKeyDemo
+cd "$(dirname "$0")/.."
+mvn -q -B compile && java -cp target/classes "com.ankurm.equalshashcode.$1"
diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/ContractDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/ContractDemo.java
new file mode 100644
index 0000000..43b7652
--- /dev/null
+++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/ContractDemo.java
@@ -0,0 +1,78 @@
+package com.ankurm.equalshashcode;
+
+import java.util.*;
+
+/** The five equals() rules, each broken on purpose. Output: output/01-contract.txt */
+public class ContractDemo {
+
+ /** A class whose equals() is fine. */
+ static class Point {
+ final int x, y;
+ Point(int x, int y) { this.x = x; this.y = y; }
+ @Override public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof Point p)) return false;
+ return x == p.x && y == p.y;
+ }
+ @Override public int hashCode() { return Objects.hash(x, y); }
+ }
+
+ /** Subclass that adds a field and uses instanceof: breaks symmetry. */
+ static class ColorPoint extends Point {
+ final String color;
+ ColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
+ @Override public boolean equals(Object o) {
+ if (!(o instanceof ColorPoint c)) return false; // a plain Point is never equal
+ return super.equals(o) && color.equals(c.color);
+ }
+ @Override public int hashCode() { return Objects.hash(super.hashCode(), color); }
+ }
+
+ /** Fix attempt: ignore colour when comparing with a plain Point. Breaks transitivity. */
+ static final class LenientColorPoint extends Point {
+ final String color;
+ LenientColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
+ @Override public boolean equals(Object o) {
+ if (!(o instanceof Point)) return false;
+ if (!(o instanceof LenientColorPoint c)) return o.equals(this);
+ return super.equals(o) && color.equals(c.color);
+ }
+ @Override public int hashCode() { return super.hashCode(); }
+ }
+
+ /** Consistency: equals() that depends on something that changes between calls. */
+ static final class Flaky {
+ static int calls;
+ @Override public boolean equals(Object o) { return o instanceof Flaky && (calls++ % 2 == 0); }
+ @Override public int hashCode() { return 1; }
+ }
+
+ public static void main(String[] args) {
+ Point p = new Point(1, 2);
+ System.out.println("== Reflexive / null ==");
+ System.out.println("p.equals(p) = " + p.equals(p));
+ System.out.println("p.equals(null) = " + p.equals(null));
+
+ System.out.println("\n== Symmetry broken by instanceof + extra field ==");
+ Point plain = new Point(1, 2);
+ ColorPoint red = new ColorPoint(1, 2, "red");
+ System.out.println("plain.equals(red) = " + plain.equals(red));
+ System.out.println("red.equals(plain) = " + red.equals(plain));
+ List list = new ArrayList<>(List.of(plain));
+ System.out.println("List.of(plain).contains(red) = " + list.contains(red));
+ List list2 = new ArrayList<>(List.of(red));
+ System.out.println("List.of(red).contains(plain) = " + list2.contains(plain));
+
+ System.out.println("\n== Transitivity broken by the lenient fix ==");
+ LenientColorPoint r = new LenientColorPoint(1, 2, "red");
+ LenientColorPoint b = new LenientColorPoint(1, 2, "blue");
+ System.out.println("r.equals(plain) = " + r.equals(plain));
+ System.out.println("plain.equals(b) = " + plain.equals(b));
+ System.out.println("r.equals(b) = " + r.equals(b));
+
+ System.out.println("\n== Consistency broken by a changing equals() ==");
+ Flaky f = new Flaky();
+ System.out.println("f.equals(f2) x4 = " + f.equals(new Flaky()) + " " + f.equals(new Flaky())
+ + " " + f.equals(new Flaky()) + " " + f.equals(new Flaky()));
+ }
+}
diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/EntityIdDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/EntityIdDemo.java
new file mode 100644
index 0000000..c3328cb
--- /dev/null
+++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/EntityIdDemo.java
@@ -0,0 +1,71 @@
+package com.ankurm.equalshashcode;
+
+import java.util.*;
+
+/**
+ * The JPA entity trap, simulated without a database: an id that is null until "save" assigns it.
+ * No Hibernate on the classpath, so this shows the Java mechanism only. Output: output/06-entity-id.txt
+ */
+public class EntityIdDemo {
+
+ static long sequence = 100;
+
+ /** equals/hashCode on the database id: what many IDE templates generate. */
+ static final class IdEntity {
+ Long id; final String name;
+ IdEntity(String name) { this.name = name; }
+ void save() { if (id == null) id = ++sequence; } // stands in for persist()
+ @Override public boolean equals(Object o) { return o instanceof IdEntity e && Objects.equals(id, e.id); }
+ @Override public int hashCode() { return Objects.hashCode(id); }
+ }
+
+ /** Common workaround: constant hashCode, id-based equals when both ids are set. */
+ static final class ConstantHashEntity {
+ Long id; final String name;
+ ConstantHashEntity(String name) { this.name = name; }
+ void save() { if (id == null) id = ++sequence; }
+ @Override public boolean equals(Object o) {
+ if (this == o) return true;
+ return o instanceof ConstantHashEntity e && id != null && id.equals(e.id);
+ }
+ @Override public int hashCode() { return ConstantHashEntity.class.hashCode(); }
+ }
+
+ /** Business key: set once at construction and never changed. */
+ static final class NaturalKeyEntity {
+ Long id; final String email;
+ NaturalKeyEntity(String email) { this.email = email; }
+ void save() { if (id == null) id = ++sequence; }
+ @Override public boolean equals(Object o) { return o instanceof NaturalKeyEntity e && email.equals(e.email); }
+ @Override public int hashCode() { return email.hashCode(); }
+ }
+
+ public static void main(String[] args) {
+ System.out.println("== id-based equals/hashCode, id assigned on save ==");
+ IdEntity a = new IdEntity("a"), b = new IdEntity("b");
+ System.out.println("two unsaved entities equal? " + a.equals(b) + " (both ids null)");
+ Set set = new HashSet<>();
+ set.add(a);
+ set.add(b);
+ System.out.println("set size after adding two different unsaved entities = " + set.size());
+ Set set2 = new HashSet<>();
+ IdEntity c = new IdEntity("c");
+ set2.add(c);
+ c.save();
+ System.out.println("add, then save(): set.contains(c) = " + set2.contains(c));
+
+ System.out.println("\n== constant hashCode ==");
+ ConstantHashEntity d = new ConstantHashEntity("d");
+ Set set3 = new HashSet<>();
+ set3.add(d);
+ d.save();
+ System.out.println("add, then save(): set.contains(d) = " + set3.contains(d));
+
+ System.out.println("\n== natural key, immutable ==");
+ NaturalKeyEntity e = new NaturalKeyEntity("a@example.com");
+ Set set4 = new HashSet<>();
+ set4.add(e);
+ e.save();
+ System.out.println("add, then save(): set.contains(e) = " + set4.contains(e));
+ }
+}
diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/HashCodeRecipeDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/HashCodeRecipeDemo.java
new file mode 100644
index 0000000..9a98de3
--- /dev/null
+++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/HashCodeRecipeDemo.java
@@ -0,0 +1,34 @@
+package com.ankurm.equalshashcode;
+
+import java.util.*;
+
+/** hashCode() recipes and what the JDK hands you. Output: output/07-hashcode-recipes.txt */
+public class HashCodeRecipeDemo {
+ public static void main(String[] args) {
+ System.out.println("== what the JDK uses ==");
+ System.out.println("\"hello\".hashCode() = " + "hello".hashCode());
+ int h = 0; for (char ch : "hello".toCharArray()) h = 31 * h + ch;
+ System.out.println("31*h + c over \"hello\" = " + h);
+ System.out.println("List.of(1,2,3).hashCode() = " + List.of(1, 2, 3).hashCode());
+ System.out.println("Arrays.hashCode({1,2,3}) = " + Arrays.hashCode(new int[]{1, 2, 3}));
+ System.out.println("Objects.hash(1,2,3) = " + Objects.hash(1, 2, 3));
+ System.out.println("Set.of(1,2,3).hashCode() = " + Set.of(1, 2, 3).hashCode() + " (sum of elements)");
+ System.out.println("Map.of(1,2).hashCode() = " + Map.of(1, 2).hashCode() + " (key^value per entry, summed)");
+
+ System.out.println("\n== Objects.hash(...) traps ==");
+ System.out.println("Objects.hash(\"a\") vs \"a\".hashCode() = " + Objects.hash("a") + " vs " + "a".hashCode());
+ System.out.println("Objects.hash(1, 2) == Objects.hash(2, 1) ? " + (Objects.hash(1, 2) == Objects.hash(2, 1)));
+ System.out.println("Objects.hash() (no args) = " + Objects.hash());
+ System.out.println("Objects.hash((Object) null) = " + Objects.hash((Object) null));
+
+ System.out.println("\n== Arrays as fields ==");
+ int[] x = {1, 2}, y = {1, 2};
+ System.out.println("x.equals(y) = " + x.equals(y) + ", Arrays.equals = " + Arrays.equals(x, y));
+ System.out.println("x.hashCode() == y.hashCode() ? " + (x.hashCode() == y.hashCode()));
+ System.out.println("Arrays.hashCode equal ? " + (Arrays.hashCode(x) == Arrays.hashCode(y)));
+
+ System.out.println("\n== equal hashCodes do not mean equal objects ==");
+ System.out.println("\"Aa\".hashCode() = " + "Aa".hashCode() + ", \"BB\".hashCode() = " + "BB".hashCode()
+ + ", equal strings? " + "Aa".equals("BB"));
+ }
+}
diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MissingHashCodeDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MissingHashCodeDemo.java
new file mode 100644
index 0000000..85d6094
--- /dev/null
+++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MissingHashCodeDemo.java
@@ -0,0 +1,73 @@
+package com.ankurm.equalshashcode;
+
+import java.util.*;
+
+/** equals() without hashCode(): the HashSet/HashMap bug. Output: output/02-missing-hashcode.txt */
+public class MissingHashCodeDemo {
+
+ static final class Broken {
+ final String isbn;
+ Broken(String isbn) { this.isbn = isbn; }
+ @Override public boolean equals(Object o) { return o instanceof Broken b && isbn.equals(b.isbn); }
+ // hashCode() NOT overridden: Object.hashCode() is identity-based
+ }
+
+ static final class Fixed {
+ final String isbn;
+ Fixed(String isbn) { this.isbn = isbn; }
+ @Override public boolean equals(Object o) { return o instanceof Fixed b && isbn.equals(b.isbn); }
+ @Override public int hashCode() { return isbn.hashCode(); }
+ }
+
+ /** hashCode() that is legal but useless: every key in one bucket. */
+ static final class Constant {
+ static long equalsCalls;
+ final int id;
+ Constant(int id) { this.id = id; }
+ @Override public boolean equals(Object o) { equalsCalls++; return o instanceof Constant c && id == c.id; }
+ @Override public int hashCode() { return 42; }
+ }
+
+ static final class Good {
+ static long equalsCalls;
+ final int id;
+ Good(int id) { this.id = id; }
+ @Override public boolean equals(Object o) { equalsCalls++; return o instanceof Good c && id == c.id; }
+ @Override public int hashCode() { return Integer.hashCode(id); }
+ }
+
+ public static void main(String[] args) {
+ Broken a = new Broken("978-0134685991"), b = new Broken("978-0134685991");
+ System.out.println("== equals() without hashCode() ==");
+ System.out.println("a.equals(b) = " + a.equals(b));
+ System.out.println("a.hashCode() == b.hashCode() = " + (a.hashCode() == b.hashCode()));
+ Set bad = new HashSet<>(List.of(a));
+ bad.add(b);
+ System.out.println("HashSet size after adding a then b = " + bad.size());
+ System.out.println("HashSet.contains(new equal key) = " + bad.contains(new Broken("978-0134685991")));
+ Map badMap = new HashMap<>();
+ badMap.put(a, "Effective Java");
+ System.out.println("HashMap.get(equal key) = " + badMap.get(b));
+
+ Fixed c = new Fixed("978-0134685991"), d = new Fixed("978-0134685991");
+ Set ok = new HashSet<>(List.of(c));
+ ok.add(d);
+ System.out.println("\n== with hashCode() ==");
+ System.out.println("HashSet size after adding c then d = " + ok.size());
+ System.out.println("HashSet.contains(new equal key) = " + ok.contains(new Fixed("978-0134685991")));
+
+ System.out.println("\n== a legal but useless hashCode(): return 42 ==");
+ int n = 2000;
+ Map m1 = new HashMap<>();
+ for (int i = 0; i < n; i++) m1.put(new Constant(i), i);
+ Constant.equalsCalls = 0;
+ for (int i = 0; i < n; i++) m1.get(new Constant(i));
+ Map m2 = new HashMap<>();
+ for (int i = 0; i < n; i++) m2.put(new Good(i), i);
+ Good.equalsCalls = 0;
+ for (int i = 0; i < n; i++) m2.get(new Good(i));
+ System.out.println("keys = " + n);
+ System.out.println("equals() calls for " + n + " gets, constant hash = " + Constant.equalsCalls);
+ System.out.println("equals() calls for " + n + " gets, good hash = " + Good.equalsCalls);
+ }
+}
diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MutableKeyDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MutableKeyDemo.java
new file mode 100644
index 0000000..a3cd51c
--- /dev/null
+++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/MutableKeyDemo.java
@@ -0,0 +1,46 @@
+package com.ankurm.equalshashcode;
+
+import java.util.*;
+
+/** Mutating a key after it went into a hash collection. Output: output/03-mutable-key.txt */
+public class MutableKeyDemo {
+
+ static final class Account {
+ String owner;
+ Account(String owner) { this.owner = owner; }
+ @Override public boolean equals(Object o) { return o instanceof Account a && owner.equals(a.owner); }
+ @Override public int hashCode() { return owner.hashCode(); }
+ @Override public String toString() { return "Account(" + owner + ")"; }
+ }
+
+ public static void main(String[] args) {
+ Account acc = new Account("asha");
+ Map balances = new HashMap<>();
+ Set set = new HashSet<>();
+ balances.put(acc, 100);
+ set.add(acc);
+ System.out.println("before: get = " + balances.get(acc) + ", contains = " + set.contains(acc));
+
+ acc.owner = "ankur"; // mutate the key while it is inside the collections
+
+ System.out.println("\nafter acc.owner = \"ankur\":");
+ System.out.println("map.get(acc) = " + balances.get(acc));
+ System.out.println("set.contains(acc) = " + set.contains(acc));
+ System.out.println("map.containsKey(acc) = " + balances.containsKey(acc));
+ System.out.println("map.size() = " + balances.size());
+ System.out.println("iteration still sees it = " + balances.keySet());
+ System.out.println("map.remove(acc) = " + balances.remove(acc));
+ System.out.println("map.size() after remove = " + balances.size());
+
+ // adding it again creates a second entry for the very same object
+ set.add(acc);
+ System.out.println("\nset.add(acc) again -> size = " + set.size() + " " + set + " (one object, two entries)");
+
+ // Strings are safe because they are immutable
+ Map safe = new HashMap<>();
+ String k = "asha";
+ safe.put(k, 1);
+ k = k + "!"; // new String; the key inside the map is untouched
+ System.out.println("\nimmutable key: map.get(\"asha\") = " + safe.get("asha"));
+ }
+}
diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/RecordsDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/RecordsDemo.java
new file mode 100644
index 0000000..541b474
--- /dev/null
+++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/RecordsDemo.java
@@ -0,0 +1,54 @@
+package com.ankurm.equalshashcode;
+
+import java.util.*;
+
+/** What a record generates and where it surprises. Output: output/05-records.txt */
+public class RecordsDemo {
+
+ record Book(String isbn, String title) {}
+ record WithArray(String name, int[] values) {}
+ record WithList(String name, List values) {}
+ record WithDouble(double d) {}
+ record Tagged(String isbn, String title) { // custom equals: identity is the ISBN only
+ @Override public boolean equals(Object o) { return o instanceof Tagged t && isbn.equals(t.isbn); }
+ @Override public int hashCode() { return isbn.hashCode(); }
+ }
+
+ public static void main(String[] args) {
+ System.out.println("== generated equals/hashCode ==");
+ Book a = new Book("1", "Effective Java"), b = new Book("1", "Effective Java");
+ System.out.println("a.equals(b) = " + a.equals(b));
+ System.out.println("a.hashCode() == b.hashCode() = " + (a.hashCode() == b.hashCode()));
+ System.out.println("new HashSet of both, size = " + new HashSet<>(List.of(a)).size());
+ System.out.println("a == b = " + (a == b));
+
+ System.out.println("\n== array component: reference equality ==");
+ WithArray w1 = new WithArray("x", new int[]{1, 2}), w2 = new WithArray("x", new int[]{1, 2});
+ System.out.println("w1.equals(w2) = " + w1.equals(w2));
+ System.out.println("same array shared: " + new WithArray("x", w1.values()).equals(w1));
+
+ System.out.println("\n== mutable List component: equals works, hashCode can change ==");
+ List list = new ArrayList<>(List.of(1, 2));
+ WithList wl = new WithList("x", list);
+ int before = wl.hashCode();
+ list.add(3);
+ System.out.println("hashCode before = " + before + ", after list.add(3) = " + wl.hashCode());
+ Set set = new HashSet<>();
+ List l2 = new ArrayList<>(List.of(1, 2));
+ WithList key = new WithList("y", l2);
+ set.add(key);
+ l2.add(3);
+ System.out.println("record in HashSet, list mutated -> contains = " + set.contains(key));
+
+ System.out.println("\n== doubles compare with Double.compare semantics ==");
+ System.out.println("NaN record equals NaN record = " + new WithDouble(Double.NaN).equals(new WithDouble(Double.NaN)));
+ System.out.println("Double.NaN == Double.NaN = " + (Double.NaN == Double.NaN));
+ System.out.println("0.0 record equals -0.0 record = " + new WithDouble(0.0).equals(new WithDouble(-0.0)));
+ System.out.println("0.0 == -0.0 = " + (0.0 == -0.0));
+
+ System.out.println("\n== overriding equals in a record ==");
+ Tagged t1 = new Tagged("1", "Effective Java"), t2 = new Tagged("1", "Effective Java 3e");
+ System.out.println("t1.equals(t2) = " + t1.equals(t2));
+ System.out.println("toString still shows both fields: " + t2);
+ }
+}
diff --git a/equals-hashcode/src/main/java/com/ankurm/equalshashcode/SymmetryDemo.java b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/SymmetryDemo.java
new file mode 100644
index 0000000..a2aedfa
--- /dev/null
+++ b/equals-hashcode/src/main/java/com/ankurm/equalshashcode/SymmetryDemo.java
@@ -0,0 +1,16 @@
+package com.ankurm.equalshashcode;
+
+import java.sql.Timestamp;
+import java.util.Date;
+
+/** The JDK itself breaks symmetry once. Output: output/04-jdk-symmetry.txt */
+public class SymmetryDemo {
+ public static void main(String[] args) {
+ long ms = 1_700_000_000_123L;
+ Date date = new Date(ms);
+ Timestamp ts = new Timestamp(ms);
+ System.out.println("date.equals(ts) = " + date.equals(ts));
+ System.out.println("ts.equals(date) = " + ts.equals(date));
+ System.out.println("date.getTime() == ts.getTime() = " + (date.getTime() == ts.getTime()));
+ }
+}
diff --git a/equals-hashcode/src/test/java/com/ankurm/equalshashcode/EqualsHashCodeTest.java b/equals-hashcode/src/test/java/com/ankurm/equalshashcode/EqualsHashCodeTest.java
new file mode 100644
index 0000000..347fa19
--- /dev/null
+++ b/equals-hashcode/src/test/java/com/ankurm/equalshashcode/EqualsHashCodeTest.java
@@ -0,0 +1,108 @@
+package com.ankurm.equalshashcode;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.sql.Timestamp;
+import java.util.*;
+import org.junit.jupiter.api.Test;
+
+/** Pins every claim the post makes as an assertion. */
+class EqualsHashCodeTest {
+
+ @Test void instanceofPlusExtraFieldBreaksSymmetry() {
+ var plain = new ContractDemo.Point(1, 2);
+ var red = new ContractDemo.ColorPoint(1, 2, "red");
+ assertTrue(plain.equals(red));
+ assertFalse(red.equals(plain));
+ }
+
+ @Test void lenientFixBreaksTransitivity() {
+ var plain = new ContractDemo.Point(1, 2);
+ var r = new ContractDemo.LenientColorPoint(1, 2, "red");
+ var b = new ContractDemo.LenientColorPoint(1, 2, "blue");
+ assertTrue(r.equals(plain));
+ assertTrue(plain.equals(b));
+ assertFalse(r.equals(b));
+ }
+
+ @Test void equalsWithoutHashCodeBreaksHashSet() {
+ var a = new MissingHashCodeDemo.Broken("x");
+ var b = new MissingHashCodeDemo.Broken("x");
+ assertTrue(a.equals(b));
+ Set set = new HashSet<>(List.of(a));
+ set.add(b);
+ assertEquals(2, set.size());
+ assertFalse(set.contains(new MissingHashCodeDemo.Broken("x")));
+ }
+
+ @Test void withHashCodeHashSetDedupes() {
+ Set set = new HashSet<>();
+ set.add(new MissingHashCodeDemo.Fixed("x"));
+ set.add(new MissingHashCodeDemo.Fixed("x"));
+ assertEquals(1, set.size());
+ }
+
+ @Test void constantHashCodeTurnsLookupsQuadratic() {
+ int n = 2000;
+ Map m = new HashMap<>();
+ for (int i = 0; i < n; i++) m.put(new MissingHashCodeDemo.Constant(i), i);
+ MissingHashCodeDemo.Constant.equalsCalls = 0;
+ for (int i = 0; i < n; i++) m.get(new MissingHashCodeDemo.Constant(i));
+ assertEquals(2_002_999L, MissingHashCodeDemo.Constant.equalsCalls);
+ }
+
+ @Test void mutatingAKeyLosesTheEntry() {
+ var acc = new MutableKeyDemo.Account("asha");
+ Map m = new HashMap<>();
+ m.put(acc, 100);
+ acc.owner = "ankur";
+ assertNull(m.get(acc));
+ assertEquals(1, m.size());
+ assertNull(m.remove(acc));
+ assertEquals(1, m.size());
+ }
+
+ @Test void timestampIsNotSymmetricWithDate() {
+ long ms = 1_700_000_000_123L;
+ assertTrue(new Date(ms).equals(new Timestamp(ms)));
+ assertFalse(new Timestamp(ms).equals(new Date(ms)));
+ }
+
+ @Test void recordsGenerateValueEquality() {
+ record Book(String isbn, String title) {}
+ assertEquals(new Book("1", "t"), new Book("1", "t"));
+ assertEquals(new Book("1", "t").hashCode(), new Book("1", "t").hashCode());
+ }
+
+ @Test void recordArrayComponentUsesReferenceEquality() {
+ record W(int[] v) {}
+ assertNotEquals(new W(new int[]{1}), new W(new int[]{1}));
+ }
+
+ @Test void recordDoubleUsesCompareSemantics() {
+ record D(double d) {}
+ assertEquals(new D(Double.NaN), new D(Double.NaN));
+ assertNotEquals(new D(0.0), new D(-0.0));
+ }
+
+ @Test void idBasedHashCodeChangesOnSave() {
+ var c = new EntityIdDemo.IdEntity("c");
+ Set s = new HashSet<>();
+ s.add(c);
+ c.save();
+ assertFalse(s.contains(c));
+ }
+
+ @Test void naturalKeySurvivesSave() {
+ var e = new EntityIdDemo.NaturalKeyEntity("a@example.com");
+ Set s = new HashSet<>();
+ s.add(e);
+ e.save();
+ assertTrue(s.contains(e));
+ }
+
+ @Test void objectsHashOfListMatchesListHashCode() {
+ assertEquals(List.of(1, 2, 3).hashCode(), Objects.hash(1, 2, 3));
+ assertEquals("Aa".hashCode(), "BB".hashCode());
+ }
+}
diff --git a/pom.xml b/pom.xml
index 11a8cf0..a1a7b17 100644
--- a/pom.xml
+++ b/pom.xml
@@ -37,6 +37,7 @@
reflection
jmh-basics
string-format
+ equals-hashcode