インタビューの質問5-CurrentHashMap

CurrentHashMapの原則

HashMapはスレッド非同期であるため、データ処理の効率は高いものの、マルチスレッドの場合はセキュリティ上の問題があり、CurrentHashMapはマルチスレッドセーフの問題を解決するように設計されています。

HashMapが配置されたときに、挿入された要素が容量(負荷率によって決定される)を超えると、拡張操作がトリガーされます。これは再ハッシュであり、元の配列の内容を新しい拡張配列に再ハッシュします。マルチスレッド環境では、put操作に同時に他の要素があります。ハッシュ値が同じ場合、同じ配列内のリンクリストで同時に表される可能性があり、結果として閉ループになります。 、get中に無限ループが発生するため、HashMapはスレッドセーフではありません。

JDK7でのCurrentHashMap

JDK1.7バージョンでは、ConcurrentHashMapのデータ構造はセグメント配列と複数のHashEntryで構成されています。主な実装原則は、次の図に示すように、ロック分離のアイデアを実現し、マルチスレッドのセキュリティ問題を解決することです。 :

ここに画像の説明を挿入

CurrentHashMapの構造

セグメント配列の意味は、ロックのために大きなテーブルを複数の小さなテーブルに分割することです。これは、前述のロック分離テクノロジーであり、各セグメント要素は、HashMapと同じHashEntry配列+リンクリストを格納します。データストレージ構造は同じです。

ConcurrentHashMapと
HashMapand Hashtableの最大の違いは、指定されたHashEntryにHashを2回配置して取得することです。最初はハッシュがセグメントに到達し、2回目はセグメントのエントリに到達し、次にエントリのリンクリストをトラバースします。

初期化

ConcurrentHashMapの初期化は、ssizeで表されるビット演算によってセグメントのサイズを初期化することです。ソースコードは次のとおりです。

private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
    
    
        // For serialization compatibility
        // Emulate segment calculation from previous version of this class
        int sshift = 0;
        int ssize = 1;
        while (ssize < DEFAULT_CONCURRENCY_LEVEL) {
    
    
            ++sshift;
            ssize <<= 1;
        }
        int segmentShift = 32 - sshift;
        int segmentMask = ssize - 1;

ssizeは演算(ssize
<< = 1)によって計算されるため、concurrencyLevelの値に関係なく、セグメントのサイズは常に2のN乗になります。もちろん、最大concurrencyLevelは16ビットでのみ表すことができます。バイナリ、つまり65536、つまり、セグメントサイズは最大65536であり、concurrencyLevel要素は初期化に指定されていません。セグメントサイズssizeのデフォルトは
DEFAULT_CONCURRENCY_LEVEL = 16です。

各Segment要素の下のHashEntryの初期化も、次のように、capで表されるビット単位のAND演算に従って計算されます。

int cap = 1; while(cap <c)
cap << = 1上記のように、HashEntryサイズの計算も2のN乗(cap << = 1)であり、capの初期値は1であるため、HashEntry 2の最小容量を持っています

プット操作

 static class Segment<K,V> extends ReentrantLock implements Serializable {
    
    
        private static final long serialVersionUID = 2249069246763182397L;
        final float loadFactor;
        Segment(float lf) {
    
     this.loadFactor = lf; }
    }

上記のセグメントの継承システムから、セグメントがロック機能も備えたReentrantLockを実装していることがわかります。put操作が実行されると、最初のキーハッシュが実行されてセグメントの位置が特定されます。セグメントが初期化されていない、つまり、割り当てはCAS操作によって実行され、次に2番目のハッシュ操作が実行されて対応するHashEntryの位置が検索されます。ここでは、継承されたロックの特性が使用されます。指定されたHashEntry位置(リンクリストの最後)に挿入されると、ReentrantLockのtryLock()メソッドを継承してロックの取得を試みます。取得が成功した場合は、対応する位置に直接挿入します。はすでにセグメントのロックを取得しているスレッドである場合、現在のスレッドはスピンモードでスピンし(スピンロックを理解していない場合は、参照:スピンロックの原則とjavaスピンロックを参照)、tryLock()メソッドの呼び出しを続行しますロックを取得し、指定された回数後に電話を切り、ウェイクアップを待ちます。

取得する

ConcurrentHashMapのget操作はHashMapに似ていますが、ConcurrentHashMapが最初にセグメントを見つけるためにハッシュを通過し、次に指定されたHashEntryを見つけるためにハッシュを実行し、比較のためにHashEntryの下のリンクリストをトラバースし、成功した場合に戻る必要がある点が異なります。 、そうでない場合はnullを返します

