天天看點

Java高效并發之樂觀鎖、悲觀鎖、互斥同步、非互斥同步

樂觀鎖和悲觀鎖

首先我們了解下兩種不同思路的鎖,樂觀鎖和悲觀鎖。

這兩種鎖機制,是在多使用者環境并發控制的兩種所機制。下面看百度百科對樂觀鎖和悲觀鎖兩種鎖機制的定義:

樂觀鎖( Optimistic Locking ) 相對悲觀鎖而言,樂觀鎖機制采取了更加寬松的加鎖機制。悲觀鎖大多數情況下依靠資料庫的鎖機制實作,以保證操作最大程度的獨占性。但随之而來的就是資料庫性能的大量開銷,特别是對長事務而言,這樣的開銷往往無法承受。而樂觀鎖機制在一定程度上解決了這個問題。樂觀鎖,大多是基于資料版本( Version )記錄機制實作。何謂資料版本?即為資料增加一個版本辨別,在基于資料庫表的版本解決方案中,一般是通過為資料庫表增加一個 “version” 字段來實作。讀取出資料時,将此版本号一同讀出,之後更新時,對此版本号加一。此時,将送出資料的版本資料與資料庫表對應記錄的目前版本資訊進行比對,如果送出的資料版本号大于資料庫表目前版本号,則予以更新,否則認為是過期資料。

悲觀鎖(Pessimistic Lock),正如其名,具有強烈的獨占和排他特性。它指的是對資料被外界(包括本系統目前的其他事務,以及來自外部系統的事務處理)修改持保守态度,是以,在整個資料處理過程中,将資料處于鎖定狀态。悲觀鎖的實作,往往依靠資料庫提供的鎖機制(也隻有資料庫層提供的鎖機制才能真正保證資料通路的排他性,否則,即使在本系統中實作了加鎖機制,也無法保證外部系統不會修改資料)。

簡而言之:

悲觀鎖:假定會發生并發沖突,屏蔽一切可能違反資料完整性的操作。

樂觀鎖:假設不會發生并發沖突,隻在送出操作時檢查是否違反資料完整性。 樂觀鎖不能解決髒讀的問題。

Java中的樂觀鎖和悲觀鎖:我們都知道,cpu是分時複用的,也就是把cpu的時間片,配置設定給不同的thread/process輪流執行,時間片與時間片之間,需要進行cpu切換,也就是會發生程序的切換。切換涉及到清空寄存器,緩存資料。然後重新加載新的thread所需資料。當一個線程被挂起時,加入到阻塞隊列,在一定的時間或條件下,在通過notify(),notifyAll()喚醒回來。在某個資源不可用的時候,就将cpu讓出,把目前等待線程切換為阻塞狀态。等到資源(比如一個共享資料)可用了,那麼就将線程喚醒,讓他進入runnable狀态等待cpu排程。這就是典型的悲觀鎖的實作。獨占鎖是一種悲觀鎖,synchronized就是一種獨占鎖,它假設最壞的情況,并且隻有在確定其它線程不會造成幹擾的情況下執行,會導緻其它所有需要鎖的線程挂起,等待持有鎖的線程釋放鎖。

但是,由于在程序挂起和恢複執行過程中存在着很大的開銷。當一個線程正在等待鎖時,它不能做任何事,是以悲觀鎖有很大的缺點。舉個例子,如果一個線程需要某個資源,但是這個資源的占用時間很短,當線程第一次搶占這個資源時,可能這個資源被占用,如果此時挂起這個線程,可能立刻就發現資源可用,然後又需要花費很長的時間重新搶占鎖,時間代價就會非常的高。

是以就有了樂觀鎖的概念,他的核心思路就是,每次不加鎖而是假設沒有沖突而去完成某項操作,如果因為沖突失敗就重試,直到成功為止。在上面的例子中,某個線程可以不讓出cpu,而是一直while循環,如果失敗就重試,直到成功為止。是以,當資料争用不嚴重時,樂觀鎖效果更好。比如CAS就是一種樂觀鎖思想的應用。

