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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?