1. 程式人生 > >Map 綜述(三):徹頭徹尾理解 ConcurrentHashMap

Map 綜述(三):徹頭徹尾理解 ConcurrentHashMap

摘要:

  ConcurrentHashMap是J.U.C(java.util.concurrent包)的重要成員,它是HashMap的一個執行緒安全的、支援高效併發的版本。在預設理想狀態下,ConcurrentHashMap可以支援16個執行緒執行併發寫操作及任意數量執行緒的讀操作。本文將結合Java記憶體模型,分析JDK原始碼,探索ConcurrentHashMap高併發的具體實現機制,包括其在JDK中的定義和結構、併發存取、重雜湊和跨段操作,並著重剖析了ConcurrentHashMap讀操作不需要加鎖和分段鎖機制的內在奧祕和原理。

友情提示:

  本文所有關於 ConcurrentHashMap 的原始碼都是基於 JDK 1.6

的,不同 JDK 版本之間會有些許差異,但不影響我們對 ConcurrentHashMap 的資料結構、原理等整體的把握和了解。

  由於 ConcurrentHashMap 的原始碼實現依賴於Java記憶體模型,所以閱讀本文需要讀者瞭解Java記憶體模型與Volatile語義,具體詳見《Java 併發:volatile 關鍵字解析》一文。同時,ConcurrentHashMap的原始碼會涉及到雜湊演算法和連結串列資料結構,所以,讀者需要對雜湊演算法和基於連結串列的資料結構有所瞭解,特別是對HashMap的進一步瞭解和回顧。關於HashMap的詳細介紹,請移步我的博文《Map 綜述(一):徹頭徹尾理解 HashMap》

一. ConcurrentHashMap 概述

  筆者曾在《Map 綜述(一):徹頭徹尾理解 HashMap》一文中提到,HashMap 是 Java Collection Framework 的重要成員,也是Map族(如下圖所示)中我們最為常用的一種。不過遺憾的是,HashMap不是執行緒安全的。也就是說,在多執行緒環境下,操作HashMap會導致各種各樣的執行緒安全問題,比如在HashMap擴容重雜湊時出現的死迴圈問題,髒讀問題等。HashMap的這一缺點往往會造成諸多不便,雖然在併發場景下HashTable和由同步包裝器包裝的HashMap(Collections.synchronizedMap(Map<K,V> m) )可以代替HashMap,但是它們都是通過使用一個全域性的鎖來同步不同執行緒間的併發訪問,因此會帶來不可忽視的效能問題。慶幸的是,JDK為我們解決了這個問題,它為HashMap提供了一個執行緒安全的高效版本 —— ConcurrentHashMap。在ConcurrentHashMap中,無論是讀操作還是寫操作都能保證很高的效能:在進行讀操作時(幾乎)不需要加鎖,而在寫操作時通過鎖分段技術只對所操作的段加鎖而不影響客戶端對其它段的訪問。特別地,在理想狀態下,ConcurrentHashMap 可以支援 16 個執行緒執行併發寫操作(如果併發級別設為16),及任意數量執行緒的讀操作。

            這裡寫圖片描述

  如下圖所示,ConcurrentHashMap本質上是一個Segment陣列,而一個Segment例項又包含若干個桶,每個桶中都包含一條由若干個 HashEntry 物件連結起來的連結串列。總的來說,ConcurrentHashMap的高效併發機制是通過以下三方面來保證的(具體細節見後文闡述):

  • 通過鎖分段技術保證併發環境下的寫操作;

  • 通過 HashEntry的不變性、Volatile變數的記憶體可見性和加鎖重讀機制保證高效、安全的讀操作;

  • 通過不加鎖和加鎖兩種方案控制跨段操作的的安全性。

                ConcurrentHashMap.jpg-26.2kB

