HashMap スレッドのセキュリティ不足の問題と解決策

HashMap スレッドのセキュリティ不足の問題と解決策

HashMapこれがスレッドセーフではないことは誰もが知っているので、使用する必要がありますConcurrentHashMapしかし、なぜHashMapスレッドセーフではないのでしょうか?

最初に宣言させてください:スレッドの不安定性は ** 、 **のような問題HashMapを引き起こします。このうち、無限ループとデータ消失はJDK1.7で発生し、JDK1.8で解決された問題ですが、1.8でもデータの上書きなどの問題が残ります。死循环数据丢失数据覆盖

JDK1.7拡張による無限ループとデータロスの解析

HashMap のスレッドの不安定性は、主に拡張方法で発生します, つまり、根本的な原因は転送方法にあります. JDK1.7 での HashMap の転送方法は次のとおりです.

void transfer(Entry[] newTable, boolean rehash) {
    
    
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
    
    
            while(null != e) {
    
    
                Entry<K,V> next = e.next;
                if (rehash) {
    
    
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

このコードはHashMap展開操作であり、各バケットの添字を再配置し、ヘッド補間法を使用して要素を新しい配列に移行します。先頭挿入メソッドは、連結リストの順序を逆にしますが、これも無限ループを形成するポイントです。ヘッダーの挿入方法を理解したら、無限ループとデータ損失を引き起こす方法を調べ続けます。

HashMap2 つのスレッド A と B が、次のものに対して同時に展開操作を実行しているとします。

画像-20220909190803488

通常の展開後の結果は次のとおりです。

画像-20220909190812666

しかし、スレッド A がtransfer上記の関数の 11 行目まで実行すると、CPU タイム スライスが使い果たされ、スレッド A が中断されます。つまり、次の図のようになります。

画像-20220909190831389

この時点でスレッド A: e=3、next=7、e.next=null

画像-20220909190843876

スレッド A のタイム スライスが使い果たされると、CPU はスレッド B の実行を開始し、スレッド B でデータ移行を正常に完了します。

画像-20220909190855912

Java メモリ モデルによると、スレッド B がデータ マイグレーションを実行した後、メイン メモリ内の newTable とテーブルは最新、つまり 7.next=3、3.next=null です。

次に、スレッド A は CPU タイム スライスを取得し、引き続き newTable[i] = e を実行し、新しい配列に対応する位置に 3 を配置します. このサイクルを実行した後のスレッド A の状況は次のとおりです。

画像-20220909191034760

次に、次のサイクルを実行し続けます。この時点で e=7、メイン メモリからe.next を読み取ると、メイン メモリで 7.next=3 であることが判明したため、 next=3で、途中で 7 を配置します。新しい配列への head の挿入を繰り返し、このサイクルを実行し続けると、結果は次のようになります。

画像-20220909191337807

次のサイクルを実行すると、next=e.next=null が検出されるため、このサイクルが最後のサイクルになります。次に、e.next=newTable[i]、つまり 3.next=7 を実行すると、3 と 7 が結合され、newTable[i]=e を実行すると、3 が連結リストに再挿入されます。実行結果を下図に示します。

画像-20220909191412668

e.next=null は、この時点で next=null を意味すると前述しましたが、e=null を実行すると、次のサイクルは実行されません。この時点で、スレッド A と B の展開操作は完了していますが、当然、スレッド A の実行後、HashMap にリング構造が現れ、今後 HashMap を操作すると無限ループが発生します。

そして上の図から、拡張中に要素5が不可解に失われ、データ損失の問題が発生したことがわかります.

JDK1.8 でスレッドが安全でない

transferJDK1.8の問題点はJDK1.8で解決されていますが、 JDK1.8のソースコードを読んでみると関数が見つからないことがわかりますresize. また、JDK1.8では要素の挿入時に末尾挿入方式を採用しています(順番はおかしくありません)。

ただし、JDK1.8 ではデータの上書きが発生します。

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);
        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;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

次の 2 つの状況からの分析

  • 2 つのスレッド A と B が put 操作を実行しており、ハッシュ関数によって計算された挿入添え字が同じであると仮定すると、スレッド A が putVal メソッドの 6 行目のコードを実行すると (ハッシュ衝突があるかどうかを判断するため) その結果、タイムスライスへのアクセスは中断され、スレッドBはタイムスライスを取得した後、添字に要素を挿入し、通常の挿入を完了した後、スレッドAはタイムスライスを取得し、ハッシュの衝突判定が行われたため、以前は、Then を判断せずに直接挿入するため、スレッド B によって挿入されたデータがスレッド A によって上書きされるため、スレッドは安全ではありません。
  • さらに、 HashMap の要素数を決定するためにputVal メソッドの size パラメータが使用されます. マルチスレッドで、スレッド A と B が同時に put 操作を実行すると、HashMap の現在のサイズが 10 であると仮定すると、スレッド A が **size++** を実行すると、メイン メモリから取得した size の値は 10 になり、+1 操作を実行できる状態になりますが、タイム スライスが枯渇するため、CPU を放棄する必要があります。スレッド B は喜んで CPU を取得するか、メイン メモリからサイズを取得します。値 10 は +1 操作であり、put 操作が完了し、size=11 がメイン メモリに書き戻され、スレッド A が再び CPU を取得して続行します。 (サイズの値はこの時点ではまだ 10 です), put 操作が完了したとき, それでも size=11 をメモリに書き戻します. このとき、スレッド A と B の両方が put 操作を実行しましたが、値はof size が 1 だけ増えました。データの上書きにより、スレッドが安全ではないと言われています。

要約する

HashMapスレッドの不安定性は、主に次の 2 つの側面に反映されます。
1. JDK1.7 では、拡張操作が同時に実行されると、リング チェーンとデータ損失が発生します。
2. JDK1.8 では、put 操作を同時に実行すると、データの上書きが発生します。

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

ハッシュテーブル (非推奨)

マルチスレッドの安全性を実現するために、HashTable はほぼすべてのメソッドに同期ロックを追加します (ロックはクラスのインスタンス、つまりマップ構造全体です)スレッドが Hashtable の同期メソッドにアクセスするとき、他のスレッドも必要です同期メソッドにアクセスすることはブロックされます。

このソリューションはあまり使用されていないため、ここでは説明しません

Collections.synchronizedMap (通常は使用されません)

Collections.synchronizedMap() は新しい Map 実装を返します

Map<String,String> map = Collections.synchronizedMap(new HashMap<>());

上記のメソッドを呼び出すときは、Map を渡す必要があります.下の図に示すように、2 つのコンストラクターがあります.mutex パラメーターを渡すと、渡されたオブジェクトにオブジェクトの排他ロックが割り当てられます.
そうでない場合は、オブジェクト除外ロックをこれ、つまり、上記の Map である synchronizedMap を呼び出すオブジェクトに割り当てます。

画像-20220909193330432

すべての安全でない HashMap メソッドをカプセル化する Collections.synchronizedMap()

画像-20220909193412113

カプセル化には 2 つの重要なポイントがあります. 1)相互排除のために
古典的な同期
を使用します. 2) プロキシ モードを使用して新しいクラスを作成します.これも Map インターフェースを実装します. ハッシュマップ上で, 同期はオブジェクトをロックします. したがって, 最初に適用するものロックの場合、他のスレッドがブロックに入り、ウェイクアップを待機します

