Java コレクション HashMap によって引き起こされる一連の問題

ハッシュマップ

ソースコードを読んだ後の収穫は少なくありません.たくさんの記事を読むよりも自分で記事を書く方が良いです.アウトプットに固執するという原則に固執するため,この記事の長さは非常に長くなる可能性がありますO(∩_∩ )O、そしてレビュー用にもっと香ります!いくつかの問題を解決する新しいテクノロジーが登場すると思いますが、新しいテクノロジーを学ぶには、まずそのテクノロジーが何をするのかを理解する必要がありますか? 使い方?、そして彼の実現原理は何ですか?最後は実装方法(ソースコード)ですので、ソースコードを直接アップロードするのは不親切な場合もあります!ソースコードを入れる理由はありません。ただのフーリガンです。笑

データ構造の HashMap はどのように見えますか? その構造と根底にある原則は?

1.これはjdk1.7と1.8の点についてです

1.1、在jdk1.7是这样的

HashMap は非常に一般的に使用されるデータ構造です. 配列とリンクされたリストで構成されるデータ構造です. 配列のすべての場所に Key-Value のインスタンスが格納されます. Java7 では Entry と呼ばれ, Java8 では Node と呼ばれます.
ここに画像の説明を挿入

HashMap はキーと値のペアの形式で格納されることがわかっています. put が挿入されると、キーのハッシュに従ってインデックス値が計算され、ハッシュ値に従って配列の特定の位置に挿入されます.これも HashSet が乱れる理由で、詳細は次のとおり: put
ここに画像の説明を挿入
( "b", 28), put("a", 21), この 2 つの要素に対して、hash(b)=2 の場合、要素 b は index=2 の位置に追加され、同じように配置された要素 a にも当てはまります。

なぜ再び連結リストを導入するのですか?

哈希本身就存在概率性,有可能出现两个key计算出来的hash值是相同的
这就导致了哈希冲突,同一个位置需要插入两个元素
解决办法就是在下方生成一个链表来储存相应的元素

ここに画像の説明を挿入put("c", 30)
c、b、およびハッシュ値が両方とも 2 であるとすると、上記の状況が発生すると、2 の位置に連結リストが生成され、新しい要素が格納されます。

実際には、ここには別の問題があります. ハッシュマップは b と c が同じ要素ではないことをどのように判断するのでしょうか? 2 つの b が格納されている場合はどうなるでしょうか? それはわかりますが、マシンは注文を実行するだけです!

同じハッシュ値を直接上書きしてはならないのは理にかなっていますか?

実際には, ハッシュ競合が発生した場合, リンクされたリストに新しい要素を追加する必要があります. キーの内容が一貫しているかどうかを比較する eques メソッドもあります. キーは通常 String 型の文字列で格納されます. 、そして String がこの 2 つのメソッド (hashCode と eques) を書き換えることが起こります (文字列 "gdejicbegh" と文字列 "hgebcijedg" は同じ hashCode() 戻り値 -801038016 を持ち、eques はそれらを異なるものとして比較できます)。 case 連結リストに対応する要素を追加して満足

上記の要素の格納は、Java7 では Entry、Java8 では Node と呼ばれますが、新しい要素はどのようにして連結リストに追加されるのでしょうか。

Java8以降は末尾挿入方式、 Java7以前は先頭挿入方式を使用(各要素を記録)

static class Node<K,V> implements Map.Entry<K,V> {
    
    
        final int hash;//根据key计算的hash值
        final K key;//key
        V value;//值
        Node<K,V> next;//下一个元素