二. HashMap 執行緒不安全的典型表現

  我們先回顧一下HashMap。HashMap是一個數組連結串列,當一個key/Value對被加入時,首先會通過Hash演算法定位出這個鍵值對要被放入的桶,然後就把它插到相應桶中。如果這個桶中已經有元素了,那麼發生了碰撞,這樣會在這個桶中形成一個連結串列。一般來說,當有資料要插入HashMap時,都會檢查容量有沒有超過設定的thredhold,如果超過,需要增大HashMap的尺寸,但是這樣一來,就需要對整個HashMap裡的節點進行重雜湊操作。關於HashMap的重雜湊操作本文不再詳述,讀者可以參考《Map 綜述(一):徹頭徹尾理解 HashMap》一文。在此,筆者藉助陳皓的《疫苗:JAVA HASHMAP的死迴圈》一文說明HashMap執行緒不安全的典型表現 —— 死迴圈

  HashMap重雜湊的關鍵原始碼如下:

 /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable) {

        // 將原陣列 table 賦給陣列 src
        Entry[] src = table;
        int newCapacity = newTable.length;

        // 將陣列 src 中的每條鏈重新新增到 newTable 中
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;   // src 回收

                // 將每條鏈的每個元素依次新增到 newTable 中相應的桶中
                do {
                    Entry<K,V> next = e.next;

                    // e.hash指的是 hash(key.hashCode())的返回值;
                    // 計算在newTable中的位置,注意原來在同一條子鏈上的元素可能被分配到不同的桶中
                    int i = indexFor(e.hash, newCapacity);   
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

1、單執行緒環境下的重雜湊過程演示

           HashMap-rehash1.jpg-68kB

  單執行緒情況下,rehash 不會出現任何問題,如上圖所示。假設hash演算法就是最簡單的 key mod table.length(也就是桶的個數)。最上面的是old hash表,其中的Hash表桶的個數為2, 所以對於 key = 3、7、5 的鍵值對在 mod 2以後都衝突在table[1]這裡了。接下來的三個步驟是,Hash表resize成4,然後對所有的鍵值對重雜湊的過程。

2、多執行緒環境下的重雜湊過程演示

  假設我們有兩個執行緒,我用紅色和淺藍色標註了一下,被這兩個執行緒共享的資源正是要被重雜湊的原來1號桶中的Entry鏈。我們再回頭看一下我們的transfer程式碼中的這個細節:

do {
    Entry<K,V> next = e.next;       // <--假設執行緒一執行到這裡就被排程掛起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

  而我們的執行緒二執行完成了,於是我們有下面的這個樣子:

           HashMap-rehash2.jpg-39.8kB

  注意,在Thread2重雜湊後,Thread1的指標e和指標next分別指向了Thread2重組後的連結串列(e指向了key(3),而next指向了key(7))。此時,Thread1被排程回來執行:Thread1先是執行 newTalbe[i] = e;然後是e = next,導致了e指向了key(7),而下一次迴圈的next = e.next導致了next指向了key(3),如下圖所示:

           HashMap-rehash3.jpg-35.9kB

  這時,一切安好。Thread1有條不紊的工作著:把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移,如下圖所示:

           HashMap-rehash4.jpg-45.7kB

  在此時,特別需要注意的是,當執行e.next = newTable[i]後,會導致 key(3).next 指向了 key(7),而此時的key(7).next 已經指向了key(3),環形連結串列就這樣出現了,如下圖所示。於是,當我們的Thread1呼叫HashMap.get(11)時,悲劇就出現了 —— Infinite Loop。

           HashMap-rehash5.jpg-41.1kB

  這是HashMap在併發環境下使用中最為典型的一個問題,就是在HashMap進行擴容重雜湊時導致Entry鍊形成環。一旦Entry鏈中有環,勢必會導致在同一個桶中進行插入、查詢、刪除等操作時陷入死迴圈。

三. ConcurrentHashMap 在 JDK 中的定義

  為了更好的理解 ConcurrentHashMap 高併發的具體實現,我們先來了解它在JDK中的定義。ConcurrentHashMap類中包含兩個靜態內部類 HashEntry 和 Segment,其中 HashEntry 用來封裝具體的K/V對,是個典型的四元組;Segment 用來充當鎖的角色,每個 Segment 物件守護整個ConcurrentHashMap的若干個桶 (可以把Segment看作是一個小型的雜湊表),其中每個桶是由若干個 HashEntry 物件連結起來的連結串列。總的來說,一個ConcurrentHashMap例項中包含由若干個Segment例項組成的陣列,而一個Segment例項又包含由若干個桶,每個桶中都包含一條由若干個 HashEntry 物件連結起來的連結串列。特別地,ConcurrentHashMap 在預設併發級別下會建立16個Segment物件的陣列,如果鍵能均勻雜湊,每個 Segment 大約守護整個散列表中桶總數的 1/16。

1、類結構定義

  ConcurrentHashMap 繼承了AbstractMap並實現了ConcurrentMap介面,其在JDK中的定義為:

public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
        implements ConcurrentMap<K, V>, Serializable {

    ...
}

2、成員變數定義

  與HashMap相比,ConcurrentHashMap 增加了兩個屬性用於定位段,分別是 segmentMask 和 segmentShift。此外,不同於HashMap的是,ConcurrentHashMap底層結構是一個Segment陣列,而不是Object陣列,具體原始碼如下:

    /**
     * Mask value for indexing into segments. The upper bits of a
     * key's hash code are used to choose the segment.
     */
    final int segmentMask;  // 用於定位段,大小等於segments陣列的大小減 1,是不可變的

    /**
     * Shift value for indexing within segments.
     */
    final int segmentShift;    // 用於定位段,大小等於32(hash值的位數)減去對segments的大小取以2為底的對數值,是不可變的

    /**
     * The segments, each of which is a specialized hash table
     */
    final Segment<K,V>[] segments;   // ConcurrentHashMap的底層結構是一個Segment陣列

3、段的定義:Segment

  Segment 類繼承於 ReentrantLock 類,從而使得 Segment 物件能充當鎖的角色。每個 Segment 物件用來守護它的成員物件 table 中包含的若干個桶。table 是一個由 HashEntry 物件組成的連結串列陣列,table 陣列的每一個數組成員就是一個桶。

  在Segment類中,count 變數是一個計數器,它表示每個 Segment 物件管理的 table 陣列包含的 HashEntry 物件的個數,也就是 Segment 中包含的 HashEntry 物件的總數。特別需要注意的是,之所以在每個 Segment 物件中包含一個計數器,而不是在 ConcurrentHashMap 中使用全域性的計數器,是對 ConcurrentHashMap 併發性的考慮:因為這樣當需要更新計數器時,不用鎖定整個ConcurrentHashMap。事實上,每次對段進行結構上的改變,如在段中進行增加/刪除節點(修改節點的值不算結構上的改變),都要更新count的值,此外,在JDK的實現中每次讀取操作開始都要先讀取count的值。特別需要注意的是,count是volatile的,這使得對count的任何更新對其它執行緒都是立即可見的。modCount用於統計段結構改變的次數,主要是為了檢測對多個段進行遍歷過程中某個段是否發生改變,這一點具體在談到跨段操作時會詳述。threashold用來表示段需要進行重雜湊的閾值。loadFactor表示段的負載因子,其值等同於ConcurrentHashMap的負載因子的值。table是一個典型的連結串列陣列,而且也是volatile的,這使得對table的任何更新對其它執行緒也都是立即可見的。段(Segment)的定義如下:

    /**
     * Segments are specialized versions of hash tables.  This
     * subclasses from ReentrantLock opportunistically, just to
     * simplify some locking and avoid separate construction.
     */
    static final class Segment<K,V> extends ReentrantLock implements Serializable {

        /**
         * The number of elements in this segment's region.
         */
        transient volatile int count;    // Segment中元素的數量,可見的

        /**
         * Number of updates that alter the size of the table. This is
         * used during bulk-read methods to make sure they see a
         * consistent snapshot: If modCounts change during a traversal
         * of segments computing size or checking containsValue, then
         * we might have an inconsistent view of state so (usually)
         * must retry.
         */
        transient int modCount;  //對count的大小造成影響的操作的次數(比如put或者remove操作)

        /**
         * The table is rehashed when its size exceeds this threshold.
         * (The value of this field is always <tt>(int)(capacity *
         * loadFactor)</tt>.)
         */
        transient int threshold;      // 閾值,段中元素的數量超過這個值就會對Segment進行擴容

        /**
         * The per-segment table.
         */
        transient volatile HashEntry<K,V>[] table;  // 連結串列陣列

        /**
         * The load factor for the hash table.  Even though this value
         * is same for all segments, it is replicated to avoid needing
         * links to outer object.
         * @serial
         */
        final float loadFactor;  // 段的負載因子,其值等同於ConcurrentHashMap的負載因子

        ...
    }

  我們知道,ConcurrentHashMap允許多個修改(寫)操作併發進行,其關鍵在於使用了鎖分段技術,它使用了不同的鎖來控制對雜湊表的不同部分進行的修改(寫),而 ConcurrentHashMap 內部使用段(Segment)來表示這些不同的部分。實際上,每個段實質上就是一個小的雜湊表,每個段都有自己的鎖(Segment 類繼承了 ReentrantLock 類)。這樣,只要多個修改(寫)操作發生在不同的段上,它們就可以併發進行。下圖是依次插入 ABC 三個 HashEntry 節點後,Segment 的結構示意圖:

                  segment.jpg-10.9kB

4、基本元素:HashEntry

  HashEntry用來封裝具體的鍵值對,是個典型的四元組。與HashMap中的Entry類似,HashEntry也包括同樣的四個域,分別是key、hash、value和next。不同的是,在HashEntry類中,key,hash和next域都被宣告為final的,value域被volatile所修飾,因此HashEntry物件幾乎是不可變的,這是ConcurrentHashmap讀操作並不需要加鎖的一個重要原因。next域被宣告為final本身就意味著我們不能從hash鏈的中間或尾部新增或刪除節點,因為這需要修改next引用值,因此所有的節點的修改只能從頭部開始。對於put操作,可以一律新增到Hash鏈的頭部。但是對於remove操作,可能需要從中間刪除一個節點,這就需要將要刪除節點的前面所有節點整個複製(重新new)一遍,最後一個節點指向要刪除結點的下一個結點(這在談到ConcurrentHashMap的刪除操作時還會詳述)。特別地,由於value域被volatile修飾,所以其可以確保被讀執行緒讀到最新的值,這是ConcurrentHashmap讀操作並不需要加鎖的另一個重要原因。實際上,ConcurrentHashMap完全允許多個讀操作併發進行,讀操作並不需要加鎖。HashEntry代表hash鏈中的一個節點,其結構如下所示:

    /**
     * ConcurrentHashMap 中的 HashEntry 類
     * 
     * ConcurrentHashMap list entry. Note that this is never exported
     * out as a user-visible Map.Entry.
     *
     * Because the value field is volatile, not final, it is legal wrt
     * the Java Memory Model for an unsynchronized reader to see null
     * instead of initial value when read via a data race.  Although a
     * reordering leading to this is not likely to ever actually
     * occur, the Segment.readValueUnderLock method is used as a
     * backup in case a null (pre-initialized) value is ever seen in
     * an unsynchronized access method.
     */
    static final class HashEntry<K,V> {
       final K key;                       // 宣告 key 為 final 的
       final int hash;                   // 宣告 hash 值為 final 的
       volatile V value;                // 宣告 value 被volatile所修飾
       final HashEntry<K,V> next;      // 宣告 next 為 final 的

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

        @SuppressWarnings("unchecked")
        static final <K,V> HashEntry<K,V>[] newArray(int i) {
        return new HashEntry[i];
        }
    }

  與HashMap類似,在ConcurrentHashMap中,如果在雜湊時發生碰撞,也會將碰撞的 HashEntry 物件鏈成一個連結串列。由於HashEntry的next域是final的,所以新節點只能在連結串列的表頭處插入。下圖是在一個空桶中依次插入 A,B,C 三個 HashEntry 物件後的結構圖(由於只能在表頭插入,所以連結串列中節點的順序和插入的順序相反):

                 HashEntry.jpg-4.3kB

  與HashEntry不同的是,HashMap 中的 Entry 類結構如下所示:

    /**
     * HashMap 中的 Entry 類
     */
    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        final int hash;

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
        ...
    }

四. ConcurrentHashMap 的建構函式

  ConcurrentHashMap 一共提供了五個建構函式,其中預設無參的建構函式和引數為Map的建構函式 為 Java Collection Framework 規範的推薦實現,其餘三個建構函式則是 ConcurrentHashMap 專門提供的。

1、ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)

  該建構函式意在構造一個具有指定容量、指定負載因子和指定段數目/併發級別(若不是2的冪次方,則會調整為2的冪次方)的空ConcurrentHashMap,其相關原始碼如下:

    /**
     * Creates a new, empty map with the specified initial
     * capacity, load factor and concurrency level.
     *
     * @param initialCapacity the initial capacity. The implementation
     * performs internal sizing to accommodate this many elements.
     * @param loadFactor  the load factor threshold, used to control resizing.
     * Resizing may be performed when the average number of elements per
     * bin exceeds this threshold.
     * @param concurrencyLevel the estimated number of concurrently
     * updating threads. The implementation performs internal sizing
     * to try to accommodate this many threads.
     * @throws IllegalArgumentException if the initial capacity is
     * negative or the load factor or concurrencyLevel are
     * nonpositive.
     */
    public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();

        if (concurrencyLevel > MAX_SEGMENTS)              
            concurrencyLevel = MAX_SEGMENTS;

        // Find power-of-two sizes best matching arguments
        int sshift = 0;            // 大小為 lg(ssize) 
        int ssize = 1;            // 段的數目,segments陣列的大小(2的冪次方)
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        segmentShift = 32 - sshift;      // 用於定位段
        segmentMask = ssize - 1;      // 用於定位段
        this.segments = Segment.newArray(ssize);   // 建立segments陣列

        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;    // 總的桶數/總的段數
        if (c * ssize < initialCapacity)
            ++c;
        int cap = 1;     // 每個段所擁有的桶的數目(2的冪次方)
        while (cap < c)
            cap <<= 1;

        for (int i = 0; i < this.segments.length; ++i)      // 初始化segments陣列
            this.segments[i] = new Segment<K,V>(cap, loadFactor);
    }

