鎖的本質
我們先來討論鎖的出現是為了解決什么問題,鎖要保證的事情其實很好理解,同一件事(一個代碼塊)在同一時刻只能由一個人(線程)操作。
這里所說的鎖為排他鎖,暫不考慮讀寫鎖的情況
我們在這里打個比方,假設有10個人要過獨木橋(獨木橋只能承載一個人的重量),他們可以排好隊一個一個的過,后面一個人看到前面過去了之后他便跟着過去,直到所有的人都過去。
那如果我們用計算機模擬這個過程呢,沒錯,我們的程序不會排好隊,更不會有看到前面的人已經通過這種主觀能動性。所以這有點類似於所有的人都是蒙着眼睛的,但他們的聽力是良好的,如果有人過去了之后在橋的另一頭大喊一聲“我已經通過了”,其他人便開始爭着喊“下一個我過”。如果兩個人幾乎同時喊,在現實中我們很難搞清楚誰先誰后,甚至兩個暴躁的人會打起來。但在計算機中他們不會,他們都如此聽話如此可靠,而且在時間上總會分清誰先誰后,不會出現同時喊的狀況。
我們先來總結一下這個過程正常工作的兩個先決條件
- 同一時刻,只能有一個人搶到鎖(過橋的權利)
- 當操作完成之后,必須釋放鎖(過去橋之后,要告訴其他人現在可以過橋了)
很簡單,對吧,但鎖的真正意義就在於此,只是不同的場景對這兩點有不同的實現方式罷了。
Java中的鎖
可見性
提到Java中的鎖,就不得不提Java的內存模型,如下圖(假設在多核CPU上),這里可以使CPU的一個核心類比一個線程(這是一個簡化的模型,事實上比這個模型復雜的多):
注意:這里只是類比,CPU的Cache與JMM中的工作內存並不嚴格一致,但兩者有一定交集,在這里做這樣的類比並不會誤導我們想要的出來的結論
讀到這里或許有的讀者會有問題,為什么CPU要把數據抓到工作內存去,而不是直接從主內存里面拿呢。這要從計算機的組成原理上講起,CPU和內存在物理上是分離的,CPU從主存抓取數據如同遠房探親。從主內存中抓取數據比對數據的操作上要快數十倍甚至上百倍。這有點像你想和小明打一個小時游戲,但是要花幾天甚至數十天的時間把小明請過來。這樣為了省時間,我們可以把小明請過來,多打幾天游戲在讓他回去。事實上也的確如此,我們的CPU之所以要在Cache 中操作之前Fetch過來的數據,就是為了節省這一段時間。但這就在兩個Thread中產生兩個副本,而他們互相不知道對方有沒有更改過cache到的數據。但充滿智慧的CPU架構師給出了這種通知的保證(MESI協議,這大概是相當早的分布式緩存一致性解決方案了),這個協議的原理比較復雜,在此不在贅述,但這並不影響我們對鎖的理解。我們只要知道,操作系統提供了這樣的支持,並留給了system call
的native api
就足夠了。這個方案解決了CPU對變量的可見性。在java中通過使用volatile
實現變量的可見性保證,而其保證的原理正是借助與CPU的緩存一致性協議實現的,操作系統將其抽象為lock
操作,
原子性
似乎有了可見性對元素的操作就完全可靠了,但事實並非如此,這取決於對變量進行操作的過程,我們i++為例說明這一點,但在此之前,我們先來看一下i++在Java中的執行過程
代碼如下:
public class Test {
static int i=1;
public static void main(String[] args) {
i++;
}
}
我們使用javap -verbose Test.class
查看Java中main方法的虛指令
0: getstatic #2 // Field i:I
3: iconst_2
4: iadd
5: putstatic #2 // Field i:I
8: return
這個過程的語義與下圖相同
考慮以下情況,在第三步回寫的過程中算出的結果已經保留了,假設線程A在此卡頓了一會兒,其他線程已經更改了i的值,然后線程A才回過神來,但結果還是剛才算出的結果3,這時它進行回寫操作的時候,就會覆蓋其他線程對i的賦值,就會導致值的不一致現象。
可以看到,之所以會出現這種現象,就是因為i++這個操作沒有像我們想象的那樣,一下子就完成,而是分成了很多步。我們稱這種操作為非原子性操作,就是i++操作的非原子性,導致在哪怕保證了變量的可見性的情況下仍然會導致數據操作相互覆蓋(線程不安全)的情況。
隔離區(臨界區)
終於講到Java中的鎖了,根據獨木橋的例子,要想保證多個線程對變量的操作絕對安全,就要保證對變量操作的串行化。Java中使用synchronized
關鍵字提供了前文提到過的兩個先決條件。下面我們來詳講一下java中的synchronized
關鍵字。我們先來看以下代碼
public class Test {
public static void main(String[] args) {
synchronized (Test.class) {
}
}
}
同樣使用javap -verbose Test.class
0: ldc #2 // class Test
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: return
我們重點看monitorenter和monitorexit兩個指令,根據我們前面所講的兩個先決條件,我們至少可以推斷monitorenter在背后所做的事情有
- 告訴其他線程,我拿到了鎖(下一個過獨木橋的人)
而monitorexit在背后做的事情當有
- 告訴其他線程,我釋放了鎖(你們可以過橋了)
這里其實還有個問題,它標示鎖的方式是什么,這就要提到java對象在內存中的模型了,事實上Test.class對象在內存中有個頭部,通過設置這個對象頭獲取該對象的鎖,而對這個鎖的設置操作是用指令cmpxchg
保證原子性的,由操作系統和硬件底層支持。
事實上Java對鎖進行了優化,包括偏向鎖和輕量級鎖。所以通不通知其他線程並不是那么絕對的,而且monitor背后所做的事情也絕對不是這么簡單,在這個模型中,其他線程確認自己有沒有獲得鎖是主動過來看Test.class的對象頭有沒有被設置為已獲取鎖狀態。如果沒有,自己就上鎖。如果已經被鎖住了,這個線程就需要發出system call
來阻塞自己,但Java自己做不了這件事情,它必須借助操作系統完成,借助操作系統發出system call
到自己被阻塞這個過程需要幾萬的個時鍾周期。而這個代價是相當昂貴的,對於CPU的執行速度來說,幾萬個時鍾周期可以做很多的事情,這時如果我們樂觀的認為,這個鎖馬上就能釋放,我就願意花費幾百個時鍾周期不停的判斷這個鎖是否釋放,總比調用system call
的開銷要低一些,這就是樂觀鎖的原理。
在synchronized
釋放鎖之前,任何線程都不能進入synchronized
的方法體內,不管在中間有多少操作,其他線程都必須等待操作完成之后釋放鎖的通知,這就保證了數據在多線程的絕對安全。
同時,在上面的字節碼可以看出,當程序順序執行時,在第6步monitorexit之后,會直接跳轉到底15步返回,但若中間發生了異常,會在第12步先monitorexit然后,在拋出異常,這其實是編譯器替我們完成了加鎖和釋放鎖的過程,而且編譯器替我們做了在發生異常的情況下也釋放鎖的保證。