Sychronized和Lock對比剖析

一 開篇

客觀的講,sychronized lock都屬於悲觀鎖(共享的資源每次只能給一個線程使用,其他線程處於阻塞狀態,用完之後才釋放資源給其他線程使用)。都能夠實現數據的同步訪問,sychronized是java中的一個關鍵字,屬於java內置的語言特性,在Java1.5之後,在java.util.concurrent.locks包下提供了另外一種方式來實現同步訪問,那就是Lock。

那麼疑問來了,既然sychronized可以實現數據的同步訪問,那麼爲什麼還要增加lock呢?接下來我們來講解下增加lock的原因。

二 sychronized 和 Lock的區別

1.來源:sychronized是java的關鍵字,是java的內置特性。Lock不是java關鍵字,是java.util.concurrent.locks包下的一個類,通過這個類可以實現數據的同步訪問。

2.釋放鎖:sychronized方法(代碼塊)執行完畢之後或者執行期間出現異常,系統會自動釋放鎖,不會導致死鎖現象。Lock必須要用戶去手動釋放鎖,如果沒有主動釋放,那麼很可能出現死鎖現象。

3.響應中斷:Lock可以讓等待的線程響應中斷,而sychronized不行,會一直等待下去

4.獲取鎖狀態:Lock可以知道線程是否成功獲取鎖,sychronized不行

5.高併發效率:Lock可以提高多線程作業效率

在性能上來說,如果資源競爭不是很激烈的情況下,兩者性能相差不多,當有大量的線程競爭資源時,而Lock的性能遠遠高於sychronized,所以根據情況選擇合適的鎖是關鍵

三 爲什麼引入Lock類?

1.爲了能夠靈活設置線程等待時間和響應中斷,提高程序執行效率

從上面瞭解到,sychronized釋放鎖的情況有兩種:1.sychronized方法(代碼塊)執行完畢。2.線程執行過程中發生異常,JVM自動釋放鎖。那麼如果中途遇到等待IO或者sleep等原因被阻塞了,其他線程只能乾等,這種情況下,程序的執行效率將會非常低下。但Lock可以設置線程等待的時間和相應中斷!!!,其他線程可放棄等待,去做其他事情。

2.有效識別線程讀操作,提高資源可利用範圍

sychronized加鎖時,多個線程都只是進行讀操作,所以當一個線程在進行讀操作時,其他線程只能等待無法進行讀操作。

但Lock可以使得多個線程都是讀操作時,線程之間不會發生衝突。

3.sychronized無法知道線程有沒有獲取到鎖,但Lock可以知道

tryLock()和tryLock(long time,Unit unit)方法是有返回值的,獲取到鎖返回true,否則返回false

四 java.util.concurrent.locks包下的類和接口介紹

java.util.concurrent.locks包下,我們需要關注的也就:兩個接口Lock接口,ReadWriteLock接口),兩個實現類ReentrantLock類,ReentrantReadWriteLock),其中,ReentrantLock類是Lock接口的唯一實現,ReentrantReadWriteLock類是ReadWriteLock接口的實現。

1.Lock接口

通過查看源碼可知,Lock是一個接口,方法如下:

public interface Lock {
    //獲取鎖,無返回值
    void lock();
    //獲取鎖,獲取不到中斷(發音:因特rub特-波類)
    void lockInterruptibly() throws InterruptedException;
    //獲取鎖,成功返回true,否則false
    boolean tryLock();
    //嘗試一段時間內獲取鎖,成功返回true,否則false
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    //手動釋放鎖
    void unlock();
    Condition newCondition();
}

Lock接口中的方法:lock(),lockInterruptibly(),tryLock(),tryLock(long time,TimeUnit unit)都是用來獲取鎖的。unLock()是用來釋放鎖的,newCondition()方法暫不做贅述。

1. lock()方法

lock()方法是比較常用的一個方法,就是用來獲取鎖。如果鎖已被其他線程獲取,則進行等待。

前面講到,如果使用Lock,必須要手動釋放鎖,即便發生異常,也不會釋放鎖。因此,使用Lock時,建議必須要用try{}catch(){} 捕獲一下,然後將釋放鎖的操作放在finally()代碼塊中,防止死鎖發生,使用方式如下。

