14-Java鎖的概述

14-鎖的概述

樂觀鎖與悲觀鎖

​ 樂觀鎖與悲觀鎖是資料庫中引入的名詞,但是在並發包里也引入了類似的思想,在這裡我們還是有必要需要了解一下。

​ 悲觀鎖指數據被外界修改持保守態度,認為數據會很容易被其他執行緒修改,所以在數據處理前先要對數據進行加鎖,在整個數據處理中,使得數據處於鎖定狀態。悲觀鎖的實現往往依靠資料庫提供的鎖機制,即在資料庫中,在對數據記錄操作前給記錄加排它鎖。如果獲取鎖失敗,則說明數據正在被其他執行緒修改,當前執行緒則等待或者拋出異常。如果獲取鎖成功,則對記錄進行操作,然後提交事務後釋放排它鎖。

​ 下面我們看一個例子,看它如何使用悲觀鎖避免多執行緒同時對一個記錄進行修改。

public int updateEntry(long id){
    //使用悲觀鎖獲取指定記錄(1)
    EntryObject entry=query("select * from table1 where id=#{id} for update",id);
    //修改記錄內容(2)
    String name=generatorName(entry);
    entry.setName(name);
    //update操作(3)
    int count=udpate("update table1 set name=#{name},age=#{age} where id=#{id}",entry);
    return count;
}

​ 在如上程式碼中,假設updateEntry,query,update使用了事務切面的方法,並且事務傳播性被設置為required。執行updateEntry方法時如果上層調用方法裡面沒有開啟事務,則會即時開啟一個事務,然後執行程式碼(1),程式碼(1)調用query()方法,其根據Id查詢出一條記錄來,由於事務傳播是erquired所以執行query時沒有開啟新的事務,而是加入了updateEntry開啟的事務,也就是在updateEntry方法執行完畢提交事務時,query才會被提交,也就是說記錄的鎖會持續到updateEntry執行結束。

​ 程式碼(2)則對獲取到的記錄進行修改,程式碼(3)把修改的新內容寫入資料庫,同樣update方法也沒有開啟新的事務,而是加入了updateEntry的事務。也就是說updateEntry,query,update公用一個事務。

​ 當多個執行緒同時調用updateEntry方法,並且傳遞的是同一個id,只有一個執行緒執行程式碼(1)會成功,其他執行緒則會被阻塞掛起,這是因為在同一時間只能有一個執行緒可以獲取到對應的鎖,在獲得鎖的執行緒釋放鎖之前(updateEntry執行完畢,提交事務前),其他執行緒必須等待,也就是同一時間只有一個執行緒可以對該記錄進行修改。

​ 樂觀鎖是相對悲觀鎖的,他認為數據在一般情況下不會造成衝突,所以在訪問記錄前不會加排它鎖,而是在數據進行更新或者提交的時候才會對數據衝突與否進行檢測。具體來說,根據update返回的行數讓用戶決定如何去做,將上面的例子改為使用樂觀鎖的程式碼如下。

public int updateEntry(long id){
    //使用樂觀鎖獲取指定記錄(1)
    EntryObject entry=query("select * from table1 where id=#{id}",id);
    //修改欄位內容(2)
    String name=generatorName(entry);
    entry.setName(name);
    //update操作(3)
    int count=update("update table1 set name=#{name},age=#{age},version=#{version}+1 where id=#{id} and version=#{version}",entry);
}

​ 在如上程式碼中,當多個執行緒調用updateEntry方法並且傳入相同的id時,多個執行緒可以同時執行程式碼(1)獲取id對應的記錄並放入到本地棧裡面,然後可以同時執行程式碼(2)對自己棧上的記錄進行修改,多個執行緒修改後各自的entry裡面的屬性都應該不一樣了。然後多個執行緒可以同時執行程式碼(3),程式碼(3)中的update語句的where條件加入了version=#{version}條件,並且set語句多了verson=#{vesion}+1表達式,該表達式的意思是,如果資料庫里id=#{id} and version=#{version}的記錄存在,則更新version的值為原來的+1,這有點CAS操作的意思。

​ 加入多個執行緒同時執行updateEntry並傳遞相同的id,那麼它們執行程式碼(1)時獲取的entry是同一個,獲取的entry的version值是相同的(這裡假設version=0),當多個執行緒執行程式碼(3)時,由於update語句是原子性的,假如執行緒A執行update成功了,那麼這時候id對應的記錄的version就應該為1了,其他執行緒執行程式碼(3)更新時發現資料庫裡面已經沒有了vesion=0的語句,所以會返回影響行號為0。在業務上根據返回為0就可以知道當先更新就沒有更新成功,那麼接下來就有兩個辦法,如果業務發現更新失敗了,下面可以什麼都不做,也可以選擇重試,如果選擇重試,則下面的updateEntry修改如下:

public boolean updateEntry(long id){
    boolean result=false;
    int retryNum=5;
    while(retryNum>0){
     	 //使用樂觀鎖獲取指定記錄(1.1)
    	EntryObject entry=query("select * from table1 where id=#{id}",id);
    	//修改欄位內容(2.1)
    	String name=generatorName(entry);
    	entry.setName(name);
    	//update操作(3.1)
    	int count=update("update table1 set name=#{name},age=#{age},version=#{version}+1 		where id=#{id} and version=#{version}",entry);   
        if(count==1){
            result=true;
            break;
        }
        retryNum--;
    }
    
    return result;
}