JDK1.5中引入了底層的支援,在int、long和對象的引用等類型上都公開了CAS的操作,并且JVM把它們編譯為底層硬體提供的最有效的方法,在運作CAS的平台上,運作時把它們編譯為相應的機器指令。在java.util.concurrent.atomic包下面的所有的原子變量類型中,比如AtomicInteger,都使用了這些底層的JVM支援為數字類型的引用類型提供一種高效的CAS操作。在CAS操作中,會出現ABA問題。就是如果V的值先由A變成B,再由B變成A,那麼仍然認為是發生了變化,并需要重新執行算法中的步驟。有簡單的解決方案:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本号,即使這個值由A變為B,然後為變為A,版本号也是不同的。AtomicStampedReference和AtomicMarkableReference支援在兩個變量上執行原子的條件更新。AtomicStampedReference更新一個“對象-引用”二進制組,通過在引用上加上“版本号”,進而避免ABA問題,AtomicMarkableReference将更新一個“對象引用-布爾值”的二進制組。

互斥(阻塞)同步和非互斥(非阻塞)同步

了解了什麼是線程安全之後,緊接着的一個問題就是我們應該如何實作線程安全,這聽起來似乎是一件由代碼如何編寫來決定的事情,确實,如何實作線程安全與代碼編寫有很大的關系,但虛拟機提供的同步和鎖機制也起到了非常重要的作用。本節中,代碼編寫如何實作線程安全和虛拟機如何實作同步與鎖這兩者都會有所涉及,相對而言更偏重後者一些,隻要讀者了解了虛拟機線程安全手段的運作過程,自己去思考代碼如何編寫并不是一件困難的事情。

1.互斥(阻塞)同步

互斥同步(Mutual Exclusion&Synchronization)是常見的一種并發正确性保障手段。同步是指在多個線程并發通路共享資料時,保證共享資料在同一個時刻隻被一個(或者是一些,使用信号量的時候)線程使用。而互斥是實作同步的一種手段,臨界區(CriticalSection)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥實作方式。是以,在這4個字裡面,互斥是因,同步是果;互斥是方法,同步是目的。

在Java中,最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯之後,會在同步塊的前後分别形成monitorenter和monitorexit這兩個位元組碼指令,這兩個位元組碼都需要一個reference類型的參數來指明要鎖定和解鎖的對象。如果Java程式中的synchronized明确指定了對象參數,那就是這個對象的reference;如果沒有明确指定,那就根據synchronized修飾的是執行個體方法還是類方法,去取對應的對象執行個體或Class對象來作為鎖對象。

根據虛拟機規範的要求,在執行monitorenter指令時,首先要嘗試擷取對象的鎖。如果這個對象沒被鎖定,或者目前線程已經有了那個對象的鎖,把鎖的計數器加1,相應的,在執行monitorexit指令時會将鎖計數器減1,當計數器為0時,鎖就被釋放。如果擷取對象鎖失敗,那目前線程就要阻塞等待,直到對象鎖被另外一個線程釋放為止。

在虛拟機規範對monitorenter和monitorexit的行為描述中,有兩點是需要特别注意的。首先,synchronized同步塊對同一條線程來說是可重入的,不會出現自己把自己鎖死的問題。其次,同步塊在已進入的線程執行完之前,會阻塞後面其他線程的進入。第12章講過,Java的線程是映射到作業系統的原生線程之上的,如果要阻塞或喚醒一個線程,都需要作業系統來幫忙完成,這就需要從使用者态轉換到核心态中,是以狀态轉換需要耗費很多的處理器時間。對于代碼簡單的同步塊(如被synchronized修飾的getter()或setter()方法),狀态轉換消耗的時間有可能比使用者代碼執行的時間還要長。是以synchronized是Java語言中一個重量級(Heavyweight)的操作,有經驗的程式員都會在确實必要的情況下才使用這種操作。而虛拟機本身也會進行一些優化,譬如在通知作業系統阻塞線程之前加入一段自旋等待過程,避免頻繁地切入到核心态之中。

