Java併發基礎-鎖的使用及原理(可重入鎖、讀寫鎖、內建鎖、訊號量等)

icarusliu81發表於2018-03-20

本文目錄:

1 基礎

1.1 可重入鎖

可重入鎖表示的是,如果一個執行緒在未釋放已獲得鎖的情況下再次對該物件加鎖,將可以加鎖成功。而且可以不斷的加鎖成功多次。但需要注意的是,每次加鎖操作必須對應著一次釋放鎖的操作。
如以下示例是可以執行的(但完全沒這麼寫的必要):

public synchronized void a() {
    ...

    synchronized (this) {
        synchronized (this) {
            ...
        }
    }

    ...
}

為什麼需要可重入鎖?先看以下示例(使用內建鎖):

public class TestObject{
  public synchronized void a() {
      ...

      b();  

      ...
  }

  public synchronized void b() {
      ... 
  }

}

public static void main(String[] args) {
    TestObject obj = new TestObject();  
    obj.a();  
}

以上示例中,a方法呼叫b方法,兩個方法都被內建鎖鎖定,如果不可重入,那麼在呼叫b的時候當前執行緒就會等待鎖的釋放-而實際鎖又被自己佔用,因此死鎖就出現了。而可重入鎖就是為了解決這個問題而出現的。
那為什麼a方法和b方法可能會需要同時加鎖呢?這是因為外部物件可能會單獨呼叫b方法而不去呼叫a方法!如果b沒有進行加鎖處理那麼可能會導致併發問題。

注意:
實際上可重入鎖如ReentrantLock在其內部有一個計數器用於儲存當前執行緒對該鎖的加鎖次數;如果為0是表示當前執行緒沒有獲取到該鎖。

1.2 讀寫鎖

讀寫鎖內部實際上包含有兩個鎖物件:一個負責對讀操作加鎖,一個負責對寫操作加鎖。讀操作不是排他的,也就是說同一時刻可以有多個執行緒同時佔用讀鎖;而寫操作必須是排它的,如果寫鎖被某個執行緒佔有,那麼任何的執行緒不但獲取不到寫鎖,也獲取不到讀鎖。
使用讀寫鎖能夠有效的提高併發;就是因為排它鎖不允許同時讀,而讀寫鎖允許。

2 內建鎖synchronized

物件的內建鎖,它有以下特性:

  • 可以使用在方法或者程式碼段上; 但不可以使用在構造方法上。
  • 它是可重入的;
  • 當synchronized用於同一個物件時,同一時刻只有一個執行緒能夠進入它被synchronized包圍的區域。

    舉個例子,如果某類的A方法和B方法都使用了synchronized關鍵字,當執行緒1在呼叫A方法時,無論執行緒2想要呼叫A或者B方法,它都只能等待A的呼叫完成。這是因為進入synchronized包圍區域後,表示的是這個物件的內建鎖已經被這個執行緒獲取到,其它要進入synchronized區域的執行緒都只能等待。

  • 每一個物件都有一個內建鎖,物件可以是類例項化後的物件,也可以是類本身。

  • 當內建鎖使用在靜態方法上時,表示的是對獲取的類本身的內建鎖,而不是例項化後的內建鎖。

使用示例:

class Test {
    private Object obj = new Object();

    /**
     * 使用在方法上
     */
    public synchronized void test1() {
        //使用在this上,也使用在方法上時是使用同一個物件的內建鎖
        synchronized(this) {...}

        //使用在某個物件上,表示的是對這個物件的內建鎖進行加鎖
        synchronized (obj) {...}
    }


    //使用在靜態方法上,
    public static synchronized static void test2() {
        //使用在Class上,表示的是對類的內建鎖進行加鎖,與使用在靜態方法上加鎖的是同一個物件
        synchronized (Test.class) {...}
    }

}

內建鎖可以簡化加鎖操作,也能夠避免在使用Lock的時候出現一些很常見的問題如死鎖等。因此synchronized能夠滿足需求時可以考慮優先使用內建鎖。
但某些複雜場景下可能內建鎖無法滿足需求,如處理流程是下面這樣的:

image

獲取A鎖後再獲取B鎖,然後先釋放A鎖。這種場景使用內建鎖就無法滿足。必須使用顯示鎖(Lock)