        Node(int hash, K key, V value, Node<K,V> next) {
    
    
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

ヘッダー挿入を使用する理由 後にテールプラグに変更されたのはなぜですか?

連結リストのクエリ速度が非常に遅いことは誰もが知っていることですが、新しい put 要素は get される確率が高いため、前に置くことである意味クエリ効率が向上するのでしょうか。私はそうだと思いました!

しかし、なぜ尾栓方式に変更したのかというと、やはり頭部栓方式には解決すべき問題があり、この問題の発生にはその膨張機構が関係しているのです。

リサイズ()? 1.7 拡張方法は?

1.7では元の2倍の長さの新しいEntry配列が作成され、ソースコードの説明が最初に与えられます(短い本:https://www.jianshu.com/p/08e12481c611の内容が引用されています)

void transfer (HashMapEntry[]newTable ){
    
    
            //新容量数组桶大小为旧的table的2倍
            int newCapacity = newTable.length;
            // 遍历旧的数组桶table
            for (HashMapEntry<K, V> e : table) {
    
    
                // 如果这个数组位置上有元素且存在哈希冲突的链表结构则继续遍历链表
                while (null != e) {
    
    
                    //取当前数组索引位上单向链表的下一个元素
                    HashMapEntry<K, V> next = e.next;
                    //重新依据hash值计算元素在扩容后数组中的索引位置
                    //(Hash的公式---> index = HashCode(Key) & (Length - 1))因为长度变了
                    //所以需要重新计算
                    int i = indexFor(e.hash, newCapacity);
                    //将数组i的元素赋值给当前链表元素的下一个节点
                    e.next = newTable[i];
                    //将链表元素放入数组位置
                    newTable[i] = e;
                    //将当前数组索引位上单向链表的下一个元素赋值给e进行新的一圈链表遍历
                    e = next;
                }
            }
        }

展開プロセス全体は、配列要素を取り出し (実際の配列インデックス位置にある各要素は、それぞれの独立した一方向リンク リストの先頭です。つまり、ハッシュの競合が発生した後に配置された最後の競合する要素です)、トラバースします。一方向連結リスト要素という見出しの要素は、トラバースされた各要素のハッシュ値に基づいて、新しい配列でそれらの添字を計算し、それらを交換します (つまり、元のハッシュの競合がある一方向連結リストの末尾は、展開された一方向連結リストの先頭)

ここに画像の説明を挿入

トピックに戻りますが、これはどのような問題につながりますか?

上の図では、ヘッダー挿入方式を使用して容量拡張が完了した後に、このような状況が発生することがわかります. 上図の元の連結リストでは、9–>5–>1 と新しい連結リスト5–>9、しかし現時点では。9 の次の要素ノードはまだ 5 を指しているため、5–>9–>5–>9–>5–>9 の無限ループが発生します。このとき、要素を取得しようとすると、無限ループが発生します。 . .

これは 1.8 末尾挿入方式です。末尾挿入方式により、新しく作成されたリンク リストと古いリンク リストの順序が少なくとも一致するため、このような状況を回避できます。

jdk1.8 のいくつかのアップグレードについて話しましょう. jdk1.8 が配列 + リンク リスト + 赤黒ツリーの方法で実装されていることは誰もが知っています. 具体的にどのようなアップグレードが行われたのですか?

1.8 上記の問題を解決するためにテール挿入法を使用することに加えて、赤黒木を追加することは、主に、リンクされたリストが長くなりすぎる原因となるハッシュの競合によって引き起こされる効率の問題を解決することです. リンクされたリストが長くなればなるほど、トラバーサルは遅くなります. 連結リストが 8 に達すると, 連結リストは赤黒木に変換されます. これは平衡二分木とも呼ばれます. 連結リストと比較して, クエリは非常に高速です.

再び問題が発生. Hashmap の連結リストのサイズが 8 を超えると、自動的に赤黒ツリーに変換されます. 削除が 6 未満になると、再び連​​結リストになります. なぜですか?

ポアソン分布によると、デフォルトの負荷係数が 0.75 の場合、1 つのハッシュ スロットの要素数が 8 である確率は 100 万分の 1 未満であるため、7 が流域として使用される場合にのみ変換が実行されます。 6 以下であり、連結リストに変換されます。結局、下部全体の構造はハッシュの衝突を解決するためのものです

次に、HashMap のデフォルトの初期化長が 16 であるのはなぜですか? 他に何もありませんか?

2の累乗であれば、実用性は8や32とほぼ同じです。その理由は次のとおりです。

虽然我们有链表红黑树等一系列解决方案,但是根本问题还是在于减少哈希冲突才能提高效率,
用2次幂次方作为初始值,目的是为了Hash算法均匀分布的原则尽量减少哈希冲突

インデックスの計算式はわかっています: index = HashCode (Key) & (Length-1)

1. HashMap の長さがデフォルトの 16 であると仮定すると、Length-1 を計算した結果は、10 進数で 15、2 進数で 1111 です。

3. 上記の 2 つの結果に対して AND 演算を実行します。

ハッシュ アルゴリズムによって得られる最終的なインデックス結果は、キーのハッシュコード値の最後の数桁に完全に依存していると言えます。

HashMap の長さが 10 であると仮定して、今の操作手順を繰り返します。
ここに写真の説明を書きます

この結果だけを見ると、表面上は問題ありません。新しい HashCode 101110001110101110 1011 をもう一度試してみましょ
ここに写真の説明を書きます
う: 別の HashCode 101110001110101110 1111 を試してみましょう:

ここに画像の説明を挿入
はい、HashCode の最後から 2 桁目と 3 桁目が 0 から 1 に変わりましたが、演算の結果は常に 1001 です。つまり、HashMap の長さが 10 の場合、一部のインデックス結果は表示される可能性が高くなりますが、一部のインデックス結果は表示されません (0111 など)!
これにより、より多くのハッシュの衝突が発生します。これは明らかに、ハッシュ アルゴリズムの一様分布の原則に準拠していません。

一方、長さが 16 またはその他の 2 のべき乗である場合、Length-1 の値は、すべてのバイナリ ビットがすべて 1 であるということです。この場合、index の結果は、index の最後の数ビットの値に相当します。ハッシュコード。入力 HashCode 自体が均等に分散されている限り、ハッシュ アルゴリズムの結果は均一です。

これらについて説明した後、HashMap のスレッド セーフについて説明します。

これはスレッドセーフではありません. まず第一に, 複数のスレッドが同時に put メソッドを使用して要素を追加する場合, 厳密に 2 つの put キーが (同じハッシュ値で) 衝突すると仮定すると, HashMap、これらの 2 つのキーは配列の同じ場所に追加されるため、スレッドの 1 つによって配置されたデータは最終的に上書きされます。2つ目は、要素数が配列サイズ* loadFactorを超えることを複数のスレッドが同時に検出した場合、複数のスレッドが同時にNode配列を展開し、要素の位置を再計算してデータをコピーしますが、最終的には1つだけですthread expands 配列がテーブルに割り当てられます。つまり、他のスレッドが失われ、それぞれのスレッドによって配置されたデータも失われます。

では、スレッド セーフの問題を解決するためにどのような方法が使用されるのでしょうか?

3 つの方法:

//Hashtable
Map<String, String> hashtable = new Hashtable<>();
  
//synchronizedMap
Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());
  
//ConcurrentHashMap
Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();

1. HashTable ソース コードでは、次の get メソッドと put メソッドのように、synchronized を使用してスレッドの安全性を確保しています。

public synchronized V get(Object key) {
    
    
       // 省略实现
    }
public synchronized V put(K key, V value) {
    
    
    // 省略实现
    }

、一方のスレッドが put メソッドを使用すると、もう一方のスレッドが put メソッドを使用できないだけでなく、get メソッドも使用できないため、効率が非常に低く、現在は基本的に選択されていません。

1.1 Hashtable と HashMap の違いを教えてください。

Hashtable ではキーまたは値を null にすることはできませんが、HashMap のキー値は null にすることができます。
これは、Hashtable がフェールセーフ機構 (フェイルセーフ) を使用しているため、今回読み取ったデータが必ずしも最新のデータであるとは限りません。

null 値を使用すると、再度 contains(key) を呼び出してキーが存在するかどうかを判断できないため、対応するキーが存在しないか空であるかを判断できなくなります. ConcurrentHashMap も同様です.

実装が異なります。Hashtable は Dictionary クラスを継承し、HashMap は AbstractMap クラスを継承します。
初期容量が異なります。HashMap の初期容量は 16、Hashtable の初期容量は 11、両方のデフォルトの負荷率は 0.75 です。
異なる反復子: HashMap の反復子反復子はフェイルファストですが、Hashtable の列挙子はフェイルファストではありません。

したがって、要素の追加や削除など、他のスレッドが HashMap の構造を変更すると、ConcurrentModificationException がスローされますが、Hashtable はスローされません。

Fail-fast (高速障害): コレクションに対するすべての操作が modCount の値を変更するため、modCount が一貫しているかどうかをチェックして判断します (詳細については、ArrayList に関する記事を参照してください。原理は同じです)。

2. SynchronizedMap は、
SynchronizedMap クラスの synchronized 同期キーワードを使用して、Map での操作がスレッドセーフであることを確認します。

// synchronizedMap方法
 2 public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
    
    
 3        return new SynchronizedMap<>(m);
 4    }
 5 // SynchronizedMap类
 6 private static class SynchronizedMap<K,V>
 7        implements Map<K,V>, Serializable {
    
    
 8        private static final long serialVersionUID = 1978198479659022715L;
 9 
10        private final Map<K,V> m;     // Backing Map
11        final Object      mutex;        // Object on which to synchronize
12 
13        SynchronizedMap(Map<K,V> m) {
    
    
14            this.m = Objects.requireNonNull(m);
15            mutex = this;
16        }
17 
18        SynchronizedMap(Map<K,V> m, Object mutex) {
    
    
19            this.m = m;
20            this.mutex = mutex;
21        }
22 
23        public int size() {
    
    
24            synchronized (mutex) {
    
    return m.size();}
25        }
26        public boolean isEmpty() {
    
    
27            synchronized (mutex) {
    
    return m.isEmpty();}
28        }
29        public boolean containsKey(Object key) {
    
    
30            synchronized (mutex) {
    
    return m.containsKey(key);}
31        }
32        public boolean containsValue(Object value) {
    
    
33            synchronized (mutex) {
    
    return m.containsValue(value);}
34        }
35        public V get(Object key) {
    
    
36            synchronized (mutex) {
    
    return m.get(key);}
37        }
38 
39        public V put(K key, V value) {
    
    
40            synchronized (mutex) {
    
    return m.put(key, value);}
41        }
42        public V remove(Object key) {
    
    
43            synchronized (mutex) {
    
    return m.remove(key);}
44        }
45        // 省略其他方法

3.
ConcurrentHashMap の具体的な実装については、このブログを参照してください:アドレス

以下の簡単な紹介も参照してください。

ConcurrentHashMap の最下層は配列+連結リストベースですが、jdk1.7 と 1.8 では具体的な実装が若干異なります。8 では、CHM はセグメント (ロック セグメント) の概念を放棄しましたが、CAS アルゴリズムを使用してそれを実装する新しい方法を有効にしました。

3.1最初は1.7でこんな感じ

セグメント配列と HashEntry で構成されます. HashMap と同様に、配列と連結リストです。
セグメントは ConcurrentHashMap の内部クラスであり、主なコンポーネントは次のとおりです。

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    
        
private static final long serialVersionUID = 2249069246763182397L;    
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶    
transient volatile HashEntry<K,V>[] table;   
 transient int count;        
 // 记得快速失败(fail—fast)么?    
 transient int modCount;        
 // 大小    
 transient int threshold;        
 // 负载因子    
 final float loadFactor;}

HashEntry は HashMap に似ていますが、違いは、volatile を使用してデータの値と次のノードを変更することです。

揮発性の特徴は何ですか?

