互斥鎖
文章目錄
1. 線程不安全的三大原因
線程不安全的源頭來自於無法保證以下特性:
- 原子性 :把一個或者多個操作在CPU中的執行不被中斷的特性;
- 操作系統的線程切換可以發生在任何一個指令之後,這就是原子性的根源;
- 可見性:一個線程對共享變量的修改,其他線程能夠立馬得知;
- 緩存是導致可見性問題的根因
- 有序性: 保證程序按照代碼的書寫順序來執行;
- 導致有序性問題的就是編譯器的優化,會帶來指令重排序;
1.三大特性問題舉例
以上只是做了個簡單的分析介紹,下面來舉一些示例來進行說明
1. 原子性:
private int count = 1;
count += 1; //2
假如線程一在執行上述語句2時,在CPU裏應該分成三條指令:
- (1)向內存讀取count到工作內存
- (2)執行+1操作
- (3)寫回count到內存
當線程一執行到1的時候,時間片完了,這時線程二獲取到了時間片,來執行這三個步驟,並執行完成,此時寫回內存的是2,然後線程一又獲取到了時間片,然後繼續執行(2)、(3),最後寫回去的還是2,這就產生了原子性的問題;
2. 可見性
上面說了,可見性是緩存造成的,這是典型的硬件給軟件挖的坑!?,爲了提升運算效率,引入了緩存,同時也帶來了風險,(當時單核CPU的情形下是沒有風險的,因爲操作的都是同一塊緩存,不存在不可見問題),但現在多核多線程的時代,緩存帶來的不可見問題是肯定不可避免的;
這裏來舉個簡單的例子:(count初始值爲0)
count += 1;
假如線程一來執行這條語句,會先從內存中讀取count到自己的工作內存中,然後執行加一,然後寫回主內存,問題就發生在這,寫回主內存的時間是不確定的,假如在寫回主內存前,假如有線程二來執行這條語句,此時從主存中讀取到的count到工作內存爲1,加一後爲2,這時最終寫回主內存的count值肯定爲2;
當然,發生上述是小概率事件,但你把線程數調到10000、100000,最終count的值肯定小於10000、100000;
3. 有序性
編譯器爲了優化性能,有時候會改變程序中語句的先後順序,但不會改變程序最終的執行結果,這裏指單線程下不會產生與原來不同的結果,但多線程就未必了,有時會產生意想不到的結果;
這裏最典型的例子就是double check(雙重單例模式)裏面可能產生空指針異常的問題,這裏就不再詳述,我的前面一篇博客有介紹:
2. Java解決有序性和可見性的方案
開門見山:
Java爲了解決有序性和可見性,引入了Java內存模型,這裏就不再詳細介紹,上面說到了,要解決這兩個問題,最直接的就是禁用緩存和編譯優化,但這大大的降低了效率,在單線程的情況下本來就線程安全,但卻使用不了緩存和編譯優化;
所以Java內存模型做到了按需禁用緩存以及編譯優化,將設置權交給程序員,至於如何按需禁用,Java裏提供了很多方法:volatile、synchronized、final、Happens-before原則;
這裏看一個例子:
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 這裏 x 會是多少呢?
}
}
}
問題:
如上代碼所示,線程A執行writer方法,這時線程B執行reader方法,當v==true,進入if塊裏面時,x爲多少?
解答:
在JDK5之間可能是42,也有可能0,但在jdk5之後,x肯定爲42,具體爲什麼,先來看看happens-berore原則:
在談happens-berore規則前,先說明,happens-before並不是先行發生的意思,一定不要這樣理解,它的意思是:
- A Happens before B,代表A操作的結果對B是可見的;
Happens-before原則約束了編譯器的優化行爲,雖然允許編譯器優化,但是要求編譯器優化後一定遵守Happens-before原則;
1 . 規則一:程序的順序性規則
一個線程中,按照程序的順序,前面的操作happens-before後續的任何操作。
對於這一點,可能會有疑問。順序性是指,我們可以按照順序推演程序的執行結果,但是編譯器未必一定會按照這個順序編譯,但是編譯器保證結果一定==順序推演的結果。
2 . 規則二:volatile規則
對一個volatile變量的寫操作,happens-before後續對這個變量的讀操作。
3 . 規則三:傳遞性規則
如果A happens-before B,B happens-before C,那麼A happens-before C。
jdk1.5的增強就體現在這裏。回到上面例子中,線程A中,根據規則一,對變量x的寫操作是happens-before對變量v的寫操作的,根據規則二,對變量v的寫操作是happens-before對變量v的讀操作的,最後根據規則三,也就是說,線程A對變量x的寫操作,一定happens-before線程B對v的讀操作,那麼線程B在註釋處讀到的變量x的值,一定是42.
4 . 規則四:管程中的鎖規則
對一個鎖的解鎖操作,happens-before後續對這個鎖的加鎖操作。
這一點很好理解,例如線程一進入了一個synchronized塊,當它釋放鎖後,線程二又立馬獲取了鎖進入了synchronized塊,這時線程一在其中數據的改變對線程二是可見的;
5 . 規則五:線程start()規則
主線程A啓動線程B,線程B中可以看到主線程啓動B之前的操作。也就是start() happens before 線程B中的操作。
6 . 規則六:線程join()規則
主線程A等待子線程B完成,當子線程B執行完畢後,主線程A可以看到線程B的所有操作。也就是說,子線程B中的任意操作,happens-before join()的返回。
3. 互斥鎖——解決原子性問題
好啦這裏纔到本文的主題,前面提到,原子性問題是因爲CPU在切換線程時並不是以高級語言中的語句爲準,而是以指令,高級語言中的語句一般由好幾個指令構成,所以切換線程的時候就可能中斷在高級語言中的一個完整操作,這就導致了原子性問題的發生;
1. Long變量在32位機器上的問題
大家都知道,Long變量是8個字節,64位,如果在32位上的機器要操作(寫)Long變量,就得分成兩次寫操作來完成,即一次寫高32位,一次寫低32位,即:
Long x = 0;
x = 100; //2
看似x = 100是個原子操作,但是這裏在32位機器上卻被分割成了兩個步驟,假如在寫完高32位時,時間片完了,另一個線程也來寫高32位,這時就發生了線程安全問題了,前面一個的值就會被覆蓋;
2. 互斥的定義
所謂互斥:就是同一時刻只有一個線程執行!!(⭐)
只有保證了對共享變量的修改是互斥的,就能保證原子性問題了;
談到互斥,這裏就想到了鎖,沒錯,就是如下這個典型的模型:
這裏,我們把一段需要互斥執行的代碼稱爲臨界區,線程進入臨界區前,先嚐試加鎖,如果失敗,說明鎖被其他線程佔用,就需要等待那個線程釋放鎖,然後獲取鎖;
但我們需要明確:我們鎖的是什麼?我們保護的又是什麼?
即鎖和鎖保護的資源應當是有關係的,這裏就有了改進的鎖模型:(這大大提升了多線程下的併發效率)
運用這種鎖模型,我們需要注意的是:
- 認清鎖和資源的關係,不要出現“鎖自家門保護別家財產的事情”;
3. Java提供的互斥鎖:synchronized
synchronized模型就是上面的改進鎖模型,需要注意的是:
- Java中的lock和unlock這兩個操作在synchronized中是被Java默默加上,不需要程序員去關心;這帶來的好處就是不用去擔心忘記解鎖帶來的問題;
關於synchronized,這裏有我之前專門的一篇文章來進行介紹:https://wonderyang.github.io/2019/05/13/線程同步與死鎖(synchronized關鍵字詳解)/
4. 超典型的銀行轉賬問題
案例分析:
- 賬戶類(Account)有兩個成員變量,分別是賬戶餘額balance和賬戶密碼password;
- 該類提供兩個方法來訪問balance,分別是withdraw(取款)和getBalance(查看餘額);
- 該類提供兩個方法來訪問password,分別是updatePassword(更改密碼)和getPassword(查看密碼);
1. 保護沒有關聯的多個資源
對於上述案例,我們要用怎樣的鎖來保護資源呢?
- (1)所有方法加上synchronized;
- (2)保護餘額對應一個鎖,賬戶密碼對應一個鎖;
上述兩種方法,第一種假如更改密碼和查看餘額就不能同時進行,但理論上它兩應該可以同時進行,因爲他們操作的不是一個資源,不會出現線程安全的問題,採用方法一會導致效率嚴重下降;
下面就來實現一下第二種方案,這種方案中,用不同的鎖對受保護資源進行精細化管理,能夠提升性能,這種鎖也叫細粒度鎖(⭐);
class Account {
private String password;
private Integer balance;
//保護密碼的鎖
private final Object pwLock = new Object();
//保護餘額的鎖
private final Object balLock = new Object();
public void updatePassword(String newPassword) {
synchronized (pwLock) {
this.password = newPassword;
}
}
public String getPassword() {
synchronized (pwLock) {
return password;
}
}
public void withdraw(Integer amt) {
synchronized (balLock) {
this.balance -= amt;
}
}
public Integer getBalance() {
synchronized (balLock) {
return balance;
}
}
}
2. 保護有關聯關係的多個資源
這裏還是上面那個例子,同樣有Account賬戶類,這裏來討論一下轉賬的問題,在A向B轉賬過程中,A賬戶餘額要減少,B賬戶餘額要增加,爲了線程安全,A和B賬戶的餘額都應該收到保護,那我們怎麼去保護這兩個有關聯的資源呢?
設轉賬操作的方法爲transfer;
1. 誤區方式
誤區:有人想到直接這樣不就好了?:
class Account {
private Integer balance;
public synchronized void transfer(Account target, int amt) {
if(this.balance >= amt)
balance -= amt;
target.balance += amt;
}
}
這樣做的話,我們new一個A賬戶和一個B賬戶,然後A賬戶進行轉賬操作,這時A賬戶就拿到了鎖進入了transfer方法中,試問此時B賬戶能不能執行他自己的轉賬操作?
有人會說不能啊,那個方法都被A鎖住了,是嗎?A拿的那把鎖是A的對象鎖,跟B有什麼關係,所以B此時也能執行自己的轉賬操作,此時併發問題就產生了嘛!!!
上面的問題就在:用A對象的鎖試圖去保護B賬戶這個資源,這當然是不行的;
舉例分析:
我們假設線程 1 執行賬戶 A 轉賬戶 B 的操作,線程 2 執行賬戶 B 轉賬戶 C 的操作。這兩個線程分別在顆 CPU 上同時執行,那它們是互斥的嗎?我們期望是,但實際上並不是。因爲線程 1 鎖定的是賬戶 A 的實例(A.this),而線程 2 鎖定的是賬戶 B 的實例(B.this),所以這兩個線程可以同時進入臨界區 transfer()。同時進入臨界區的結果是什麼呢?線程 1 和線程 2 都會讀到賬戶 B 的餘額爲 200,導致最終賬戶 B 的餘額可能是 300(線程 1 後於線程 2 寫 B.balance,線程 2 寫的 B.balance 值被線程 1 覆蓋),可能是 100(線程 1 先於線程 2 寫 B.balance,線程 1 寫的 B.balance 值被線程 2 覆蓋),就是不可能是 200。
2. 同一把鎖方式
對於上面的轉賬操作,我們緊接着就會想到,用同一把鎖就能解決問題了呀,這種方式是完全可以的,下面來看看代碼:
class Account {
private Integer balance;
private Object transferLock;
public Account(Object transferLock) {
this.transferLock = transferLock;
}
public void transfer(Account target, int amt) {
synchronized (transferLock) {
if(this.balance >= amt) {
balance -= amt;
target.balance += amt;
}
}
}
}
這個方式的缺點:
- 要求在創建賬戶的同時就得傳入同一個對象(指給需要轉帳的那兩個賬戶),如果出現了傳入的不是同一對象,就會鎖不住轉賬的兩方;
- 在真實的項目中,創建Account對象的代碼可能分佈在各個工程中,傳入共享的Lock就會變得很難;
3. 用全局鎖(class對象鎖)
這裏直接上代碼,衆所周知,鎖住class對象的話,創建的所有Account賬戶就會持有同一把鎖,一旦鎖住一個,其他賬戶的所有操作就都不能執行了,這樣嚴重影響了併發,現實生活中的轉賬就不能影響到其他用戶(除轉賬對象賬戶)的操作;
class Account {
private Integer balance;
public void transfer(Account target, int amt) {
synchronized (this.getClass()) {
if(this.balance >= amt) {
balance -= amt;
target.balance += amt;
}
}
}
}
4. 雙重鎖
對於上面的例子,還有一種方式可以解決:
class Account {
private Integer balance;
public void transfer(Account target, int amt) {
//鎖定轉出賬戶
synchronized (this) {
//鎖定轉入賬戶
synchronized (target) {
if(this.balance >= amt) {
balance -= amt;
target.balance += amt;
}
}
}
}
}
這種方法一看,挺好的呀,消除了3中的弊病,也消除了2中的弊病,但是這也有一個致命缺點,容易造成死鎖;
具體的:
- 當A賬戶準備給B賬戶轉賬,進入第一個synchronized塊後未進入第二個synchronized塊之前,此時B賬戶也執行給A賬戶轉賬,也進入了第一個未進入第二個synchronized塊,此時A賬戶等待B賬戶的鎖,B賬戶等待A賬戶的鎖,就進入了死鎖狀態;
這裏再分析一下死鎖,死鎖的四個必要條件: (⭐)
-
互斥: 共享資源X和Y只能被一個線程佔有;
- 這個條件我們無法破壞;?
-
佔有且等待: 線程1已經獲得共享資源X,在等待共享資源Y的時候,不釋放共享資源X;
- 我們可以設置一個類用來管理這兩把鎖,要麼都拿到,要麼都別拿,這樣就不會出現各持一把鎖的情況;
-
不可搶佔: 其他線程不能強行搶佔線程1佔有的資源;
- 這裏synchronized申請資源的時候,如果申請不到就直接進入阻塞,所以要破壞不可搶佔得使用Lock,這裏後面再具體說;
-
循環等待: 線程1等待線程2的佔有的鎖,線程2等待線程1佔有的鎖;
-
破壞這個條件,需要對資源進行排序,然後按序申請資源。這個實現非常簡單,我們假設每個賬戶都有不同的屬性 id,這個 id 可以作爲排序字段,申請的時候,我們可以按照從小到大的順序來申請。比如下面代碼中,①~⑥處的代碼對轉出賬戶(this)和轉入賬戶(target)排序,然後按照序號從小到大的順序鎖定賬戶。這樣就不存在“循環”等待了。
代碼如下:class Account { private int id; private int balance; // 轉賬 void transfer(Account target, int amt){ Account left = this //① Account right = target; //② if (this.id > target.id) { //③ left = target; //④ right = this; //⑤ } //⑥ // 鎖定序號小的賬戶 synchronized(left){ // 鎖定序號大的賬戶 synchronized(right){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } }
-