Java 鎖機制瞭解一下

喝水會長肉發表於2021-12-01

在多執行緒環境下,程式往往會出現一些執行緒安全問題,為此,Java提供了一些執行緒的同步機制來解決安全問題,比如:synchronized鎖和Lock鎖都能解決執行緒安全問題。

悲觀鎖和樂觀鎖

我們可以將鎖大體分為兩類:

  • 悲觀鎖

  • 樂觀鎖

顧名思義,悲觀鎖總是假設最壞的情況,每次獲取資料的時候都認為別的執行緒會修改,所以每次在拿資料的時候都會上鎖,這樣其它執行緒想要修改這個資料的時候都會被阻塞直到獲取鎖。比如MySQL資料庫中的表鎖、行鎖、讀鎖、寫鎖等,Java中的synchronized和ReentrantLock等。

而樂觀鎖總是假設最好的情況,每次獲取資料的時候都認為別的執行緒不會修改,所以並不會上鎖,但是在修改資料的時候需要判斷一下在此期間有沒有別的執行緒修改過資料,如果沒有修改過則正常修改,如果修改過則這次修改就是失敗的。常見的樂觀鎖有版本號控制、CAS演算法等。

悲觀鎖應用

案例如下:


public 
class 
LockDemo 
{


    static int count = 0 ;

    public static void main (String [ ] args ) throws InterruptedException {
       List <Thread > threadList = new ArrayList < > ( ) ;
        for (int i = 0 ; i < 50 ; i ++ ) {
           Thread thread = new Thread ( ( ) - > {
                for (int j = 0 ; j < 1000 ; ++j ) {
                   count ++ ;
                }
            } ) ;
           thread . start ( ) ;
           threadList . add (thread ) ;
        }
        // 等待所有執行緒執行完畢
        for (Thread thread : threadList ) {
           thread . join ( ) ;
        }
       System .out . println (count ) ;
    }
}

在該程式中一共開啟了50個執行緒,並線上程中對共享變數count進行++操作,所以如果不發生執行緒安全問題,最終的結果應該是 50000,但該程式中一定存線上程安全問題,執行結果為:

48634

若想解決執行緒安全問題,可以使用synchronized關鍵字:


public 
class 
LockDemo 
{


    static int count = 0 ;

    public static void main (String [ ] args ) throws InterruptedException {
       List <Thread > threadList = new ArrayList < > ( ) ;
        for (int i = 0 ; i < 50 ; i ++ ) {
           Thread thread = new Thread ( ( ) - > {
                // 使用synchronized關鍵字解決執行緒安全問題
                synchronized ( LockDemo .class ) {
                    for (int j = 0 ; j < 1000 ; ++j ) {
                       count ++ ;
                    }
                }
            } ) ;
           thread . start ( ) ;
           threadList . add (thread ) ;
        }
        for (Thread thread : threadList ) {
           thread . join ( ) ;
        }
       System .out . println (count ) ;
    }
}

將修改count變數的操作使用synchronized關鍵字包裹起來,這樣當某個執行緒在進行++操作時,別的執行緒是無法同時進行++的,只能等待前一個執行緒執行完1000次後才能繼續執行,這樣便能保證最終的結果為 50000

使用ReentrantLock也能夠解決執行緒安全問題:


public 
class 
LockDemo 
{


    static int count = 0 ;

    public static void main (String [ ] args ) throws InterruptedException {
       List <Thread > threadList = new ArrayList < > ( ) ;
       Lock lock = new ReentrantLock ( ) ;
        for ( int i = 0 ; i < 50 ; i ++ ) {
           Thread thread = new Thread ( ( ) - > {
                // 使用ReentrantLock關鍵字解決執行緒安全問題
               lock . lock ( ) ;
                try {
                    for ( int j = 0 ; j < 1000 ; ++j ) {
                       count ++ ;
                    }
                } finally {
                   lock . unlock ( ) ;
                }
        //java學習交流:737251827  進入可領取學習資源及對十年開發經驗大佬提問,免費解答!
            } ) ;
           thread . start ( ) ;
           threadList . add (thread ) ;
        }
        for ( Thread thread : threadList ) {
           thread . join ( ) ;
        }
       System .out . println (count ) ;
    }
}