2、ConcurrentHashMap(int initialCapacity, float loadFactor)

  該建構函式意在構造一個具有指定容量、指定負載因子和預設併發級別(16)的空ConcurrentHashMap,其相關原始碼如下:

    /**
     * Creates a new, empty map with the specified initial capacity
     * and load factor and with the default concurrencyLevel (16).
     *
     * @param initialCapacity The implementation performs internal
     * sizing to accommodate this many elements.
     * @param loadFactor  the load factor threshold, used to control resizing.
     * Resizing may be performed when the average number of elements per
     * bin exceeds this threshold.
     * @throws IllegalArgumentException if the initial capacity of
     * elements is negative or the load factor is nonpositive
     *
     * @since 1.6
     */
    public ConcurrentHashMap(int initialCapacity, float loadFactor) {
        this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);  // 預設併發級別為16
    }

3、ConcurrentHashMap(int initialCapacity)

  該建構函式意在構造一個具有指定容量、預設負載因子(0.75)和預設併發級別(16)的空ConcurrentHashMap,其相關原始碼如下:

    /**
     * Creates a new, empty map with the specified initial capacity,
     * and with default load factor (0.75) and concurrencyLevel (16).
     *
     * @param initialCapacity the initial capacity. The implementation
     * performs internal sizing to accommodate this many elements.
     * @throws IllegalArgumentException if the initial capacity of
     * elements is negative.
     */
    public ConcurrentHashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

