Java併發工具之鎖分類

1. Lock簡介、地位、作用

  • 鎖是一種工具,用於控制對共享資源的訪問;
  • Lock和synchronized,這兩個是最常見的鎖,它們都可以達到線程安全的目的,但是在使用上和功能上又有較大的不同;
  • Lock並不是用來代替synchronized的,而是當使用synchronized不合適或者不滿足要求的時候,來提供高級功能的;
  • Lock接口中最常見的實現類是ReentrantLock;
  • 通常情況下,Lock只允許一個線程來訪問這個共享資源,不過有的時候,一些特殊的實現也可允許併發訪問,比如ReadWriteLock裏面的ReadLock;

2. 爲什麼synchronized不夠用?

  • 鎖的釋放情況少:1、 代碼執行完畢;2、發生異常;一旦發生阻塞,其他線程只能乾等;
  • 不夠靈活:加鎖和釋放鎖的時機單一;
  • 無法知道是否成功獲取到了鎖;

3. Lock主要方法介紹

  • 在Lock中聲明瞭四個方法來獲取鎖:

     1. lock():最普通的鎖,Lock不會像synchronized一樣在異常時自動釋放鎖,必須自己try/finally;
     2. tryLock():嘗試獲取鎖,如果當前鎖沒有被其他線程佔用,則獲取成功;
     3. tryLock(long time, TimeUnit unit):超時就放棄;
     4. lockInterruptibly():相當於tryLock(long time, TimeUnit unit)把超時時間設置爲無限,在等待鎖的過程中,線程可以被中斷;
    
  • 用lock演示死鎖:

public class LockDeadLock {

    private static Lock lockA = new ReentrantLock();
    private static Lock lockB = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                lockA.lock();
                try {
                    System.out.println("獲取到lockA");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("正在嘗試獲取lockB...");
                    lockB.lock();
                    try {
                        System.out.println("獲取到lockB");
                    } finally {
                        lockB.unlock();
                    }
                } finally {
                    lockA.unlock();
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                lockB.lock();
                try {
                    System.out.println("獲取到lockB");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("正在嘗試獲取lockA...");
                    lockA.lock();
                    try {
                        System.out.println("獲取到lockA");
                    } finally {
                        lockB.unlock();
                    }
                } finally {
                    lockB.unlock();
                }
            }
        });
        t1.start();
        t2.start();
    }
}
  • 用tryLock避免死鎖
public class TryLockDeadLock {

    private static Lock lockA = new ReentrantLock();
    private static Lock lockB = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    if (lockA.tryLock(800, TimeUnit.MILLISECONDS)) {
                        try {
                            System.out.println("獲取到lockA");
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            System.out.println("正在嘗試獲取lockB...");
                            try {
                                if (lockB.tryLock(800, TimeUnit.MILLISECONDS)) {
                                    try {
                                        System.out.println("獲取到lockB");
                                    } finally {
                                        lockB.unlock();
                                    }
                                } else {
                                    System.out.println("沒有獲取到lockB");
                                }
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        } finally {
                            lockA.unlock();
                        }

                    } else {
                        System.out.println("沒有獲取到lockA");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    if (lockB.tryLock(800, TimeUnit.MILLISECONDS)) {
                        try {
                            System.out.println("獲取到lockB");
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            System.out.println("正在嘗試獲取lockA...");
                            try {
                                if (lockA.tryLock(800, TimeUnit.MILLISECONDS)) {
                                    try {
                                        System.out.println("獲取到lockA");
                                    } finally {
                                        lockA.unlock();
                                    }
                                } else {
                                    System.out.println("沒有獲取到lockA");
                                }
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        } finally {
                            lockB.unlock();
                        }
                    } else {
                        System.out.println("沒有獲取到lockB");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();
        t2.start();
    }
}
獲取到lockA
獲取到lockB
正在嘗試獲取lockB...
正在嘗試獲取lockA...
沒有獲取到lockB
沒有獲取到lockA
  • 用lockInterruptibly演示等待鎖的過程中響應中斷:
public class LockInterruptibly implements Runnable {

    private static Lock lockA = new ReentrantLock();

    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName() + "正在嘗試獲取lockA...");
            lockA.lockInterruptibly();
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "在休眠中被中斷了");
            } finally {
                lockA.unlock();
            }
        } catch (InterruptedException e) {
            System.out.println(Thread.currentThread().getName() + "在嘗試獲取鎖時被中斷了");
        } 
    }

    public static void main(String[] args) throws InterruptedException {
        LockInterruptibly lockInterruptibly = new LockInterruptibly();
        Thread t1 = new Thread(lockInterruptibly);
        Thread t2 = new Thread(lockInterruptibly);
        t1.start();
        Thread.sleep(500);
        t2.start();
        Thread.sleep(2000);
        t2.interrupt();
    }
}
Thread-0正在嘗試獲取lockA...
Thread-1正在嘗試獲取lockA...
Thread-1在嘗試獲取鎖時被中斷了

