在之前講線程同步時,介紹了synchronized的鎖的使用及底層原理,也介紹了synchronized鎖的一些使用侷限,本篇文章來介紹一下Java中提供的另一種線程同步機制——顯式鎖。Java併發包中的顯式鎖接口和類位於包java.util.concurrent.locks下,主要接口和類有:
- 鎖接口Lock,主要實現類是ReentrantLock
- 讀寫鎖接口ReadWriteLock,主要實現類是ReentrantReadWriteLock
1. Lock接口
S.N. | 方法 | 說明 |
1 | void lock() | 獲取鎖,獲取鎖不成功會阻塞當前線程 |
2 | void unlock() | 釋放鎖 |
3 | void lockInterruptibly() throws InterruptedException | 獲取鎖,獲取鎖不成功會阻塞等待,如果等待期間被其他線程中斷了,拋出InterruptedException |
4 | boolean tryLock() | 嘗試獲取鎖,立即返回,不阻塞,如果獲取成功,返回true,否則返回false |
5 | boolean tryLock(long time, TimeUnit unit) throws InterruptedException | 先嚐試獲取鎖,如果能成功則立即返回true,否則阻塞等待,但等待的最長時間爲指定的參數,在等待的同時響應中斷,如果發生了中斷,拋出InterruptedException,如果在等待的時間內獲得了鎖,返回true,否則返回false |
6 | Condition newCondition() | 新建一個條件,用於顯式鎖的協作,使用Condition條件,可以起到和synchronized鎖使用wait/signal同樣的效果 |
從上述方法可以看出,相比synchronized,顯式鎖支持以非阻塞方式獲取鎖、可以響應中斷、可以限時,比synchronized靈活的多。
2. ReentrantLock
Lock接口的主要實現類是ReentrantLock,它的基本用法lock/unlock實現了與synchronized一樣的語義,包括:
- 可重入,一個線程在持有一個鎖的前提下,可以繼續獲得該鎖
- 可以解決競態條件問題
- 可以保證內存可見性
2.1 方法說明
S.N. | 方法 | 說明 |
1 | public ReentrantLock() | 構造函數,獲取非公平所對象 |
2 | public ReentrantLock(boolean fair) | 構造函數,獲取鎖對象,fair參數用於控制是否爲公平鎖 |
3 | public boolean isLocked() | 判斷鎖是否被持有,只要有線程持有就返回true(不一定是當前線程持有) |
4 | public boolean isHeldByCurrentThread() | 判斷鎖是否被當前線程持有 |
5 | public int getHoldCount() | 鎖被當前線程持有的數量,如果鎖不被當前線程持有返回0 |
6 | public final boolean isFair() | 判斷鎖是否公平 |
7 | public final boolean hasQueuedThreads() | 判斷是否有線程在等待該鎖 |
8 | public final boolean hasQueuedThread(Thread thread) | 判斷指定的線程thread是否在等待該鎖 |
9 | public final int getQueueLength() | 獲取在等待該鎖的線程個數 |
ReentrantLock實現了Lock接口,所以除了上述方法,還實現了上述Lock的所有方法。
2.2 使用示例
還用之前的計數器做示例,之前爲了安全地改變計數,使用了synchronized同步,這裏展示一下如何使用顯式鎖同步,如下:
public class Counter {
private final Lock lock = new ReentrantLock();
private volatile int count;
public void incr() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
需要注意的是,使用顯式鎖,一定要記得調用unlock,一般而言,應該將lock之後的代碼包裝到try語句內,在finally語句內釋放鎖。上述示例代碼是顯式鎖最基本的用法,使用起來跟synchronized非常類似,下面分別介紹一下Lock接口提供的lockInterruptibly和tryLock。
2.2.1 lockInterruptibly響應中斷
之前在Java編程拾遺『線程中斷』一文中講到,
使用synchronized關鍵字獲取鎖的過程中不響應中斷請求,這是synchronized的侷限性。也就是講使用synchronized鎖,處於BLOCKED狀態的線程對象調用interrupt()方法,線程是不會響應的,所以線程自然也不會終止。但是顯式鎖中提供了lockInterruptibly()方法,可以在等待鎖的過程中響應interrupt()方法。先來看一下之前使用synchronized鎖的情況:
public class InterruptSynchronizedDemo {
private static Object lock = new Object();
private static class A extends Thread {
@Override
public void run() {
synchronized (lock) {
while (!Thread.currentThread().isInterrupted()) {
}
}
System.out.println("exit");
}
}
public static void test() throws InterruptedException {
synchronized (lock) {
A a = new A();
a.start();
Thread.sleep(1000);
a.interrupt();
a.join();
}
}
public static void main(String[] args) throws InterruptedException {
test();
}
}
test方法在持有鎖lock的情況下啓動線程a,而線程a也去嘗試獲得鎖lock,所以會進入鎖等待隊列,隨後test調用線程a的interrupt方法並等待線程線程a結束。但事實上,test方法會一直運行下去,無法終止。說明synchronized鎖是無法響應中斷的。下面我們換成顯式鎖:
public class InterruptSynchronizedDemo {
private static Lock lock = new ReentrantLock();
private static class A extends Thread {
@Override
public void run() {
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
System.out.println("獲取鎖期間線程被中斷");
return;
}
try {
while (!Thread.currentThread().isInterrupted()) {
}
} finally {
lock.unlock();
}
System.out.println("exit");
}
}
public static void test() throws InterruptedException {
lock.lockInterruptibly();
try {
A a = new A();
a.start();
Thread.sleep(1000);
a.interrupt();
a.join();
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
test();
}
}
運行結果:
獲取鎖期間線程被中斷
說明顯式鎖的lockInterruptibly方法獲取鎖,實可以響應中斷的。使用lockInterruptibly獲取鎖等待期間,如果線程被終止,線程將拋出InterruptedException。
2.2.2 tryLock避免死鎖
使用lock方法獲取鎖時,獲取不到鎖就會阻塞當前線程,之後重新獲取鎖。所以如果出現相互等待鎖的情況,lock獲取鎖的方式就會發生死鎖,如下例子,銀行賬戶之間轉賬,用類Account表示賬戶:
public class Account {
private Lock lock = new ReentrantLock();
private volatile double money;
public Account(double initialMoney) {
this.money = initialMoney;
}
public void add(double money) {
lock.lock();
try {
this.money += money;
} finally {
lock.unlock();
}
}
public void reduce(double money) {
lock.lock();
try {
this.money -= money;
} finally {
lock.unlock();
}
}
public double getMoney() {
return money;
}
void lock() {
lock.lock();
}
void unlock() {
lock.unlock();
}
boolean tryLock() {
return lock.tryLock();
}
}
Account裏的money表示當前餘額,add/reduce用於修改餘額。在賬戶之間轉賬,需要兩個賬戶都鎖定,如果不使用tryLock,直接使用lock,代碼看上去可以這樣:
public class AccountMgr {
public static class NoEnoughMoneyException extends Exception {}
public static void transfer(Account from, Account to, double money)
throws NoEnoughMoneyException {
from.lock();
try {
to.lock();
try {
if (from.getMoney() >= money) {
from.reduce(money);
to.add(money);
} else {
throw new NoEnoughMoneyException();
}
} finally {
to.unlock();
}
} finally {
from.unlock();
}
}
}
但這麼寫是有問題的,如果兩個賬戶同時給對方轉賬,都先獲取了第一個鎖,則會發生死鎖。我們寫段代碼來模擬這個過程:
public static void simulateDeadLock() {
final int accountNum = 10;
final Account[] accounts = new Account[accountNum];
final Random rnd = new Random();
for (int i = 0; i < accountNum; i++) {
accounts[i] = new Account(rnd.nextInt(10000));
}
int threadNum = 100;
Thread[] threads = new Thread[threadNum];
for (int i = 0; i < threadNum; i++) {
threads[i] = new Thread() {
public void run() {
int loopNum = 100;
for (int k = 0; k < loopNum; k++) {
int i = rnd.nextInt(accountNum);
int j = rnd.nextInt(accountNum);
int money = rnd.nextInt(10);
if (i != j) {
try {
transfer(accounts[i], accounts[j], money);
} catch (NoEnoughMoneyException e) {
}
}
}
}
};
threads[i].start();
}
}
以上創建了10個賬戶,100個線程,每個線程執行100次循環,在每次循環中,隨機挑選兩個賬戶進行轉賬。基本上每次都會發生死鎖。
下面使用tryLock來進行修改,先定義一個tryTransfer方法:
public static boolean tryTransfer(Account from, Account to, double money)
throws NoEnoughMoneyException {
if (from.tryLock()) {
try {
if (to.tryLock()) {
try {
if (from.getMoney() >= money) {
from.reduce(money);
to.add(money);
} else {
throw new NoEnoughMoneyException();
}
return true;
} finally {
to.unlock();
}
}
} finally {
from.unlock();
}
}
return false;
}
如果兩個鎖都能夠獲得,且轉賬成功,則返回true,否則返回false,不管怎樣,結束都會釋放所有鎖。transfer方法可以循環調用該方法以避免死鎖,代碼如下:
public static void transfer(Account from, Account to, double money)
throws NoEnoughMoneyException {
boolean success = false;
do {
success = tryTransfer(from, to, money);
if (!success) {
Thread.yield();
}
} while (!success);
}
上述代碼,使用tryLock(),可以避免死鎖。在持有一個鎖,獲取另一個鎖,獲取不到的時候,可以釋放已持有的鎖,給其他線程機會獲取鎖,然後再重試獲取所有鎖。
2.2.3 顯式條件
鎖用於解決競態條件問題,條件是線程間的協作機制。顯式鎖與synchronzied相對應,而顯式條件與wait/notify相對應。wait/notify與synchronized配合使用,顯式條件與顯式鎖配合使用。
條件與鎖相關聯,創建條件變量需要通過顯式鎖,Lock接口定義了創建方法:
Condition newCondition()
Condition表示條件變量,是一個接口,定義了線程之間協作的各種方法,如下:
S.N. | 方法 | 說明 |
1 | void await() throws InterruptedException | 對應Object的wait(),使當前線程等待,又RUNNING進入WAITTING狀態 |
boolean await(long time, TimeUnit unit) throws InterruptedException | 等待時間是相對時間,如果由於等待超時返回,返回值爲false,否則爲true,等待期間線程被中斷會拋異常 | |
long awaitNanos(long nanosTimeout) throws InterruptedException | 等待時間是相對時間,參數單位是納秒,返回值是nanosTimeout減去實際等待的時間 | |
boolean awaitUntil(Date deadline) throws InterruptedException | 等待時間是絕對時間,如果由於等待超時返回,返回值爲false,否則爲true | |
void awaitUninterruptibly() | 該方法不會由於中斷結束,但當它返回時,如果等待過程中發生了中斷,中斷標誌位會被設置 | |
void signal() | 喚醒一個等待的線程 | |
void signalAll() | 喚醒所有等待的線程 |
一般而言,與Object的wait方法一樣,調用await方法前需要先獲取鎖,如果沒有鎖,會拋出異常IllegalMonitorStateException。await在進入等待隊列後,會釋放鎖,釋放CPU,當其他線程將它喚醒後,或等待超時後,或發生中斷異常後,它都需要重新獲取鎖,獲取鎖後,纔會從await方法中退出。
另外,與Object的wait方法一樣,await返回後,不代表其等待的條件就一定滿足了,通常要將await的調用放到一個循環內,只有條件滿足後才退出。
一般而言,signal/signalAll與notify/notifyAll一樣,調用它們需要先獲取鎖,如果沒有鎖,會拋出異常IllegalMonitorStateException。signal與notify一樣,挑選一個線程進行喚醒,signalAll與notifyAll一樣,喚醒所有等待的線程,但這些線程被喚醒後都需要重新競爭鎖,獲取鎖後纔會從await調用中返回。
2.2.3.1 使用示例
之前講線程協作的文章Java編程拾遺『線程協作』中講到同時開始(發令槍)的協作場景,使用Object的wait()合notifyAll()方法實現。這裏來看一下如何使用顯示條件來實現:
public class WaitThread extends Thread {
private volatile boolean fire = false;
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
@Override
public void run() {
try {
lock.lock();
try {
while (!fire) {
condition.await();
}
} finally {
lock.unlock();
}
System.out.println("fired");
} catch (InterruptedException e) {
Thread.interrupted();
}
}
public void fire() {
lock.lock();
try {
this.fire = true;
condition.signalAll();
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
WaitThread waitThread = new WaitThread();
waitThread.start();
Thread.sleep(1000);
System.out.println("fire");
waitThread.fire();
}
}
需要特別注意的是,不要將signal/signalAll與notify/notifyAll混淆,notify/notifyAll是Object中定義的方法,Condition對象也有,稍不注意就會誤用,比如,對上面例子中的fire方法,可能會寫爲:
public void fire() {
lock.lock();
try {
this.fire = true;
condition.notify();
} finally {
lock.unlock();
}
}
寫成這樣,編譯器不會報錯,但運行時會拋出IllegalMonitorStateException,因爲notify的調用不在synchronized語句內。
同樣,避免將鎖與synchronzied混用,那樣非常令人混淆,比如:
public void fire() {
synchronized(lock){
this.fire = true;
condition.signal();
}
}
總之,要記住一個規則:顯式條件與顯式鎖配合使用,wait/notify與synchronized配合使用。
2.2.3.1 使用顯示條件實現消費者生產者模式
public class MyBlockingQueue<E> {
private Queue<E> queue = null;
private int limit;
private Lock lock = new ReentrantLock();
private Condition notFull = lock.newCondition();
private Condition notEmpty = lock.newCondition();
public MyBlockingQueue(int limit) {
this.limit = limit;
queue = new ArrayDeque<>(limit);
}
public void put(E e) throws InterruptedException {
lock.lockInterruptibly();
try{
while (queue.size() == limit) {
notFull.await();
}
queue.add(e);
notEmpty.signal();
}finally{
lock.unlock();
}
}
public E take() throws InterruptedException {
lock.lockInterruptibly();
try{
while (queue.isEmpty()) {
notEmpty.await();
}
E e = queue.poll();
notFull.signal();
return e;
}finally{
lock.unlock();
}
}
}
定義了兩個等待條件:不滿(notFull)、不空(notEmpty),在put方法中,如果隊列滿,則在noFull上等待,在take方法中,如果隊列空,則在notEmpty上等待,put操作後通知notEmpty,take操作後通知notFull。而在之前Java編程拾遺『線程協作』那篇文章介紹的生產者消費者實現,使通過notifyAll通知所有等待線程實現的,喚醒了本不必要喚醒的線程。而使用顯示條件避免了不必要的喚醒和檢查。
3. ReadWriteLock
上面介紹了java.util.concurrent.locks包下的的顯式鎖Lock,接下來介紹該包下另一個鎖——讀寫鎖接口ReadWriteLock,及其主要實現類ReentrantReadWriteLock。
讀寫鎖模式將讀取與寫入分開處理,在讀取數據之前必須獲取用來讀取的鎖定,而寫入的時候必須獲取用來寫入的鎖定。因爲讀取時實例的狀態不會改變,所以多個線程可以同時讀取;但是,寫入會改變實例的狀態,所以當有一個線程寫入的時候,其它線程既不能讀取與不能寫入。也就是說,讀寫鎖要遵守以下三個原則:
- 允許多個線程同時讀共享變量
- 只允許一個線程寫共享變量
- 如果一個寫線程正常執行寫操作,此時禁止讀線程讀取共享變量
ReentrantReadWriteLock提供一把讀鎖、一把寫鎖實現了讀寫鎖模式,本篇文章僅簡單介紹以下ReentrantReadWriteLock讀寫鎖的使用,關於實現原理會在下篇文章中介紹。
- 線程進入讀鎖的條件(滿足其中一個即可):
- 沒有任何線程持有寫鎖
- 有線程持有寫鎖,但是持有寫鎖的線程是當前線程
- 線程進入寫鎖的條件(滿足其中一個即可)
- 沒有任何線程持有讀鎖或寫鎖
- 有線程持有寫鎖,但是持有寫鎖的線程是當前線程
從上述條件我們可以看出,如果一個線程在持有讀鎖的前提下,是不能獲取寫鎖的(讀寫鎖不支持升級)。但是如果一個線程在持有寫鎖的前提下,實可以獲取讀鎖的(讀寫鎖支持降級)。並且單獨看讀鎖合寫鎖,都是可重入的。
如下代碼會產生死鎖,因爲同一個線程中,在沒有釋放讀鎖的情況下,就去申請寫鎖,這屬於鎖升級,ReentrantReadWriteLock是不支持的:
ReadWriteLock rtLock = new ReentrantReadWriteLock();
rtLock.readLock().lock();
System.out.println("get readLock.");
rtLock.writeLock().lock();
System.out.println("blocking");
ReentrantReadWriteLock支持鎖降級,如下代碼不會產生死鎖:
ReadWriteLock rtLock = new ReentrantReadWriteLock();
rtLock.writeLock().lock();
System.out.println("writeLock");
rtLock.readLock().lock();
System.out.println("get read lock");
但是這段鎖降級的代碼雖然不會導致死鎖,但沒有正確的釋放鎖。從寫鎖降級成讀鎖,並不會自動釋放當前線程獲取的寫鎖,仍然需要顯示的釋放,否則別的線程永遠也獲取不到寫鎖。
下面通過緩存的示例,來看一下讀寫鎖的使用:
public class Cache {
private Map<String, Object> map = new HashMap<>(128);
private ReadWriteLock rwl = new ReentrantReadWriteLock();
public Object get(String id) {
Object value = null;
rwl.readLock().lock();//首先開啓讀鎖,從緩存中去取
try {
if (map.get(id) == null) { //如果緩存中沒有請求的數據,釋放讀鎖,上寫鎖
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
if (map.get(id) == null) { //防止多寫線程重複查詢賦值
value = getFromDB(); //此時可以去數據庫中查找,這裏簡單的模擬一下
map.put(id, value); //持有寫鎖,寫緩存
}
rwl.readLock().lock(); //加讀鎖降級寫鎖
} finally {
rwl.writeLock().unlock(); //釋放寫鎖
}
} else {
value = map.get(id);
System.out.println("命中緩存");
}
} finally {
rwl.readLock().unlock(); //最後釋放讀鎖
}
return value;
}
private String getFromDB() {
String value = String.valueOf(new Random().nextInt(100));
System.out.println("數據庫查詢");
return value;
}
}
public class CacheTest {
private static Cache cache = new Cache();
public static void main(String[] args) {
Random random = new Random();
Thread[] threads = new Thread[10];
/**
* 這裏啓動10個線程,每個線程進行50次查詢,所有線程每次查詢的key分佈比較集中(1 ~ 10)
* 緩存命中的概率會很高
*/
for (int i = 0; i < threads.length; i++) {
Runnable runnable = () -> {
for (int j = 0; j < 50; j ++) {
String key = String.valueOf(random.nextInt(10));
Object value = cache.get(key);
}
};
threads[i] = new Thread(runnable);
threads[i].start();
}
}
}
從運行結果可以看出,最開始會進行幾次數據庫查詢操作,之後都是從緩存中獲取的。
在多線程的環境下,對同一份數據進行讀寫,會涉及到線程安全的問題。比如在一個線程讀取數據的時候,另外一個線程在寫數據,而導致前後數據的不一致性;一個線程在寫數據的時候,另一個線程也在寫,同樣也會導致線程前後看到的數據的不一致性。這時候可以在讀寫方法中加入互斥鎖(synchronized、顯式鎖),任何時候只能允許一個線程的一個讀或寫操作,而不允許其他線程的讀或寫操作,這樣是可以解決這樣以上的問題,但是效率卻大打折扣了。因爲在真實的業務場景中,一份數據,讀取數據的操作次數通常高於寫入數據的操作,而線程與線程間的讀讀操作是不涉及到線程安全的問題,沒有必要加入互斥鎖,只要在讀-寫,寫-寫場景下互斥就行了,對於讀讀操作場景,多個線程可以共享讀鎖。總的來說,ReentrantReadWriteLock相比於ReentrantLock,是一種更細粒度的鎖,對於讀多寫少的場景,可以獲得更高的效率。
最後總結一下顯式鎖和synchronized的區別:
- synchronized在發生異常時,會自動釋放線程佔有的鎖,因此不會導致死鎖現象發生;而顯式鎖在發生異常時,如果沒有主動通過unLock()去釋放鎖,則很可能造成死鎖現象,因此使用顯式鎖時需要在finally塊中釋放鎖
- 顯式鎖可以讓等待鎖的線程響應中斷,而synchronized不行,使用synchronized時,等待的線程會一直等待下去,不能夠響應中斷
- 顯式鎖語義更豐富,可以提供公平鎖/非公平鎖,但是synchronized只能提供非公平鎖
- 通過顯式鎖可以獲取鎖的狀態(鎖是否被持有、是否被當前線程持有等),而synchronized卻無法辦到
- 顯式鎖可以提供更細粒度的鎖——讀寫鎖,可以提高多個線程進行讀操作的效率,synchronized只能做到所有操作都阻塞
- 性能上來說,在資源競爭不激烈的情形下,Lock性能稍微比synchronized差點(JDK對synchronized進行了一些列優化)。但是當同步非常激烈的時候,ReentrantLock性能要高於synchronized的性能
參考鏈接:
1. 《Java編程的邏輯》
2. Java API
3. 【死磕Java併發】—–J.U.C之AQS(一篇就夠了)