利点: コードの実装は非常にシンプルで、一目で理解できます。

短所: ロックの観点からは、基本的に可能な最大のコード ブロックをロックするため、パフォーマンスは比較的低くなります。

ConcurrentHashMap (一般的に使用)

JDK 1.7では、同時更新操作を実現するためにセグメント ロック機構が採用されています. 最下層は、2 つのコア静的内部クラス Segment と HashEntry を含む、配列 + リンク リストのストレージ構造を採用しています.

①. セグメントは ReentrantLock (再入可能ロック) を継承してロックとして機能します. 各セグメント オブジェクトは各ハッシュ マッピング テーブルの複数のバケットを保護します. ②.
HashEntry はマッピング テーブルのキーと値のペアをカプセル化するために使用されます.
③. 各バケットは複数の HashEntry オブジェクトによってリンクされたリンク リスト

セグメント ロック: セグメント配列では、セグメント オブジェクトは HashEntry 配列に対応するロックです. この配列内のデータ同期は同じロックに依存し、異なる HashEntry 配列の読み取りと書き込みは互いに干渉しません.

JDK 1.8では、ノード + CAS + 同期化を使用して同時実行セキュリティを確保するために、元のセグメント セグメント ロックが放棄されましたSegment クラスをキャンセルし、テーブル配列を直接使用してキーと値のペアを格納します。Node オブジェクトで構成される連結リストの長さが TREEIFY_THRESHOLD を超えると、連結リストが赤黒木に変換されてパフォーマンスが向上します。一番下のレイヤーを配列+連結リスト+赤黒木に変更。

CAS のパフォーマンスは非常に高いですが、同期は以前から常に重量級のロックでしたが、jdk1.8 では同期を導入し、ロック アップグレードの方法を使用しました。

ロックを取得する同期方法については、JVM はロック アップグレードの最適化方法を使用します。これは、バイアス ロックを使用して同じスレッドに優先度を与え、再度ロックを取得します。失敗した場合、軽量の CAS にアップグレードされます。ロックに失敗した場合、スレッドがシステムによって一時停止されるのを防ぐために、短時間スピンします。最後に、上記のすべてが失敗した場合は、重いロックにアップグレードしてください。

偏ったロック: マルチスレッドの競合なしで不要な軽量ロックの実行を最小限に抑えるために、同期ブロックを実行するスレッドは常に 1 つだけであり、ロック解放の実行が完了するまで他のスレッドは同期を実行しません。
軽量ロック: 競合する 2 つのスレッドがある場合、軽量ロックにアップグレードされます。軽量ロックを導入する主な目的は、マルチスレッド競合なしでオペレーティング システムのミューテックスを使用して、従来の重量ロックのパフォーマンス消費を削減することです。
重量ロック: ほとんどの場合、同じ時点で、複数のスレッドが同じロックを求めて競合することがよくあります. 悲観的ロック モードでは、競合に失敗したスレッドは、ブロックされた状態と目覚めた状態の間で絶えず切り替わりますが、これは比較的コストがかかります.

紙面の都合上、ConcurrentHashMap のソースコード解析はここではお見せできませんが、詳細な内容のブログは時間があるときに後で追加します。

おすすめ

転載: blog.csdn.net/m0_61820867/article/details/126827803
おすすめ