4. 鎖的分類

  • 線程要不要鎖住(互斥同步)同步資源:
    1. 鎖住:悲觀鎖,不能讓其他線程在我操作的時候去操作這個對象
    2. 不鎖住:樂觀鎖
  • 多線程能否共享一把鎖:
    1. 共享:共享鎖,比如:讀鎖
    2. 不共享:獨佔鎖,比如:寫鎖
  • 多線程競爭時,是否排隊:
    1. 排隊:公平鎖
    2. 先嚐試插隊,插隊失敗再排隊:非公平鎖
  • 同一個線程是否可以重複獲取同一把鎖:
    1. 可以:可重入鎖
    2. 不可以:不可重入鎖
  • 是否可中斷:
    1. 可以:可中斷鎖
    2. 不可以:不可中斷鎖
  • 等鎖的過程:
    1. 自旋:自旋鎖,自旋:不停的嘗試,而不是進入阻塞,比如:原子類;
    2. 阻塞:非自旋鎖

5. 樂觀鎖和悲觀鎖

悲觀鎖(互斥同步鎖):如果不鎖住這個資源,別人就來爭搶,會造成數據結果錯誤,爲了保證結果的正確性,會在每次修改數據時把數據鎖住,讓別人無法訪問該數據,確保萬無一失。Java中悲觀鎖的實現是synchronized和Lock。

  1. 鎖住之後就是獨佔的,其他線程想獲得資源必須等待,有阻塞和喚醒帶來的性能劣勢;
  2. 可能永久阻塞:如果持有鎖的線程永久阻塞了,比如遇到了死鎖等問題;
  3. 優先級反轉:一旦優先級低的線程不釋放,即便優先級高的線程也拿不到鎖;

樂觀鎖(非互斥同步鎖):認爲自己在處理操作的時候不會有其他線程來干擾,所以並不會鎖住被操作對象,在更新的時候,去對比在我修改期間數據有沒有被其他人改變過:如果沒被改變過,那就說明真的是隻有我自己在操作,拿我就正常去修改數據;如果數據改變了,拿我就不繼續更新了,我會選擇放棄、報錯、重試等策略。樂觀鎖的實現一般都是利用CAS算法實現的。Java中樂觀鎖的實現就是原子類、併發容器等。

樂觀鎖和悲觀鎖的對比:

  1. 悲觀鎖的原始開銷要高於樂觀鎖,但是特點是一勞永逸;
  2. 樂觀鎖一開始的開銷比悲觀鎖小,但是如果自旋時間很長或者不停重試,那麼消耗的資源也會越來越多;

各自的使用場景:

  1. 悲觀鎖:適合併發寫入多的情況,適用於臨界區持鎖時間比較長的情況,悲觀鎖可以避免大量的無用自旋等消耗,比如:有IO操作、代碼複雜、搶佔鎖競爭激烈;
  2. 樂觀鎖:適合併發寫入少,大部分是讀取的場景,不加鎖能讓讀取性能大幅提高;

6. 可重入鎖和非可重入鎖,以ReentrantLock爲例

public class Recursion {

    private ReentrantLock lock = new ReentrantLock();

    public void go() {
        lock.lock();
        try {
            if (lock.getHoldCount() < 5) {
                System.out.println("第" + lock.getHoldCount() + "次獲取到鎖");
                go();
            }
        } finally {
            System.out.println("已釋放鎖");
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        new Recursion().go();
    }
}
1次獲取到鎖
第2次獲取到鎖
第3次獲取到鎖
第4次獲取到鎖
已釋放鎖
已釋放鎖
已釋放鎖
已釋放鎖
已釋放鎖

可以看出,ReentrantLock具有可重入的性質,等方法執行完畢統一釋放鎖。