除了synchronized之外,我們還可以使用java.util.concurrent(下文稱J.U.C)包中的重入鎖(ReentrantLock)來實作同步,在基本用法上,ReentrantLock與synchronized很相似,他們都具備一樣的線程重入特性,隻是代碼寫法上有點差別,一個表現為API層面的互斥鎖(lock()和unlock()方法配合try/finally語句塊來完成),另一個表現為原生文法層面的互斥鎖。不過,相比synchronized,ReentrantLock增加了一些進階功能,主要有以下3項:等待可中斷、可實作公平鎖,以及鎖可以綁定多個條件。

(1)等待可中斷是指當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改為處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。

(2)公平鎖是指多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock預設情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖。

(3)鎖綁定多個條件是指一個ReentrantLock對象可以同時綁定多個Condition對象,而在synchronized中,鎖對象的wait()和notify()或notifyAll()方法可以實作一個隐含的條件,如果要和多于一個的條件關聯的時候,就不得不額外地添加一個鎖,而ReentrantLock則無須這樣做,隻需要多次調用newCondition()方法即可。如果需要使用上述功能,選用ReentrantLock是一個很好的選擇,那如果是基于性能考慮呢?關于synchronized和ReentrantLock的性能問題,一般情況下,多線程環境下synchronized的吞吐量下降得非常嚴重,而ReentrantLock則能基本保持在同一個比較穩定的水準上。與其說ReentrantLock性能好,還不如說synchronized還有非常大的優化餘地。後續的技術發展也證明了這一點,JDK 1.6中加入了很多針對鎖的優化措施(13.3節我們就會講解這些優化措施),JDK 1.6釋出之後,人們就發現synchronized與ReentrantLock的性能基本上是完全持平了。是以,如果讀者的程式是使用JDK 1.6或以上部署的話,性能因素就不再是選擇ReentrantLock的理由了,虛拟機在未來的性

能改進中肯定也會更加偏向于原生的synchronized,是以還是提倡在synchronized能實作需求的情況下,優先考慮使用synchronized來進行同步。

2.非互斥(非阻塞)同步

互斥同步最主要的問題就是進行線程阻塞和喚醒所帶來的性能問題,是以這種同步也稱為阻塞同步(Blocking Synchronization)。從處理問題的方式上說,互斥同步屬于一種悲觀的并發政策,總是認為隻要不去做正确的同步措施(例如加鎖),那就肯定會出現問題,無論共享資料是否真的會出現競争,它都要進行加鎖(這裡讨論的是概念模型,實際上虛拟機會優化掉很大一部分不必要的加鎖)、使用者态核心态轉換、維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作。随着硬體指令集的發展,我們有了另外一個選擇:基于沖突檢測的樂觀并發政策,通俗地說,就是先進行操作,如果沒有其他線程争用共享資料,那操作就成功了;如果共享資料有争用,産生了沖突,那就再采取其他的補償措施(最常見的補償措施就是不斷地重試,直到成功為止),這種樂觀的并發政策的許多實作都不需要把線程挂起,是以這種同步操作稱為非阻塞同步(Non-Blocking Synchronization)。

為什麼筆者說使用樂觀并發政策需要“硬體指令集的發展”才能進行呢?因為我們需要操作和沖突檢測這兩個步驟具備原子性,靠什麼來保證呢?如果這裡再使用互斥同步來保證就失去意義了,是以我們隻能靠硬體來完成這件事情,硬體保證一個從語義上看起來需要多次操作的行為隻通過一條處理器指令就能完成。

這類指令常用的有:

測試并設定(Test-and-Set)。

擷取并增加(Fetch-and-Increment)。

交換(Swap)。

比較并交換(Compare-and-Swap,下文稱CAS)。

加載連結/條件存儲(Load-Linked/Store-Conditional,下文稱LL/SC)。

其中,前面的3條是20世紀就已經存在于大多數指令集之中的處理器指令,後面的兩條是現代處理器新增的,而且這兩條指令的目的和功能是類似的。在IA64、x86指令集中有cmpxchg指令完成CAS功能,在sparc-TSO也有casa指令實作,而在ARM和PowerPC架構下,則需要使用一對ldrex/strex指令來完成LL/SC的功能。