3 顯式鎖Lock

顯式鎖可以提供比synchronized更加靈活的加鎖功能。synchronized的所有使用場景顯式鎖都能夠滿足,而且還可以支援更多複雜的操作場景。

Java中的顯式鎖UML圖如下所示: 
這裡寫圖片描述

它主要包含了兩個介面和兩個實現:
- Lock: 最頂層的鎖介面,提供加鎖與釋放鎖的介面方法;
- ReentrantLock:Lock的一個可重入鎖的實現類;
- ReadWriteLock: 讀寫鎖介面,提供獲取讀鎖物件與獲取寫鎖物件兩個介面方法;
- ReentrantReadWriteLock:可重入讀寫鎖的實現類;

3.1 簡單示例

顯式鎖主要的方法就是lock與unlock,先通過一個簡單示例來演示鎖的使用。

class Test {
    private Lock lock = new ReentrantLock();
    private Map<String, String> map = new HashMap<>();

    /**
     * 插入
     * 如果關鍵字已經存在則不進行插入,否則進行插入
     *
     * @param key
     * @param value
     */
    public void insert(String key, String value) {
        lock.lock();
        try {
            if (!map.containsKey(key)) {
                map.put(key, value);
            }
        } finally {
            lock.unlock();
        }
    }
}

注意此處如果不加鎖,在多執行緒環境下是有導致出問題的。多個執行緒同時向map中put同一個Key對應的值,最終儲存的值將可能是某個執行緒put進去的,也可能都不是。

注意:加鎖後到釋放鎖前的所有操作都必須被try{}包圍起來,並且必須在finally中釋放鎖。否則如果在釋放鎖前某個處理丟擲異常,將不會進行鎖的釋放操作,這樣的話其它執行緒就永遠獲取不到鎖了

3.2 鎖常用操作

a. Lock介面

分類 方法 說明
加鎖 lock 加鎖,當前執行緒阻塞直到獲取到鎖
lockInterruptibly 加鎖,當前執行緒阻塞直到獲取到鎖或者是被中斷;中斷後將會丟擲InterruptedException
tryLock() 嘗試加鎖,方法會立即返回不會阻塞當前執行緒; 如果加鎖成功將返回True,否則返回False
tryLock(time, unit) 嘗試加鎖,如果在指定時間內未獲取到鎖則返回False,獲取到了則返回True; 如果等待過程中被中斷將丟擲InterruptedException
釋放鎖 unlock 當前執行緒釋放所獲取的當前鎖

b. ReadWriteLock介面

方法 說明
readLock 返回一個讀鎖物件
writeLock 返回一個寫鎖物件

3.3 讀寫鎖使用示例

如下例,假設有Cache物件對資料按Key、Value進行快取,寫時互斥而讀時可同時讀,實現如下: 

public class Cache {
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private Map<String, Object> dataMap = new HashMap<>();
    private static Cache instance = null;

    /**
     * 構造方法設定成Private防止外部呼叫
     */
    private Cache() {}

    /**
     * 獲取Cache的例項
     * @return
     */
    public synchronized static Cache getInstance() {
        if (null == instance) {
            instance = new Cache();
        }
        return instance;
    }

    /**
     * 根據Key讀取快取的值
     * 如果無值時將返回Null
     * @param key
     * @return
     */
    public Object read(String key) {
        readWriteLock.readLock().lock();
        try {
            Object obj = dataMap.get(key);
            return obj;
        } finally {
            readWriteLock.readLock().unlock();
        }
    }

