Yige

Yige

Build

ハッシュマップ

HashMap#

一、基礎概念#

JDK8 以降のバージョンでは、HashMap は赤黒木構造を導入し、その基盤となるデータ構造は配列+連結リストから配列+連結リストと配列+赤黒木に変わりました。

HashMap 全体の構造:
image.png

JDK1.8 以降の HashMap の赤黒木構造
image.png

赤黒木の変換#

参考: https://juejin.im/entry/5839ad0661ff4b007ec7cc7a
いくつかのパラメータ:

// バケットの木化閾値。バケット内の要素数がこの値を超えると、赤黒木ノードに置き換える必要があります。
static final int TREEIFY_THRESHOLD = 8;
// 木の連結リスト復元閾値。拡張時、バケット内の要素数がこの値未満になると、木構造のバケット要素を連結リスト構造に戻します。
static final int UNTREEIFY_THRESHOLD = 6;
// ハッシュテーブルの最小木化容量。ハッシュテーブルの容量がこの値を超えると、テーブル内のバケットは木化できます。そうでない場合、バケット内の要素が多すぎると拡張され、木化されません。
// 拡張と木化の選択の衝突を避けるため、この値は4 * TREEIFY_THRESHOLD未満であってはなりません。
static final int MIN_TREEIFY_CAPACITY = 64;

要素を追加する際、デフォルトではバケット内の連結リストの数が 7 を超えると、連結リストは赤黒木に変換しようとします:


// HashMapの容量が64未満の場合、赤黒木に直接変換するのではなく、拡張を選択します。
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
            resize();
}
// TREEIFY_THRESHOLDはデフォルトで8です。
if (binCount >= TREEIFY_THRESHOLD - 1) {
    treeifyBin(tab, hash);
}

resize メソッド () を呼び出して拡張する際、現在のバケット内の要素構造が赤黒木であり、要素数が連結リスト復元閾値 UNTREEIFY_THRESHOLD(デフォルトは 6)未満である場合、split () メソッドを呼び出してバケット内の木構造を縮小または直接連結リスト構造に戻します:

if (lc <= UNTREEIFY_THRESHOLD) {
    tab[index] = loHead.untreeify(map);
}

二、HashMap のデフォルトパラメータの理解#

なぜ HashMap の Entry 配列の長さはデフォルトで 16 なのか?なぜ配列の長さは必ず 2 の n 乗でなければならないのか?#

まず、HashMap が hashcode を計算する方法を見て、格納位置を取得する方法:

static int indexFor(int h, int length) {
    return h & (length-1);
}

長さ 16 または他の 2 の幂の場合、length - 1 の値はすべての二進数ビットが 1 であるため、この場合、index の結果は hashcode の後の数ビットの値と等しくなります。入力された hashcode 自体が均等に分布している限り、hash アルゴリズムの結果は均等になります。
したがって:HashMap のデフォルトの長さが 16 であり、配列の長さが 2 の幂であることは、hash 衝突の確率を低下させるためです。

HashMap の拡張制限の負荷係数はなぜ 0.75 なのか?#

HashMap の拡張方法は、新しい配列を以前の配列の 2 倍の長さで新しく作成し、現在の Entry 配列の要素をすべて移動させることによって行われます。拡張後の新しい配列の長さは以前の 2 倍であるため、拡張は相対的にリソースを消費する操作です。
拡張のトリガー条件は:臨界値 = 配列のデフォルトの長さ x 負荷係数

  • 負荷係数が 0.5 またはそれ以下の場合、最終的に得られる一時的な臨界値は明らかに非常に小さくなり、このような状況では割り当てられたメモリが無駄になり、余分な無駄なメモリスペースが存在し、ハッシュテーブルの均等分布の状況を満たさなくなります。
  • 負荷係数が 1 に達する場合、つまり Entry 配列が満杯になったときにのみ拡張が発生します。このような場合、大量の hash 衝突が発生し、連結リストが長すぎて get クエリデータの効率に影響を与えます。
  • したがって、0.5〜1 の折衷数、つまり 0.75 を選択し、上記の状況を均衡的に解決します。

