你是否還對多線程與高併發有滿臉疑問呢?

主體概要#

  • 死鎖
  • 併發最佳實踐
  • Spring與線程安全
  • HashMap與ConcurrentHashMap解析
  • 多線程併發與線程安全總結

主體內容#

一、死鎖#

1.首先,祭出一張熟悉的圖,可以看到中間的四輛直行車輛互相在等待其他車讓路,大家都動彈不得。如果沒有人指揮誰先讓步,這些車就要永遠等待在這裏了。

你是否還對多線程與高併發有滿臉疑問呢?

 

2. 那麼何爲進程的死鎖呢?是指兩個或兩個以上的線程因競爭資源而發生互相等待的現象,如果沒有外力作用,他們將無法推進下去,此時,我們稱進程呈死鎖狀態。

3.死鎖也不是那麼容易發生的,有以下四個條件

  • 互斥條件:某時間一個資源只能被一個進程佔用
  • 不可剝奪條件:某個進程佔用了資源,就只能他自己去釋放。
  • 請求和保持條件:某個進程之前申請了資源,我還想再申請資源,之前的資源還是我佔用着,別人別想動。除非我自己不想用了,釋放掉。
  • 循環等待條件:一定會有一個環互相等待。

4.這裏舉個死鎖的小例子

import lombok.extern.slf4j.Slf4j;

/**
 * 一個簡單的死鎖類
 * 當DeadLock類的對象flag==1的時候(td1),先鎖定o1,睡眠500毫秒
 * 而td1在睡眠的時候另一個flag==0的對象(td2)線程啓動,先鎖定o2,睡眠500毫秒
 * td1睡眠結束後需要鎖定o2才能繼續執行,而此時o2已經被td2鎖定
 * td2睡眠結束後需要鎖定o1才能繼續執行,而此時o2已經被td1鎖定
 * td1、td2互相等待,都需要得到對方鎖定的資源才能繼續執行,從而死鎖
 */
@Slf4j
public class DeadLock implements Runnable{
    public int flag=1;

    private static Object o1 = new Object(),o2=new Object();
    @Override
    public void run(){
        log.info("flag:{}",flag);
        if(flag==1){
            synchronized (o1){
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o2){
                    log.info("0");
                }
            }
        }
        if(flag==0){
            synchronized (o2){
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o1){
                    log.info("0");
                }
            }
        }
    }
    public static void main(String[] args) {
        DeadLock td1 = new DeadLock();
        DeadLock td2 = new DeadLock();
        td1.flag=1;
        td2.flag=0;
        //td1,td2都處於可執行狀態,但JVM線程調度先執行哪個線程是不確定的
        //td2的run()方法可能在td1的run()之前執行
        new Thread(td1).start();
        new Thread(td2).start();
    }
}

5.那麼如何避免死鎖呢?在有些情況下死鎖是可以避免的。這裏有三種用於避免死鎖的技術:

  • 加鎖順序(線程按照一定的順序加鎖)
  • 加鎖時限(線程嘗試獲取鎖的時候加上一定的時限,超過時限則放棄對該鎖的請求,並釋放自己佔有的鎖)
  • 死鎖檢測

還有一種,我們可以在死鎖發生的時候爲各線程設置優先級。

二、併發最佳實踐#

1.使用本地變量

應該總是使用本地變量,而不是創建一個類或實例變量,通常情況下,開發人員使用對象實例作爲變量可以節省內存並可以重用,因爲他們認爲每次在方法中創建本地變量會消耗很多內存。下面代碼的execute()方法被多線程調用,爲了實現一個新功能,你需要一個臨時集合Collection,代碼中這個臨時集合作爲靜態類變量使用,然後在execute方法的尾部清除這個集合以便下次重用,編寫這段代碼的人可能認爲這是線程安全的,因爲 CopyOnWriteArrayList是線程安全的,但是他沒有意識到,這個方法execute()是被多線程調用,那麼可能多線程中一個線程看到另外一個線程的臨時數據,即使使用
Collections.synchronizedList也不能保證execute()方法內的邏輯不變性,這個不變性是:這個集合是臨時集合,只用來在每個線程執行內部可見即可,不能暴露給其他線程知曉解決辦法是使用本地List而不是全局的List。

2.使用不可變類

不可變類比如String Integer等一旦創建,不再改變,不可變類可以降低代碼中需要的同步數量。

3.最小化鎖的作用域範圍:S=1/(1-a+a/n)

解釋一下意義:

a:並行計算部分所佔比例

n:並行處理結點個數

S:加速比

