HashMap の基本原理: データ構造 + put() プロセス + 2 の n 乗 + 無限ループ + データ カバレッジ問題

ナビゲーション:

 

[Java メモ + 踏み台まとめ] Java 基礎 + 上級 + JavaWeb + SSM + SpringBoot + セント レジス テイクアウェイ + SpringCloud + ダークホース観光 + Guli モール + Xuecheng オンライン + MySQL 上級記事 + デザイン モード + 面接でよくある質問 + ソース コード_vincewm Blog -CSDN ブログ

目次

1. 最下層

1.1 HashMapのデータ構造

1.2 拡張機構

1.3 put()処理

1.4 HashMap はどのようにキーを計算しますか?

1.5 なぜ HashMap 2 の容量は n 乗なのでしょうか?

1.5.1 理由

1.5.2 ユニフォーム ハッシュの拡張のデモ: 2^4 から 2^5 への拡張

2. スレッドの安全性の問題

2.1 HashMap はスレッドセーフですか? 

2.2 スレッドセーフなソリューション

2.3 JDK7拡張時の無限ループ問題

2.3.1 無限ループ問題のデモンストレーション 

2.3.2 JDK8 は無限ループ問題をどのように解決しますか?

2.4 JDK8 put時のデータカバレッジの問題

2.5 modCount の非アトミックな自己インクリメント問題


1. 最下層

1.1 HashMapのデータ構造

JDK1.7以前のバージョンでは、HashMapの最下層は「配列+一方向リンクリスト」となっていました。

JDK8ではHashMapの最下層は「配列+一方向リンクリスト+赤黒ツリー」で実装されており、赤黒ツリーを使用する主な目的はクエリのパフォーマンスを向上させることです。配列はハッシュ ルックアップに使用され、リンク リストは競合を処理するためのチェーン アドレス方法として使用され、赤黒ツリーは長さ 8 のリンク リストを置き換えます。

1.2 拡張機構

HashMap では、配列のデフォルトの初期容量は 16 で、この容量は 2 の指数で拡張されます。具体的には、配列内の要素が一定の比率に達すると HashMap が拡張されます。この比率は負荷係数と呼ばれ、デフォルトは 0.75 です。

自動拡張メカニズムは、HashMap が最初に大量のメモリを占有する必要がなく、使用中にリアルタイムで十分な領域を確保できるようにするためのものです。拡張に 2 の指数を使用するのは、ビット演算を使用して拡張演算の効率を向上させるためです。

配列の各要素にはリンク リストの先頭ノードのアドレスが格納され、リンク アドレス メソッドによって競合が処理されます。リンク リストの長さが 8 に達すると、赤黒ツリーがリンク リストを置き換えます。

1.3 put()処理

put() メソッドの実行中には、主に 4 つのステップがあります。

  1. キーのアクセス位置と、実際にはハッシュ値の余りである演算ハッシュ&(2^n-1)を計算し、ビット演算効率が高くなります。
  2. 配列を判断し、配列が空の場合、初めて初期容量の 16 に容量を拡張します。
  3. 配列アクセス位置の先頭ノードを決定し、先頭ノードが空の場合は、新たにリンクリストノードを作成して配列に格納します。
  4. 配列アクセス位置の先頭ノードを判定し、先頭ノードが空でない場合は、状況に応じて要素を上書きまたはリンクリストに挿入する(JDK7先頭挿入方式、JDK8末尾挿入方式)、赤-黒い木。
  5. 要素挿入後、要素数を判定し、しきい値を超えている場合はインデックス2で再度容量を拡張します。

このうち、3 番目のステップは、次の 3 つの小さなステップに細分化できます。

1. 要素のキーがヘッド ノードのキーと同じである場合、ヘッド ノードは直接上書きされます。

2. 要素がツリー ノードの場合は、要素をツリーに追加します。

3. 要素がリンク リスト ノードの場合、要素をリンク リストに追加します。追加後、リンクされたリストの長さを判断して、赤黒ツリーに変換するかどうかを決定する必要があります。リンクリストの長さが 8 に達しても、アレイの容量が 64 に達しない場合は、容量を拡張してください。リンクされたリストの長さが 8 に達し、配列の容量が 64 に達すると、赤黒ツリーに変換されます。

ハッシュテーブルの競合処理:オープンアドレス方式(線形検出、二次検出、再ハッシュ方式)、チェーンアドレス方式

1.4 HashMap はどのようにキーを計算しますか?

key=value&(2^n-1) #结果相当于value%(2^n),使用位运算只要是为了提高计算速度。

たとえば、現在の配列容量が 16 で、18 にアクセスしたい場合は、18&15==2 を使用できます。18%16==2 に相当します。

put() で、キーのソース コードの一部を計算します。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        // 此处省略了代码
        // i = (n - 1) & hash]
        if ((p = tab[i = (n - 1) & hash]) == null)
            
            tab[i] = newNode(hash, key, value, null);
        
 
        else {
            // 省略了代码
        }
}

1.5 なぜ HashMap 2 の容量は n 乗なのでしょうか?

1.5.1 理由

キーに対応する値のハッシュ演算を計算します。

key=value&(2^n-1)#结果相当于value%(2^n)。例如18&15和18%16值是相等的