Lock lock=...;
lock.lock();
try{
    //事務處理
}catch(Exception e){
   
}finally{
    //釋放鎖
    lock.unlock();
}

2. tryLock()方法

tryLock()方法是有返回值的,嘗試獲取鎖,如果成功返回true,否則返回false,如果拿不到鎖可以嘗試去做其他事情,不用一直等待。

tryLock(long time, TimeUnit unit)方法可tryLock()類似,區別在於獲取不到鎖時會等待一段時間,在時間期限內拿到鎖返回ture,否則返回false,使用方式如下。

Lock lock=...;
if(lock.tryLock()){
    try{
        //處理事務
    }catch(Exception e){
    
    }finally{
        //釋放鎖
        lock.unLock();
    }
}else{
    //獲取不到鎖,做其他操作
}

3.lockInterruptibly()方法

lockInterruptibly()方法比較特殊,能夠響應中斷,即中斷等待狀態,也就是或如果兩個線程A,B分別獲取鎖,A獲取到鎖是,B一直處於等待狀態,可以通過B.interrupt()方法能夠中斷線程B的等待過程。

由於lockInterruptibly()的聲明中拋出了異常,所以lock.lockInterruptibly()必須放在try{}catch{}中或者拋出InterruptedException 異常,使用代碼如下。

public class LockTest{
    private Lock lock=new ReentrantLock();

    public void method(){
        try{
            lock.lockInterruptibly();
            //事務處理
            System.out.println(Thread.currentThread().getName()+"獲取到鎖!!!");
            System.out.println(Thread.currentThread().getName()+"先睡它個30s!!!");
            Thread.sleep(30000);
        }catch(Exception e){

        }finally{
            //釋放鎖
            lock.unlock();
        }
    }

    @Test
    public void testInterruptibly() throws InterruptedException{
        Runnable t1=new Runnable(){
            @Override
            public void run(){
                method();
            }
        };
        Runnable t2=new Runnable(){
            @Override
            public void  run(){
                method();
            }
        };
        String tName=Thread.currentThread().getName();
        System.out.println(tName+"-啓動t1!");
        Thread t11=new Thread(t1);
        t11.start();
        System.out.println(tName+"-我等個5秒,再啓動t2");
        Thread.sleep(5000);
        System.out.println(tName+"-啓動t2");
        Thread t22=new Thread(t2);
        t22.start();
        System.out.println(tName+"-t2獲取不到鎖,t1睡覺了,沒釋放,我等個5秒!");
        Thread.sleep(5000);
        System.out.println(tName+"-等了5秒了,不等了,把t1中斷了!");
        t22.interrupt();

        Thread.sleep(Long.MAX_VALUE);
    }
}

注意:已經獲取到鎖的線程是無法用interrupt()方法中斷的,interrupt()方法只能中斷被阻塞的線程。因此當通過lockInterruptibly()方法獲取某個鎖時,如果不能獲取到,只有進行等待的情況下,是可以響應中斷的。

而用synchronized修飾的話,當一個線程處於等待某個鎖的狀態,是無法被中斷的,只有一直等待下去。

2. ReentrantLock實現類

ReentrantLock,(發音:瑞恩垂特Lock)意思爲“可重入鎖”,ReentrantLock具有公平和非公平兩種模式,也各有優缺點:
公平鎖是嚴格的以FIFO的方式進行鎖的競爭,但是非公平鎖是無序的鎖競爭,剛釋放鎖的線程很大程度上能比較快的獲取到鎖,隊列中的線程只能等待,所以非公平鎖可能會有“飢餓”的問題。但是重複的鎖獲取能減小線程之間的切換,而公平鎖則是嚴格的線程切換,這樣對操作系統的影響是比較大的,所以非公平鎖的吞吐量是大於公平鎖的,這也是爲什麼JDK將非公平鎖作爲默認的實現。

ReentrantLock是唯一實現了Lock接口的類,並且ReentrantLock提供了更多的方法.

lock(),tryLock(),tryLock(long time,TimeUnit unit),lockInterruptibly()方法使用方式即我們上面介紹的那樣,唯一注意的地方是聲明Lock的時候

private Lock lock = new ReentrantLock();    //注意這個地方

提示:如果希望線程能夠正確的獲取鎖,要注意:同一個實例對象,多個線程纔有競爭關係,加鎖纔有意義,不同的實例對象要有競爭關係,必須在對象類級別加鎖。