當1-a等於0時,沒有串行只有並行,最大加速比 S=n

當a=0時,只有串行沒有並行,最小加速比 S = 1

當n→∞時,極限加速比 s→ 1/(1-a)

例如,若串行代碼佔整個代碼的25%,則並行處理的總體性能不可能超過4。

該公式稱爲:"阿姆達爾定律"或"安達爾定理"。

4.使用線程池的Executor,而不是直接new Thread 執行

創建一個線程的代價是昂貴的,如果要創建一個可伸縮的Java應用,那麼你需要使用線程池。

5.寧可使用同步也不要使用線程的wait和notify

從Java1.5以後,增加了許多同步工具,如:CountDownLatch、CyclicBarrier、Semaphore等,應該優先使用這些同步工具。

6.使用BlockingQueue實現生產-消費模式

阻塞隊列不僅可以處理單個生產、單個消費,也可以處理多個生產和消費。

7.使用併發集合而不是加了鎖的同步集合

Java提供了下面幾種併發集合框架:

ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentLinkedQueue 、ConcurrentLinkedDeque等

8.使用Semaphore創建有界的訪問

爲了建立穩定可靠的系統,對於數據庫、文件系統和socket等資源必須要做有機的訪問,Semaphore可以限制這些資源開銷的選擇,Semaphore可以以最低的代價阻塞線程等待,可以通過Semaphore來控制同時訪問指定資源的線程數。

9.寧可使用同步代碼塊,也不使用同步的方法

主要針對synchronized關鍵字。使用synchronized關鍵字同步代碼塊只會鎖定一個對象,而不會將整個方法鎖定。如果更改共同的變量或類的字段,首先應該選擇的是原子型變量,然後使用volatile。如果需要互斥鎖,可以考慮使用ReentrantLock。

10.避免使用靜態變量

靜態變量在併發執行環境下會製造很多問題,如果必須使用靜態變量,那麼優先是它成爲final變量,如果用來保存集合collection,那麼可以考慮使用只讀集合,否則一定要做特別多的同步處理和併發處理操作。

三、Spring與線程安全(瞭解)#

這裏看一下Spring的線程安全,Spring作爲一個IOC/DI容器,幫助我們管理了許許多多的“bean”。但其實Spring並沒有保證這些對象的線程安全,需要由我們開發者自行編寫解決線程安全問題的代碼。

Spring對每個bean提供了一個scope屬性來表示該bean的作用域。它是bean的生命週期。例如,一個scope爲singleton的bean,在第一次被注入時,會創建爲一個單例對象,該對象會一直被複用到應用結束。

1.Spring bean

(1) singleton(單例):默認的scope,每個scope爲singleton的bean都會被定義爲一個單例對象,該對象的生命週期是與Spring IOC容器一致的(但在第一次被注入時纔會創建)。在整個Spring IoC容器裏,只有一個bean實例,所有線程共享該實例。

(2) prototype:bean被定義爲在每次注入時都會創建一個新的對象。每次請求都會創建並返回一個新的實例,所有線程都有單獨的實例使用,這種方式是比較安全的,但會消耗大量內存和計算資源。

2.無狀態對象(只要是無狀態對象,不管單例多例都是線程安全的):

我們交由Spring管理的大多數對象其實都是一些無狀態的對象,這種不會因爲多線程而導致狀態被破壞的對象很適合Spring的默認scope,每個單例的無狀態對象都是線程安全的(也可以說只要是無狀態的對象,不管單例多例都是線程安全的,不過單例畢竟節省了不斷創建對象與GC的開銷)。

無狀態的對象即是自身沒有狀態的對象,自然也就不會因爲多個線程的交替調度而破壞自身狀態導致線程安全問題。無狀態對象包括我們經常使用的DO、DTO、VO這些只作爲數據的實體模型的貧血對象,還有Service、DAO和Controller,這些對象並沒有自己的狀態,它們只是用來執行某些操作的。例如,每個DAO提供的函數都只是對數據庫的CRUD,而且每個數據庫Connection都作爲函數的局部變量(局部變量是在用戶棧中的,而且用戶棧本身就是線程私有的內存區域,所以不存在線程安全問題),用完即關(或交還給連接池)。

request(請求範圍實例):bean被定義爲在每個HTTP請求中創建一個單例對象,也就是說在單個請求中都會複用這一個單例對象。每當接受到一個HTTP請求時,就分配一個唯一實例,這個實例在整個請求週期都是唯一的。

