悲觀鎖(Synchronized)和樂觀鎖(CAS)

悲觀鎖和樂觀鎖

悲觀鎖:總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖。傳統的關係型數據庫裏邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。再比如Java裏面的同步原語synchronized關鍵字的實現也是悲觀鎖。

樂觀鎖:顧名思義,就是很樂觀,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號等機制。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,像數據庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實現方式CAS實現的。

Synchronized

在Java1.5及以前的版本中,synchronized並不是同步最好的選擇,由於併發時頻繁的阻塞和喚醒線程,會浪費許多資源在線程狀態的切換上,導致了synchronized的併發效率在某些情況下不如ReentrantLock。在Java1.6的版本中,對synchronized進行了許多優化,極大的提高了synchronized的性能。只要synchronized能滿足使用環境,建議使用synchronized而不使用ReentrantLock。

Synchronized使用

三種方式:

修飾實例方法,爲當前實例加鎖,進入同步方法前要獲得當前實例的鎖。
修飾靜態方法,爲當前類對象加鎖,進入同步方法前要獲得當前類對象的鎖。
修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼塊前要獲得給定對象的鎖。

有一個要注意的地方是對靜態方法的修飾可以和實例方法的修飾同時使用,不會阻塞,因爲一個是修飾的Class類,一個是修飾的實例對象。下面的例子可以說明這一點:

public class SynchronizedTest {