4、ConcurrentHashMap()

  該建構函式意在構造一個具有預設初始容量(16)、預設負載因子(0.75)和預設併發級別(16)的空ConcurrentHashMap,其相關原始碼如下:

    /**
     * Creates a new, empty map with a default initial capacity (16),
     * load factor (0.75) and concurrencyLevel (16).
     */
    public ConcurrentHashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

5、ConcurrentHashMap(Map<? extends K, ? extends V> m)

  該建構函式意在構造一個與指定 Map 具有相同對映的 ConcurrentHashMap,其初始容量不小於 16 (具體依賴於指定Map的大小),負載因子是 0.75,併發級別是 16, 是 Java Collection Framework 規範推薦提供的,其原始碼如下:

    /**
     * Creates a new map with the same mappings as the given map.
     * The map is created with a capacity of 1.5 times the number
     * of mappings in the given map or 16 (whichever is greater),
     * and a default load factor (0.75) and concurrencyLevel (16).
     *
     * @param m the map
     */
    public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY),
             DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
        putAll(m);
    }

  在這裡,我們提到了三個非常重要的引數:初始容量負載因子併發級別,這三個引數是影響ConcurrentHashMap效能的重要引數。從上述原始碼我們可以看出,ConcurrentHashMap 也正是通過initialCapacity、loadFactor和concurrencyLevel這三個引數進行構造並初始化segments陣列、段偏移量segmentShift、段掩碼segmentMask和每個segment的。

五. ConcurrentHashMap 的資料結構

  本質上,ConcurrentHashMap就是一個Segment陣列,而一個Segment例項則是一個小的雜湊表。由於Segment類繼承於ReentrantLock類,從而使得Segment物件能充當鎖的角色,這樣,每個 Segment物件就可以守護整個ConcurrentHashMap的若干個桶,其中每個桶是由若干個HashEntry 物件連結起來的連結串列。通過使用段(Segment)將ConcurrentHashMap劃分為不同的部分,ConcurrentHashMap就可以使用不同的鎖來控制對雜湊表的不同部分的修改,從而允許多個修改操作併發進行, 這正是ConcurrentHashMap鎖分段技術的核心內涵。進一步地,如果把整個ConcurrentHashMap看作是一個父雜湊表的話,那麼每個Segment就可以看作是一個子雜湊表,如下圖所示:

               ConcurrentHashMap示意圖.jpg-21.4kB

  注意,假設ConcurrentHashMap一共分為2^n個段,每個段中有2^m個桶,那麼段的定位方式是將key的hash值的高n位與(2^n-1)相與。在定位到某個段後,再將key的hash值的低m位與(2^m-1)相與,定位到具體的桶位。

六. ConcurrentHashMap 的併發存取

  在ConcurrentHashMap中,執行緒對對映表做讀操作時,一般情況下不需要加鎖就可以完成,對容器做結構性修改的操作(比如,put操作、remove操作等)才需要加鎖。

