Java並發包——線程同步和鎖
摘要:本文主要學習了Java並發包里有關線程同步的類和鎖的一些相關概念。
部分內容來自以下博客:
https://www.cnblogs.com/dolphin0520/p/3923167.html
https://blog.csdn.net/tyyj90/article/details/78236053
線程同步方式
對於線程安全我們前面使用了synchronized關鍵字,對於線程的協作我們使用Object.wait()和Object.notify()。在JDK1.5中java為我們提供了Lock來實現與它們相同的功能,並且性能優於它們,在JDK1.6時,JDK對synchronized做了優化,在性能上兩種方式差距不大了。
synchronized的缺陷
synchronized修飾的代碼塊,當一個線程獲取了對應的鎖,並執行該代碼塊時,其他線程便只能一直等待,等待獲取鎖的線程釋放鎖,如果沒有釋放則需要無限的等待下去。
獲取鎖的線程釋放鎖只會有兩種情況:
1)獲取鎖的線程執行完了該代碼塊,然后線程釋放對鎖的占有。
2)線程執行發生異常,此時JVM會讓線程自動釋放鎖。
總結一下,也就是說Lock提供了比synchronized更多的功能。但是要注意以下幾點:
1)Lock不是Java語言內置的,synchronized是Java語言的關鍵字,因此是內置特性。Lock是一個類,通過這個類可以實現同步訪問。
2)synchronized不需要手動釋放鎖,當synchronized方法或者synchronized代碼塊執行完之后,系統會自動讓線程釋放對鎖的占用。而Lock則必須要用戶去手動釋放鎖,如果沒有主動釋放鎖,就有可能導致出現死鎖現象。
Lock
Lock接口位於java.util.concurrent.locks包中。
1 public interface Lock { 2 // 用來獲取鎖。如果鎖已被其他線程獲取,則進行等待。 3 void lock(); 4 5 // 用來獲取鎖。允許在等待時由其它線程調用interrupt方法來中斷等待而直接返回,這時不用獲取鎖,而會拋出一個InterruptedException。 6 void lockInterruptibly() throws InterruptedException; 7 8 // 用來嘗試獲取鎖,如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線程獲取),則返回false。 9 boolean tryLock(); 10 11 // 用來嘗試獲取鎖,如果拿到鎖或者在等待期間內拿到了鎖,則返回true。如果在某段時間之內獲取失敗,就返回false。 12 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; 13 14 // 釋放鎖。 15 void unlock(); 16 17 // 獲取Condition對象。 18 Condition newCondition(); 19 }
lock方法
首先lock()方法是平常使用得最多的一個方法,就是用來獲取鎖。如果鎖已被其他線程獲取,則進行等待。
由於在前面講到如果采用Lock,必須主動去釋放鎖,並且在發生異常時,不會自動釋放鎖。因此一般來說,使用Lock必須在try{}catch{}塊中進行,並且將釋放鎖的操作放在finally塊中進行,以保證鎖一定被被釋放,防止死鎖的發生。
通常使用Lock來進行同步的話,是以下面這種形式去使用的:
1 Lock lock = ... ; 2 lock.lock(); 3 try { 4 // 處理任務 5 } catch(Exception e) { 6 7 } finally { 8 lock.unlock();// 釋放鎖 9 }
tryLock方法
tryLock()方法是有返回值的,它表示用來嘗試獲取鎖,如果獲取成功,則返回true,如果獲取失敗(即鎖已被其他線程獲取),則返回false,也就說這個方法無論如何都會立即返回。在拿不到鎖時不會一直在那等待。
tryLock(long time, TimeUnit unit)方法和tryLock()方法是類似的,只不過區別在於這個方法在拿不到鎖時會等待一定的時間,在時間期限之內如果還拿不到鎖,就返回false。如果如果一開始拿到鎖或者在等待期間內拿到了鎖,則返回true。
一般情況下通過tryLock來獲取鎖時是這樣使用的:
1 Lock lock = ... ; 2 if (lock.tryLock()) { 3 try { 4 // 處理任務 5 } catch (Exception e) { 6 7 } finally { 8 lock.unlock();// 釋放鎖 9 } 10 } else { 11 // 獲取失敗處理其他事情 12 }
lockInterruptibly方法
lockInterruptibly()方法比較特殊,當通過這個方法去獲取鎖時,如果線程正在等待獲取鎖,則這個線程能夠響應中斷,即中斷線程的等待狀態。也就使說,當兩個線程同時通過lock.lockInterruptibly()想獲取某個鎖時,假若此時線程A獲取到了鎖,而線程B只有在等待,那么對線程B調用threadB.interrupt()方法能夠中斷線程B的等待過程。
由於lockInterruptibly()的聲明中拋出了異常,所以lock.lockInterruptibly()必須放在try塊中或者在調用lockInterruptibly()的方法外聲明拋出InterruptedException。
一般的使用形式如下:
1 public void method() throws InterruptedException { 2 Lock lock = ... ; 3 lock.lockInterruptibly(); 4 try { 5 // 處理任務 6 } finally { 7 lock.unlock(); 8 } 9 }
注意,當一個線程獲取了鎖之后,是不會被interrupt()方法中斷的。因為本身在前面的文章中講過單獨調用interrupt()方法不能中斷正在運行過程中的線程,只能中斷阻塞過程中的線程。
因此當通過lockInterruptibly()方法獲取某個鎖時,如果不能獲取到,只有進行等待的情況下,是可以響應中斷的。
而用synchronized修飾的話,當一個線程處於等待某個鎖的狀態,是無法被中斷的,只有一直等待下去。
ReentrantLock
ReentrantLock類實現了Lock接口,並且ReentrantLock提供了更多的方法。
1 public class Demo { 2 public static void main(String[] args) { 3 DemoThread dt = new DemoThread(); 4 Thread t1 = new Thread(dt, "窗口1"); 5 Thread t2 = new Thread(dt, "窗口2"); 6 t1.start(); 7 t2.start(); 8 } 9 } 10 11 class DemoThread implements Runnable { 12 private int ticket = 3; 13 Lock lock = new ReentrantLock(); 14 15 @Override 16 public void run() { 17 while (ticket > 0) { 18 try { 19 Thread.sleep(1); 20 } catch (InterruptedException e) { 21 e.printStackTrace(); 22 } 23 24 lock.lock(); 25 try { 26 if (ticket > 0) { 27 System.out.println(Thread.currentThread().getName() + " 進入賣票環節 "); 28 System.out.println(Thread.currentThread().getName() + " 售賣的車票編號為: " + ticket--); 29 } 30 } catch (Exception e) { 31 e.printStackTrace(); 32 } finally { 33 lock.unlock(); 34 } 35 } 36 } 37 }
注意在聲明Lock的時候,要注意不要聲明為局部變量。
ReadWriteLock
ReadWriteLock也是一個接口,用來定義讀寫鎖。
1 public interface ReadWriteLock { 2 Lock readLock(); 3 4 Lock writeLock(); 5 }
一個用來獲取讀鎖,一個用來獲取寫鎖。也就是說將文件的讀寫操作分開,分成兩個鎖來分配給線程,從而使得多個線程可以同時進行讀操作。
ReentrantReadWriteLock
ReentrantReadWriteLock實現了ReadWriteLock接口,支持多個線程同時進行讀操作。
1 public class Demo { 2 public static void main(String[] args) { 3 DemoThread dt = new DemoThread(); 4 new Thread(() -> dt.showTicket(), "窗口1").start(); 5 new Thread(() -> dt.showTicket(), "窗口2").start(); 6 new Thread(() -> dt.showTicket(), "窗口3").start(); 7 new Thread(() -> dt.saleTicket(), "窗口4").start(); 8 } 9 } 10 11 class DemoThread { 12 private int ticket = 3; 13 ReadWriteLock lock = new ReentrantReadWriteLock(); 14 15 public void showTicket() { 16 while (ticket > 0) { 17 try { 18 Thread.sleep(1); 19 } catch (InterruptedException e) { 20 e.printStackTrace(); 21 } 22 lock.readLock().lock(); 23 try { 24 if (ticket > 0) { 25 System.out.println(Thread.currentThread().getName() + " 進入預售環節"); 26 System.out.println(Thread.currentThread().getName() + " 預售的車票編號為: " + ticket); 27 } 28 } catch (Exception e) { 29 e.printStackTrace(); 30 } finally { 31 lock.readLock().unlock(); 32 } 33 } 34 System.out.println(Thread.currentThread().getName() + " 進入結束環節"); 35 } 36 37 public void saleTicket() { 38 while (ticket > 0) { 39 try { 40 Thread.sleep(1); 41 } catch (InterruptedException e) { 42 e.printStackTrace(); 43 } 44 lock.writeLock().lock(); 45 try { 46 if (ticket > 0) { 47 System.out.println(Thread.currentThread().getName() + " 進入售票環節"); 48 System.out.println(Thread.currentThread().getName() + " 售賣的車票編號為: " + ticket--); 49 } 50 } catch (Exception e) { 51 e.printStackTrace(); 52 } finally { 53 lock.writeLock().unlock(); 54 } 55 } 56 System.out.println(Thread.currentThread().getName() + " 進入結束環節"); 57 } 58 }
運行結果如下:
1 窗口2 進入預售環節 2 窗口1 進入預售環節 3 窗口1 預售的車票編號為: 3 4 窗口2 預售的車票編號為: 3 5 窗口3 進入預售環節 6 窗口3 預售的車票編號為: 3 7 窗口4 進入售票環節 8 窗口4 售賣的車票編號為: 3 9 窗口4 進入售票環節 10 窗口4 售賣的車票編號為: 2 11 窗口2 進入預售環節 12 窗口3 進入預售環節 13 窗口1 進入預售環節 14 窗口1 預售的車票編號為: 1 15 窗口2 預售的車票編號為: 1 16 窗口3 預售的車票編號為: 1 17 窗口4 進入售票環節 18 窗口4 售賣的車票編號為: 1 19 窗口4 進入結束環節 20 窗口3 進入結束環節 21 窗口2 進入結束環節 22 窗口1 進入結束環節
從運行的結果來看,最多有三個線程在同時讀,提高了讀操作的效率。
如果有一個線程已經占用了讀鎖,則此時其他線程如果要申請寫鎖,則申請寫鎖的線程會一直等待釋放讀鎖。
如果有一個線程已經占用了寫鎖,則此時其他線程如果申請寫鎖或者讀鎖,則申請的線程會一直等待釋放寫鎖。
關於synchronized和Lock的比較
1)Lock是一個接口,而synchronized是Java中的關鍵字,synchronized是內置的語言實現。
2)synchronized在發生異常時,會自動釋放線程占有的鎖,因此不會導致死鎖現象發生。而Lock在發生異常時,如果沒有主動釋放鎖,則很可能造成死鎖現象,因此使用Lock時需要在finally塊中釋放鎖。
3)Lock可以讓等待鎖的線程響應中斷,而synchronized卻不行,使用synchronized時,等待的線程會一直等待下去,不能夠響應中斷。
4)通過Lock可以知道有沒有成功獲取鎖,而synchronized卻無法辦到。
5)Lock可以提高多個線程進行讀操作的效率。
6)synchronized的底層是一個基於CAS操作的等待隊列,synchronized還實現了自旋鎖,並針對不同的系統和硬件體系進行了優化,而Lock則完全依靠系統阻塞掛起等待線程。
7)在資源競爭不是很激烈的情況下,synchronized的性能要優於ReetrantLock,但是在資源競爭很激烈的情況下,synchronized的性能會下降幾十倍,但是ReetrantLock的性能能維持常態
鎖的分類
在讀很多並發文章中,會提及各種各樣鎖如公平鎖,樂觀鎖等等,這篇文章介紹各種鎖的分類。介紹的內容如下:
1 可重入鎖 2 獨享鎖/共享鎖 3 互斥鎖/讀寫鎖 4 公平鎖/非公平鎖 5 樂觀鎖/悲觀鎖 6 分段鎖 7 偏向鎖/輕量級鎖/重量級鎖 8 自旋鎖
上面是很多鎖的名詞,這些分類並不是全是指鎖的狀態,有的指鎖的特性,有的指鎖的設計,下面總結的內容是對每個鎖的名詞進行一定的解釋。
可重入鎖
可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,在進入內層方法會自動獲取鎖。
對於synchronized和ReentrantLock而言,都是可重入鎖。
可重入鎖的一個好處是可一定程度避免死鎖,如果不是可重入鎖的話,可能造成死鎖。
獨享鎖/共享鎖
獨享鎖是指該鎖一次只能被一個線程所持有。共享鎖是指該鎖可被多個線程所持有。
對於synchronized和ReentrantLock而言,都是獨享鎖。
但是對於ReadWriteLock而言,其讀鎖是共享鎖,其寫鎖是獨享鎖。讀鎖的共享鎖可保證並發讀是非常高效的,讀寫,寫讀,寫寫的過程是互斥的。
獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。
互斥鎖/讀寫鎖
上面講的獨享鎖/共享鎖就是一種廣義的說法,互斥鎖/讀寫鎖就是具體的實現。
互斥鎖在Java中的具體實現就是ReentrantLock。讀寫鎖在Java中的具體實現就是ReadWriteLock。
公平鎖/非公平鎖
公平鎖是指多個線程按照申請鎖的順序來獲取鎖,非公平鎖是指多個線程獲取鎖的順序並不是按照申請鎖的順序。
對於synchronized而言,是一種非公平鎖。
對於ReentrantLock而言,通過構造函數指定該鎖是否是公平鎖,默認是非公平鎖。非公平鎖的優點在於吞吐量比公平鎖大。
樂觀鎖/悲觀鎖
樂觀鎖與悲觀鎖不是指具體的什么類型的鎖,而是指看待並發同步的角度。
悲觀鎖認為對於同一個數據的並發操作,一定是會發生修改的,哪怕沒有修改,也會認為修改。因此對於同一個數據的並發操作,悲觀鎖采取加鎖的形式。悲觀的認為,不加鎖的並發操作一定會出問題。
樂觀鎖則認為對於同一個數據的並發操作,是不會發生修改的。在更新數據的時候,會采用嘗試更新,不斷重新的方式更新數據。樂觀的認為,不加鎖的並發操作是沒有事情的。
悲觀鎖在Java中的使用,就是利用各種鎖。
樂觀鎖在Java中的使用,是無鎖編程,常常采用的是CAS算法,典型的例子就是原子類,通過CAS自旋實現原子操作的更新。
分段鎖
分段鎖其實是一種鎖的設計,並不是具體的一種鎖,對於ConcurrentHashMap而言,其並發的實現就是通過分段鎖的形式來實現高效的並發操作。
分段鎖的設計目的是細化鎖的粒度,當操作不需要更新整個數組的時候,就僅僅針對數組中的一項進行加鎖操作。
偏向鎖/輕量級鎖/重量級鎖
這三種鎖是指鎖的狀態,並且是針對Synchronized。在JDK5通過引入鎖升級的機制來實現高效Synchronized。
這三種鎖的狀態是通過對象監視器在對象頭中的字段來表明的。
偏向鎖是指一段同步代碼一直被一個線程所訪問,那么該線程會自動獲取鎖。降低獲取鎖的代價。
輕量級鎖是指當鎖是偏向鎖的時候,被另一個線程所訪問,偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,提高性能。
重量級鎖是指當鎖為輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的線程進入阻塞,性能降低。
自旋鎖
在Java中,自旋鎖是指嘗試獲取鎖的線程不會立即阻塞,而是采用循環的方式去嘗試獲取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗CPU。
了解AQS
什么是AQS
AQS是英文單詞AbstractQueuedSynchronizer的縮寫,翻譯過來就是抽象的隊列式的同步器,AQS定義了一套多線程訪問共享資源的同步器框架,許多同步類實現都依賴於它,如常用的ReentrantLock、Semaphore、CountDownLatch等等。
實現方式
AQS的主要使用方式是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態。
原理
AQS維護了一個state用來代表資源共享狀態 private volatile int state; ,AQS提供了三種操作state的方法: int getState(); 、 void setState(int newState); 、 boolean compareAndSetState(int expect, int update); 。
AQS通過內置的FIFO同步隊列 static final class Node 來完成資源獲取線程的排隊工作,如果當前線程獲取同步狀態失敗(鎖)時,AQS則會將當前線程以及等待狀態等信息構造成一個節點(Node)並將其加入同步隊列,同時會阻塞當前線程,當同步狀態釋放時,則會把節點中的線程喚醒,使其再次嘗試獲取同步狀態。
資源共享方式
AQS定義兩種資源共享方式:Exclusive(獨占,只有一個線程能執行,如ReentrantLock)和Share(共享,多個線程可同時執行,如Semaphore/CountDownLatch)。
使用分析
不同的自定義同步器爭用共享資源的方式也不同。自定義同步器在實現時只需要實現共享資源state的獲取與釋放方式即可,至於具體線程等待隊列的維護(如獲取資源失敗入隊/喚醒出隊等),AQS已經在頂層實現好了。自定義同步器實現時主要實現以下幾種方法:
isHeldExclusively():該線程是否正在獨占資源。只有用到condition才需要去實現它。
tryAcquire(int):獨占方式。嘗試獲取資源,成功則返回true,失敗則返回false。
tryRelease(int):獨占方式。嘗試釋放資源,成功則返回true,失敗則返回false。
tryAcquireShared(int):共享方式。嘗試獲取資源。負數表示失敗;0表示成功,但沒有剩余可用資源;正數表示成功,且有剩余資源。
tryReleaseShared(int):共享方式。嘗試釋放資源,如果釋放后允許喚醒后續等待結點返回true,否則返回false。
以ReentrantLock為例,state初始化為0,表示未鎖定狀態。A線程lock()時,會調用tryAcquire()獨占該鎖並將state+1。此后,其他線程再tryAcquire()時就會失敗,直到A線程unlock()到state=0(即釋放鎖)為止,其它線程才有機會獲取該鎖。當然,釋放鎖之前,A線程自己是可以重復獲取此鎖的(state會累加),這就是可重入的概念。但要注意,獲取多少次就要釋放多么次,這樣才能保證state是能回到零態的。
再以CountDownLatch以例,任務分為N個子線程去執行,state也初始化為N(注意N要與線程個數一致)。這N個子線程是並行執行的,每個子線程執行完后countDown()一次,state會CAS減1。等到所有子線程都執行完后(即state=0),會unpark()主調用線程,然后主調用線程就會從await()函數返回,繼續后余動作。
一般來說,自定義同步器要么是獨占方法,要么是共享方式,他們也只需實現tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一種即可。但AQS也支持自定義同步器同時實現獨占和共享兩種方式,如ReentrantReadWriteLock。