  • 可重入的好處是:避免死鎖,提高封裝性;

性質如下:
在這裏插入圖片描述

7. 公平鎖和非公平鎖

  • 公平指的是按照線程請求的順序來分配鎖;
  • 非公平是指不完全按照請求的順序,在一定情況下,可以插隊,使得線程總體執行更快,吞吐量更大,但是有可能產生線程飢餓,也就是某些線程在長時間內始終得不到執行;
  • 爲什麼要有非公平鎖:由於喚醒的開銷比較大,避免喚醒帶來的空檔期,提高效率;
  • 公平的情況:ReentrantLock本身是非公平鎖,填寫參數爲true會改爲公平鎖;
    在這裏插入圖片描述

8. 共享鎖和排它鎖(獨佔鎖)

  • 排它鎖:比如synchronized
  • 共享鎖:又稱爲讀鎖,獲取共享鎖後,只能查看但是無法修改和刪除數據,其他線程也可以獲取共享鎖;
  • Java中的實現是ReentrantReadWriteLock,其中讀鎖是共享鎖,寫鎖是獨佔鎖
  • 在沒有讀寫鎖之前,我們假設只用ReentrantLock,那麼雖然保證了線程安全,但是也浪費了一定的資源:多個讀操作同時進行,並沒有線程安全問題。
  • 在讀的地方只用讀鎖,在寫的地方只用寫鎖。
  • 讀寫鎖規則:要麼是一個或多個線程同時有讀鎖,要麼就是一個線程有寫鎖,讀和寫不同時出現,要麼是讀鎖定,要麼是寫鎖定。
  • ReentrantReadWriteLock的具體用法:
public class ReadWriteLock {

    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了讀鎖,正在讀取...");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("釋放了讀鎖");
            readLock.unlock();
        }
    }
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了寫鎖,正在寫入...");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("釋放了寫鎖");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        new Thread(() -> write()).start();
    }

}
Thread-0得到了讀鎖,正在讀取...
Thread-1得到了讀鎖,正在讀取...
釋放了讀鎖
釋放了讀鎖
Thread-2得到了寫鎖,正在寫入...
釋放了寫鎖
Thread-3得到了寫鎖,正在寫入...
釋放了寫鎖

讀寫鎖採用的策略:

  • 公平鎖:不允許插隊;
  • 非公平鎖(默認):寫鎖可以隨時插隊,因爲寫鎖不容易獲取到鎖;讀鎖僅僅在等待隊列頭結點不是寫鎖的時候可以插隊;

9. 鎖的升降級

  • 爲什麼需要升降級:比如讀寫過程中,已經沒有寫操作了,此時不需要寫鎖,但是線程又不想釋放寫鎖,那麼就可以將寫鎖降級成讀鎖。
  • 讀寫鎖支持降級不支持升級,因爲降級可以提高效率,降級成讀鎖不會修改數據;
public class UpDownLock {

    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();

    private static void updateLock() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "獲取到讀鎖");
            System.out.println(Thread.currentThread().getName() + "正在升級成寫鎖...");
            writeLock.lock();
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "釋放了讀鎖");
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "釋放了寫鎖");
        }
    }

    private static void downLock() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "獲取到寫鎖");
            readLock.lock();
            System.out.println(Thread.currentThread().getName() + "降級成讀鎖成功");
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "釋放了讀鎖");
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "釋放了寫鎖");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> downLock());
        t1.start();
        Thread.sleep(1000);
        System.out.println("-----------------------------");
        Thread t2 = new Thread(() -> updateLock());
        t2.start();
    }
}
Thread-0獲取到寫鎖
Thread-0降級成讀鎖成功
Thread-0釋放了讀鎖
Thread-0釋放了寫鎖
-----------------------------
Thread-1獲取到讀鎖
Thread-1正在升級成寫鎖...

因爲讀寫鎖,只能多讀或一寫,如果其中一個線程想要升級成寫鎖,那麼其他線程必須放棄讀鎖,如果所有的讀線程都想升級成寫鎖,那麼就必須都得相互等待對方釋放讀鎖,而兩者都想升級就都不釋放讀鎖,這就陷入了死鎖。

