読者です 読者をやめる 読者になる 読者になる

この日記は私的なものであり所属会社の見解とは無関係です。 GitHub: takahashikzn

hashCodeとHashMapの危険な関係

Object#hashCodeは、インスタンスをHashSetとかHashMapに格納するときに重要な役割を担うメソッドです。

いわゆる『値クラス』を実装する際に、equalsなどと一緒に実装することが多いでしょう。


しかし。。。その『値クラス』をHashMapのキーとして使うとき、hashCodeの実装は相当慎重に行う必要があるのです。

例えば、

こんな、ごく単純なクラスを考えてみます。

public class DualIntKey {

    private int val1;
    private int val2;

    public DualIntKey(int val1, int val2) {
        this.val1 = val1;
        this.val2 = val2;
    }

    public int getVal1() { return val1; }
    public int getVal2() { return val2; }
    
    public void setVal1(int val1) { this.val1 = val1; }
    public void setVal2(int val2) { this.val1 = val2; }

    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        else if (!(o instanceof DualIntKey)) return false;

        final DualIntKey that = (DualIntKey) o;

        return (this.val1 == that.val1) && (this.val2 == that.val2);
    }

    @Override
    public int hashCode() {
        return this.getClass().hashCode() ^ val1 ^ val2 ^ 47;
    }
}

intを2つ持つだけの、単純な値クラスです。

で、これをHashMapのキーとして使ってみます。

public static void main(String... args) {

    final Map<DualIntKey, String> map = new HashMap<DualIntKey, String>();

    map.put(new DualIntKey(1, 2), "1, 2");

    System.out.println(map.get(new DualIntKey(1, 2)));
}

で、このコードの実行結果は当然ながら

1, 2

です。

では、

このようにすると、どうなると思いますか?

public static void main(String... args) {

    final Map<DualIntKey, String> map = new HashMap<DualIntKey, String>();

    final DualIntKey key_1_2 = new DualIntKey(1, 2);

    map.put(key_1_2, "1, 2");

    // キーの値を変える
    key_1_2.setVal2(20);

    // キーの値を変えたのだから、変更後の値を持つキーでMapを索引する。。。?
    System.out.println(map.get(new DualIntKey(1, 20)));
}


答えは、

null

です。等価なキーを指定しているのに、索引出来なくなるという事態を引き起こします。


これは恐ろしいですよ。。。デバッガでオブジェクトの状態を監視しても、一見したところ全てが正しい状態に見えるんですから。

しかも当然ながら、

public static void main(String... args) {

    final Map<DualIntKey, String> map = new HashMap<DualIntKey, String>();

    final DualIntKey key_1_2 = new DualIntKey(1, 2);

    map.put(key_1_2, "1, 2");

    // キーの値を変える
    key_1_2.setVal2(20);

    final DualIntKey key_1_20 = new DualIntKey(1, 20);

    // 当然ながら、key_1_2とkey_1_20はこの時点で完全に等価。
    System.out.println(key_1_2.hashCode() == key_1_20.hashCode());
    System.out.println(key_1_2.equals(key_1_20));

    // ↓等価なキーで索引しているのに引けない!!
    System.out.println(map.get(new DualIntKey(1, 20))); // nullを表示
}


なんてことに。それどころか、

public static void main(String... args) {

    final Map<DualIntKey, String> map = new HashMap<DualIntKey, String>();

    final DualIntKey key_1_2 = new DualIntKey(1, 2);

    map.put(key_1_2, "1, 2");

    // キーの値を変える
    key_1_2.setVal2(20);

    // ↓まさに等値のインスタンスで索引しているのに引けない!!
    System.out.println(map.get(key_1_2)); // nullを表示
}

などという、一体何が起こっているのかさっぱりワケワカラン事態に突入してしまうという。


まぁ、上記のコードは問題を単純化したものなので、ここだけ見ればわかるかもしれませんが、

  • もし『キーの値を変える』処理が全然関係ない場所で実行されていたら。。。
  • しかもそれがマルチスレッドで不確定的に実行されていたら。。。


を想像してみてください。しかも一刻一秒を争うようなトラブルシューティングの現場でのそれを。


ちなみに、HashMapにはエントリ数が閾値を超えたら内部ハッシュテーブルの再構築が走るので、
そうなったら、再び索引可能に『なってしまう』というオマケも付いて来る。


。。。僕、原因に辿りつける自信がないです。コワイヨコワイヨー。

何故こんなことが起きるのか?

を説明しようと思ったのですがメンドクサイのでやめました。
『HashMap バケット』でググッて下さい。

理論的な背景についてはこちらを。

この問題を回避する方法としては

Effective Javaにも載っていることですが、

Effective Java 第2版 (The Java Series)

Effective Java 第2版 (The Java Series)



Mapのキーになるような役割を持つクラスは大抵、『値クラス』になるはず。
そういうヤツらは『不変クラス』にするのが正解です。

インスタンス生成時にすべての状態が確定するのでhashCodeの値も不変。万事OK!というわけです。