3. ReadWriteLock接口

ReadWriteLock也是一個接口,它只定義了兩個抽象方法

public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading.
     */
    Lock readLock();
 
    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing.
     */
    Lock writeLock();
}

readLock()用來獲取讀鎖,wirteLock()用來獲取寫鎖。也就是說將資源的讀寫操作分開,分成2個鎖來分配給線程,從而使得多個線程可以同時進行讀操作。下面的ReentrantReadWriteLock實現了ReadWriteLock接口。

4.ReentrantReadWriteLock實現類

ReentrantReadWriteLock實現了接口ReadWriteLock,ReentrantReadWriteLock裏面提供了豐富的方法,最主要的兩個方法爲:readLock()和wirteLock()用來獲取讀鎖和寫鎖。

我們知道,sychronized加鎖是無法識別線程是讀操作還是寫操作的,一律是一個線程獲取到鎖後,其他線程等待狀態,但ReentrantReadWriteLock的讀鎖和寫鎖實現了讀寫分離,讀操作可以允許多個線程同時進行,使用方式如下。

private ReadWriteLock rwl=new ReentrantReadWriteLock();
    public void m1(Thread thread){
        rwl.readLock().lock();//讀鎖
        try {
            long start = System.currentTimeMillis();

            while(System.currentTimeMillis() - start <= 1) {
                System.out.println(thread.getName()+"正在進行讀操作");
            }
            System.out.println(thread.getName()+"讀操作完畢");
        } finally {
            rwl.readLock().unlock();
        }
    }

不同的線程可同時申請讀鎖,大大提高程序執行效率。

但要注意的是,如果有一個線程已經佔用了讀鎖,其他線程想要申請寫鎖,那麼申請寫鎖的線程必須要等待讀鎖釋放。

如果一個線程已經佔用了寫鎖,其他線程想申請讀鎖或者寫鎖,則申請線程必須要等待寫鎖釋放。

五 鎖概念介紹

1.可重入鎖

定義:如果鎖具備可重入性,那麼我們稱之爲可重入鎖。像sychronized和ReentrantLock都是可重入鎖。可重入性在我看來實際是表明了鎖的分配機制:鎖是基於線程分配的,而不是基於方法調用的分配

舉個簡單的例子,有兩個sychronized修飾的方法,method1和method2,其中method1調用了method2,那麼如果一個線程獲取了method1的鎖,那麼該不需要再去申請method2的鎖,可直接執行方法method2,代碼如下:

public class MyClass{
    public sychronized void method1(){
        method2();
    }

    public sychronized void method2(){
    
    }
}

上述method1和method2都被sychronized修飾了,如果sychronized不具備可重入性,假如某一時刻,線程A獲取到了method1的鎖,那麼還需申請該對象鎖,問題是A已經佔用了該對象鎖,這樣A就會一直等待永遠獲取不到的鎖。

然而,sychronized和ReentrantLock都具有可重入性,所以不會發生上述這種情況。

2.可中斷鎖

可中斷鎖:顧名思義,可以響應中斷的鎖。sychronized是不可中斷鎖,Lock是可以中斷的。

如果某個線程執行鎖代碼期間,其他線程不想等待了,想先處理其他事情,我們可以讓它中斷自己或者在別的線程中中斷它,這種就是可中斷鎖。就是前面講述的Lock中的lockInterruptiblly()方法。通過threadX.interrupt()可實現線程的中斷。

3.公平鎖和非公平鎖

遵循FIFO的原理,即儘量以請求鎖的順序來獲取鎖。比如同是有多個線程在等待一個鎖,當這個鎖被釋放時,等待時間最久的線程(最先請求的線程)會獲得該所,這種就是公平鎖。

sychronized是非公平鎖,無法保證獲取鎖的順序和請求鎖的順序一致,所以就可能出現某個線程永遠獲取不到鎖的情況。但重複的獲取鎖,可以減少線程間的切換消耗的資源,所以非公平鎖的吞吐量會比公平鎖的要大。

ReentrantLock和ReentrantReadWriteLock默認都是非公平鎖,但是可以設置成公平鎖,看下這兩個類的源代碼:

ReentrantLock內部設置了2個靜態內部類,一個NonfairSync,一個FairSync,分別用來實現公平鎖和非公平鎖。

