- JAVA
Java equals・hashCodeのオーバーライド方法|不具合を再現
Javaで equals をオーバーライドしたら、hashCode も必ずセットでオーバーライドします。「equals で等しい2つのオブジェクトは、同じ hashCode を返す」という約束(契約)があり、HashSet や HashMap はこの約束を前提に動いているからです。 片方だけだと、同じ内容のオブジェクトなのに contains が false を返したり、HashSet に重複が入ったりします。
この記事では、その現象を Java 21.0.10 で実際に再現した出力 で確認したうえで、手書き・Objects.equals/Objects.hash・IDE生成・record・Lombok の5つの書き方を比べます。さらに、HashSet に入れた後でフィールドを変えると見つからなくなる現象、継承したときの対称性の崩れ、getClass と instanceof の選び方まで、コピペで動くコードと出力つきで整理しました。
equals・hashCodeの早見表
| やりたいこと・疑問 | 結論 | 根拠(この記事の実行結果) |
|---|---|---|
| equals をオーバーライドした | hashCode も必ずオーバーライド | equals だけだと HashSet#contains が false |
| いちばん手軽な書き方(class) | Objects.equals と Objects.hash |
null も安全に扱える |
| 値を入れるだけのクラス | record にする(自動生成) |
同じ値なら HashSet に1件だけ |
| Lombok を使っている | @EqualsAndHashCode または @Value |
親クラスがあるときは callSuper を明示 |
| hashCode に入れるフィールド | equals で比べるフィールドと同じもの | 多くても少なくても約束がずれる |
| HashSet / HashMap のキーにする | 変更できない(final)フィールドで比べる | 入れた後に変えると contains が false |
| 比べる型の判定 | 子クラスと等しくしないなら getClass、final クラスなら instanceof |
instanceof 版は継承で対称性が崩れた |
| 同じ「もの」か(同一性)を知りたい | == |
== は参照、equals は中身 |
| 文字列の比較 | equals(== ではない) |
別記事「Javaの文字列比較」で解説 |
| 小数点(double/float)のフィールド | Double.compare / Double.hashCode |
== だと NaN や -0.0 で食い違う |
| 配列のフィールド | Arrays.equals / Arrays.hashCode |
Objects.equals は配列の参照を比べる |
| BigDecimal を TreeSet に入れる | compareTo と equals の違いに注意 |
1.0 と 1.00 で件数が変わった |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java equals・hashCodeのオーバーライド方法|不具合を再現」
実行環境
この記事のコードは、すべて次の環境で実行しました。各コードは1ファイルで完結しているので、java ファイル名.java で実行できます(コンパイル不要の単一ファイル実行)。
$ java -version
openjdk version "21.0.10" 2026-01-20
OpenJDK Runtime Environment (build 21.0.10+7-Ubuntu-124.04)
OpenJDK 64-Bit Server VM (build 21.0.10+7-Ubuntu-124.04, mixed mode, sharing)
# 日本語を含む出力を表示するときの実行コマンド
$ java -Dstdout.encoding=UTF-8 EqualsOnly.java
hashCode の数値(2025221430 など)は実行のたびに変わることがあります。 Object の hashCode は「同じ実行の中で一貫していればよい」と決められているためです。比べるべきは「同じか違うか」です。
equals と hashCode の契約をやさしく説明
equals と hashCode には、Java の公式ドキュメント(java.lang.Object)で決められた 「一般契約」 があります。契約といっても難しいものではなく、「同じかどうかの判定は、こうふるまってほしい」という約束です。
equals の5つの約束
| 約束 | 意味 | たとえ |
|---|---|---|
| 反射性 | x.equals(x) は true |
自分は自分と同じ |
| 対称性 | x.equals(y) が true なら y.equals(x) も true |
AさんがBさんと同じなら、BさんもAさんと同じ |
| 推移性 | x.equals(y) と y.equals(z) が true なら x.equals(z) も true |
A=B、B=C なら A=C |
| 一貫性 | 比べる情報が変わらない限り、何度呼んでも同じ結果 | 気分で答えが変わらない |
| null | x.equals(null) は false |
null とは等しくない(例外も出さない) |
hashCode の3つの約束
| 約束 | 意味 |
|---|---|
| 一貫性 | equals で使う情報が変わらない限り、同じオブジェクトは同じ値を返す(別の実行で同じ値である必要はない) |
| equals で等しいなら同じ値 | a.equals(b) が true なら a.hashCode() == b.hashCode() |
| 等しくないなら違う値が望ましい | 必須ではないが、違う値の方がハッシュ表が速くなる |
大事なのは2つ目です。 逆向き(hashCode が同じなら equals も true)は求められていません。hashCode が同じで中身が違う状態は「衝突」と呼ばれ、許されています。
Java 25 の API ドキュメント(Object)でも、この契約の記述は Java 21 と同じです(公式ドキュメントで確認)。
なぜ hashCode が必要なのか:HashSet の探し方
HashSet や HashMap は、要素を探すときに 最初に hashCode で「棚」を決め、その棚の中だけを equals で調べます。全件を equals で比べないので速いのです。
flowchart LR
A["set.contains(b)"] --> B["b.hashCode() を計算"]
B --> C["ハッシュ値から棚(バケット)を決める"]
C --> D{"棚の中に要素がある?"}
D -- ない --> E["false(equals は呼ばれない)"]
D -- ある --> F["棚の中の要素と equals で比べる"]
F --> G["一致すれば true"]
equals が true になる相手でも、hashCode が違えば別の棚を探すので、equals まで進まずに false になります。 これが「equals だけ書くと見つからない」理由です。
equals だけオーバーライドすると起きること(実行結果)
会員を表す Member クラスで、equals だけ をオーバーライドした場合を確認します。
import java.util.*;
public class EqualsOnly {
// equals だけオーバーライドしたクラス
static class Member {
private final String id;
private final String name;
Member(String id, String name) { this.id = id; this.name = name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member other)) return false;
return id.equals(other.id) && name.equals(other.name);
}
// hashCode はオーバーライドしていない
}
public static void main(String[] args) {
Member a = new Member("M001", "佐藤");
Member b = new Member("M001", "佐藤");
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("a.hashCode() = " + a.hashCode());
System.out.println("b.hashCode() = " + b.hashCode());
Set<Member> set = new HashSet<>();
set.add(a);
System.out.println("set.contains(b) = " + set.contains(b));
set.add(b);
System.out.println("set.size() = " + set.size());
Map<Member, Integer> points = new HashMap<>();
points.put(a, 100);
System.out.println("points.get(b) = " + points.get(b));
List<Member> list = new ArrayList<>(List.of(a));
System.out.println("list.contains(b) = " + list.contains(b));
}
}
実行結果(Java 21.0.10):
a.equals(b) = true
a.hashCode() = 2025221430
b.hashCode() = 1378084334
set.contains(b) = false
set.size() = 2
points.get(b) = null
list.contains(b) = true
ポイントは4つです。
a.equals(b)はtrueなのに、hashCodeが違う(Objectの既定の hashCode はオブジェクトごとに別の値を返すため)HashSet#contains(b)がfalse、add(b)すると 同じ内容の会員が2件 入るHashMap#get(b)がnull。同じ ID の会員でポイントを引けないArrayList#contains(b)はtrue。List は hashCode を使わず equals だけで全件を調べるため
最後の点が分かりにくさの原因です。List では正しく動くので、Set や Map に入れたときに初めて気づくことが多い現象です。
equals と hashCode を両方オーバーライドした場合(直した後)
hashCode を1メソッド足すだけで直ります。
// Both.java:EqualsOnly.java の Member に hashCode を足しただけ(main は同じ。List の行は省略)
@Override
public int hashCode() {
return Objects.hash(id, name); // equals で比べるフィールドと同じものを渡す
}
実行結果(Java 21.0.10):
a.equals(b) = true
a.hashCode() = 73253393
b.hashCode() = 73253393
set.contains(b) = true
set.size() = 1
points.get(b) = 100
hashCode がそろい、contains は true、重複は入らず、get で値が取れるようになりました。
比較表:equals だけ/hashCode だけ/両方
hashCode だけをオーバーライドした場合も試しました(HashOnly.java:id の hashCode を返すだけで equals は書かない)。
import java.util.*;
public class HashOnly {
static class Member {
private final String id;
Member(String id) { this.id = id; }
@Override public int hashCode() { return id.hashCode(); }
}
public static void main(String[] args) {
Member a = new Member("M001");
Member b = new Member("M001");
System.out.println("hashCode 同じ? " + (a.hashCode() == b.hashCode()));
System.out.println("a.equals(b) = " + a.equals(b));
Set<Member> set = new HashSet<>(List.of(a));
System.out.println("set.contains(b) = " + set.contains(b));
}
}
hashCode 同じ? true
a.equals(b) = false
set.contains(b) = false
同じ棚までは行けますが、equals が Object のまま(== と同じ判定)なので一致しません。3つをまとめると次のとおりです。
| オーバーライドしたもの | a.equals(b) |
HashSet#contains(b) |
HashSet に2回 add |
HashMap#get(b) |
ArrayList#contains(b) |
|---|---|---|---|---|---|
| なし | false | false | 2件 | null | false |
| equals だけ | true | false | 2件 | null | true |
| hashCode だけ | false | false | 2件 | null | false |
| 両方 | true | true | 1件 | 100 | true |
(4行とも Java 21.0.10 で実行した結果です。)
equals・hashCode の書き方一覧(5パターン)
| 書き方 | 向いている場面 | コード量 | 注意点 |
|---|---|---|---|
| 手書き(31 を掛ける) | 速さを細かく調整したい、外部依存なし | 多い | null の扱いを自分で書く |
Objects.equals / Objects.hash |
普通のクラス全般(迷ったらこれ) | 少ない | Objects.hash は呼ぶたびに配列を作る |
| IDE 生成 | フィールドが多いクラス | 0(自動) | フィールドを足したら作り直す |
record |
値を入れるだけの不変クラス | 0(自動) | 全フィールドが比較対象になる |
Lombok @EqualsAndHashCode |
Lombok を導入済みのプロジェクト | 0(自動) | 継承時は callSuper を明示 |
パターン1:手書き(31 を掛けて合成する)
static final class ItemA {
private final String code; // 備品コード
private final int qty; // 数量
ItemA(String code, int qty) { this.code = code; this.qty = qty; }
@Override public boolean equals(Object o) {
if (this == o) return true; // 同じ参照なら即 true
if (!(o instanceof ItemA)) return false; // 型が違う・null なら false
ItemA other = (ItemA) o;
return qty == other.qty
&& (code == null ? other.code == null : code.equals(other.code));
}
@Override public int hashCode() {
int result = (code == null ? 0 : code.hashCode());
result = 31 * result + Integer.hashCode(qty);
return result;
}
}
31 を掛けるのは、フィールドの並び順も値に反映させて衝突を減らすためです(31 は奇数の素数で、31 * i が (i << 5) - i に置き換えられ計算が軽い、という理由がよく挙げられます)。
パターン2:Objects.equals と Objects.hash(おすすめ)
static final class ItemB {
private final String code;
private final int qty;
ItemB(String code, int qty) { this.code = code; this.qty = qty; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ItemB other = (ItemB) o;
return qty == other.qty && Objects.equals(code, other.code);
}
@Override public int hashCode() {
return Objects.hash(code, qty);
}
}
Objects.equals(a, b) は、どちらかが null でも例外を出さずに比べられます。 Objects.hash(...) は渡した値をまとめて Arrays.hashCode で計算します。手書きより短く、読み間違いが起きにくい書き方です。
Java 16 以降なら、パターンマッチングの instanceof で型チェックとキャストを1行にできます。
@Override public boolean equals(Object o) {
return o instanceof ItemB other
&& qty == other.qty
&& Objects.equals(code, other.code);
}
パターン3:IDE が生成するコードの形
IntelliJ IDEA や Eclipse には、フィールドを選ぶだけで equals と hashCode を作る機能があります(IntelliJ:Code → Generate → equals() and hashCode()、Eclipse:Source → Generate hashCode() and equals())。生成されるコードは、おおむね次のような形です(IDE の種類・版・設定で細部は変わります)。
// IntelliJ IDEA のテンプレート「java.util.Objects.equals and hashCode」に近い形
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
ItemB itemB = (ItemB) o;
return qty == itemB.qty && Objects.equals(code, itemB.code);
}
@Override
public int hashCode() {
return Objects.hash(code, qty);
}
Eclipse の旧来の生成コードは、final int prime = 31; int result = 1; から始めて各フィールドを result = prime * result + ... で足し込み、equals は if (getClass() != obj.getClass()) return false; のあとにフィールドを1つずつ if 文で比べる形です(設定により Objects.hash を使う形にもなります)。
IDE 生成で気をつけたいのは、フィールドを追加したときに作り直すのを忘れやすいことです。生成したコードは普通のコードとして残るため、追加したフィールドは自動では比較対象になりません。
パターン4:record(自動生成を実行で確認)
Java 16 で正式になった record は、全フィールドを使った equals・hashCode・toString を自動で作ります。
import java.util.*;
public class Styles {
record ItemC(String code, int qty) {}
public static void main(String[] args) {
System.out.println("record : " + new ItemC("PC-01", 3).equals(new ItemC("PC-01", 3))
+ " / hash=" + new ItemC("PC-01", 3).hashCode());
System.out.println("record toString: " + new ItemC("PC-01", 3));
System.out.println("null を含む : " + new ItemC(null, 3).equals(new ItemC(null, 3)));
Set<ItemC> set = new HashSet<>();
set.add(new ItemC("PC-01", 3));
set.add(new ItemC("PC-01", 3));
System.out.println("record を HashSet に2回 add → size=" + set.size());
}
}
実際には手書き版(ItemA)・Objects 版(ItemB)・record 版(ItemC)を同じファイルに並べて実行しました。実行結果(Java 21.0.10):
手書き : true / hash=-1941371064
Objects.hash : true / hash=-1941370103
record : true / hash=-1941371064
record toString: ItemC[code=PC-01, qty=3]
null を含む : true / true
record を HashSet に2回 add → size=1
3つとも同じ内容なら true、record は HashSet に1件だけ入りました。 ハッシュ値は、この環境では record と手書き版が同じ値になり、Objects.hash 版は少し違う値です(Objects.hash は初期値 1 から始めるため)。record の hashCode の具体的な計算方法は仕様で決められていないので、値そのものに頼るコードは書かないようにします。
record のメソッドが本当に自動生成されているかは、javap で確認できます。
$ cat Item.java
public record Item(String code, int qty) {}
$ javac Item.java
$ javap -p Item.class
Compiled from "Item.java"
public final class Item extends java.lang.Record {
private final java.lang.String code;
private final int qty;
public Item(java.lang.String, int);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public java.lang.String code();
public int qty();
}
javap -c -p で equals の中身を見ると、処理は invokedynamic で java.lang.runtime.ObjectMethods.bootstrap に任されていました(実行時に効率のよい比較処理が組み立てられます)。
public final boolean equals(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: invokedynamic #25, 0 // InvokeDynamic #0:equals:(LItem;Ljava/lang/Object;)Z
7: ireturn
record の基本(コンパクトコンストラクタ、配列を持つときの注意など)は、別記事「Java recordの使い方|classとの違いと実行結果で学ぶ」で詳しく扱っています。
パターン5:Lombok の @EqualsAndHashCode(公式ドキュメントで確認)
この検証環境では Maven Central から Lombok の jar を取得できないため、Lombok は実行しておらず、以下は公式ドキュメント(projectlombok.org の @EqualsAndHashCode のページ)に沿った説明です。
import lombok.EqualsAndHashCode;
@EqualsAndHashCode
public class Item {
private final String code;
private final int qty;
private transient String memo; // transient は対象外
// コンストラクタ等は省略
}
| 項目 | 公式ドキュメントの仕様 |
|---|---|
| 対象フィールド | static でも transient でもない全フィールド |
| 除外・限定 | @EqualsAndHashCode.Exclude で除外、onlyExplicitlyIncluded = true と @EqualsAndHashCode.Include で指定したものだけ |
| 親クラスの扱い | callSuper を指定しないと親クラスの equals は呼ばれず、親クラスがあれば警告が出る。親のフィールドも比べるなら callSuper = true |
canEqual メソッド |
子クラスとの比較で対称性を保つために生成(final で親が Object のクラスでは生成されない) |
| 配列・NaN | 配列は中身で比べる(deep)、浮動小数点の NaN は等しいとして扱う |
| 既に equals/hashCode がある | 警告を出して生成しない |
cacheStrategy |
hashCode をキャッシュする設定(変更できるオブジェクトには使わない) |
| lombok.config | lombok.equalsAndHashCode.callSuper = [call | skip | warn] など |
@Data と @Value も、内部で @EqualsAndHashCode を含みます。 @Data は setter も作るため、HashSet に入れた後に値を変えられる点に注意してください(次の章で再現します)。Lombok 全体の使い分けは別記事「Lombokアノテーション一覧|展開後コードと使い分け早見表」にまとめています。
可変フィールドを hashCode に含めると見つからなくなる(再現)
hashCode に使うフィールドを、HashSet に入れた後で変更すると、その要素は二度と見つからなくなります。 入れたときの hashCode で決まった棚に置かれたまま、探すときは新しい hashCode で別の棚を探すからです。
import java.util.*;
public class Mutable {
static final class Task {
private String title; // 変更できるフィールド
Task(String title) { this.title = title; }
void setTitle(String title) { this.title = title; }
@Override public boolean equals(Object o) {
return o instanceof Task t && Objects.equals(title, t.title);
}
@Override public int hashCode() { return Objects.hash(title); }
@Override public String toString() { return "Task[" + title + "]"; }
}
public static void main(String[] args) {
Set<Task> tasks = new HashSet<>();
Task t = new Task("資料作成");
tasks.add(t);
System.out.println("変更前 contains(t) = " + tasks.contains(t));
t.setTitle("資料作成(改訂)"); // HashSet に入れた後に変更
System.out.println("変更後 contains(t) = " + tasks.contains(t));
System.out.println("中身 = " + tasks);
System.out.println("remove(t) = " + tasks.remove(t));
System.out.println("size = " + tasks.size());
boolean found = false;
for (Task x : tasks) if (x.equals(t)) found = true;
System.out.println("for で1件ずつ探すと = " + found);
tasks.add(t);
System.out.println("もう一度 add → size = " + tasks.size() + " " + tasks);
}
}
実行結果(Java 21.0.10):
変更前 contains(t) = true
変更後 contains(t) = false
中身 = [Task[資料作成(改訂)]]
remove(t) = false
size = 1
for で1件ずつ探すと = true
もう一度 add → size = 2 [Task[資料作成(改訂)], Task[資料作成(改訂)]]
同じオブジェクト t なのに、contains も remove も false になりました。 中身を表示すると確かに入っており、for 文で1件ずつ調べれば見つかります。さらに add すると 同じオブジェクトが2件 入りました。
| 状況 | 結果 |
|---|---|
| 入れた直後 | contains → true |
| hashCode に使うフィールドを変更 | contains → false、remove → false |
もう一度 add |
同じオブジェクトが2件に |
直し方
| 方法 | 内容 |
|---|---|
| 比較に使うフィールドを final にする | 値を変えたいときは新しいオブジェクトを作る(record が向いている) |
| 変わらない ID だけで比べる | id のように作成後に変わらない値だけを equals・hashCode に使う |
| 変更するなら一度取り出す | remove → 変更 → add の順にする |
| キーにしない | 変更が多いオブジェクトは値側に置き、キーには ID を使う(Map<String, Task>) |
HashMap のキーでも同じことが起きます。HashSet の要素・HashMap のキーには、変更できない値を使うと覚えておくと安心です。
継承すると対称性が崩れる:getClass と instanceof の違い
親クラスで equals を書き、子クラスでフィールドを足して equals を上書きすると、p.equals(cp) と cp.equals(p) の結果が食い違うことがあります。
import java.util.*;
public class Inherit {
// 親:座標(instanceof で判定)
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 (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
// 子:色つき座標(equals を上書き)
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 cp)) return false;
return super.equals(cp) && color.equals(cp.color);
}
@Override public int hashCode() { return Objects.hash(x, y, color); }
}
// getClass で判定する版
static class GPoint {
final int x, y;
GPoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
GPoint p = (GPoint) o;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
static class GColorPoint extends GPoint {
final String color;
GColorPoint(int x, int y, String color) { super(x, y); this.color = color; }
@Override public boolean equals(Object o) {
if (!super.equals(o)) return false;
return color.equals(((GColorPoint) o).color);
}
@Override public int hashCode() { return Objects.hash(x, y, color); }
}
// 子クラスで equals を上書きしない場合
static class LabeledGPoint extends GPoint {
LabeledGPoint(int x, int y) { super(x, y); }
}
public static void main(String[] args) {
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
System.out.println("[instanceof 版]");
System.out.println("p.equals(cp) = " + p.equals(cp));
System.out.println("cp.equals(p) = " + cp.equals(p));
GPoint gp = new GPoint(1, 2);
GColorPoint gcp = new GColorPoint(1, 2, "red");
System.out.println("[getClass 版]");
System.out.println("gp.equals(gcp) = " + gp.equals(gcp));
System.out.println("gcp.equals(gp) = " + gcp.equals(gp));
System.out.println("gp.equals(new LabeledGPoint(1,2)) = " + gp.equals(new LabeledGPoint(1, 2)));
System.out.println("p.equals(匿名サブクラス) = " + p.equals(new Point(1, 2) {}));
}
}
実行結果(Java 21.0.10):
[instanceof 版]
p.equals(cp) = true
cp.equals(p) = false
[getClass 版]
gp.equals(gcp) = false
gcp.equals(gp) = false
gp.equals(new LabeledGPoint(1,2)) = false
p.equals(匿名サブクラス) = true
instanceof 版では p.equals(cp) が true、cp.equals(p) が false になり、対称性の約束が崩れました。 親から見ると「座標が同じなら同じ」、子から見ると「相手が ColorPoint でないので違う」と判定しているためです。この状態で HashSet や List#contains を使うと、どちらを基準に呼ぶかで結果が変わります。
getClass 版は、クラスが完全に一致しないと false なので対称性は保たれます。その代わり、フィールドを足していない子クラス(LabeledGPoint)や匿名サブクラスとも等しくなりません。
どちらを選ぶかの判断表
| クラスの状況 | おすすめ | 理由 |
|---|---|---|
final クラス/record |
instanceof(パターンマッチング) |
子クラスがないので対称性の問題が起きない |
| 継承される前提で、子クラスとは等しくしない | getClass() != o.getClass() |
対称性が保たれる |
| 継承される前提で、子クラスとも等しくしたい | 親で equals を final にし、子は上書きしない |
比べるフィールドが親で固定される |
| 子でフィールドを足して比べたい | 継承ではなく 合成(ColorPoint が Point をフィールドに持つ) |
公式の契約を守ったまま値を増やせる |
| Hibernate などのプロキシ(自動生成の子クラス)を使う | instanceof、または ID で比べる |
getClass だとプロキシと一致しない |
迷ったら「クラスを final にして instanceof」か「record」にすると、継承の問題そのものが起きません。
== と equals の違い
== は 同じオブジェクト(同じ参照)か を比べ、equals は 中身が同じか を比べます(中身の決め方を書くのが equals のオーバーライドです)。
import java.util.*;
public class Identity {
record Item(String code) {}
public static void main(String[] args) {
Item a = new Item("PC-01");
Item b = new Item("PC-01");
System.out.println("a == b = " + (a == b));
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("a == a = " + (a == a));
// IdentityHashMap は == で比べる
Map<Item, String> normal = new HashMap<>();
Map<Item, String> identity = new IdentityHashMap<>();
normal.put(a, "x"); normal.put(b, "y");
identity.put(a, "x"); identity.put(b, "y");
System.out.println("HashMap size = " + normal.size());
System.out.println("IdentityHashMap size = " + identity.size());
System.out.println("a.equals(null) = " + a.equals(null));
System.out.println("identityHashCode 同じ? "
+ (System.identityHashCode(a) == System.identityHashCode(b)));
}
}
実行結果(Java 21.0.10):
a == b = false
a.equals(b) = true
a == a = true
HashMap size = 1
IdentityHashMap size = 2
a.equals(null) = false
identityHashCode 同じ? false
| 比べ方 | 何を比べるか | 使うコレクション |
|---|---|---|
== |
同じオブジェクトか(参照) | IdentityHashMap |
equals |
中身が同じか(自分で定義) | HashMap、HashSet、List#contains |
hashCode |
equals の前の「棚分け」用 | HashMap、HashSet |
System.identityHashCode |
オーバーライド前の Object の hashCode |
デバッグなど |
compareTo |
大小・順序 | TreeMap、TreeSet |
String の == と equals の違い(文字列プールで == がたまたま true になる理由など)は、別記事「Javaの文字列比較|equalsと==の違いと正しい比べ方」で詳しく扱っています。
TreeSet は equals ではなく compareTo を使う
HashSet と TreeSet で「同じ」の基準が違う例として、BigDecimal を見てみます。
import java.util.*;
import java.math.BigDecimal;
public class TreeSetCase {
public static void main(String[] args) {
BigDecimal x = new BigDecimal("1.0");
BigDecimal y = new BigDecimal("1.00");
System.out.println("equals = " + x.equals(y));
System.out.println("compareTo = " + x.compareTo(y));
System.out.println("HashSet size = " + new HashSet<>(List.of(x, y)).size());
System.out.println("TreeSet size = " + new TreeSet<>(List.of(x, y)).size());
}
}
equals = false
compareTo = 0
HashSet size = 2
TreeSet size = 1
BigDecimal の equals は桁数(スケール)まで比べるため 1.0 と 1.00 は別物、compareTo は数値として同じと判定します。 自分のクラスで Comparable を実装するときは、compareTo が 0 になる組み合わせと equals が true になる組み合わせをそろえておくと、HashSet と TreeSet で件数が変わりません。
フィールドの型ごとの書き方
| フィールドの型 | equals での比べ方 | hashCode での計算 |
|---|---|---|
int・long・char など |
== |
Integer.hashCode(v)、Long.hashCode(v) |
boolean |
== |
Boolean.hashCode(v) |
double・float |
Double.compare(a, b) == 0 |
Double.hashCode(v) |
| 参照型(null なし) | a.equals(b) |
a.hashCode() |
| 参照型(null あり) | Objects.equals(a, b) |
Objects.hashCode(a) |
| 配列 | Arrays.equals(a, b)(入れ子は Arrays.deepEquals) |
Arrays.hashCode(a) |
List・Set・Map |
Objects.equals(中身で比べる) |
Objects.hashCode |
double を == で比べると、NaN == NaN が false、0.0 == -0.0 が true になり、Double.equals と結果が食い違います。 Double.compare を使うとこのずれがありません。また、配列のフィールドを Objects.equals で比べると参照の比較になるので、Arrays.equals を使います(record でも配列のフィールドは参照で比べられます)。
equals・hashCode を書くときのチェックリスト
| チェック | 確認すること |
|---|---|
| ☐ | equals と hashCode を 両方 オーバーライドした |
| ☐ | hashCode に使うフィールドは、equals で比べるフィールドと同じ(少ないのは可、多いのは不可) |
| ☐ | 引数の型は Object(equals(Item o) と書くとオーバーロードになり、上書きにならない) |
| ☐ | @Override を付けた(型を間違えたときにコンパイルエラーで気づける) |
| ☐ | null を渡すと false を返す(例外を出さない) |
| ☐ | 比較に使うフィールドは final(変更できない) |
| ☐ | 継承するなら getClass、しないならクラスを final にして instanceof |
| ☐ | double は Double.compare、配列は Arrays.equals |
| ☐ | toString も合わせて実装した(デバッグ時に中身が見える) |
よくある書き間違い
1. 引数の型を自分のクラスにしてしまう
// 直す前:equals(Object) の上書きではなく、別メソッド(オーバーロード)になる
public boolean equals(Item other) { ... }
// 直した後:引数は Object、@Override を付ける
@Override
public boolean equals(Object o) { ... }
直す前の形に @Override を付けると、コンパイル時に次のエラーが出るので、すぐ気づけます(Java 21.0.10、英語ロケールでの出力)。
WrongSig.java:4: error: method does not override or implement a method from a supertype
@Override
^
1 error
error: compilation failed
2. hashCode で equals にないフィールドを使う
// equals は id だけで比べているのに、hashCode に name も入れている
@Override public int hashCode() { return Objects.hash(id, name); }
id が同じで name が違う2つは equals が true なのに hashCode が違う、という状態になり、契約に反します。hashCode のフィールドは、equals で使うフィールドと同じか、その一部にします。
3. hashCode が常に同じ値を返す
return 42; のように固定値を返すと、契約には違反しませんが、全要素が同じ棚に集まり、HashMap の検索が全件比較に近くなります。hashCode は「なるべくばらけさせる」のが速さのコツです。
よくある質問
Q. equals をオーバーライドしないなら、hashCode もオーバーライド不要ですか?
A. はい、不要です。Object の既定の equals(== と同じ)と既定の hashCode は、最初から契約を満たしています。equals を自分で定義したときだけ、hashCode もセットで書きます。
Q. Objects.hash は遅いと聞きました。手書きにすべきですか?
A. Objects.hash は可変長引数のため、呼ぶたびに小さな配列を作ります。多くの用途では気にならない差なので、まず Objects.hash で書き、プロファイラで hashCode が目立つと分かったときに手書きへ切り替えるのがおすすめです。record を使えば、生成される処理に任せられます。
Q. record と Lombok の @Value はどちらを使えばよいですか?
A. 値を入れるだけの不変クラスなら、言語標準の record が第一候補です。継承が必要、ビルダーが欲しい、比較対象のフィールドを絞りたい(@EqualsAndHashCode.Exclude)といった場合は Lombok が便利です。
Q. JPA のエンティティでは、どのフィールドで equals を書けばよいですか?
A. 保存前は ID が null のことがあるため、ID だけで比べる方法と、作成時に決まる業務上の一意キー(会員番号など)で比べる方法がよく使われます。プロキシの子クラスと比べることがあるので、型の判定は instanceof にするのが一般的です。Lombok の @Data をエンティティに付けると全フィールドが比較対象になるため、@EqualsAndHashCode(onlyExplicitlyIncluded = true) などで範囲を決めます。
まとめ
- equals をオーバーライドしたら hashCode も必ずオーバーライドする(等しいなら同じハッシュ値、が契約)
- equals だけだと
HashSet#containsが false、重複が入り、HashMap#getが null になることを Java 21.0.10 で確認した - 書き方は
Objects.equals+Objects.hashが基本。値だけのクラスは record で自動生成、Lombok なら@EqualsAndHashCode - HashSet の要素・HashMap のキーに使うフィールドは final に。入れた後に変えると
containsもremoveも false になった - 継承するなら getClass、しないなら final クラス + instanceof。instanceof のまま子クラスで上書きすると対称性が崩れた
==は同一性、equals は中身。TreeSet は equals ではなく compareTo で判定する
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java equals・hashCodeのオーバーライド方法|不具合を再現」
株式会社RJCでは、equals と hashCode のようなJavaの基本を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。