這兩種鎖機制都是悲觀鎖的具體實現,不管其它執行緒是否會同時修改,它都直接上鎖,保證了原子操作。

樂觀鎖應用

由於執行緒的排程是極其耗費作業系統資源的,所以,我們應該儘量避免執行緒在不斷阻塞和喚醒中切換,由此產生了樂觀鎖。

在資料庫表中,我們往往會設定一個version欄位,這就是樂觀鎖的體現,假設某個資料表的資料內容如下:

+----+------+----------+ ------- +
| id | name | password | version |
+----+------+----------+ ------- +
|  1 | zs   | 123456   |    1    |
+----+------+----------+ ------- +

它是如何避免執行緒安全問題的呢?

假設此時有兩個執行緒A、B想要修改這條資料,它們會執行如下的sql語句:

select version 
from e_user where name 
= 
'zs'
;


update e_user set password = 'admin' ,version = version + 1 where name = 'zs' and version = 1 ;

首先兩個執行緒均查詢出zs使用者的版本號為1,然後執行緒A先執行了更新操作,此時將使用者的密碼修改為了admin,並將版本號加1,接著執行緒B執行更新操作,此時版本號已經為2了,所以更新肯定是失敗的,由此,執行緒B就失敗了,它只能重新去獲取版本號再進行更新,這就是樂觀鎖,我們並沒有對程式和資料庫進行任何的加鎖操作,但它仍然能夠保證執行緒安全。

CAS

仍然以最開始做加法的程式為例,在Java中,我們還可以採用一種特殊的方式來實現它:


public 
class 
LockDemo 
{


    static AtomicInteger count = new AtomicInteger ( 0 ) ;

    public static void main (String [ ] args ) throws InterruptedException {
       List <Thread > threadList = new ArrayList < > ( ) ;
        for (int i = 0 ; i < 50 ; i ++ ) {
           Thread thread = new Thread ( ( ) - > {
                for (int j = 0 ; j < 1000 ; ++j ) {
                    // 使用AtomicInteger解決執行緒安全問題
                   count . incrementAndGet ( ) ;
                }
            } ) ;
           thread . start ( ) ;
           threadList . add (thread ) ;
        }
        for (Thread thread : threadList ) {
           thread . join ( ) ;
        }
       System .out . println (count ) ;
    }
}

為何使用AtomicInteger類就能夠解決執行緒安全問題呢?

我們來檢視一下原始碼:


public final int 
incrementAndGet
(
) 
{

    return unsafe . getAndAddInt ( this , valueOffset , 1 ) + 1 ;
}

當count呼叫incrementAndGet()方法時,實際上呼叫的是UnSafe類的getAndAddInt()方法:


public final int 
getAndAddInt
(
Object var1
, long var2
, int var4
) 
{

   int var5 ;
    do {
       var5 = this . getIntVolatile (var1 , var2 ) ;
    } while ( ! this . compareAndSwapInt (var1 , var2 , var5 , var5 + var4 ) ) ;

    return var5 ;
}

getAndAddInt()方法中有一個迴圈,關鍵的程式碼就在這裡,我們假設執行緒A此時進入了該方法,此時var1即為AtomicInteger物件(初始值為0),var2的值為12(這是一個記憶體偏移量,我們可以不用關心),var4的值為1(準備對count進行加1操作)。

首先通過AtomicInteger物件和記憶體偏移量即可得到主存中的資料值:

var5 = this.getIntVolatile(var1, var2);

獲取到var5的值為0,然後程式會進行判斷:

!this.compareAndSwapInt(var1, var2, var5, var5 + var4)