  • これにより、この変数で動作するさまざまなスレッドの可視性が保証されます。つまり、スレッドが変数の値を変更すると、この新しい値が他のスレッドにすぐに表示されます。(視認性のため)
  • 命令の並べ替えは禁止されています。(秩序を達成するため)
  • volatile は、単一の読み取り/書き込みの原子性のみを保証できます。i++ は、このような操作の原子性を保証しません。

concurrentHashMap の核となるのは、Segment が ReentrantLock を継承するセグメント ロック テクノロジの使用です。
HashTable とは異なり、put 操作と get 操作の両方を同期する必要があります。理論的には、ConcurrentHashMap は CurrencyLevel (セグメント配列の数) のスレッド同時実行をサポートしています。
スレッドがセグメントにアクセスするためにロックを占有しても、他のセグメントには影響しません。
つまり、容量が 16 の場合、同時実行性は 16 であり、16 のスレッドが同時に 16 のセグメントを操作でき、スレッドセーフです。

PUT ロジック:

public V put(K key, V value) {
    
        
    Segment<K,V> s;    
    if (value == null)        
        throw new NullPointerException(); //这就是为啥他不可以put null值的原因
    int hash = hash(key);    
    int j = (hash >>> segmentShift) & segmentMask;    
    if ((s = (Segment<K,V>)UNSAFE.getObject                   
        (segments, (j << SSHIFT) + SBASE)) == null)         
        s = ensureSegment(j);    
    return s.put(key, hash, value, false);
}

彼は最初にセグメントを特定し、次に put 操作を実行します.
彼の put ソース コードを見てみましょう。

 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    
              
        // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry            
        HashEntry<K,V> node = tryLock() ? null :                
                scanAndLockForPut(key, hash, value);            
        V oldValue;            
        try {
    
                    
            HashEntry<K,V>[] tab = table;                
            int index = (tab.length - 1) & hash;                
            HashEntry<K,V> first = entryAt(tab, index);                
            for (HashEntry<K,V> e = first;;) {
    
                        
                if (e != null) {
    
                            
                    K k; 
                    // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。                        
                    if ((k = e.key) == key ||                            
                        (e.hash == hash && key.equals(k))) {
    
                               
                        oldValue = e.value;                           
                        if (!onlyIfAbsent) {
    
                                   
                            e.value = value;                                
                            ++modCount;                            
                        }                            
                        break;                        
                    }                        
                    e = e.next;                    
                }                    
                else {
    
                     
                    // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。                        
                    if (node != null)                            
                        node.setNext(first);                        
                    else                            
                        node = new HashEntry<K,V>(hash, key, value, first);                        
                    int c = count + 1;                        
                    if (c > threshold && tab.length < MAXIMUM_CAPACITY)                            
                        rehash(node);                        
                    else                            
                        setEntryAt(tab, index, node);                       
                        ++modCount;                        
                        count = c;                        
                        oldValue = null;                        
                        break;                    
                }                
            }          
        } finally {
    
                   
            //释放锁                
            unlock();            
        }           
        return oldValue;        
    }