session(會話範圍實例):bean被定義爲在一個session的生命週期內創建一個單例對象。在每個用戶會話週期內,分配一個實例,這個實例在整個會話週期都是唯一的,所有同一會話範圍的請求都會共享該實例。

application:bean被定義爲在ServletContext的生命週期中複用一個單例對象。

websocket:bean被定義爲在websocket的生命週期中複用一個單例對象。

globalsession(全局會話範圍實例):這與會話範圍實例大部分情況是一樣的,只是在使用到portlet時,由於每個portlet都有自己的會話,如果一個頁面中有多個portlet而需要共享一個bean時,纔會用到。

四、HashMap與ConcurrentHashMap解析#

爲什麼要單獨提到這兩個類呢?因爲他兩在後期開發中會經常經常用到,十分重要。

1.首先,我們來說一下HashMap的數據結構:藍色部分就是HashMap底層數組,數組的每一項都有一個鏈表。

你是否還對多線程與高併發有滿臉疑問呢?

 

HashMap有兩個因素影響它的性能,一、初始容量;二、加載因子;我們來看下源代碼:

(1)默認初始容量16

 /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

(2)默認加載因子0.75f

/**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

如何理解這兩個因素?初始容量是桶的數量,加載因子是在於桶能裝多滿的一個尺度。當哈希表中條目數量超過了加載因子乘以初始容量(這裏就是16*0.75=12)的時候,它將會調用下面的resize()方法進行擴容,然後將它的容量進行分配。

/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, 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 in the new table.
     *
     * @return the table
     */
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