sizeは、ConcurrentHashMap要素のサイズを返します

ConcurrentHashMapの要素サイズの計算は興味深い問題です。これは同時操作であるためです。つまり、サイズを計算するときに、データを同時に挿入しているため、計算されたサイズと実際のサイズに差が生じる可能性があります(
サイズを返し、複数のデータを挿入)、この問題を解決するために、JDK1.7バージョンは2つのソリューションを使用します

try {
    
    
    for (;;) {
    
    
        if (retries++ == RETRIES_BEFORE_LOCK) {
    
    
            for (int j = 0; j < segments.length; ++j) ensureSegment(j).lock(); // force creation
        }
        sum = 0L;
        size = 0;
        overflow = false;
        for (int j = 0; j < segments.length; ++j) {
    
    
            Segment<K,V> seg = segmentAt(segments, j);
            if (seg != null) {
    
     sum += seg.modCount; int c = seg.count; if (c < 0 || (size += c) < 0)
               overflow = true;
            } }
        if (sum == last) break;
        last = sum; } }
finally {
    
    
    if (retries > RETRIES_BEFORE_LOCK) {
    
    
        for (int j = 0; j < segments.length; ++j)
            segmentAt(segments, j).unlock();
    }
}

1.最初のスキームでは、ロック解除モードを使用して、ConcurrentHashMapのサイズを最大3回まで複数回計算し、前の2つの計算の結果を比較します。結果に一貫性がある場合は、そこにあると見なされます。は現在追加されている要素がなく、計算結果は正確です
。2。2番目のスキームは、最初のスキームが満たされない場合、各セグメントにロックを追加し、ConcurrentHashMapのサイズを計算して返します。

JDK8のConcurrentHashMap

JDK1.8の実装はセグメントの概念を放棄しましたが、ノード配列+リンクリスト+赤黒木というデータ構造で直接実装されています。同時実行制御は同期とCASを使用して動作します。全体は最適化され、スレッドセーフのように見えます。 。HashMap、セグメントデータ構造はJDK1.8で確認できますが、古いバージョンとの互換性を保つために、属性が簡略化されています。

ここに画像の説明を挿入

JDK1.8のputand
get実装に深く踏み込む前に、JDK8のConcurrentHashMap一定の設計とデータ構造を知っている必要があります。これらは、ConcurrentHashMapの実装構造の基礎です。基本的なプロパティを見てみましょう。

// node数组最大容量:2^30=1073741824
private static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认初始值,必须是2的幕数
private static final int DEFAULT_CAPACITY = 16
//数组可能最大值,需要与toArray()相关方法关联
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//并发级别,遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
// 负载因子
private static final float LOAD_FACTOR = 0.75f;
// 链表转红黑树阀值,> 8 链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
//树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MIN_TRANSFER_STRIDE = 16;
private static int RESIZE_STAMP_BITS = 16;
// 2^15-1,help resize的最大线程数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
// 32-16=16,sizeCtl中记录size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
// forwarding nodes的hash值
static final int MOVED     = -1;
// 树根节点的hash值
static final int TREEBIN   = -2;
// ReservationNode的hash值
static final int RESERVED  = -3;
// 可用处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
//存放node的数组
transient volatile Node<K,V>[] table;
/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
 *当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容
 *当为0时:代表当时的table还没有被初始化
 *当为正数时:表示初始化或者下一次进行扩容的大小
private transient volatile int sizeCtl;

JDK8ノード

ノードはConcurrentHashMapストレージ構造の基本単位であり、HashMapのエントリから継承され、データを格納するために使用されます。ノードデータ構造は非常に単純で、リンクリストですが、データの検索のみが可能で、変更はできません。

ソースコードは次のとおりです。

static class Node<K,V> implements Map.Entry<K,V> {
    
    
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;

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

        public final K getKey()       {
    
     return key; }
        public final V getValue()     {
    
     return val; }
        public final int hashCode()   {
    
     return key.hashCode() ^ val.hashCode(); }
        public final String toString(){
    
     return key + "=" + val; }
        public final V setValue(V value) {
    
    
            throw new UnsupportedOperationException();
        }