最初に、最初のステップでロックを取得しようとしますが、取得に失敗した場合は、他のスレッド間で競合が発生しているため、scanAndLockForPut() を使用してスピンしてロックを取得します。

ロックを取得するためにスピンを試みます。
再試行の回数が MAX_SCAN_RETRIES に達すると、成功を確実にするためにロックの取得をブロックするように変更されます。

GET ロジック:
取得ロジックは比較的単純です. ハッシュを介して特定のセグメントへのキーを見つけ、ハッシュを介して特定の要素を見つけるだけで済みます.
HashEntry の value 属性は volatile キーワードで修飾されているため、メモリの可視性が保証されているため、取得するたびに最新の値になります。
プロセス全体でロックを必要としないため、ConcurrentHashMap の get メソッドは非常に効率的です。

3.2と1.8はこんな感じ

その中で、元のセグメント セグメント ロックは放棄され、CAS + 同期化が使用され、同時実行のセキュリティが確保されます。

HashMap と非常によく似ています.これも以前の HashEntry を Node に変更しましたが,機能は同じままです.value と next は可視性を確保するために volatile で変更されます.また、赤黒ツリーも導入されます.リンクされたリストの場合特定の値より大きいものは変換されます (デフォルトは 8)。

その値のアクセス操作ですか?そして、スレッドの安全性を確保する方法は?

ConcurrentHashMap の put 操作はまだ比較的複雑ですが、大まかに次の手順に分けることができます。

  1. キーに基づいてハッシュコードを計算します。
  2. 初期化が必要かどうかを判別します。
  3. これは、現在のキーによって配置されたノードです。空の場合、現在の場所にデータを書き込むことができることを意味します。書き込みを試みるには、CAS を使用します。失敗した場合、スピンは成功を保証します。
  4. 現在の場所のハッシュコード == MOVED == -1 の場合、展開する必要があります。
  5. 満たされない場合は、同期ロックを使用してデータを書き込みます。
  6. 数値が TREEIFY_THRESHOLD より大きい場合、赤黒ツリーに変換されます。

ここに画像の説明を挿入

アオビンのCASは何ですか?

CAS は楽観的ロックの実装であり、軽量ロックです。JUC の多くのツール クラスの実装は CAS に基づいています。

CAS 操作の流れは下図のようになります. スレッドはデータを読み込む際にデータをロックしません. データを書き戻す準備をする際に元の値が変更されているかどうかを比較します. 変更されていない場合他のスレッドによって書き戻されますので、読み込み処理を実行してください。

これは、同時操作が常に発生するとは限らないという楽観的な戦略です。

CAS のパフォーマンスは非常に高いですが、synced のパフォーマンスが良くないことはわかっています.jdk1.8 アップグレード後、synced が増えたのはなぜですか?

Synchronized は以前は常に重量級のロックでしたが、後に Java 担当者がアップグレードし、現在はロック アップグレード メソッドを使用してそれを実行しています。

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

そのため、段階的にアップグレードされ、最初は多くの軽量な方法でロックされていました。

ConcurrentHashMap の get 操作はどうですか?

  • 計算されたハッシュコードのアドレス指定に従って、それがバケット上にある場合は、値を直接返します
  • 赤黒木の場合は、木に従って値を取得します。
  • 満足できない場合は、トラバースして、リンクされたリストに従って値を取得します。

ここに画像の説明を挿入

この記事は他のブログを調べて書いたものですが、以下のコンセプトがよくわからないので、Ao Bingの記事を移動させていただきました

おすすめ

転載: blog.csdn.net/weixin_44078653/article/details/105067235