1、用分段鎖機制實現多個執行緒間的併發寫操作: put(key, vlaue)

  在ConcurrentHashMap中,典型結構性修改操作包括put、remove和clear,下面我們首先以put操作為例說明對ConcurrentHashMap做結構性修改的過程。ConcurrentHashMap的put操作對應的原始碼如下:

    /**
     * Maps the specified key to the specified value in this table.
     * Neither the key nor the value can be null.
     *
     * <p> The value can be retrieved by calling the <tt>get</tt> method
     * with a key that is equal to the original key.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>
     * @throws NullPointerException if the specified key or value is null
     */
    public V put(K key, V value) {
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key.hashCode());
        return segmentFor(hash).put(key, hash, value, false);
    }

  從上面的原始碼我們看到,ConcurrentHashMap不同於HashMap,它既不允許key值為null,也不允許value值為null。此外,我們還可以看到,實際上我們對ConcurrentHashMap的put操作被ConcurrentHashMap委託給特定的段來實現。也就是說,當我們向ConcurrentHashMap中put一個Key/Value對時,首先會獲得Key的雜湊值並對其再次雜湊,然後根據最終的hash值定位到這條記錄所應該插入的段,定位段的segmentFor()方法原始碼如下:

   /**
     * Returns the segment that should be used for key with given hash
     * @param hash the hash code for the key
     * @return the segment
     */
    final Segment<K,V> segmentFor(int hash) {
        return segments[(hash >>> segmentShift) & segmentMask];
    }

  segmentFor()方法根據傳入的hash值向右無符號右移segmentShift位,然後和segmentMask進行與操作就可以定位到特定的段。在這裡,假設Segment的數量(segments陣列的長度)是2的n次方(Segment的數量總是2的倍數,具體見建構函式的實現),那麼segmentShift的值就是32-n(hash值的位數是32),而segmentMask的值就是2^n-1(寫成二進位制的形式就是n個1)。進一步地,我們就可以得出以下結論:根據key的hash值的高n位就可以確定元素到底在哪一個Segment中。緊接著,呼叫這個段的put()方法來將目標Key/Value對插到段中,段的put()方法的原始碼如下所示:

    V put(K key, int hash, V value, boolean onlyIfAbsent) {
            lock();    // 上鎖
            try {
                int c = count;
                if (c++ > threshold) // ensure capacity
                    rehash();
                HashEntry<K,V>[] tab = table;    // table是Volatile的
                int index = hash & (tab.length - 1);    // 定位到段中特定的桶
                HashEntry<K,V> first = tab[index];   // first指向桶中連結串列的表頭
                HashEntry<K,V> e = first;

                // 檢查該桶中是否存在相同key的結點
                while (e != null && (e.hash != hash || !key.equals(e.key)))  
                    e = e.next;

                V oldValue;
                if (e != null) {        // 該桶中存在相同key的結點
                    oldValue = e.value;
                    if (!onlyIfAbsent)
                        e.value = value;        // 更新value值
                }else {         // 該桶中不存在相同key的結點
                    oldValue = null;
                    ++modCount;     // 結構性修改,modCount加1
                    tab[index] = new HashEntry<K,V>(key, hash, first, value);  // 建立HashEntry並將其鏈到表頭
                    count = c;      //write-volatile,count值的更新一定要放在最後一步(volatile變數)
                }
                return oldValue;    // 返回舊值(該桶中不存在相同key的結點,則返回null)
            } finally {
                unlock();      // 在finally子句中解鎖
            }
        }

  從原始碼中首先可以知道,ConcurrentHashMap對Segment的put操作是加鎖完成的。在第二節我們已經知道,Segment是ReentrantLock的子類,因此Segment本身就是一種可重入的Lock,所以我們可以直接呼叫其繼承而來的lock()方法和unlock()方法對程式碼進行上鎖/解鎖。需要注意的是,這裡的加鎖操作是針對某個具體的Segment,鎖定的也是該Segment而不是整個ConcurrentHashMap。因為插入鍵/值對操作只是在這個Segment包含的某個桶中完成,不需要鎖定整個ConcurrentHashMap。因此,其他寫執行緒對另外15個Segment的加鎖並不會因為當前執行緒對這個Segment的加鎖而阻塞。故而 相比較於 HashTable 和由同步包裝器包裝的HashMap每次只能有一個執行緒執行讀或寫操作,ConcurrentHashMap 在併發訪問效能上有了質的提高。在理想狀態下,ConcurrentHashMap 可以支援 16 個執行緒執行併發寫操作(如果併發級別設定為 16),及任意數量執行緒的讀操作。

  在將Key/Value對插入到Segment之前,首先會檢查本次插入會不會導致Segment中元素的數量超過閾值threshold,如果會,那麼就先對Segment進行擴容和重雜湊操作,然後再進行插入。重雜湊操作暫且不表,稍後詳述。第8和第9行的操作就是定位到段中特定的桶並確定連結串列頭部的位置。第12行的while迴圈用於檢查該桶中是否存在相同key的結點,如果存在,就直接更新value值;如果沒有找到,則進入21行生成一個新的HashEntry並且把它鏈到該桶中連結串列的表頭,然後再更新count的值(由於count是volatile變數,所以count值的更新一定要放在最後一步)。

 到此為止,除了重雜湊操作,ConcurrentHashMap的put操作已經介紹完了。此外,在ConcurrentHashMap中,修改操作還包括putAll()和replace()。其中,putAll()操作就是多次呼叫put方法,而replace()操作實現要比put()操作簡單得多,此不贅述。

2、ConcurrentHashMap 的重雜湊操作 : rehash()

  上面敘述到,在ConcurrentHashMap中使用put操作插入Key/Value對之前,首先會檢查本次插入會不會導致Segment中節點數量超過閾值threshold,如果會,那麼就先對Segment進行擴容和重雜湊操作。特別需要注意的是,ConcurrentHashMap的重雜湊實際上是對ConcurrentHashMap的某個段的重雜湊,因此ConcurrentHashMap的每個段所包含的桶位自然也就不盡相同。針對段進行rehash()操作的原始碼如下:

     void rehash() {
            HashEntry<K,V>[] oldTable = table;    // 擴容前的table
            int oldCapacity = oldTable.length;
            if (oldCapacity >= MAXIMUM_CAPACITY)   // 已經擴到最大容量,直接返回
                return;

            /*
             * Reclassify nodes in each list to new Map.  Because we are
             * using power-of-two expansion, the elements from each bin
             * must either stay at same index, or move with a power of two
             * offset. We eliminate unnecessary node creation by catching
             * cases where old nodes can be reused because their next
             * fields won't change. Statistically, at the default
             * threshold, only about one-sixth of them need cloning when
             * a table doubles. The nodes they replace will be garbage
             * collectable as soon as they are no longer referenced by any
             * reader thread that may be in the midst of traversing table
             * right now.
             */

            // 新建立一個table,其容量是原來的2倍
            HashEntry<K,V>[] newTable = HashEntry.newArray(oldCapacity<<1);   
            threshold = (int)(newTable.length * loadFactor);   // 新的閾值
            int sizeMask = newTable.length - 1;     // 用於定位桶
            for (int i = 0; i < oldCapacity ; i++) {
                // We need to guarantee that any existing reads of old Map can
                //  proceed. So we cannot yet null out each bin.
                HashEntry<K,V> e = oldTable[i];  // 依次指向舊table中的每個桶的連結串列表頭

                if (e != null) {    // 舊table的該桶中連結串列不為空
                    HashEntry<K,V> next = e.next;
                    int idx = e.hash & sizeMask;   // 重雜湊已定位到新桶
                    if (next == null)    //  舊table的該桶中只有一個節點
                        newTable[idx] = e;
                    else {    
                        // Reuse trailing consecutive sequence at same slot
                        HashEntry<K,V> lastRun = e;
                        int lastIdx = idx;
                        for (HashEntry<K,V> last = next;
                             last != null;
                             last = last.next) {
                            int k = last.hash & sizeMask;
                            // 尋找k值相同的子鏈,該子鏈尾節點與父鏈的尾節點必須是同一個
                            if (k != lastIdx) {
                                lastIdx = k;
                                lastRun = last;
                            }
                        }

                        // JDK直接將子鏈lastRun放到newTable[lastIdx]桶中
                        newTable[lastIdx] = lastRun;

                        // 對該子鏈之前的結點,JDK會挨個遍歷並把它們複製到新桶中
                        for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                            int k = p.hash & sizeMask;
                            HashEntry<K,V> n = newTable[k];
                            newTable[k] = new HashEntry<K,V>(p.key, p.hash,
                                                             n, p.value);
                        }
                    }
                }
            }
            table = newTable;   // 擴容完成
        }

  其實JDK官方的註釋已經解釋的很清楚了。由於擴容是按照2的冪次方進行的,所以擴充套件前在同一個桶中的元素,現在要麼還是在原來的序號的桶裡,或者就是原來的序號再加上一個2的冪次方,就這兩種選擇。根據本文前面對HashEntry的介紹,我們知道連結指標next是final的,因此看起來我們好像只能把該桶的HashEntry鏈中的每個節點複製到新的桶中(這意味著我們要重新建立每個節點),但事實上JDK對其做了一定的優化。因為在理論上原桶裡的HashEntry鏈可能存在一條子鏈,這條子鏈上的節點都會被重雜湊到同一個新的桶中,這樣我們只要拿到該子鏈的頭結點就可以直接把該子鏈放到新的桶中,從而避免了一些節點不必要的建立,提升了一定的效率。因此,JDK為了提高效率,它會首先去查詢這樣的一個子鏈,而且這個子鏈的尾節點必須與原hash鏈的尾節點是同一個,那麼就只需要把這個子鏈的頭結點放到新的桶中,其後面跟的一串子節點自然也就連線上了。對於這個子鏈頭結點之前的結點,JDK會挨個遍歷並把它們複製到新桶的鏈頭(只能在表頭插入元素)中。特別地,我們注意這段程式碼:

for (HashEntry<K,V> last = next;
     last != null;
     last = last.next) {
    int k = last.hash & sizeMask;
    if (k != lastIdx) {
        lastIdx = k;
        lastRun = last;
    }
}
newTable[lastIdx] = lastRun;

  在該程式碼段中,JDK直接將子鏈lastRun放到newTable[lastIdx]桶中,難道這個操作不會覆蓋掉newTable[lastIdx]桶中原有的元素麼?事實上,這種情形時不可能出現的,因為桶newTable[lastIdx]在子鏈新增進去之前壓根就不會有節點存在,這還是因為table的大小是按照2的冪次方的方式去擴充套件的。假設原來table的大小是2^k大小,那麼現在新table的大小是2^(k+1)大小,而定位桶的方式是:

// sizeMask = newTable.length - 1,即 sizeMask = 11...1,共k+1個1。
int idx = e.hash & sizeMask;

  因此這樣得到的idx實際上就是key的hash值的低k+1位的值,而原table的sizeMask也全是1的二進位制,不過總共是k位,那麼原table的idx就是key的hash值的低k位的值。所以,如果元素的hashcode的第k+1位是0,那麼元素在新桶的序號就是和原桶的序號是相等的;如果第k+1位的值是1,那麼元素在新桶的序號就是原桶的序號加上2^k。因此,JDK直接將子鏈lastRun放到newTable[lastIdx]桶中就沒問題了,因為newTable中新序號處此時肯定是空的。

3、ConcurrentHashMap 的讀取實現 :get(Object key)

  與put操作類似,當我們從ConcurrentHashMap中查詢一個指定Key的鍵值對時,首先會定位其應該存在的段,然後查詢請求委託給這個段進行處理,原始碼如下:

/**
     * Returns the value to which the specified key is mapped,
     * or {@code null} if this map contains no mapping for the key.
     *
     * <p>More formally, if this map contains a mapping from a key
     * {@code k} to a value {@code v} such that {@code key.equals(k)},
     * then this method returns {@code v}; otherwise it returns
     * {@code null}.  (There can be at most one such mapping.)
     *
     * @throws NullPointerException if the specified key is null
     */
    public V get(Object key) {
        int hash = hash(key.hashCode());
        return segmentFor(hash).get(key, hash);
    }

  我們緊接著研讀Segment中get操作的原始碼:

    V get(Object key, int hash) {
            if (count != 0) {            // read-volatile,首先讀 count 變數
                HashEntry<K,V> e = getFirst(hash);   // 獲取桶中連結串列頭結點
                while (e != null) {
                    if (e.hash == hash && key.equals(e.key)) {    // 查詢鏈中是否存在指定Key的鍵值對
                        V v = e.value;
                        if (v != null)  // 如果讀到value域不為 null,直接返回
                            return v;   
                        // 如果讀到value域為null,說明發生了重排序,加鎖後重新讀取
                        return readValueUnderLock(e); // recheck
                    }
                    e = e.next;
                }
            }
            return null;  // 如果不存在,直接返回null
        }

  瞭解了ConcurrentHashMap的put操作後,上述原始碼就很好理解了。但是有一個情況需要特別注意,就是鏈中存在指定Key的鍵值對並且其對應的Value值為null的情況。在剖析ConcurrentHashMap的put操作時,我們就知道ConcurrentHashMap不同於HashMap,它既不允許key值為null,也不允許value值為null。但是,此處怎麼會存在鍵值對存在且的Value值為null的情形呢?JDK官方給出的解釋是,這種情形發生的場景是:初始化HashEntry時發生的指令重排序導致的,也就是在HashEntry初始化完成之前便返回了它的引用。這時,JDK給出的解決之道就是加鎖重讀,原始碼如下:

 /**
         * Reads value field of an entry under lock. Called if value
         * field ever appears to be null. This is possible only if a
         * compiler happens to reorder a HashEntry initialization with
         * its table assignment, which is legal under memory model
         * but is not known to ever occur.
         */
        V readValueUnderLock(HashEntry<K,V> e) {
            lock();
            try {
                return e.value;
            } finally {
                unlock();
            }
        }