compareAndSwapInt()是一個本地方法,它的作用是比較並交換,即:判斷var1的值與主存中取出的var5的值是否相同,此時肯定是相同的,所以會將var5+var4的值賦值給var1,並返回true,對true取反為false,所以迴圈就結束了,最終方法返回1。

這是一切正常的執行流程,然而當發生併發時,處理情況就不太一樣了,假設此時執行緒A執行到了getAndAddInt()方法:


public final int 
getAndAddInt
(
Object var1
, long var2
, int var4
) 
{

   int var5 ;
    do {
       var5 = this . getIntVolatile (var1 , var2 ) ;
    } while ( ! this . compareAndSwapInt (var1 , var2 , var5 , var5 + var4 ) ) ;

    return var5 ;
}


執行緒A此時獲取到var1的值為0(var1即為共享變數AtomicInteger),當執行緒A正準備執行下去時,執行緒B搶先執行了,執行緒B此時獲取到var1的值為0,var5的值為0,比較成功,此時var1的值就變為1;這時候輪到執行緒A執行了,它獲取var5的值為1,此時var1的值不等於var5的值,此次加1操作就會失敗,並重新進入迴圈,此時var1的值已經發生了變化,此時重新獲取var5的值也為1,比較成功,所以將var1的值加1變為2,若是在獲取var5之前別的執行緒又修改了主存中var1的值,則本次操作又會失敗,程式重新進入迴圈。

這就是利用自旋的方式來實現一個樂觀鎖,因為它沒有加鎖,所以省下了執行緒排程的資源,但也要避免程式一直自旋的情況發生。

手寫一個自旋鎖


public 
class 
LockDemo 
{


    private AtomicReference <Thread > atomicReference = new AtomicReference < > ( ) ;

    public void lock ( ) {
        // 獲取當前執行緒物件
       Thread thread = Thread . currentThread ( ) ;
        // 自旋等待
        while ( !atomicReference . compareAndSet ( null , thread ) ) {
        }
    }

    public void unlock ( ) {
        // 獲取當前執行緒物件
       Thread thread = Thread . currentThread ( ) ;
       atomicReference . compareAndSet (thread , null ) ;
    }
//java學習交流:737251827  進入可領取學習資源及對十年開發經驗大佬提問,免費解答!

    static int count = 0 ;

    public static void main (String [ ] args ) throws InterruptedException {
       LockDemo lockDemo = new LockDemo ( ) ;
       List <Thread > threadList = new ArrayList < > ( ) ;
        for ( int i = 0 ; i < 50 ; i ++ ) {
           Thread thread = new Thread ( ( ) - > {
               lockDemo . lock ( ) ;
                for ( int j = 0 ; j < 1000 ; j ++ ) {
                   count ++ ;
                }
               lockDemo . unlock ( ) ;
            } ) ;
           thread . start ( ) ;
           threadList . add (thread ) ;
        }
        // 等待執行緒執行完畢
        for ( Thread thread : threadList ) {
           thread . join ( ) ;
        }
       System .out . println (count ) ;
    }
}


使用CAS的原理可以輕鬆地實現一個自旋鎖,首先,AtomicReference中的初始值一定為null,所以第一個執行緒在呼叫lock()方法後會成功將當前執行緒的物件放入AtomicReference,此時若是別的執行緒呼叫lock()方法,會因為該執行緒物件與AtomicReference中的物件不同而陷入迴圈的等待中,直到第一個執行緒執行完++操作,呼叫了unlock()方法,該執行緒才會將AtomicReference值置為null,此時別的執行緒就可以跳出迴圈了。

通過CAS機制,我們能夠在不新增鎖的情況下模擬出加鎖的效果,但它的缺點也是顯而易見的:

  • 迴圈等待佔用CPU資源

  • 只能保證一個變數的原子操作

  • 會產生ABA問題



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70010294/viewspace-2845157/,如需轉載,請註明出處,否則將追究法律責任。

相關文章