        public final boolean equals(Object o) {
    
    
            Object k, v, u; Map.Entry<?,?> e;
            return ((o instanceof Map.Entry) &&
                    (k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
                    (v = e.getValue()) != null &&
                    (k == key || k.equals(key)) &&
                    (v == (u = val) || v.equals(u)));
        }

        /**
         * Virtualized support for map.get(); overridden in subclasses.
         */
        Node<K,V> find(int h, Object k) {
    
    
            Node<K,V> e = this;
            if (k != null) {
    
    
                do {
    
    
                    K ek;
                    if (e.hash == h &&
                        ((ek = e.key) == k || (ek != null && k.equals(ek))))
                        return e;
                } while ((e = e.next) != null);
            }
            return null;
        }
    }

TreeNode

TreeNodeはNodeを継承しますが、データ構造はバイナリツリー構造に置き換えられます。これは赤黒木データの格納構造です。赤黒木にデータを格納するために使用されます。リンクされたノードの数がリストが8より大きい場合、赤黒木構造に変換されます。彼は、ノードではなくTreeNodeをストレージ構造として使用して、黒赤木に変換します。ソースコードは次のとおりです。

static final class TreeNode<K,V> extends Node<K,V> {
    
    
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;

        TreeNode(int hash, K key, V val, Node<K,V> next,
                 TreeNode<K,V> parent) {
    
    
            super(hash, key, val, next);
            this.parent = parent;
        }

        Node<K,V> find(int h, Object k) {
    
    
            return findTreeNode(h, k, null);
        }

        /**
         * Returns the TreeNode (or null if not found) for the given key
         * starting at given root.
         */
        final TreeNode<K,V> findTreeNode(int h, Object k, Class<?> kc) {
    
    
            if (k != null) {
    
    
                TreeNode<K,V> p = this;
                do  {
    
    
                    int ph, dir; K pk; TreeNode<K,V> q;
                    TreeNode<K,V> pl = p.left, pr = p.right;
                    if ((ph = p.hash) > h)
                        p = pl;
                    else if (ph < h)
                        p = pr;
                    else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
                        return p;
                    else if (pl == null)
                        p = pr;
                    else if (pr == null)
                        p = pl;
                    else if ((kc != null ||
                              (kc = comparableClassFor(k)) != null) &&
                             (dir = compareComparables(kc, k, pk)) != 0)
                        p = (dir < 0) ? pl : pr;
                    else if ((q = pr.findTreeNode(h, k, kc)) != null)
                        return q;
                    else
                        p = pl;
                } while (p != null);
            }
            return null;
        }
    }

TreeBin

TreeBinは、文字通りツリー構造を格納するためのコンテナーとして理解でき、ツリー構造はTreeNodeを参照するため、TreeBinはTreeNodeをカプセル化するコンテナーであり、黒赤木を変換するためのいくつかの条件とロック制御を提供します。

まとめと考察

実際、JDK1.8バージョンのConcurrentHashMapのデータ構造はHashMapに近いことがわかります。比較的言えば、ConcurrentHashMapは、同時実行を制御するための同期操作のみを追加します。JDK1.7バージョンからReentrantLock + Segment + HashEntryからJDK1.8バージョンへ同期+ CAS + HashEntry +赤黒木、比較的言えば、

次の考え方を要約します

JDK1.8の実装により、ロックの粒度が低下します。JDK1.7バージョンのロックの粒度はセグメントに基づいており、複数のHashEntryが含まれますが、JDK1.8ロックの粒度はHashEntry(最初のノード)です。

JDK1.8バージョンのデータ構造がシンプルになり、操作がより明確かつスムーズになりました。同期には同期が使用されているため、セグメントロックの概念やセグメントデータ構造は必要ありません。粒度が低下し、実装の複雑さも増しました

JDK1.8は、赤黒木を使用してリンクリストを最適化します。長いリンクリストに基づくトラバーサルは非常に長いプロセスであり、赤黒木のトラバーサル効率は、リンクリストの特定のしきい値ではなく、非常に高速です。したがって、最適なパートナーを形成します

JDK1.8がリエントラントロックReentrantLockの代わりに同期された組み込みロックを使用するのはなぜですか?次の点があると思います

1.粒度が低下するため、同期は比較的低粒度のロック方法でReentrantLockよりも悪くはありません。粗粒度のロックでは、ReentrantLockは、低粒度でより柔軟な条件を使用して、各低粒度の境界を制御できます。 -粒度のロックインにより、Conditionの利点はなくなります。2。JVM
開発チームは同期をあきらめたことはなく、JVMに基づく同期の最適化スペースは大きくなります。APIを使用するよりも埋め込みキーワードを使用する方が自然
です。 3.大量のデータ操作の下で、JVMのメモリ不足のために、APIベースのReentrantLockはボトルネックではありませんが、選択の基礎でもありますが、より多くのメモリを消費します。

おすすめ

転載: blog.csdn.net/zyf_fly66/article/details/114016876