看下ReentrantLock的構造函數,如下圖,根據構造函數可以看到,默認無參情況下是非公平鎖。設置鎖的公平性可以

ReentrantLock lock=new ReentrantLock(true);

傳true參數代表公平鎖,不傳或者傳false,代表非公平鎖。 

 

此外,ReentrantLock類中還定義了很多其他的方法,比如:

  1.   isFair()        //判斷鎖是否是公平鎖
  2.   isLocked()    //判斷鎖是否被任何線程獲取了
  3.   isHeldByCurrentThread()   //判斷鎖是否被當前線程獲取了
  4.   hasQueuedThreads()   //判斷是否有線程在等待該鎖

 在ReentrantReadWriteLock中也有類似的方法,同樣也可以設置爲公平鎖和非公平鎖。不過要記住,ReentrantReadWriteLock並未實現Lock接口,它實現的是ReadWriteLock接口。

 

4.讀寫鎖

  讀寫鎖其實就是將對資源的訪問分成了2個鎖,一個讀鎖和一個寫鎖。

  正因爲有了讀寫鎖,才使得多個線程之間的讀操作不會發生衝突。

  ReadWriteLock就是讀寫鎖,它是一個接口,ReentrantReadWriteLock實現了這個接口。

  可以通過readLock().lock()獲取讀鎖,通過writeLock().lock()獲取寫鎖。

六 拓展 volatile

在java線程併發處理中,有一個關鍵字volatile的使用目前存在很大的混淆,以爲使用這個關鍵字,在進行多線程併發處理的時候就可以萬事大吉。

Java語言是支持多線程的,爲了解決線程併發的問題,在語言內部引入了 同步塊 和 volatile 關鍵字機制。

用volatile修飾的變量,線程在每次使用變量的時候,都會讀取變量修改後的最的值。volatile很容易被誤用,用來進行原子性操作。

舉個例子,實際操作,我們實現一個計數器,每次線程啓動的時候,會調用計數器inc方法,對計數器進行加一,同時啓動1000個線程,去進行i++計算,看看實際結果,運行結果:Counter.count=995

實際運算結果每次可能都不一樣,本機的結果爲:運行結果:Counter.count=995,可以看出,在多線程的環境下,Counter.count並沒有期望結果是1000

很多人以爲,這個是多線程併發問題,只需要在變量count之前加上volatile就可以避免這個問題,那我們在修改代碼看看,看看結果是不是符合我們的期望

運行結果:Counter.count=992

運行結果還是沒有我們期望的1000,下面我們分析一下原因

  • 在 java 垃圾回收整理一文中,描述了jvm運行時刻內存的分配。其中有一個內存區域是jvm虛擬機棧,每一個線程運行時都有一個線程棧,
  • 線程棧保存了線程運行時候變量值信息。當線程訪問某一個對象時候值的時候,首先通過對象的引用找到對應在堆內存的變量的值,然後把堆內存
  • 變量的具體值load到線程本地內存中,建立一個變量副本,之後線程就不再和對象在堆內存變量值有任何關係,而是直接修改副本變量的值,
  • 在修改完之後的某一個時刻(線程退出之前),自動把線程變量副本的值回寫到對象在堆中變量。這樣在堆中的對象的值就產生變化了。
  • read and load 從主存複製變量到當前工作內存use and assign  執行代碼,改變共享變量值 store and write 用工作內存數據刷新主存相關內容
  • 其中use and assign 可以多次出現
  • 但是這一些操作並不是原子性,也就是 在read load之後,如果主內存count變量發生修改之後,線程工作內存中的值由於已經加載,不會產生對應的變化,所以計算出來的結果會和預期不一樣

對於volatile修飾的變量,jvm虛擬機只是保證從主內存加載到線程工作內存的值是最新的

例如假如線程1,線程2 在進行read,load 操作中,發現主內存中count的值都是5,那麼都會加載這個最新的值

在線程1堆count進行修改之後,會write到主內存中,主內存中的count變量就會變爲6

線程2由於已經進行read,load操作,在進行運算之後,也會更新主內存count的變量值爲6

導致兩個線程及時用volatile關鍵字修改之後,還是會存在併發的情況。

參考鏈接:https://www.cnblogs.com/handsomeye/p/5999362.html

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