    /**
     * 將Key、Value的值寫入快取
     * 在寫的過程中不能讓其它執行緒讀取,否則可能導致讀出來的資料是一個不可預料的值。
     * 
     * @param key
     * @param value
     */
    public void write(String key, Object value) {
        readWriteLock.writeLock().lock();
        try {
            dataMap.put(key, value);
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }

}

4 訊號量Semaphore

訊號量是一種輕量鎖,它主要用於控制某些有限資源在多執行緒之間的分配使用。假設最多隻允許向資料庫建立5個連線,那麼同時有5個執行緒可以使用連線,如果同時請求的執行緒數超過5個,那麼其它未獲取許可的執行緒就只能等待正在執行中的執行緒釋放連線。
Semaphore提供acquire()方法來獲取許可,使用release方法來釋放許可,初始化的時候可以設定訊號量的個數,也可以設定該訊號量的公平性引數。

說明
- 公平性指的是對訊號量的請求是否是FIFO(先入先出)的;如果設定成True,那麼先呼叫acquire方法的執行緒將優先獲取到訊號量; 如果設定成False,那麼將不會保證這個順序,後提交的可能比在等待中的更加早的獲取到訊號量。預設情況下是設定成False的。
- acquire方法也可以一次性獲取多個訊號量;當訊號量數不夠時,將會阻塞直到有訊號量被其它執行緒釋放並且數目足夠。注意以下場景:如果設定成公平的,當前可使用的訊號量為2個,A執行緒先申請的訊號量為3個,B執行緒後申請兩個,那麼A與B都將會等待,而不會因為能夠滿足B執行緒的需求而優先讓B執行緒獲取到足夠的訊號量。

4.1 訊號量使用示例

下例模擬實現連線池。

public static class ConnectionPool {
    private Semaphore semaphore = new Semaphore(10);
    private List<Connection> connections = new ArrayList<>();
    private static ConnectionPool instance = null;

    /**
     * 構造方法設定成Private防止外部呼叫
     */
    private ConnectionPool() {}

    /**
     * 獲取Cache的例項
     * @return
     */
    public synchronized static ConnectionPool getInstance() {
        if (null == instance) {
            instance = new ConnectionPool();

            //初始化連線池,假設一個字串就是一個連線
            for (int i = 0; i < 10; i++) {
                instance.connections.add(new Connection(i));
            }
        }
        return instance;
    }

    /**
     * 從連線池中獲取連線
     * 如果連線池中連線不夠,則會阻塞當前執行緒,直接有其它執行緒釋放連線或者當前執行緒被中斷
     *
     * @return 返回所獲取的連線,如果執行緒被中斷,則返回空;
     */
    public Connection getConnection() {
        try {
            //獲取一個訊號量,如果沒有則當前執行緒阻塞
            semaphore.acquire();

            //表示已經獲取到了訊號量,那麼此時連線池中肯定有未使用的連線
            synchronized (this) {
                //從連線池中獲取未使用連線,由於可能會有多個執行緒同時獲取連線,因此此處必須要進行同步處理
                for (Connection conn: connections) {
                    if (!conn.isUsed) {
                        conn.setUsed(true);
                        return conn.clone();
                    }
                }

                return null;
            }
        } catch (InterruptedException e) {
            //當前執行緒被中斷時
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 將連線歸還到連線池中
     *
     * @param connection
     */
    public void release(Connection connection) {
        Assert.assertNotNull(connection);
        //歸還連線時,必須先將所使用的連線設定成未使用後再呼叫訊號量的release,否則如果先release再歸還可能導致其它執行緒被喚醒後獲取連線失敗。
        synchronized (this) {
            for (Connection conn: connections) {
                if (conn.getId() == connection.getId()) {
                    conn.setUsed(false);

                    break;
                }
            }
        }

        //釋放訊號量 
        semaphore.release();
    }
}

4.2 方法清單

訊號量的使用方法可以分成兩類,一類是acquire,一類是release,清單如下:

分類 方法 說明
獲取訊號量 acquire 獲取訊號量,當前執行緒阻塞直到獲取到訊號量或被中斷;中斷後將丟擲InterruptedException
acquireUninterruptibly 獲取訊號量,不會被中斷;當前執行緒阻塞直到獲取到訊號量
tryAcquire() 嘗試獲取訊號量,方法會立即返回不會阻塞當前執行緒; 如果獲取成功將返回True,否則返回False;如果正好有一個訊號量被釋放,則會獲取到該訊號量,不管是否有執行緒已經在它之前在排隊等待訊號量,即使是在公平值設定成True的情況下。
tryAcquire(time, unit) 嘗試獲取訊號量,如果在指定時間內未獲取到鎖則返回False,獲取到了則返回True; 如果等待過程中被中斷將丟擲InterruptedException
釋放訊號量 release 當前執行緒釋放所獲取的訊號量

其中,每個acquire與release方法還有一個帶int引數的變種,表示的是獲取或者釋放指定個數的訊號量。

相關文章