原子性指一個或多個操做在CPU執行的過程不被中斷的特性。前面提到原子性問題產生的源頭是線程切換,而線程切換依賴於CPU中斷。因而得出,禁用CPU中斷就能夠禁止線程切換從而解決原子性問題。可是這種狀況只適用於單核,多核時不適用。html
以在 32 位 CPU 上執行 long 型變量的寫操做爲例來講明。
long 型變量是 64 位,在 32 位 CPU 上執行寫操做會被拆分紅兩次寫操做(寫高 32 位和寫低 32 位,以下圖所示,圖來自【參考1】)。
java
在單核 CPU 場景下,同一時刻只有一個線程執行,禁止 CPU 中斷,意味着操做系統不會從新調度線程,即禁止了線程切換,得到 CPU 使用權的線程就能夠不間斷地執行。因此兩次寫操做必定是:要麼都被執行,要麼都沒有被執行,具備原子性。
可是在多核場景下,同一時刻,可能有兩個線程同時在執行,一個線程執行在 CPU-1 上,一個線程執行在 CPU-2 上。此時禁止 CPU 中斷,只能保證 CPU 上的線程連續執行,並不能保證同一時刻只有一個線程執行。若是這兩個線程同時向內存寫 long 型變量高 32 位的話,那麼就會形成咱們寫入的變量和咱們讀出來的是不一致的。程序員
因此解決原子性問題的重要條件仍是爲:同一時刻只能有一個線程對共享變量進行操做,即互斥。若是咱們可以保證對共享變量的修改是互斥的,那麼,不管是單核 CPU 仍是多核 CPU,就都能保證原子性。編程
下面將介紹實現互斥訪問的方案,加鎖機制。併發
咱們把一段須要互斥執行的代碼稱爲臨界區。
線程在進入臨界區以前,首先嚐試加鎖 lock(),若是成功,則進入臨界區,此時咱們稱這個線程持有鎖;
不然就等待或阻塞,直到持有鎖的線程釋放鎖。持有鎖的線程執行完臨界區的代碼後,執行解鎖 unlock()。app
鎖和鎖要保護的資源是要對應的。這個指的是兩點:①咱們要保護一個資源首先要建立一把鎖;②鎖要鎖對資源,即鎖A應該用來保護資源A,而不能用它來鎖資源B。this
因此,最後的鎖模型以下:(圖來自【參考1】)spa
鎖是一種通用的技術方案,Java 語言提供的 synchronized
關鍵字,就是鎖的一種實現。操作系統
synchronized 關鍵字能夠用來修飾方法,也能夠用來修飾代碼塊,它的使用示例以下:線程
class X { // 修飾非靜態方法 synchronized void foo() { // 臨界區 } // 修飾靜態方法 synchronized static void bar() { // 臨界區 } // 修飾代碼塊 Object obj = new Object(); void baz() { synchronized(obj) { // 臨界區 } } }
與上面的鎖模型比較,能夠發現synchronized修飾的方法和代碼塊都沒有顯式地有加鎖和釋放鎖操做。可是這並不表明沒有這兩個操做,這兩個操做Java編譯器會幫咱們自動實現。Java 編譯器會在 synchronized 修飾的方法或代碼塊先後自動加上加鎖 lock() 和解鎖 unlock(),這樣的好處在於代碼更簡潔,而且Java程序員也沒必要擔憂會忘記釋放鎖了。
而後咱們再觀察能夠發現:只有修飾代碼塊的時候,鎖定了一個 obj 對象。那麼修飾方法的時候鎖了什麼呢?
這是Java的一個隱式規則:
對於上面的例子,synchronized 修飾靜態方法至關於:
class X { // 修飾靜態方法 synchronized(X.class) static void bar() { // 臨界區 } }
修飾非靜態方法,至關於:
class X { // 修飾非靜態方法 synchronized(this) void foo() { // 臨界區 } }
每一個Java對象均可以用做一個實現同步的鎖,這些鎖被稱爲內置鎖(Intrinsic Lock)或者監視器鎖(Monitor Lock)。被synchronized關鍵字修飾的方法或者代碼塊,稱爲同步代碼塊(Synchronized Block)。線程在進入同步代碼塊以前會自動獲取鎖,而且在退出同步代碼塊時自動釋放鎖,這在前面也提到過。
Java的內置鎖至關於一種互斥體(或互斥鎖),這也就是說,最多隻有一個線程可以持有這個鎖。因爲每次只能有一個線程執行內置鎖保護的代碼塊,所以,由這個鎖保護的同步代碼塊會以原子的方式執行。
當某個線程請求一個由其餘線程所持有的鎖時,發出請求的線程會被阻塞。然而,因爲內置鎖是可重入的,因此當某個線程試圖獲取一個已經由它本身所持有的鎖時,這個請求就會成功。
重入實現的一個方法是:爲每一個鎖關聯一個獲取計數器和一個全部者線程。
當計數器值爲0時,這個鎖就被認爲是沒有被任何線程持有的。當線程請求一個未被持有的鎖時,JVM將記下鎖的持有者,而且將計數器加1。若是同一個線程再次獲取這個鎖,計數器將加1,而當線程退出同步代碼塊時,計數器會相應地減1。當計數器爲0時,這個鎖將被釋放。
下面這段代碼,若是內置鎖是不可重入的,那麼這段代碼將發生死鎖。
public class Widget{ public synchronized void doSomething(){ .... } } public class LoggingWidget extends Widget{ public synchronized void doSomething(){ System.out.println(toString() + ": call doSomething"); super.doSomething(); } }
前面咱們介紹原子性問題時提到count+=1
存在原子性問題,那麼如今咱們使用synchronized來使count+=1成爲一個原子操做。
代碼以下所示。
class SafeCalc { long value = 0L; long get() { return value; } synchronized void addOne() { value += 1; } }
SafeCalc 這個類有兩個方法:一個是 get() 方法,用來得到 value 的值;另外一個是 addOne() 方法,用來給 value 加 1,而且 addOne() 方法咱們用 synchronized 修飾。下面咱們分析看這個代碼是否存在併發問題。
addOne() 方法,被 synchronized 修飾後,不管是單核 CPU 仍是多核 CPU,只有一個線程可以執行 addOne() 方法,因此必定能保證原子操做。
那麼可見性呢?是否能夠保證一個線程調用addOne()使value加一的結果對另外一個線程後面調用addOne()時可見?
答案是能夠的。這就須要回顧到咱們上篇博客提到的Happens-Before規則其中關於管程中的鎖規則:對同一個鎖的解鎖 Happens-Before 後續對這個鎖的加鎖。即,一個線程在臨界區修改的共享變量(該操做在解鎖以前),對後續進入臨界區(該操做在加鎖以後)的線程是可見的。
此時還不能掉以輕心,咱們分析get()方法。執行 addOne() 方法後,value 的值對 get() 方法是可見的嗎?答案是這個可見性沒有保證。管程中鎖的規則,是隻保證後續對這個鎖的加鎖的可見性,而 get() 方法並無加鎖操做,因此可見性無法保證。因此,最終的解決辦法爲也是用synchronized修飾get()方法。
class SafeCalc { long value = 0L; synchronized long get() { return value; } synchronized void addOne() { value += 1; } }
代碼轉換成咱們的鎖模型爲:(圖來自【參考1】)
get() 方法和 addOne() 方法都須要訪問 value 這個受保護的資源,這個資源用 this 這把鎖來保護。線程要進入臨界區 get() 和 addOne(),必須先得到 this 這把鎖,這樣 get() 和 addOne() 也是互斥的。
受保護資源和鎖之間的關聯關係很是重要,一個合理的關係爲:鎖和受保護資源之間的關聯關係是 1:N 。
拿球賽門票管理來類比,一個座位(資源)能夠用一張門票(鎖)來保護,可是不能夠有兩張門票預約了同一個座位,否則這兩我的就會fight。
在現實中咱們可使用多把鎖鎖同一個資源,若是放在併發領域中,線程A得到鎖1和線程B得到鎖2均可以訪問共享資源,那麼達到互斥訪問共享資源的目的。因此,在併發編程中使用多把鎖鎖同一個資源不可行。或許有人會想:要同時得到鎖1和鎖2才能夠訪問共享資源,這樣應該是就可行的。我以爲是能夠的,可是能用一個鎖就能夠保護資源,爲何還要加一個鎖呢?
多把鎖鎖一個資源不能夠,可是咱們能夠用同一把鎖來保護多個資源,這個對應到現實球賽門票就是能夠用一張門票預約全部座位,即「包場」。
下面舉一個在併發編程中使用多把鎖來保護同一個資源將會出現的併發問題:
class SafeCalc { static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } }
把 value 改爲靜態變量,把 addOne() 方法改爲靜態方法。
仔細觀察,就會發現改動後的代碼是用兩個鎖保護一個資源。get()所使用的鎖是this,而addOne()所使用的鎖是SafeCalc.class。兩把鎖保護一個資源的示意圖以下(圖來自【參考1】)。
因爲臨界區 get() 和 addOne() 是用兩個鎖保護的,所以這兩個臨界區沒有互斥關係,臨界區 addOne() 對 value 的修改對臨界區 get() 也沒有可見性保證,這就致使併發問題。
Synchronized是 Java 在語言層面提供的互斥原語,Java中還有其餘類型的鎖。可是做爲互斥鎖,原理都是同樣的,首先要有一個鎖,而後是要鎖住什麼資源以及在哪裏加鎖就須要在設計層面考慮。
最後一個主題提的鎖和受保護資源的關係很是重要,在使用鎖時必定要好好注意。
參考: [1]極客時間專欄王寶令《Java併發編程實戰》 [2]Brian Goetz.Tim Peierls. et al.Java併發編程實戰[M].北京:機械工業出版社,2016