	public static synchronized void StaticSyncTest() {

		for (int i = 0; i < 3; i++) {
			System.out.println("StaticSyncTest");
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
	}

	public synchronized void NonStaticSyncTest() {

		for (int i = 0; i < 3; i++) {
			System.out.println("NonStaticSyncTest");
			try {
				TimeUnit.SECONDS.sleep(1);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
	}
}

public static void main(String[] args) throws InterruptedException {

    SynchronizedTest synchronizedTest = new SynchronizedTest();
    new Thread(new Runnable() {
		@Override
		public void run() {
			SynchronizedTest.StaticSyncTest();
		}
	}).start();
    new Thread(new Runnable() {
		@Override
		public void run() {
			synchronizedTest.NonStaticSyncTest();
		}
	}).start();
}

//StaticSyncTest
//NonStaticSyncTest
//StaticSyncTest
//NonStaticSyncTest
//StaticSyncTest
//NonStaticSyncTest

代碼中我們開啓了兩個線程分別鎖定靜態方法和實例方法,從打印的輸出結果中我們可以看到,這兩個線程鎖定的是不同對象,可以併發執行。

Synchronized底層原理

我們看一段synchronized關鍵字經過編譯後的字節碼:

if (null == instance) {   
	synchronized (DoubleCheck.class) {
		if (null == instance) {   
			instance = new DoubleCheck();   
		}
	}
}

在這裏插入圖片描述在這裏插入圖片描述

可以看到synchronized關鍵字在同步代碼塊前後加入了monitorenter和monitorexit這兩個指令。monitorenter指令會獲取鎖對象,如果獲取到了鎖對象,就將鎖計數器加1,未獲取到則會阻塞當前線程。monitorexit指令會釋放鎖對象,同時將鎖計數器減1。

Java1.6對Synchronized的優化

JDK1.6對對synchronized的優化主要體現在引入了“偏向鎖”和“輕量級鎖”的概念,同時synchronized的鎖只可升級,不可降級
在這裏插入圖片描述

偏向鎖:

偏向鎖的思想是指如果一個線程獲得了鎖,那麼就從無鎖模式進入偏向模式,這一步是通過CAS操作來做的,進入偏向模式的線程每一次訪問這個鎖的同步代碼塊時都不需要再進行同步操作,除非有其他線程訪問這個鎖。
偏向鎖提高的是那些帶同步但無競爭的代碼的性能,也就是說如果你的同步代碼塊很長時間都是同一個線程訪問,偏向鎖就會提高效率,因爲他減少了重複獲取鎖和釋放鎖產生的性能消耗。如果你的同步代碼塊會頻繁的在多個線程之間訪問,可以使用參數-XX:-UseBiasedLocking來禁止偏向鎖產生,避免在多個鎖狀態之間切換。

輕量級鎖:

偏向鎖優化了只有一個線程進入同步代碼塊的情況,當多個線程訪問鎖時偏向鎖就升級爲了輕量級鎖。
輕量級鎖的思想是當多個線程進入同步代碼塊後,多個線程未發生競爭時一直保持輕量級鎖,通過CAS來獲取鎖。如果發生競爭,首先會採用CAS自旋操作來獲取鎖,自旋在極短時間內發生,有固定的自旋次數,一旦自旋獲取失敗,則升級爲重量級鎖。
輕量級鎖優化了多個線程進入同步代碼塊的情況,多個線程未發生競爭時,可以通過CAS獲取鎖,減少鎖狀態切換。當多個線程發生競爭時,不是直接阻塞線程,而是通過CAS自旋來嘗試獲取鎖,減少了阻塞線程的概率,這樣就提高了synchronized鎖的性能。

synchronized的等待喚醒機制

synchronized的等待喚醒是通過notify/notifyAll和wait三個方法來實現的,這三個方法的執行都必須在同步代碼塊或同步方法中進行,否則將會報錯。

wait方法的作用是使當前執行代碼的線程進行等待,notify/notifyAll相同,都是通知等待的代碼繼續執行,notify只通知任一個正在等待的線程,notifyAll通知所有正在等待的線程。wait方法跟sleep不一樣,他會釋放當前同步代碼塊的鎖,notify在通知任一等待的線程時不會釋放鎖,只有在當前同步代碼塊執行完成之後纔會釋放鎖。下面的代碼可以說明這一點:

public static void main(String[] args) throws InterruptedException {
    waitThread();
    notifyThread();
}

private static Object lockObject = new Object();
	
private static void waitThread() {
    
    Thread watiThread = new Thread(new Runnable() {
        
        @Override
        public void run() {
            
            synchronized (lockObject) {
                System.out.println(Thread.currentThread().getName() + "wait-before");
                
                try {
                    TimeUnit.SECONDS.sleep(2);
                    lockObject.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
                System.out.println(Thread.currentThread().getName() + "after-wait");
            }
            
        }
    },"waitthread");
    watiThread.start();
}

private static void notifyThread() {
    
    Thread watiThread = new Thread(new Runnable() {
        
        @Override
        public void run() {
            
            synchronized (lockObject) {
                System.out.println(Thread.currentThread().getName() + "notify-before");
                
                lockObject.notify();
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } 
                
                System.out.println(Thread.currentThread().getName() + "after-notify");
            }
            
        }
    },"notifythread");
    watiThread.start();
}

//waitthreadwait-before
//notifythreadnotify-before
//notifythreadafter-notify
//waitthreadafter-wait

代碼中notify線程通知之後wait線程並沒有馬上啓動,還需要notity線程執行完同步代碼塊釋放鎖之後wait線程纔開始執行。

CAS

在synchronized的優化過程中我們看到大量使用了CAS操作,CAS全稱Compare And Set(或Compare And Swap),CAS包含三個操作數:內存位置(V)、原值(A)、新值(B)。簡單來說CAS操作就是一個虛擬機實現的原子操作,這個原子操作的功能就是將舊值(A)替換爲新值(B),如果舊值(A)未被改變,則替換成功,如果舊值(A)已經被改變則什麼都不做。進入一個自旋操作,即不斷的重試。

CAS使用

可以通過AtomicInteger類的自增代碼來說明這個問題,當不使用同步時下面這段代碼很多時候不能得到預期值10000,因爲noncasi[0]++不是原子操作,代碼如下:

private static void IntegerTest() throws InterruptedException {

    final Integer[] noncasi = new Integer[]{ 0 };

    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(new Runnable() {

            @Override
            public void run() {
                for (int j = 0; j < 1000; j++) {
                    noncasi[0]++;
                }
            }
        });
        thread.start();
    }
    
    while (Thread.activeCount() > 2) {
        Thread.sleep(10);
    }
    System.out.println(noncasi[0]);
}

//7889

當使用AtomicInteger的getAndIncrement方法來實現自增之後相當於將casi.getAndIncrement()操作變成了原子操作:

private static void AtomicIntegerTest() throws InterruptedException {

    AtomicInteger casi = new AtomicInteger();
    casi.set(0);

    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(new Runnable() {

            @Override
            public void run() {
                for (int j = 0; j < 1000; j++) {
                    casi.getAndIncrement();
                }
            }
        });
        thread.start();
    }
    while (Thread.activeCount() > 2) {
        Thread.sleep(10);
    }
    System.out.println(casi.get());
}

//10000

當然也可以通過synchronized關鍵字來達到目的,但CAS操作不需要加鎖解鎖以及切換線程狀態,效率更高。

再來看看casi.getAndIncrement()具體做了什麼,在JDK1.8之前getAndIncrement是這樣實現的(類似incrementAndGet):

private volatile int value;

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

通過compareAndSet將變量自增,如果自增成功則完成操作,如果自增不成功,則自旋進行下一次自增,由於value變量是volatile修飾的,通過volatile的可見性,每次get()都能獲取到最新值,這樣就保證了自增操作每次自旋一定次數之後一定會成功。

JDK1.8中則直接將getAndAddInt方法直接封裝成了原子性的操作,更加方便使用:

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

CAS底層原理

CAS通過調用JNI的代碼實現的。JNI:Java Native Interface爲JAVA本地調用,允許java調用其他語言。
而compareAndSwapInt就是藉助C來調用CPU底層指令實現的。
更多參考:JAVA CAS原理深度分析

CAS的缺陷

1.ABA問題

問題:

因爲CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。

解決方法:

從Java1.5開始JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

2.循環開銷過大

問題:

前面說過,如果舊值(A)已經被改變,就會進入自旋操作。
自旋CAS(也就是不成功就一直循環執行直到成功)如果長時間不成功,會給CPU帶來非常大的執行開銷。例如,Unsafe下的getAndAddInt方法會一直循環,知道成功纔會返回。

解決方案:

如果JVM能支持處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。

3.只能保證一個共享變量的原子操作

問題:

當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性。

解決方案;

可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合併成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裏來進行CAS操作。

concurrent包的實現

CAS操作是實現Java併發包的基石,他理解起來比較簡單但同時也非常重要。Java併發包就是在CAS操作和volatile基礎上建立的

由於java的CAS同時具有 volatile 讀和volatile寫的內存語義,因此Java線程之間的通信現在有了下面四種方式:

A線程寫volatile變量,隨後B線程讀這個volatile變量。
A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。
A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。
A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量。

如果我們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:

首先,聲明共享變量爲volatile;
然後,使用CAS的原子條件更新來實現線程之間的同步;
同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間的通信。

下圖中列舉了J.U.C包中的部分類支撐圖:
在這裏插入圖片描述

參考鏈接:Java併發(4)- synchronized與CAS
參考鏈接:JAVA CAS原理深度分析

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