這裏講的都是默認初始化容量和默認加載因子,實際過程中,我們是可以對這兩個參數進行指定的,這裏我們看下HashMap的構造函數,它提供了四個構造函數,代碼中initialCapacity就是初始容量,loadFactor就是加載因子。

 /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    /**
     * Constructs a new <tt>HashMap</tt> with the same mappings as the
     * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified <tt>Map</tt>.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

2.接下來,我們看一下HashMap的尋址方式,對於一個新插入的數據或者我們需要讀取的數據,HashMap需要將它的key按照一定的計算規則計算出它的哈希值,並對我們的數組長度進行取模,結果作爲它在數組中的index下標,然後進行查找。在計算機中,取模的代價遠遠大於位操作的代價,因此HashMap要求這裏的數組長度必須爲2的n次方,此時它將替代哈希值對2的n-1次方進行與運算,它的結果與我們的取模操作是相同的。HashMap並不要求用戶在指定HashMap容量時必須傳入一個2的N次方的整數,而是會通過Integer.highestOneBit算出比指定整數小的最大的2N值,我們來看下它的代碼:

  /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

3.衆所周知,HashMap是非線程安全的,該方法並不保證線程安全,而且在多線程併發調用時,可能出現死循環。由於Key的哈希值的分佈直接決定了所有數據在哈希表上的分佈或者說決定了哈希衝突的可能性,因此爲防止糟糕的Key的hashCode實現(例如低位都相同,只有高位不相同,與2^N-1取與後的結果都相同),JDK 1.7的HashMap通過如下方法使得最終的哈希值的二進制形式中的1儘量均勻分佈從而儘可能減少哈希衝突。

int h = hashSeed;
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);

(1)resize死循環

transfer方法

當HashMap的size超過Capacity*loadFactor時,需要對HashMap進行擴容。具體方法是,創建一個新的,長度爲原來Capacity兩倍的數組,保證新的Capacity仍爲2的N次方,從而保證上述尋址方式仍適用。同時需要通過如下transfer方法將原來的所有數據全部重新插入(rehash)到新的數組中。

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;
    }
  }
}

該方法並不保證線程安全,而且在多線程併發調用時,可能出現死循環。其執行過程如下。從步驟2可見,轉移時鏈表順序反轉。 1. 遍歷原數組中的元素 2. 對鏈表上的每一個節點遍歷:用next取得要轉移那個元素的下一個,將e轉移到新數組的頭部,使用頭插法插入節點 3. 循環2,直到鏈表節點全部轉移 4. 循環1,直到所有元素全部轉移(2)單線程rehash

單線程情況下,rehash無問題。下圖演示了單線程條件下的rehash過程。

你是否還對多線程與高併發有滿臉疑問呢?

 

(3)多線程併發下的rehash

這裏假設有兩個線程同時執行了put操作並引發了rehash,執行了transfer方法,並假設線程已進入transfer方法並執行完next = e.next後,因爲線程調度所分配時間片用完而“暫停”,此時線程二完成了transfer方法的執行。此時狀態如下。

你是否還對多線程與高併發有滿臉疑問呢?

 

接着線程1被喚醒,繼續執行第一輪循環的剩餘部分

e.next = newTable[1] = null
newTable[1] = e = key(5)
e = next = key(9)

結果如下圖所示:

你是否還對多線程與高併發有滿臉疑問呢?

 

接着執行下一輪循環,結果狀態圖如下所示

你是否還對多線程與高併發有滿臉疑問呢?

 

繼續下一輪循環,結果狀態圖如下所示

你是否還對多線程與高併發有滿臉疑問呢?

 

此時循環鏈表形成,並且key(11)無法加入到線程1的新數組。在下一次訪問該鏈表時會出現死循環。

(4)Fast-fail

產生原因

在使用迭代器的過程中如果HashMap被修改,那麼
ConcurrentModificationException將被拋出,也即Fast-fail策略。

當HashMap的iterator()方法被調用時,會構造並返回一個新的EntryIterator對象,並將EntryIterator的expectedModCount設置爲HashMap的modCount(該變量記錄了HashMap被修改的次數)。

HashIterator() {
  expectedModCount = modCount;
  if (size > 0) { // advance to first entry
  Entry[] t = table;
  while (index < t.length && (next = t[index++]) == null)
    ;
  }
}

在通過該Iterator的next方法訪問下一個Entry時,它會先檢查自己的expectedModCount與HashMap的modCount是否相等,如果不相等,說明HashMap被修改,直接拋出`
ConcurrentModificationException`。該Iterator的remove方法也會做類似的檢查。該異常的拋出意在提醒用戶及早意識到線程安全問題。4.接下來,介紹一下ConcurrentHashMap。

(1)Java 8基於CAS的ConcurrentHashMap

Java7中的ConcurrentHashMap的底層數據結構仍然是數組和鏈表。與HashMap不同的是,ConcurrentHashMap最外層不是一個大的數組,而是一個Segment的數組。每個Segment包含一個與HashMap數據結構差不多的鏈表數組。整體數據結構如下圖所:

你是否還對多線程與高併發有滿臉疑問呢?

 

a.尋址方式

在讀寫某個Key時,先取該Key的哈希值。並將哈希值的高N位對Segment個數取模從而得到該Key應該屬於哪個Segment,接着如同操作HashMap一樣操作這個Segment。爲了保證不同的值均勻分佈到不同的Segment,需要通過如下方法計算哈希值。

private int hash(Object k) {
  int h = hashSeed;
  if ((0 != h) && (k instanceof String)) {
    return sun.misc.Hashing.stringHash32((String) k);
  }
  h ^= k.hashCode();
  h += (h <<  15) ^ 0xffffcd7d;
  h ^= (h >>> 10);
  h += (h <<   3);
  h ^= (h >>>  6);
  h += (h <<   2) + (h << 14);
  return h ^ (h >>> 16);
}

同樣爲了提高取模運算效率,通過如下計算,ssize即爲大於concurrencyLevel的最小的2的N次方,同時segmentMask爲2^N-1。這一點跟上文中計算數組長度的方法一致。對於某一個Key的哈希值,只需要向右移segmentShift位以取高sshift位,再與segmentMask取與操作即可得到它在Segment數組上的索引。

int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
  ++sshift;
  ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];

b.同步方式

Segment繼承自ReentrantLock,所以我們可以很方便的對每一個Segment上鎖。

對於讀操作,獲取Key所在的Segment時,需要保證可見性(請參考如何保證多線程條件下的可見性)。具體實現上可以使用volatile關鍵字,也可使用鎖。但使用鎖開銷太大,而使用volatile時每次寫操作都會讓所有CPU內緩存無效,也有一定開銷。ConcurrentHashMap使用如下方法保證可見性,取得最新的Segment。

Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)

獲取Segment中的HashEntry時也使用了類似方法

HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
  (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE)

對於寫操作,並不要求同時獲取所有Segment的鎖,因爲那樣相當於鎖住了整個Map。它會先獲取該Key-Value對所在的Segment的鎖,獲取成功後就可以像操作一個普通的HashMap一樣操作該Segment,並保證該Segment的安全性。 同時由於其它Segment的鎖並未被獲取,因此理論上可支持concurrencyLevel(等於Segment的個數)個線程安全的併發讀寫。 獲取鎖時,並不直接使用lock來獲取,因爲該方法獲取鎖失敗時會掛起(參考[可重入鎖](
http://www.jasongj.com/java/multi_thread/#重入鎖))。事實上,它使用了自旋鎖,如果tryLock獲取鎖失敗,說明鎖被其它線程佔用,此時通過循環再次以tryLock的方式申請鎖。如果在循環過程中該Key所對應的鏈表頭被修改,則重置retry次數。如果retry次數超過一定值,則使用lock方法申請鎖。 這裏使用自旋鎖是因爲自旋鎖的效率比較高,但是它消耗CPU資源比較多,因此在自旋次數超過閾值時切換爲互斥鎖。c.size操作

put、remove和get操作只需要關心一個Segment,而size操作需要遍歷所有的Segment才能算出整個Map的大小。一個簡單的方案是,先鎖住所有Sgment,計算完後再解鎖。但這樣做,在做size操作時,不僅無法對Map進行寫操作,同時也無法進行讀操作,不利於對Map的並行操作。

爲更好支持併發操作,ConcurrentHashMap會在不上鎖的前提逐個Segment計算3次size,如果某相鄰兩次計算獲取的所有Segment的更新次數(每個Segment都與HashMap一樣通過modCount跟蹤自己的修改次數,Segment每修改一次其modCount加一)相等,說明這兩次計算過程中無更新操作,則這兩次計算出的總size相等,可直接作爲最終結果返回。如果這三次計算過程中Map有更新,則對所有Segment加鎖重新計算Size。該計算方法代碼如下

public int size() {
  final Segment<K,V>[] segments = this.segments;
  int size;
  boolean overflow; // true if size overflows 32 bits
  long sum;         // sum of modCounts
  long last = 0L;   // previous sum
  int retries = -1; // first iteration isn't retry
  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();
    }
  }
  return overflow ? Integer.MAX_VALUE : size;
}

d.不同之處

ConcurrentHashMap與HashMap相比,有以下不同點

ConcurrentHashMap線程安全,而HashMap非線程安全HashMap允許Key和Value爲null,而ConcurrentHashMap不允許HashMap不允許通過Iterator遍歷的同時通過HashMap修改,而ConcurrentHashMap允許該行爲,並且該更新對後續的遍歷可見(2)Java 8基於CAS的ConcurrentHashMap

注:本章的代碼均基於JDK 1.8.0_111

數據結構 Java 7爲實現並行訪問,引入了Segment這一結構,實現了分段鎖,理論上最大併發度與Segment個數相等。Java 8爲進一步提高併發性,摒棄了分段鎖的方案,而是直接使用一個大的數組。同時爲了提高哈希碰撞下的尋址性能,Java 8在鏈表長度超過一定閾值(8)時將鏈表(尋址時間複雜度爲O(N))轉換爲紅黑樹(尋址時間複雜度爲O(long(N)))。其數據結構如下圖所示

你是否還對多線程與高併發有滿臉疑問呢?

 

a.尋址方式

Java 8的ConcurrentHashMap同樣是通過Key的哈希值與數組長度取模確定該Key在數組中的索引。同樣爲了避免不太好的Key的hashCode設計,它通過如下方法計算得到Key的最終哈希值。不同的是,Java 8的ConcurrentHashMap作者認爲引入紅黑樹後,即使哈希衝突比較嚴重,尋址效率也足夠高,所以作者並未在哈希值的計算上做過多設計,只是將Key的hashCode值與其高16位作異或並保證最高位爲0(從而保證最終結果爲正整數)。

static final int spread(int h) {
  return (h ^ (h >>> 16)) & HASH_BITS;
}

b.同步方式

對於put操作,如果Key對應的數組元素爲null,則通過CAS操作將其設置爲當前值。如果Key對應的數組元素(也即鏈表表頭或者樹的根元素)不爲null,則對該元素使用synchronized關鍵字申請鎖,然後進行操作。如果該put操作使得當前鏈表長度超過一定閾值,則將該鏈表轉換爲樹,從而提高尋址效率。

對於讀操作,由於數組被volatile關鍵字修飾,因此不用擔心數組的可見性問題。同時每個元素是一個Node實例(Java 7中每個元素是一個HashEntry),它的Key值和hash值都由final修飾,不可變更,無須關心它們被修改後的可見性問題。而其Value及對下一個元素的引用由volatile修飾,可見性也有保障。

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

對於Key對應的數組元素的可見性,由Unsafe的getObjectVolatile方法保證。

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
  return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

c.size操作

put方法和remove方法都會通過addCount方法維護Map的size。size方法通過sumCount獲取由addCount方法維護的Map的size。

五、多線程併發與線程安全總結#

一張圖總結

你是否還對多線程與高併發有滿臉疑問呢?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章