一、前言
借用Java並發編程實踐中的話”編寫正確的程式並不容易,而編寫正常的並發程式就更難了”,相比於順序執行的情況,多線程的安全執行緒問題是微妙而且出乎意料的,因為在沒有進行適當同步的情況下多線程中各個操作的順序是不可預期的,本文算是對多線程情況下同步策略的一個簡單介紹。
二、 什麼是安全執行緒問題
安全執行緒問題是指當多個線程同時讀寫一個狀態變數,並且沒有任何同步措施時候,導致髒資料或者其他不可預見的結果的問題。Java中首要的同步策略是使用Synchronized關鍵字,它提供了可重新進入的獨佔鎖。 三、 什麼是共用變數可見度問題
要談可見度首先需要介紹下多執行緒共用變數時候的Java中記憶體模型。
Java記憶體模型規定了所有的變數都存放在主記憶體中,當線程使用變數時候都是把主記憶體裡面的變數拷貝到了自己的工作空間或者叫做工作記憶體。
如圖是雙核CPU系統架構,每核有自己的控制器和運算器,其中控制器包含一組寄存器和操作控制器,運算器執行算術邏輯運算,並且有自己的一級緩衝,並且有些架構裡面雙核還有個共用的二級緩衝。對應Java記憶體模型裡面的工作記憶體,在實現上這裡是指L1或者L2緩衝或者自己cpu的寄存器。
當線程操作一個共用變數時候操作流程為: 線程首先從主記憶體拷貝共用變數到自己的工作空間 然後對工作空間裡的變數進行處理 處理完後更新變數值到主記憶體
那麼假如線程A和B同時去處理一個共用變數,會出現什麼情況那。
首先他們都會去走上面的三個流程,假如線程A拷貝共用變數到了工作記憶體,並且已經對資料進行了更新但是還沒有更新會主記憶體(結果可能目前存放在當前cpu的寄存器或者快取),這時候線程B拷貝共用變數到了自己的工作記憶體進行處理,處理後,線程A才把自己的處理結果更更新到主記憶體或者緩衝,可知 線程B處理的並不是線程A處理後的結果,也就是說線程A處理後的變數值對線程B不可見,這就是共用變數的不可見度問題。
構成共用變數記憶體不可見原因是因為三步流程不是原子性操作,下面知道使用恰當同步就可以解決這個問題。
我們知道ArrayList是線程不安全的,因為他的讀寫方法沒有同步策略,會導致髒資料和不可預期的結果,下面我們就一一講解如何解決。
這是線程不安全的public class ArrayList<E> { public E get(int index) { rangeCheck(index); return elementData(index); } public E set(int index, E element) { rangeCheck(index); E oldValue = elementData(index); elementData[index] = element; return oldValue; }}
四、原子性
4.1 介紹
假設線程A執行操作Ao和線程B執行操作Bo ,那麼從A看,當B線程執行Bo操作時候,那麼Bo操作全部執行,要麼全部不執行,我們稱Ao和Bo操作互為原子性操作,在設計計數器時候一般都是先讀取當前值,然後+1,然後更新會變數,是讀-改-寫的過程,這個過程必須是原子性的操作。
public class ThreadNotSafeCount { private Long value; public Long getCount() { return value; } public void inc() { ++value; } }
如上代碼是線程不安全的,因為不能保證++value是原子性操作。方法一是使用Synchronized進行同步如下:
public class ThreadSafeCount { private Long value; public synchronized Long getCount() { return value; } public synchronized void inc() { ++value; } }
注意,這裡不能簡單的使用volatile修飾value進行同步,因為變數值依賴了當前值
使用Synchronized確實可以實現安全執行緒,即實現可見度和同步,但是Synchronized是獨佔鎖,沒有擷取內部鎖的線程會被阻塞掉,那麼有沒有剛好的實現那。答案是肯定的。 4.2 原子變數類
原子變數類比鎖更輕巧,比如AtomicLong代表了一個Long值,並提供了get,set方法,get,set方法語義和volatile相同,因為AtomicLong內部就是使用了volatile修飾的真正的Long變數。另外提供了原子性的自增自減操作,所以計數器可以改下為:
public class ThreadSafeCount { private AtomicLong value = new AtomicLong(0L); public Long getCount() { return value.get(); } public void inc() { value.incrementAndGet(); } }
那麼相比使用synchronized的好處在於原子類操作不會導致線程的掛起和重新調度,因為他內部使用的是cas的非阻塞演算法。
常用的原子類變數為:AtomicLong,AtomicInteger,AtomicBoolean,AtomicReference. 五 CAS介紹
CAS 即CompareAndSet,也就是比較並設定,CAS有三個運算元分別為:記憶體位置,舊的預期值,新的值,操作含義是當記憶體位置的變數值為舊的預期值時候使用新的值替換舊的值。通俗的說就是看記憶體位置的變數值是不是我給的舊的預期值,如果是則使用我給的新的值替換他,如果不是返回給我舊值。這個是處理器提供的一個原子性指令。上面介紹的AtomicLong的自增就是使用這種方式實現:
public final long incrementAndGet() { for (;;) { long current = get();(1) long next = current + 1;(2) if (compareAndSet(current, next))(3) return next; } } public final boolean compareAndSet(long expect, long update) { return unsafe.compareAndSwapLong(this, valueOffset, expect, update); }
假如當前值為1,那麼線程A和檢查B同時執行到了(3)時候各自的next都是2,current=1,假如線程A先執行了3,那麼這個是原子性操作,會把檔期值更新為2並且返回1,if判斷true所以incrementAndGet返回2.這時候線程B執行3,因為current=1而當前變數實際值為2,所以if判斷為false,繼續迴圈,如果沒有其他線程去自增變數的話,這次線程B就會更新變數為3然後退出。
這裡使用了無限迴圈使用CAS進行輪詢檢查,雖然一定程度浪費了cpu資源,但是相比鎖來說避免的線程環境切換和調度。 六、什麼是可重新進入鎖
當一個線程要擷取一個被其他線程佔用的鎖時候,該線程會被阻塞,那麼當一個線程再次擷取它自己已經擷取的鎖時候是否會被阻塞那?如果不需要阻塞那麼我們說該鎖是可重新進入鎖,也就是說只要該線程擷取了該鎖,那麼可以無限制次數進入被該鎖鎖住的代碼。
先看一個例子如果鎖不是可重新進入的,看看會出現什麼問題。
public class Hello{ public Synchronized void helloA(){ System.out.println("hello"); } public Synchronized void helloB(){ System.out.println("hello B"); helloA(); }}
如上面代碼當調用helloB函數前會先擷取內建鎖,然後列印輸出,然後調用helloA方法,調用前會先去擷取內建鎖,如果內建鎖不是可重新進入的那麼該調用就會導致死結了,因為線程持有並等待了鎖。
實際上內部鎖是可重新進入鎖,例如synchronized關鍵字管理的方法,可重新進入鎖的原理是在鎖內部維護了一個線程標示,標示該鎖目前被那個線程佔用,然後關聯一個計數器,一開始計數器值為0,說明該鎖沒有被任何線程佔用,當一個線程擷取了該鎖,計數器會變成1,其他線程在擷取該鎖時候發現鎖的所有者不是自己所以被阻塞,但是當擷取該鎖的線程再次擷取鎖時候發現鎖擁有者是自己會把計數器值+1, 當釋放鎖後計數器會-1,當計數器為0時候,鎖裡面的線程標示重設為null,這時候阻塞的線程會擷取被喚醒來擷取該鎖。 七、Synchronized關鍵字 7.1 Synchronized介紹
synchronized塊是Java提供的一種強制性內建鎖,每個Java對象都可以隱式的充當一個用於同步的鎖的功能,這些內建的鎖被稱為內部鎖或者叫監視器鎖,執行代碼在進入synchronized代碼塊前會自動擷取內部鎖,這時候其他線程訪問該同步代碼塊時候會阻塞掉。拿到內部鎖的線程會在正常退出同步代碼塊或者異常拋出後釋放內部鎖,這時候阻塞掉的線程才能擷取內部鎖進入同步代碼塊。 7.2 Synchronized同步執行個體
內部鎖是一種互斥鎖,具體說是同時只有一個線程可以拿到該鎖,當一個線程拿到該鎖並且沒有釋放的情況下,其他線程只能等待。
對於上面說的ArrayList可以使用synchronized進行同步來處理可見度問題。
使用synchronized對方法進行同步public class ArrayList<E>{ public synchronized E get(int index) { rangeCheck(index); return elementData(index); } public synchronized E set(int index, E element) { rangeCheck(index); E oldValue = elementData(index); elementData[index] = element; return oldValue; }}
如圖當線程A擷取內部鎖進入同步代碼塊後,線程B也準備要進入同步塊,但是由於A還沒釋放鎖,所以B現在進入等待,使用同步可以保證線程A擷取鎖到釋放鎖期間的變數值對B擷取鎖後都可見。也就是說當B開始執行A執行的代碼同步塊時候可以看到A操作的所有變數值,這裡具體說是當線程B擷取b的值時候能夠保證擷取的值是2。這時因為線程A進入同步塊修改變數值後,會在退出同步塊前把值重新整理到主記憶體,而線程B在進入同步塊前會首先清空本地記憶體內容,從主記憶體重新擷取變數值,所以實現了可見度。但是要注意一點所有線程使用的是同一個鎖。
注意 Synchronized關鍵字會引起線程環境切換和線程調度。 八、 ReentrantReadWriteLock介紹
使用synchronized可以實現同步,但是缺點是同時只有一個線程可以訪問共用變數,但是正常情況下,對於多個讀操作操作共用變數時候是不需要同步的,synchronized時候無法實現多個讀線程同時執行,而大部分情況下讀操作次數多於寫操作,所以這大大降低了並發性,所以出現了ReentrantReadWriteLock,它可以實現讀寫分離,多個線程同時進行讀取,但是最多一個寫線程存在。
對於上面的方法現在可以修改為:
public class ArrayList<E>{ private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); public E get(int index) { Lock readLock = readWriteLock.readLock(); readLock.lock(); try { return list.get(index); } finally { readLock.unlock(); } } public E set(int index, E element) { Lock wirteLock = readWriteLock.writeLock(); wirteLock.lock(); try { return list.set(index, element); } finally { wirteLock.unlock(); } }}
如代碼在get方法時候通過 readWriteLock.readLock()擷取了讀鎖,多個線程可以同時擷取這讀鎖,set方法通過readWriteLock.writeLock()擷取了寫鎖,同時只有一個線程可以擷取寫鎖,其他線程在擷取寫鎖時候會阻塞直到寫鎖被釋放。假如一個線程已經擷取了讀鎖,這時候如果一個線程要擷取寫鎖時候要等待直到釋放了讀鎖,如果一個線程擷取了寫鎖,那麼所有擷取讀鎖的線程需要等待直到寫鎖被釋放。所以相比synchronized來說運行多個讀者同時存在,所以提高了並發量。
注意 需要使用者顯示調用Lock與unlock操作 九、 Volatile變數
對於避免不可見度問題,Java還提供了一種弱形式的同步,即使用了volatile關鍵字。該關鍵字確保了對一個變數的更新對其他線程可見。當一個變數被聲明為volatile時候,線程寫入時候不會把值緩衝在寄存器或者或者在其他地方,當線程讀取的時候會從主記憶體重新擷取最新值,而不是使用當前線程的拷貝記憶體變數值。
volatile雖然提供了可見度保證,但是不能使用他來構建複合的原子性操作,也就是說當一個變數依賴其他變數或者更新變數值時候新值依賴當前老值時候不在適用。與synchronized相似之處在於如圖
如圖線程A修改了volatile變數b的值,然後線程B讀取了改變數值,那麼所有A線程在寫入變數b值前可見的變數值,在B讀取volatile變數b後對線程B都是可見的,圖中線程B對A操作的變數a,b的值都可見的。volatile的記憶體語義和synchronized有類似之處,具體說是說當線程寫入了volatile變數值就等價於線程退出synchronized同步塊(會把寫入到本地記憶體的變數值同步到主記憶體),讀取volatile變數值就相當於進入同步塊(會先清空本地記憶體變數值,從主記憶體擷取最新值)。
下面的Integer也是線程不安全的,因為沒有進行同步措施
public class ThreadNotSafeInteger { private int value; public int get() { return value; } public void set(int value) { this.value = value; } }
使用synchronized關鍵字進行同步如下:
public class ThreadSafeInteger { private int value; public synchronized int get() { return value; } public synchronized void set(int value) { this.value = value; } }
等價於使用volatile進行同步如下:
public class ThreadSafeInteger { private volatile int value; public int get() { return value; } public void set(int value) { this.value = value; } }
這裡使用synchronized和使用volatile是等價的,但是並不是所有情況下都是等價,一般只有滿足下面所有條件才能使用volatile 寫入變數值時候不依賴變數的當前值,或者能夠保證只有一個線程修改變數值。 寫入的變數值不依賴其他變數的參與。 讀取變數值時候不能因為其他原因進行枷鎖。
另外 加鎖可以同時保證可見度和原子性,而volatile只保證變數值的可見度。
注意 volatile關鍵字不會引起線程環境切換和線程調度。另外volatile還用來解決重排序問題,後面會講到。 十、 樂觀鎖與悲觀鎖 10.1 悲觀鎖
悲觀鎖,指資料被外界修改持保守態度(悲觀),在整個資料處理過程中,將資料處於鎖定狀態。 悲觀鎖的實現,往往依靠資料庫提供的鎖機制 。資料庫中實現是對資料記錄進行操作前,先給記錄加排它鎖,如果擷取鎖失敗,則說明資料正在被其他線程修改,則等待或者拋出異常。如果加鎖成功,則擷取記錄,對其修改,然後事務提交後釋放排它鎖。
一個例子:select * from 表 where .. for update;
悲觀鎖是先加鎖再存取原則,處理加鎖會讓資料庫產生額外的開銷,還有增加產生死結的機會,另外在多個線程唯讀情況下不會產生資料不一致行問題,沒必要使用鎖,只會增加系統負載,降低並發性,因為當一個事務鎖定了該條記錄,其他讀該記錄的事務只能等待。 10.2 樂觀鎖
樂觀鎖是相對悲觀鎖來說的,它認為資料一般情況下不會造成衝突,所以在訪問記錄前不會加獨佔鎖定,而是在資料進行提交更新的時候,才會正式對資料的衝突與否進行檢測,具體說根據update返回的行數讓使用者決定如何去做。樂觀鎖並不會使用資料庫提供的鎖機制,一般在表添加version欄位或者使用業務狀態來做。
具體可以參考:https://www.atatech.org/articles/79240
樂觀鎖直到提交的時候才去鎖定,所以不會產生任何鎖和死結。 十一、獨佔鎖與共用鎖定
根據鎖能夠被單個線程還是多個線程共同持有,鎖又分為獨佔鎖和共用鎖定。獨佔鎖保證任何時候都只有一個線程能讀寫權限,ReentrantLock就是以獨佔方式實現的互斥鎖。共用鎖定則可以同時有多個讀線程,但最多隻能有一個寫線程,讀和寫是互斥的,例如ReadWriteLock讀寫鎖,它允許一個資源可以被多線程同時進行讀操作,或者被一個線程 寫操作,但兩者不能同時進行。
獨佔鎖是一種悲觀鎖,每次訪問資源都先加上互斥鎖,這限制了並發性,因為讀操作並不會影響資料一致性,而獨佔鎖只允許同時一個線程讀取資料,其他線程必須等待當前線程釋放鎖才能進行讀取。
共用鎖定則是一種樂觀鎖,它放寬了加鎖的條件,允許多個線程同時進行讀操作。 十二、公平鎖與非公平鎖
根據線程擷取鎖的搶佔機制鎖可以分為公平鎖和非公平鎖,公平鎖表示線程擷取鎖的順序是按照線程加鎖的時間多少來決定的,也就是最早加鎖的線程將最早擷取鎖,也就是先來先得的FIFO順序。而非公平鎖則運行闖入,也就是先來不一定先得。
ReentrantLock提供了公平和非公平鎖的實現:
公平鎖ReentrantLock pairLock = new ReentrantLock(true);
非公平鎖 ReentrantLock pairLock = new ReentrantLock(false);
如果建構函式不傳遞參數,則預設是非公平鎖。
在沒有公平性需求的前提下盡量使用非公平鎖,因為公平鎖會帶來效能開銷。
假設線程A已經持有了鎖,這時候線程B請求該鎖將會被掛起,當線程A釋放鎖後,假如當前有線程C也需要擷取該鎖,如果採用非公平鎖方式,則根據線程調度策略線程B和C兩者之一可能擷取鎖,這時候不需要任何其他幹涉,如果使用公平鎖則需要把C掛起,讓B擷取當前鎖。 十三、 AbstractQueuedSynchronizer介紹
AbstractQueuedSynchronizer提供了一個隊列,大多數開發人員可能從來不會直接用到AQS,AQS有個變數用來存放狀態資訊 state,可以通過protected的getState,setState,compareAndSetState函數進行調用。對於ReentrantLock來說,state可以用來表示該線程獲可重新進入鎖的次數,semaphore來說state用來表示當前可用訊號的個數,FutuerTask用來表示任務狀態(例如還沒開始,運行,完成,取消)。 十四、CountDownLatch原理 14.1 一個例子
public class Test { private static final int ThreadNum = 10; public static void main(String[] args) { //建立一個CountDownLatch執行個體,管理計數為ThreadNum CountDownLatch countDownLatch = new CountDownLatch(ThreadNum); //建立一個固定大小的線程池 ExecutorService executor = Executors.newFixedThreadPool(ThreadNum); //添加線程到線程池 for(int i =0;i<ThreadNum;++i){ executor.execute(new Person(countDownLatch, i+1)); } System.out.println("開始等待全員簽到..."); try { //等待所有線程執行完畢 countDownLatch.await(); System.out.println("簽到完畢,開始吃飯"); } catch (InterruptedException e) { e.printStackTrace(); }finally { executor.shutdown(); } } static class Person implements Runnable{ private CountDownLatch countDownLatch; private int index; public Person(CountDownLatch cdl,int index){ this.countDownLatch = cdl; this.index = index; } @Override public void run() { try { Thread.sleep(1000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } System.out.println("person " + index +"簽到"); //線程執行完畢,計數器減一 countDownLatch.countDown(); } }}
如上代碼,建立一個線程池和CountDownLatch執行個體,每個線程通過建構函式傳入CountDownLatch的執行個體,主線程通過await等待線程池裡麵線程任務全部執行完畢,子線程則執行完畢後調用countDown計數器減一,等所有子線程執行完畢後,主線程的await才會返回。 14.2 原理
先看下類圖:
可知CountDownLatch內部還是使用AQS實現的。
首先通過建構函式初始化AQS的狀態值
public CountDownLatch(int count) { if (count < 0) throw new IllegalArgumentException("count < 0"); this.sync = new Sync(count); } Sync(int count) { setState(count); }
然後看下await方法:
public final void acquireSharedInterruptibly(int arg) throws InterruptedException { //如果線程被中斷則拋異常 if (Thread.interrupted()) throw new InterruptedException(); //嘗試看當前是否計數值為0,為0則直接返回,否者進入隊列等待 if (tryAcquireShared(arg) < 0) doAcquireSharedInterruptibly(arg); } protected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; }
如果tryAcquireShared返回-1則 進入doAcquireSharedInterruptibly
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException { //排入佇列狀態為共用節點 final Node node = addWaiter(Node.SHARED); boolean failed = true; try { for (;;) { final Node p = node.predecessor(); if (p == head) { int r = tryAcquireShared(arg); if (r >= 0) { //如果多個線程調用了await被放入隊列則一個個返回。 setHeadAndPropagate(node, r); p.next = null; // help GC failed = false; return; } } //shouldParkAfterFailedAcquire會把當前節點狀態變為SIGNAL類型,然後調用park方法把當先線程掛起, if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }
調用await後,當前線程會被阻塞,直到所有子線程調用了countdown方法,並在計數為0時候調用該線程unpark方法啟用線程,然後該線程重新tryAcquireShared會返回1。
然後看下 countDown方法:
委託給sync public void countDown() { sync.releaseShared(1); }
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }
首先看下tryReleaseShared
protected boolean tryReleaseShared(int releases) { //迴圈進行cas,直到當前線程成功完成cas使計數值(狀態值state)減一更新到state for (;;) { int c = getState(); if (c == 0) return false; int nextc = c-1; if (compareAndSetState(c, nextc)) return nextc == 0; } }
該函數一直返回false直到當前計數器為0時候才返回true。
返回true後會調用doReleaseShared,該函數主要作用是調用uppark方法啟用調用await的線程,代碼如下:
private void doReleaseShared() { for (;;) { Node h = head; if (h != null && h != tail) { int ws = h.waitStatus; //節點類型為SIGNAL,把類型在通過cas設定回去,然後調用unpark啟用調用await的線程 if (ws == Node.SIGNAL) { if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // loop to recheck cases unparkSuccessor(h); } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; // loop on failed CAS }