CAS指令需要有3個操作數,分别是記憶體位置(在Java中可以簡單了解為變量的記憶體位址,用V表示)、舊的預期值(用A表示)和新值(用B表示)。CAS指令執行時,當且僅當V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新了V的值,都會傳回V的舊值,上述的處理過程是一個原子操作。

在JDK  1.5之後,Java程式中才可以使用CAS操作,該操作由sun.misc.Unsafe類裡面的compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供,虛拟機在内部對這些方法做了特殊處理,即時編譯出來的結果就是一條平台相關的處理器CAS指令,沒有方法調用的過程,或者可以認為是無條件内聯進去了  。

由于Unsafe類不是提供給使用者程式調用的類(Unsafe.getUnsafe()的代碼中限制了隻有啟動類加載器(Bootstrap  ClassLoader)加載的Class才能通路它),是以,如果不采用反射手段,我們隻能通過其他的Java  API來間接使用它,如J.U.C包裡面的整數原子類,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe類的CAS操作。

我們不妨拿一段代碼來看看如何使用CAS操作來避免阻塞同步,代碼如代碼清單12-1所示。我們曾經通過這段20個線程自增10000次的代碼來證明volatile變量不具備原子性,那麼如何才能讓它具備原子性呢?把“race++”操作或increase()方法用同步塊包裹起來當然是一個辦法,但是如果改成如代碼清單13-4所示的代碼,那效率将會提高許多。

/**
*Atomic變量自增運算測試
*
*@author
*/
public class AtomicTest{
    public static AtomicInteger race=new AtomicInteger(0);
    public static void increase(){
        race.incrementAndGet();
    }
    private static final int THREADS_COUNT=20;
    public static void main(String[]args)throws Exception{
        Thread[]threads=new Thread[THREADS_COUNT];
        for(int i=0;i<THREADS_COUNT;i++){
            threads[i]=new Thread(new Runnable(){
                @Override
                public void run(){
                    for(int i=0;i<10000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while(Thread.activeCount()>1)
        Thread.yield();
        System.out.println(race);
    }
}
           

運作結果如下:

200000

使用AtomicInteger代替int後,程式輸出了正确的結果,一切都要歸功于incrementAndGet()方法的原子性。它的實作其實非常簡單,如下代碼清單。

/**
 * Atomically increment by one the current value.
 *
 * @return the updated value
 */
public final int incrementAndGet(){
        for(;){
            int current=get();
            int next=current+1;
            if(compareAndSet(current,next))
            return next;
        }
}
           

incrementAndGet()方法在一個無限循環中,不斷嘗試将一個比目前值大1的新值賦給自己。如果失敗了,那說明在執行“擷取-設定”操作的時候值已經有了修改,于是再次循環進行下一次操作,直到設定成功為止。盡管CAS看起來很美,但顯然這種操作無法涵蓋互斥同步的所有使用場景,并且CAS從語義上來說并不是完美的,存在這樣的一個邏輯漏洞:如果一個變量V初次讀取的時候是A值,并且在準備指派的時候檢查到它仍然為A值,那我們就能說它的值沒有被其他線程改變過了嗎?如果在這段期間它的值曾經被改成了B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過。這個漏洞稱為CAS操作的“ABA”問題。J.U.C包為了解決這個問題,提供了一個帶有标記的原子引用類“AtomicStampedReference”,它可以通過控制變量值的版本來保證CAS的正确性。不過目前來說這個類比較“雞肋”,大部分情況下ABA問題不會影響程式并發的正确性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效。

鎖優化

高效并發是從JDK 1.5到JDK 1.6的一個重要改進,HotSpot虛拟機開發團隊在這個版本上花費了大量的精力去實作各種鎖優化技術,如适應性自旋(Adaptive  Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)和偏向鎖(Biased Locking)等,這些技術都是為了線上程之間更高效地共享資料,以及解決競争問題,進而提高程式的執行效率。

繼續閱讀