總結:

  • 鎖申請和釋放策略:要麼多讀,要麼一寫;
  • 插隊策略:寫鎖可以插隊,讀鎖僅僅在等待隊列頭結點不是寫鎖的時候可以插隊;
  • 升降級策略:只能降級,提高效率;不能升級,會導致死鎖;
  • 使用場景:適合讀多寫少情況,可以提高併發效率;

10. 可中斷鎖

  • synchronized不是可中斷鎖;
  • Lock是可中斷鎖,因爲tryLock(設置超時時間)和lockInterruptibly(等待獲取鎖期間)都能響應中斷;

11. 自旋鎖和阻塞鎖

  • 阻塞或喚醒一個Java線程需要操作系統切換CPU狀態來完成,這種狀態轉換需要耗費處理器時間;
  • 如果同步代碼塊中的內容過於簡單,狀態轉換消耗的時間有可能比用戶代碼執行的時間還要長
  • 在許多場景中,同步資源的鎖定時間很短,爲了這一小段時間去切換線程,線程掛起和恢復現場的花費可能會讓系統得不償失;
  • 如果物理機有多個處理器,能夠讓兩個以上的線程同時並行執行,我們就可以讓後的那個請求鎖的線程不放棄CPU的執行時間(不阻塞),看看持有鎖的線程是否很快就會釋放鎖;
  • 而爲了讓當前線程等待一下,我們需要讓當前線程進行自旋,如果在自旋完成後前面鎖定同步資源的線程已經釋放了鎖,那麼當前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開銷,這就是自旋鎖。
  • 阻塞鎖和自旋鎖相反,阻塞所如果沒有拿到鎖,會直接把線程阻塞,直到被喚醒;

自旋鎖的缺點:

  • 如果鎖被佔用時間過長,那麼自旋的線程只會白白浪費處理器資源;
  • 在自旋的過程中,一直消耗CPU,所以雖然自旋鎖的起始開銷低於悲觀鎖,但是隨着自旋時間的增長,開銷也是線性增長的;

原子類是自旋鎖實現的,AtomicInteger的實現:自旋鎖的實現原理是CAS,AtomicInteger中調用unsafe進行自增操作的源碼中的do-while循環就是一個自旋操作,如果修改過程中遇到其他線程競爭導致沒修改成功,就在while裏死循環,直到修改成功

  • 自己實現一個簡單的自旋鎖:
public class SpinLock implements Runnable {
    private AtomicReference<Thread> sign = new AtomicReference<>();

    @Override
    public void run() {
        lock();
        System.out.println(Thread.currentThread().getName() + "獲取到了自旋鎖");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        unlock();

    }

    public void lock() {
        Thread current = Thread.currentThread();
        while (!sign.compareAndSet(null, current)) {
//            System.out.println(Thread.currentThread().getName() + "正在嘗試獲取自旋鎖...");
        }
    }

    public void unlock() {
        Thread current = Thread.currentThread();
        sign.compareAndSet(current, null);
        System.out.println(current.getName() + "釋放了自旋鎖");
    }

    public static void main(String[] args) {
        SpinLock spinLock = new SpinLock();
        Thread t1 = new Thread(spinLock);
        Thread t2 = new Thread(spinLock);
        t1.start();
        t2.start();
    }
}
Thread-0獲取到了自旋鎖
Thread-0釋放了自旋鎖
Thread-1獲取到了自旋鎖
Thread-1釋放了自旋鎖

自旋鎖的使用場景:

  • 使用於併發度不高的情況;
  • 適合臨界區比較小的情況;

12. 鎖優化

  • 自旋鎖和自適應:提高效率,嘗試自旋的時候,如果嘗試不到就轉爲阻塞鎖;
  • 鎖消除:有一些場景下不必要加鎖,JVM會分析出來直接消除了;
  • 鎖粗化:消除了加鎖解鎖的過程,把前後相鄰的synchronized代碼塊合併一起;

13. 寫代碼時候如何優化鎖和提高併發性能

  1. 縮小同步代碼塊;
  2. 儘量不要鎖住方法;
  3. 減少請求鎖的次數;
  4. 鎖中儘量不要包含鎖,不要嵌套鎖;
  5. 選擇合適的鎖的類型以及合適的工具類;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章