4、ConcurrentHashMap 存取小結

  在ConcurrentHashMap進行存取時,首先會定位到具體的段,然後通過對具體段的存取來完成對整個ConcurrentHashMap的存取。特別地,無論是ConcurrentHashMap的讀操作還是寫操作都具有很高的效能:在進行讀操作時不需要加鎖,而在寫操作時通過鎖分段技術只對所操作的段加鎖而不影響客戶端對其它段的訪問。

七. ConcurrentHashMap 讀操作不需要加鎖的奧祕

  在本文第二節,我們介紹到HashEntry物件幾乎是不可變的(只能改變Value的值),因為HashEntry中的key、hash和next指標都是final的。這意味著,我們不能把節點新增到連結串列的中間和尾部,也不能在連結串列的中間和尾部刪除節點。這個特性可以保證:在訪問某個節點時,這個節點之後的連結不會被改變,這個特性可以大大降低處理連結串列時的複雜性。與此同時,由於HashEntry類的value欄位被宣告是Volatile的,因此Java的記憶體模型就可以保證:某個寫執行緒對value欄位的寫入馬上就可以被後續的某個讀執行緒看到。此外,由於在ConcurrentHashMap中不允許用null作為鍵和值,所以當讀執行緒讀到某個HashEntry的value為null時,便知道產生了衝突 —— 發生了重排序現象,此時便會加鎖重新讀入這個value值。這些特性互相配合,使得讀執行緒即使在不加鎖狀態下,也能正確訪問 ConcurrentHashMap。總的來說,ConcurrentHashMap讀操作不需要加鎖的奧祕在於以下三點:

  • 用HashEntery物件的不變性來降低讀操作對加鎖的需求;

  • 用Volatile變數協調讀寫執行緒間的記憶體可見性;

  • 若讀時發生指令重排序現象,則加鎖重讀;

  由於我們在介紹ConcurrentHashMap的get操作時,已經介紹到了第三點,此不贅述。下面我們結合前兩點分別從執行緒寫入的兩種角度 —— 對散列表做非結構性修改的操作和對散列表做結構性修改的操作來分析ConcurrentHashMap是如何保證高效讀操作的。