2^n-1 と 2^(n+1)-1 の 2 進数は、最初の桁と最後の数桁を除いて同じです。このようにして、追加された要素を HashMap の各位置に均等に分散して、ハッシュの衝突を防ぐことができます。

たとえば、バイナリ値 15 (つまり 2^4-1) は 1111、バイナリ値 31 は 11111、バイナリ値 63 は 111111、バイナリ値 127 は 1111111 です。

1.5.2 ユニフォーム ハッシュの拡張のデモ: 2^4 から 2^5 への拡張

0&(2^4-1)=0;0&(2^5-1)=0

16&(2^4-1)=0; 16&(2^5-1)=16。したがって、展開後も、キーが 0 である一部の値の位置は変更されず、一部の値は展開後の新しい位置に移行されます。

1&(2^4-1)=1;1&(2^5-1)=1

17&(2^4-1)=1; 17&(2^5-1)=17。したがって、展開後も、キーが 1 である一部の値の位置は変更されず、一部の値は展開後の新しい位置に移行されます。

剰余を使った展開を示します。

AND 演算を理解するのが少し難しいと感じる場合は、剰余を使用して次のことを示すことができます。

16 から 32 への拡張を想定: 1%16=1、17%16=1; 1%32=1、17%32=17。

1 と 17 の元のキーは両方とも 1 です。拡張後も、1 のキーは 1 のままで、17 のキーは 17 になります。このようにして、元のキーが 1 である値は、展開されたハッシュ テーブル内で均等にハッシュ化されます (一部の値は変更されずに残り、一部の値は展開後に新しい位置に移動します)。

2. スレッドの安全性の問題

2.1 HashMap はスレッドセーフですか? 

HashMap はスレッドセーフではないため、マルチスレッド環境では無限ループの問題やデータ カバレッジの問題が発生する可能性があります。

マルチスレッドでは、Collections ツール クラスと JUC パッケージの ConcurrentHashMap を使用することをお勧めします。

2.2 スレッドセーフなソリューション

  • ハッシュテーブルを使用する (古い API は推奨されません)
  • Collections ツール クラスを使用して、HashMap をスレッドセーフな HashMap にラップします。
    Collections.synchronizedMap(map);
  • より安全な ConcurrentHashMap を使用する (推奨) ConcurrentHashMap はスロット (リンク リストのヘッド ノード) をロックして、パフォーマンスは低下しますがスレッドの安全性を確保します。
  • synchronized または Lock を使用して HashMap をロックした後の操作は、マルチスレッド キューの実行と同等になります (より面倒なのでお勧めできません)。

2.3 JDK7拡張時の無限ループ問題

2.3.1 無限ループ問題のデモンストレーション 

シングルスレッドの拡張プロセス:

JDK7では、HashMapのチェーンアドレス方式は競合時の先頭挿入方式を採用しており、容量拡張時も引き続き先頭挿入方式を採用しているため、リンクリスト内のノードの順序が逆になります。

リンク リストを同時に展開する 2 つのスレッド T1 と T2 がある場合、両方ともヘッド ノードと 2 番目のノードをマークします。このとき、T2 はブロックされます。T1 が展開を実行した後、リンク リスト ノードの順序は次のようになります。反転すると、循環リンク リスト (B.next=A; A.next=B) が生成され、無限ループになります。

2.3.2 JDK8 は無限ループ問題をどのように解決しますか?

JDK8 の末尾挿入メソッドは、無限ループの問題を解決します。

JDK8では、HashMapは末尾挿入方式を採用しており、展開時にリンクリストのノードの位置が反転しないため、展開の無限ループの問題は解決されていますが、リンクリストに必要な処理が必要なため、パフォーマンスが少し悪くなります。尾部を見つけるために横切る必要があります。 

たとえば、A->B->C を移行する必要がある場合、最初に先頭ノード A を移動し、次に B を移動して A の末尾に挿入し、次に C を移動して末尾を挿入すると、結果は次のようになります。まだ A-->B-->C です。順序は変更されておらず、拡張スレッドは

2.4 JDK8 put時のデータカバレッジの問題

HashMap はスレッドセーフではないため、2 つの同時スレッドによって挿入されたデータのハッシュ剰余が等しい場合、データの上書きが発生する可能性があります。

スレッド A は、リンク リストの NULL 位置を見つけて挿入の準備ができるとブロックされ、スレッド B が NULL 位置を見つけて正常に挿入します。スレッドAの回復では、nullと判定したため、この位置を直接上書き挿入し、スレッドBが挿入したデータを上書きします。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)     // 如果没有 hash 碰撞,则直接插入
            tab[i] = newNode(hash, key, value, null);
    }

2.5 modCount の非アトミックな自己インクリメント問題

modCount: HashMap のメンバー変数。HashMap が変更された回数を記録するために使用されます。

put は modCount++ 操作を実行しますが、この操作は読み取り、追加、保存に分割されます。これはアトミックな操作ではなく、スレッドの安全性の問題もあります。 

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        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;
            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 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
//put会执行modCount++操作,这步操作分为读取、增加、保存,不是一个原子性操作,也会出现线程安全问题。 
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

おすすめ

転載: blog.csdn.net/qq_40991313/article/details/131620721