​ 如上程式碼使用retryNum設置更新失敗後的重試次數,如果程式碼(3.1)執行後返回0,額說明程式碼(1.1)獲取的記錄已經被修改了,則循環一遍,重新通過程式碼(1.1)獲取最新的數據,然後執行程式碼(3.1)嘗試更新,這類似CAS的自旋操作,只是這裡沒有死循環,而是指定了嘗試次數。

​ 樂觀鎖並不會使用資料庫提供的鎖機制,一般在表中添加version欄位或者使用業務狀態來實現。樂觀鎖直到提交才鎖定,所以不會產生任何死鎖。

公平鎖與非公平鎖

​ 根據執行緒獲取鎖的機制,鎖可以分為公平鎖和非公平鎖,公平鎖表示執行緒獲取鎖的順序是按照執行緒請求鎖的時間早晚來決定的,也就是最早請求鎖的執行緒獲取到鎖,而非公平鎖在運行時闖入,也就是先來不一定先得。

ReentrantLock提供了公平與非公平鎖的實現。

  • 公平鎖:ReentrantLock pairLock=new ReentrantLock(true);

  • 非公平鎖:ReentrantLock pairLock=new ReentrantLock(false);

  • 如果構造函數不傳入參數,就默認就是非公平鎖。

    假如執行緒A已經持有了鎖,這時候執行緒B請求該鎖會被掛起。當執行緒A釋放鎖後,假如當前也有執行緒C也需要獲取到該鎖,如果採用非公平鎖方式,則根據執行緒調度策略,執行緒B和執行緒C兩者之一可能獲得該鎖,這時候不需要任何其他干涉。而如果採用公平鎖,則需要把C執行緒掛起,讓執行緒B獲取到該鎖。在沒有公平性需求的情況下盡量使用非公平鎖,因為公平鎖會帶來性能消耗。

獨佔鎖與共享鎖

​ 根據鎖只能被單個執行緒持有還是多個執行緒共享哎,可以將鎖分為獨佔鎖和共享鎖。

​ 獨佔鎖保證任何時候都只有一個執行緒得到鎖,ReenTrantLock就是以獨佔的方式實現的,共享鎖則可以同時由多個執行緒持有,,例如ReadWriteLock讀寫鎖,它允許一個資源可以同時被多個執行緒進行讀操作。

​ 獨佔鎖是一種悲觀鎖,由於每次訪問資源都需要先加上互斥鎖,這限制了並發性,因為讀操作並不會影響數據的一致性,而獨佔鎖只允許同一時間只能允許同一個執行緒讀取數據,其他執行緒必須等待當前執行緒釋放鎖後才能釋放。共享鎖是一種樂觀鎖,它放鬆了加鎖的條件,允許多個執行緒同時進行讀操作。

什麼是可重入鎖

​ 當一個執行緒要獲取一個被其他執行緒持有的獨佔鎖時,該執行緒會被阻塞,那麼當一個執行緒再次獲取它自己已經獲取的鎖時是否會被阻塞呢?如果不阻塞,那麼我們說該鎖是可重入的,也就是只要執行緒獲取到了該鎖,那麼可以無限次數(在以後的文章中我們將知道嚴格來說是有限次)地進入被該鎖鎖住的程式碼。

下面看一個例子:

public class Hello{
    public synchronized void helloA(){
        System.out.println("helloA");
    }
    
    public synchronized void helloB(){
        System.out.println("helloB");
    }
}

​ 在如上程式碼中,調用helloB方法前會先獲取內置鎖,然後列印輸出,之後調用helloA方法,在調用前會先獲取內置鎖,如果內置鎖是不可重入的,那麼調用執行緒將會一直被阻塞。實際上,synchronized內部鎖是可重入鎖,可重入鎖的原理是在鎖內部維護一個執行緒標示,用來標示該鎖被目前哪個執行緒佔用,然後關聯一個計數器,一開始計數器為0,說明該鎖沒有被任何執行緒佔用,當一個執行緒獲取到該鎖時,計數器的值就會變為1,這時其他執行緒再次來獲取到該鎖時發現鎖的持有者不是自己就會被阻塞掛起。

​ 當時當獲取到了該鎖的執行緒再次發現鎖的擁有者仍然是自己的時候,就會把計數器+1,當釋放鎖後計數器-1,當計數器為0的時候,鎖裡面的執行緒標示被指為null,之時候被阻塞的執行緒會被喚醒來競爭獲取該鎖。

自旋鎖

​ 由於Java中的執行緒與作業系統中的執行緒一一對應,所以當一個執行緒獲取鎖失敗後,會被切換到內核狀態而掛起。當該執行緒獲取到鎖的時候有需要將其切換到內核狀態而喚醒該執行緒,而從用戶狀態切換到內核狀態開銷是比較大的,在一定程度上會影響並發性能。自旋鎖的原則是:當前執行緒在獲取鎖時,如果發現鎖已經被其他執行緒佔有,它不馬上阻塞自己,在不放棄CPU使用權的情況下,多次嘗試獲取(默認為10次,可以使用XX:PreBlockSpinsh參數設置該值),很有可能在後面幾次嘗試中其他執行緒已經釋放了該鎖。如果嘗試了指定次數後仍然沒有獲取到該鎖則當前執行緒才會被掛起,由此看來自旋鎖就是利用CPU時間來獲取執行緒阻塞與調度的開銷,但是很有可能這些CPU時間白白浪費了。