三、HashMap の基本操作#

get 操作#

/**
 * hash(key)メソッドを使用してkeyのハッシュ値を計算します。
 * getNode()メソッドを使用してnodeを取得します。
 * nodeがnullの場合はnullを返し、そうでない場合はnode.valueを返します。
 */
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
 * getNodeは以下のステップに分かれます:
 * 1. ハッシュテーブルが空であるか、keyに対応するバケットが空である場合、nullを返します。
 * 2. 空でない場合、バケット内の最初のノードが指定されたパラメータhashとkeyに一致する場合、このノードを直接返します。
 * 3. 最初のノードが一致しない場合、後続の検索を行います:
 *  3.1 現在のバケットが赤黒木を使用している場合、赤黒木のgetTreeNodeを呼び出してノード値を取得します。
 *  3.2 連結リスト構造の場合、連結リストを直接走査して検索します。
 * 4. ノードが見つかった場合はvalueを返し、そうでない場合はnullを返します。
 */
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // ハッシュテーブルとkeyに対応するバケットが空でない場合
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 最適化ポイント 1: 不要なクエリを減らす
        // バケット内の最初のノードが指定されたパラメータhashとkeyに一致する場合、このノードを直接返します。
        if (first.hash == hash && // 常に最初のノードをチェック
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 最初のノードが一致しない場合、後続の検索を行います。
        if ((e = first.next) != null) {
            // 現在のバケットが赤黒木を使用している場合、赤黒木のgetTreeNodeを呼び出してノード値を取得します。
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 連結リスト構造の場合、連結リストを直接走査して検索します。
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

put 操作#

image.png

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

/**
 * putValメソッドは以下のステップに分かれます
 * 1. ハッシュテーブルが空である場合、resizeメソッドを呼び出して作成します。
 * 2. 指定されたkey値が衝突しない場合、すなわちハッシュ衝突がない場合、直接キーと値のペアを挿入します。
 * 3. 衝突がある場合、この衝突ノードを見つけます。
 *  3.1 最初のノードが一致した場合、記録します。そうでない場合は、さらに検索を続けます。
 *  3.2 現在が赤黒木構造の場合、putTreeValメソッドを呼び出して挿入します。
 *  3.3 連結リスト構造の場合、連結リストをループして検索し、最後まで見つからなかった場合は尾挿入法を使用してノードを挿入し、見つかった場合はこのノードを記録してループを終了します。
 * 4. 衝突ノードが見つかった場合、onlyIfAbsentがtrueであるかどうかを判断します。これは古い値を変更することを許可するかどうかを示します。
 * 5. HashMapの変更回数modCountと容量サイズsizeをインクリメントし、sizeがthresholdを超えた場合、臨界値に達したことを示し、拡張を行います。
 *
 * @param hash 指定されたkeyのhash値
 * @param key 指定されたkey
 * @param value 挿入する必要があるvalue値
 * @param e, 古い値の置き換えを許可しないことを示します。
 * @param evict HashMapが作成モードにあるかどうかを示します。
 * @return 指定されたkeyが存在する場合、古い値が置き換えられた場合はこの値を返し、そうでない場合はnullを返します。もちろんvalue値がnullである可能性もあります。
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // ハッシュテーブルが空である場合、resizeメソッドを呼び出して作成します。
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 指定されたkey値が衝突しない場合、すなわちハッシュ衝突がない場合、直接キーと値のペアを挿入します。
    // (n - 1) & hashはhash % nと等価ですが、ビット演算の方が速いです。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 衝突がある場合、この衝突ノードを見つけます。
    else {
        Node<K,V> e; K k;
        // 最初のノードが一致した場合、記録します。そうでない場合は、さらに検索を続けます。
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 現在が赤黒木構造の場合、putTreeValメソッドを呼び出して挿入します。
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 連結リスト構造の場合、連結リストをループして検索し、最後まで見つからなかった場合は尾挿入法を使用してノードを挿入します。
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 連結リストの長さが臨界値に達しているかどうかを確認し、達している場合は赤黒木構造に変換します。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1は最初のため
                        treeifyBin(tab, hash);
                    break;
                }
                // 見つかった場合はこのノードを記録し、ループを終了します。
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 値がnullでない場合、衝突ノードが見つかったことを示します。
        if (e != null) { // 既存のキーのマッピング
            V oldValue = e.value;
            // onlyIfAbsentがtrueであるかどうかを判断します。これは古い値を変更することを許可するかどうかを示します。
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // sizeが臨界値に達したかどうかを判断し、拡張が必要です。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

拡張操作#

/**
 * tableを初期化または拡張します。拡張する場合、毎回容量が2倍になります。二進数計算の後、元のノードは元の位置にいるか、または「元の位置 + 旧容量」に割り当てられます。
 * resizeの手順の要約:
 * 1. 拡張後の容量と臨界値を計算します。
 * 2. HashMapの臨界値を更新します。
 * 3. 計算された拡張容量に基づいて新しい配列を作成し、HashMapのtable参照を新しい配列に指し示します。
 * 4. 古い配列の要素をtableにコピーします。
 */
final Node<K,V>[] resize() {
    // 新しいoldtab配列を作成して拡張前の配列tableを保存します。
    Node<K,V>[] oldTab = table;
    // 古い配列の長さを取得します。
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 古い配列の臨界値
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 古い配列の容量が0より大きい場合
    if (oldCap > 0) {
        // 古い配列の容量が最大値を超える場合、臨界値を整数の最大値に設定します。
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 古い配列の容量がデフォルト容量(16)を超え、倍増後の新しい配列の容量が最大値未満の場合、古い配列の臨界値も倍増します。
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 臨界値を倍増
    }
    // 古い容量 <= 0 かつ 臨界値 > 0 の場合
    else if (oldThr > 0) // 初期容量は閾値に置かれました
        // 新しい配列の容量を古い配列の臨界値に設定します。
        newCap = oldThr;
    // 古い容量 <= 0 かつ 臨界値 <= 0 の場合
    else {
        // 新しい容量をデフォルト値(16)に設定します。
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 新しい臨界値をデフォルト容量 * デフォルト負荷係数(16 * 0.75)= 12に設定します。
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 上記の条件判断の中で、oldThr > 0の場合のみnewThr == 0です。
    if (newThr == 0) {
        // 合法的な判断を行い、新しい臨界値を設定します。
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 新しい配列に古い配列の内容をコピーする操作を行います。
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 古いハッシュテーブルの各ノードを走査して配列にコピーします。
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            // eに古いノードの値を記録します。
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 古いバケットのストレージ衝突hash値の構造が赤黒木の場合
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 古いバケットのストレージ衝突hash値の構造が連結リスト構造の場合
                else { // 順序を保持
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // java 7はwhileループ内で、単一の計算を行った後、単一で配列に挿入します。マルチスレッドの場合、環状問題が発生します。
                    // java 8は、連結リスト全体のwhileループが終了した後にのみ配列に値を割り当てるため、マルチスレッドの場合でも環状にはなりません。

                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // ノードは元の位置にあります。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // ノードは「元の位置 + 旧容量」に割り当てられます。
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

四、Java における HashMap の key 値がクラスオブジェクトである場合、そのクラスは何を満たす必要がありますか?#

一般的に String、Integer のようなラッパークラスを key として使用する理由は:

  • このようなラッパータイプは final タイプであり、変更不可能であるため、hash 値の変更不可能性と計算の正確性が保証されます。
  • 内部で equals ()、hashcode () メソッドをオーバーライドしています。

したがって、HashMap 内の key がクラスオブジェクトである場合、hashcode と equals () メソッドをオーバーライドする必要があります。

五、HashMap はどのようにハッシュ衝突を解決しますか?#

ハッシュ衝突を解決する方法#

ハッシュ衝突を解決する一般的な方法の分析

オープンアドレス法#

衝突が発生したそのセルから、一定の順序でハッシュテーブルから空いているセルを見つけます。そして、衝突した要素をそのセルに格納する方法です。必要なテーブルの長さは、格納する必要がある要素の数以上でなければなりません。
以下の方法が含まれます:

  • 線形探索法
    衝突が発生したセルから、次のセルが空いているかどうかを順次判断します。最後のセルに達したら、テーブルの先頭から順次判断します。空いているセルに出会うか、すべてのセルを探査するまで続けます。
  • 平方探索法
    衝突が発生した場合、衝突が発生したセル d [i] に 1²、2² などを加えます。すなわち d [i] + 1²、d [i] + 2²、d [i] + 3²... 空いているセルが見つかるまで続けます。
  • 二重ハッシュ関数探索法

チェインアドレス法(ラチェ法)#

チェインアドレス法の考え方は、同じハッシュ値を持つ要素を同義語の単一連結リストに構成し、単一連結リストのヘッドポインタをハッシュテーブルの i 番目のセルに格納し、検索、挿入、削除は主に同義語の連結リスト内で行われます。

再ハッシュ法#

異なるハッシュ関数を複数同時に構築することです:
Hi = RHi(key) i= 1,2,3 ... k;
H1 = RH1 (key) で衝突が発生した場合、H2 = RH2 (key) を使用して計算し、衝突が発生しなくなるまで続けます。この方法は集約を生じにくいですが、計算時間が増加します。

六、HashMap は JDK1.7 と JDK1.8 でどのように異なりますか?#

  • 1.8 では赤黒木が追加され、探索の時間計算量が元の連結リストの O (n) から O (logn) に変わりました。

  • データを挿入する際、1.7 は頭挿入法に基づいており、逆順と環状連結リストの無限ループ問題が発生しますが、1.8 は尾挿入法に基づいてこの問題を回避しました。詳細は:jdk1.7HashMap 連結リストが環状になる原因と jdk1.8 の解決策を参照してください。

  • 拡張後のデータ格納位置の計算方法が異なります。

    1. JDK 1.7 は hashCode () + 4 回のビット演算 + 5 回の排他的論理和演算(9 回の乱数化)を使用します。

    static final int hash(int h) {
    h ^= k.hashCode();
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
    }

      ```
    

    2. JDK 1.8 は乱数化関数を簡素化し、2 回の乱数化のみを行います = 1 回のビット演算 + 1 回の排他的論理和演算。
    Java static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // a. key = nullの場合、hash値 = 0であるため、HashMapのkeyはnullにできます。 // 注:HashTableと比較すると、HashTableはkeyを直接hashCode()にし、keyがnullの場合は例外をスローするため、HashTableのkeyはnullにできません。 // b. key ≠ nullの場合、まずkeyのhashCode()を計算し(これをhと呼びます)、次にハッシュコードに乱数化処理を行います:ビット単位で排他的論理和(^)を行い、ハッシュコード自身を右に16ビットシフトした二進数とします。 }

七、HashMap はなぜ hashCode () 処理後のハッシュ値を直接 table のインデックスとして使用しないのか?#

hashCode () を直接使用せず、乱数化関数処理を選択することで、key に基づいて生成されるハッシュコード(ハッシュ値)の分布がより均等でランダム性があり、hash 値の衝突を避けることができます。

八、HashMap、LinkedHashMap、TreeMap の違い#

  • HashMap を走査する際、その出力は無秩序です。
  • LinkedHashMap は HashMap から継承され、高効率であり、HashMap の基盤の上に内部に要素の順序を保持するための連結リストを追加しています。
  • TreeMap は SortedMap インターフェースを実装しており、要素をソートできます。
  • LinkedHashMap は要素が集合に入る順序またはアクセスされた順序に基づいてソートされ、TreeMap は要素の固有の順序(Comparator または Comparable によって決定される)に基づいています。

九、HashMap データ損失問題#

参考: HashMap ソースコードとマルチスレッド環境でのデータ損失の問題

十、参考リンク#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。