1、用HashEntery物件的不變性來降低讀操作對加鎖的需求

  非結構性修改操作只是更改某個HashEntry的value欄位的值。由於對Volatile變數的寫入操作將與隨後對這個變數的讀操作進行同步,所以當一個寫執行緒修改了某個HashEntry的value欄位後,Java記憶體模型能夠保證讀執行緒一定能讀取到這個欄位更新後的值。所以,寫執行緒對連結串列的非結構性修改能夠被後續不加鎖的讀執行緒看到。

  對ConcurrentHashMap做結構性修改時,實質上是對某個桶指向的連結串列做結構性修改。如果能夠確保在讀執行緒遍歷一個連結串列期間,寫執行緒對這個連結串列所做的結構性修改不影響讀執行緒繼續正常遍歷這個連結串列,那麼讀/寫執行緒之間就可以安全併發訪問這個ConcurrentHashMap。在ConcurrentHashMap中,結構性修改操作包括put操作、remove操作和clear操作,下面我們分別分析這三個操作:

  • clear操作只是把ConcurrentHashMap中所有的桶置空,每個桶之前引用的連結串列依然存在,只是桶不再引用這些連結串列而已,而連結串列本身的結構並沒有發生任何修改。因此,正在遍歷某個連結串列的讀執行緒依然可以正常執行對該連結串列的遍歷。

  • 關於put操作的細節我們在上文已經單獨介紹過,我們知道put操作如果需要插入一個新節點到連結串列中時會在連結串列頭部插入這個新節點,此時連結串列中的原有節點的連結並沒有被修改。也就是說,插入新的健/值對到連結串列中的操作不會影響讀執行緒正常遍歷這個連結串列。

  下面來分析 remove 操作,先讓我們來看看 remove 操作的原始碼實現:

    /**
     * Removes the key (and its corresponding value) from this map.
     * This method does nothing if the key is not in the map.
     *
     * @param  key the key that needs to be removed
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>
     * @throws NullPointerException if the specified key is null
     */
    public V remove(Object key) {
    int hash = hash(key.hashCode());
        return segmentFor(hash).remove(key, hash, null);
    }

  同樣地,在ConcurrentHashMap中刪除一個鍵值對時,首先需要定位到特定的段並將刪除操作委派給該段。Segment的remove操作如下所示:

        /**
         * Remove; match on key only if value null, else match both.
         */
        V remove(Object key, int hash, Object value) {
            lock();     // 加鎖
            try {
                int c = count - 1;      
                HashEntry<K,V>[] tab = table;
                int index = hash & (tab.length - 1);        // 定位桶
                HashEntry<K,V> first = tab[index];
                HashEntry<K,V> e = first;
                while (e != null && (e.hash != hash || !key.equals(e.key)))  // 查詢待刪除的鍵值對
                    e = e.next;

                V oldValue = null;
                if (e != null) {    // 找到
                    V v = e.value;
                    if (value == null || value.equals(v)) {
                        oldValue = v;
                        // All entries following removed node can stay
                        
            
           

相關推薦

Map 綜述徹頭徹尾理解 ConcurrentHashMap

摘要:   ConcurrentHashMap是J.U.C(java.util.concurrent包)的重要成員,它是HashMap的一個執行緒安全的、支援高效併發的版本。在預設理想狀態下,ConcurrentHashMap可以支援16個執行緒執行併發寫操作

Map 綜述徹頭徹尾理解 HashMap

定位 內存 時間 ase prev tails max maximum 技術 轉載自:https://blog.csdn.net/justloveyou_/article/details/62893086 摘要:   HashMap是Map族中最為常用的一種,也是 Java

Yii2基礎筆記深入理解Yii2中的view

首先,yii2 view在vendor/views中也是一個物件 一、render方法 任意一個controller都有5種render方法: 1.render(view檔名,待傳遞的引數陣列);如: `render('about',['num'=&

Dubbo深入理解Dubbo原始碼之如何將服務釋出到註冊中心

一、前言   前面有說到Dubbo的服務發現機制,也就是SPI,那既然Dubbo內部實現了更加強大的服務發現機制,現在我們就來一起看看Dubbo在發現服務後需要做什麼才能將服務註冊到註冊中心中。 二、Dubbo服務註冊簡介   首先需要明白的是Dubbo是依賴於Spring容器的(至於為什麼在上篇部落格中有介

JVM 原始碼分析深入理解 CAS

前言什麼是 CASJava 中的 CASJVM 中的 CAS 前言 在上一篇文章中,我們完成了原始碼的編譯和除錯環境的搭建。 鑑於 CAS 的實現原理比較簡單, 然而很多人對它不夠了解,所以本篇將從 CAS 入手,首先介紹它的使用,然後分析它在 Hotsport 虛擬機器中的具體實現。 什麼是 CAS C

機器學習之支持向量機核函數和KKT條件的理解

麻煩 ron 現在 調整 所有 核函數 多項式 err ges 註:關於支持向量機系列文章是借鑒大神的神作,加以自己的理解寫成的;若對原作者有損請告知,我會及時處理。轉載請標明來源。 序: 我在支持向量機系列中主要講支持向量機的公式推導,第一部分講到推出拉格朗日對偶函數的對

深入理解MyBatis的原理配置文件上

dynamic 如何 turn ready conf 屬性。 支持 left bool 前言:前文提到一個入門的demo,從這裏開始,會了解深入 MyBatis 的配置,本文講解 MyBatis 的配置文件的用法。 目錄 1、properties 元素 2、設置(set

深入理解MyBatis的原理配置文件用法

pac amt 單個 gis obb rri tab obj 用戶 前言:前文講解了 MyBatis 的配置文件一部分用法,本文將繼續講解 MyBatis 的配置文件的用法。 目錄 1、typeHandler 類型處理器 2、ObjectFactory 3、插件 4、e

深入理解線性迴歸演算法淺談貝葉斯線性迴歸

前言 上文介紹了正則化項與貝葉斯的關係,正則化項對應於貝葉斯的先驗分佈,因此通過設定引數的先驗分佈來調節正則化項。本文首先介紹了貝葉斯線性迴歸的相關性質,和正則化引數λ的作用,然後簡單介紹了貝葉斯思想的模型比較,最後總結全文。   目錄 1、後驗引數分佈和預測變數分

detectron程式碼理解RPN構建與相應的損失函式

1.RPN的構建 對RPN的構建在FPN.py的add_fpn_rpn_output函式中 def add_fpn_rpn_outputs(model, blobs_in, dim_in, spatial_scales): """Add RPN on FPN specific out

跳躍NLP曲線自然語言處理研究綜述翻譯

3. 重疊NLP曲線 隨著網際網路時代的到來,文明經歷了深刻的影響,我們現在比以往任何時候都經歷的快很多。即使是適應、發展和創新技術,也會讓人感到恍惚,即淘汰就在眼前。特別是NLP研究在過去15年中並沒有像其它技術那樣發展。 雖然NLP研究在執行人工智慧行為

Gradle 理解 Task

task在gradle佔有很重要的地位,因為在gradle中任何執行操作都是通過task來執行。task可以理解成任務,作用就是執行某些指定的操作。 以Android為例,Gradle構建編譯一個Android專案的時候,需要執行很多操作流程。整個過程可以通過命令列gradle asse

tensorflow+faster rcnn程式碼理解損失函式構建

前面兩篇部落格已經敘述了基於vgg模型構建faster rcnn的過程: tensorflow+faster rcnn程式碼理解(一):構建vgg前端和RPN網路 tensorflow+faster rcnn程式碼解析(二):anchor_target_layer、proposal_targ

白話Spring原始碼spring框架的理解

一、為什麼需要Spring 我們想一下如果沒有spring框架我們是怎麼去開發web應用呢? 我估計大部分程式碼是跟業務無關而跟底層或者網路介面互動;物件,模組關係錯綜複雜;開發週期特別的長很容易流產;後期維護時程式碼會越來越爛,最後可能無法維護。。。 那spring框架給我們解決什麼問

理解JVMJVM命令工具

jps(JVM Process Status Tool) 虛擬機器程序狀況工具,可以列出正在執行的虛擬機器程序,並顯示虛擬機器執行主類(MainClass,main()函式所在的類)名稱以及這些程序的本地虛擬機器唯一ID(Local Virtual Machi

深度學習筆記影象理解個層次

deep learning 簡稱DL,小編剛接觸計算機視覺利用深度學習進行影象處理,先普及一下對影象進行處理的三個層次。 一是分類(classification) 即是將影象結構化為某一類別的資訊,用事先確定好的類別(string)或例項ID來描述圖片。其中ImageNe

原型鏈與繼承理解Object.create()

Object.create(proto[, propertiesObject]):接受兩個引數,返回一個物件引數一:proto應該是新建立物件的prototype引數二:可選。該引數物件是一組屬性與值,該物件的屬性名稱將是新建立的物件的屬性名稱,值是屬性描述符(這些屬性描述符

深入理解協程async/await實現非同步協程

原創不易,轉載請聯絡作者 深入理解協程分為三部分進行講解: 協程的引入 yield from實現非同步協程 async/await實現非同步協程 本篇為深入理解協程系列文章的最後一篇。 從本篇你將瞭解到: async/await的使用。 如何從yield from風格的協程修改為async/aw

深入理解JS中的物件class 的工作原理

**目錄** - 序言 - class 是一個特殊的函式 - class 的工作原理 - class 繼承的原型鏈關係 - 參考 **1.序言** ECMAScript 2015(ES6) 中引入的 JavaScript 類實質上是 JavaScript 現有的基於原型的繼承的語法糖。類語法(cla

C#中的深度學習理解神經網路結構

在這篇文章中,我們將回顧監督機器學習的基礎知識,以及訓練和驗證階段包括哪些內容。 在這裡,我們將為不瞭解AI的讀者介紹機器學習(ML)的基礎知識,並且我們將描述在監督機器學習模型中的訓練和驗證步驟。 ML是AI的一個分支,它試圖通過歸納一組示例而不是接收顯式指令來讓機器找出如何執行任務。ML有三種正規化:監督