推薦學習:《》
本篇文章介紹的內容為Java多執行緒中的執行緒安全問題,此處的安全問題並不是指的像駭客入侵造成的安全問題,執行緒安全問題是指因多執行緒搶佔式執行而導致程式出現bug的問題。
首先我們需要明白作業系統中執行緒的排程是搶佔式執行的,或者說是隨機的,這就造成執行緒排程執行時執行緒的執行順序是不確定的,有一些程式碼執行順序不同不影響程式執行的結果,但也有一些程式碼執行順序發生改變了重寫的執行結果會受影響,這就造成程式會出現bug,對於多執行緒並行時會使程式出現bug的程式碼稱作執行緒不安全的程式碼,這就是執行緒安全問題。
下面,將介紹一種典型的執行緒安全問題範例,整數自增問題。
有一天,老師佈置了這樣一個問題:使用兩個執行緒將變數count
自增10
萬次,每個執行緒承擔5
萬次的自增任務,變數count
的初始值為0
。
這個問題很簡單,最終的結果我們也能夠口算出來,答案就是10
萬。
小明同學做事非常迅速,很快就寫出了下面的一段程式碼:
class Counter { private int count; public void increase() { ++this.count; } public int getCount() { return this.count; }}public class Main11 { private static final int CNT = 50000; private static final Counter counter = new Counter(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { for (int i = 0; i < CNT; i++) { counter.increase(); } }); Thread thread2 = new Thread(() -> { for (int j = 0; j < CNT; j++) { counter.increase(); } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(counter.getCount()); }}
按理來說,結果應該是10
萬,我們來看看執行結果:
執行的結果比10
萬要小,你可以試著執行該程式你會發現每次執行的結果都不一樣,但絕大部分情況,結果都會比預期的值要小,下面我們就來分析分析為什麼會這樣。
上面我們使用多執行緒執行了一個程式,將一個變數值為0的變數自增10萬次,但是最終實際結果比我們預期結果要小,原因就是執行緒排程的順序是隨機的,造成執行緒間自增的指令集交叉,導致執行時出現兩次自增但值只自增一次的情況,所以得到的結果會偏小。
我們知道一次自增操作可以包含以下幾條指令:
load
。add
。save
。我們來畫一條時間軸,來總結一下常見的幾種情況:
情況1: 執行緒間指令集,無交叉,執行結果與預期相同,圖中暫存器A表示執行緒1所用的暫存器,暫存器B表示執行緒2所用的暫存器,後續情況同理。
情況2: 執行緒間指令集存在交叉,執行結果低於預期結果。
情況3: 執行緒間指令集完全交叉,實際結果低於預期。
根據上面我們所列舉的情況,發現執行緒執行時沒有交叉指令的時候執行結果是正常的,但是一旦有了交叉會導致自增操作的結果會少1
,綜上可以得到一個結論,那就是由於自增操作不是原子性的,多個執行緒並行執行時很可能會導致執行的指令交叉,導致執行緒安全問題。
那如何解決上述執行緒不安全的問題呢?當然有,那就是對物件加鎖。
為了解決由於「搶佔式執行」所導致的執行緒安全問題,我們可以對操作的物件進行加鎖,當一個執行緒拿到該物件的鎖後,會將該物件鎖起來,其他執行緒如果需要執行該物件的任務時,需要等待該執行緒執行完該物件的任務後才能執行。
舉個例子,假設要你去銀行的ATM機存錢或者取款,每臺ATM機一般都在一間單獨的小房子裡面,這個小房子有一扇門一把鎖,你進去使用ATM機時,門會自動的鎖上,這個時候如果有人要來取款,那它得等你使用完並出來它才能進去使用ATM,那麼這裡的「你」相當於執行緒,ATM相當於一個物件,小房子相當於一把鎖,其他的人相當於其他的執行緒。
在java中最常用的加鎖操作就是使用synchronized
關鍵字進行加鎖。
synchronized 會起到互斥效果, 某個執行緒執行到某個物件的 synchronized 中時, 其他執行緒如果也執行到同一個物件 synchronized 就會阻塞等待。
執行緒進入 synchronized 修飾的程式碼塊, 相當於加鎖
,退出 synchronized 修飾的程式碼塊, 相當於 解鎖
。
java中的加鎖操作可以使用synchronized
關鍵字來實現,它的常見使用方式如下:
方式1: 使用synchronized
關鍵字修飾普通方法,這樣會使方法所在的物件加上一把鎖。
例如,就以上面自增的程式為例,嘗試使用synchronized
關鍵字進行加鎖,如下我對increase
方法進行了加鎖,實際上是對某個物件加鎖,此鎖的物件就是this
,本質上加鎖操作就是修改this
物件頭的標記位。
class Counter { private int count; synchronized public void increase() { ++this.count; } public int getCount() { return this.count; }}
多執行緒自增的main方法如下,後面會以相同的栗子介紹synchronized
的其他用法,後面就不在列出這段程式碼了。
public class Main11 { private static final int CNT = 50000; private static final Counter counter = new Counter(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { for (int i = 0; i < CNT; i++) { counter.increase(); } }); Thread thread2 = new Thread(() -> { for (int j = 0; j < CNT; j++) { counter.increase(); } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(counter.getCount()); }}
看看執行結果:
方式2: 使用synchronized
關鍵字對程式碼段進行加鎖,但是需要顯式指定加鎖的物件。
例如:
class Counter { private int count; public void increase() { synchronized (this){ ++this.count; } } public int getCount() { return this.count; }}
執行結果:
方式3: 使用synchronized
關鍵字修飾靜態方法,相當於對當前類的類物件進行加鎖。
class Counter { private static int count; synchronized public static void increase() { ++count; } public int getCount() { return this.count; }}
執行結果:
常見的用法差不多就是這些,對於執行緒加鎖(執行緒拿鎖),如果兩個執行緒同時拿一個物件的鎖,就會產生鎖競爭,兩個執行緒同時拿兩個不同物件的鎖不會產生鎖競爭。
對於synchronized
這個關鍵字,它的英文意思是同步,但是同步在計算機中是存在多種意思的,比如在多執行緒中,這裡同步的意思是「互斥」;而在IO或網路程式設計中同步指的是「非同步」,與多執行緒沒有半點的關係。
synchronized 的工作過程:
lock
unlock
synchronized 同步塊對同一條執行緒來說是可重入的,不會出現自己把自己鎖死的問題,即死鎖問題,關於死鎖後續文章再做介紹。
綜上,synchronized關鍵字加鎖有如下性質:互斥性,重新整理記憶體性,可重入性。
synchronized關鍵字也相當於一把監視器鎖monitor lock,如果不加鎖,直接使用wait
方法(一種執行緒等待的方法,後面細說),會丟擲非法監視器異常,引發這個異常的原因就是沒有加鎖。
對自增那個程式碼上鎖後,我們再來分析一下為什麼加上了所就執行緒安全了,先列程式碼:
class Counter { private int count; synchronized public void increase() { ++this.count; } public int getCount() { return this.count; }}public class Main11 { private static final int CNT = 50000; private static final Counter counter = new Counter(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { for (int i = 0; i < CNT; i++) { counter.increase(); } }); Thread thread2 = new Thread(() -> { for (int j = 0; j < CNT; j++) { counter.increase(); } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(counter.getCount()); }}
多執行緒並行執行時,上一次就分析過沒有指令集交叉就不會出現問題,因此這裡我們只討論指令交叉後,加鎖操作是如何保證執行緒安全的,不妨記加鎖為lock
,解鎖為unlock
,兩個執行緒執行過程如下:
執行緒1首先拿到目標物件的鎖,對物件進行加鎖,處於lock
狀態,當執行緒2來執行自增操作時會發生阻塞,直到執行緒1的自增操作完畢,處於unlock
狀態,執行緒2才會就緒取執行執行緒2的自增操作。
加鎖後執行緒就是序列執行,與單執行緒其實沒有很大的區別,那多執行緒是不是沒有用了呢?但是對方法加鎖後,執行緒執行該方法才會加鎖,執行完該方法就會自動解鎖,況且大部分操作並行執行是不會造成執行緒安全的,只有少部分的修改操作才會有可能導致執行緒安全問題,因此整體上多執行緒執行效率還是比單執行緒高得多。
首先,執行緒不安全根源是執行緒間的排程充滿隨機性,導致原有的邏輯被改變,造成執行緒不安全,這個問題無法解決,無可奈何。
多個執行緒針對同一資源進行寫(修改)操作,並且針對資源的修改操作不是原子性的,可能會導致執行緒不安全問題,類似於資料庫的事務。
由於編譯器的優化,記憶體可見性無法保證,就是當執行緒頻繁地對同一個變數進行讀操作時,會直接從暫存器上讀值,不會從記憶體上讀值,這樣記憶體的值修改時,執行緒就感知不到該變數已經修改,會導致執行緒安全問題(這是編譯器優化的結果,現代的編譯器都有類似的優化不止於Java),因為相比於暫存器,從內容中讀取資料的效率要小的多,所以編譯器會盡可能地在邏輯不變的情況下對程式碼進行優化,單執行緒情況下是不會翻車的,但是多執行緒就不一定了,比如下面一段程式碼:
import java.util.Scanner;public class Main12 { private static int isQuit; public static void main(String[] args) { Thread thread = new Thread(() -> { while (isQuit == 0) { } System.out.println("執行緒thread執行完畢!"); }); thread.start(); Scanner sc = new Scanner(System.in); System.out.println("請輸入isQuit的值,不為0執行緒thread停止執行!"); isQuit = sc.nextInt(); System.out.println("main執行緒執行完畢!"); }}
執行結果:
我們從執行結果可以知道,輸入isQuit
後,執行緒thread
沒有停止,這就是編譯器優化導致執行緒感知不到記憶體可見性,從而導致執行緒不安全。
我們可以使用volatile
關鍵字保證記憶體可見性。
我們可以使用volatile
關鍵字修飾isQuit
來保證記憶體可見性。
import java.util.Scanner;public class Main12 { volatile private static int isQuit; public static void main(String[] args) { Thread thread = new Thread(() -> { while (isQuit == 0) { } System.out.println("執行緒thread執行完畢!"); }); thread.start(); Scanner sc = new Scanner(System.in); System.out.println("請輸入isQuit的值,不為0執行緒thread停止執行!"); isQuit = sc.nextInt(); System.out.println("main執行緒執行完畢!"); }}
執行結果:
synchronized與volatile關鍵字的區別:synchronized
關鍵字能保證原子性,但是是否能夠保證記憶體可見性要看情況(上面這個栗子是不行的),而volatile
關鍵字只能保證記憶體可見性不能保證原子性。
保證記憶體可見性就是禁止編譯器做出如上的優化而已。
import java.util.Scanner;public class Main12 { private static int isQuit; //鎖物件 private static final Object lock = new Object(); public static void main(String[] args) { Thread thread = new Thread(() -> { synchronized (lock) { while (isQuit == 0) { } System.out.println("執行緒thread執行完畢!"); } }); thread.start(); Scanner sc = new Scanner(System.in); System.out.println("請輸入isQuit的值,不為0執行緒thread停止執行!"); isQuit = sc.nextInt(); System.out.println("main執行緒執行完畢!"); }}
執行結果:
編譯器優化除了導致記憶體可見性感知不到的問題,還有指令重排序也會導致執行緒安全問題,指令重排序也是編譯器優化之一,就是編譯器會智慧地(保證原有邏輯不變的情況下)調整程式碼執行順序,從而提高程式執行的效率,單執行緒沒問題,但是多執行緒可能會翻車,這個原因瞭解即可。
Java 標準庫中很多都是執行緒不安全的。這些類可能會涉及到多執行緒修改共用資料, 又沒有任何加鎖措施。例如,ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder。
但是還有一些是執行緒安全的,使用了一些鎖機制來控制,例如,Vector (不推薦使用),HashTable (不推薦使用),ConcurrentHashMap (推薦),StringBuffer。
還有的雖然沒有加鎖, 但是不涉及 「修改」, 仍然是執行緒安全的,例如String。
線上程安全問題中可能你還會遇到JMM模型,在這裡補充一下,JMM其實就是把作業系統中的暫存器,快取和記憶體重新封裝了一下,其中在JMM中暫存器和快取稱為工作記憶體,記憶體稱為主記憶體。
其中快取分為一級快取L1,二級快取L2和三級快取L3,從L1到L3空間越來越大,最大也比記憶體空間小,最小也比暫存器空間大,存取速度越來越慢,最慢也比記憶體的存取速度快,最快也沒有暫存器存取快。
除了Thread類中的能夠實現執行緒等待的方法,如join
,sleep
,在Object類中也提供了相關執行緒等待的方法。
序號 | 方法 | 說明 |
---|---|---|
1 | public final void wait() throws InterruptedException | 釋放鎖並使執行緒進入WAITING狀態 |
2 | public final native void wait(long timeout) throws InterruptedException; | 相比於方法1,多了一個最長等待時間 |
3 | public final void wait(long timeout, int nanos) throws InterruptedException | 相比於方法2,等待的最長時間精度更大 |
4 | public final native void notify(); | 喚醒一個WAITING狀態的執行緒,並加鎖,搭配wait方法使用 |
5 | public final native void notifyAll(); | 喚醒所有處於WAITING狀態的執行緒,並加鎖(很可能產生鎖競爭),搭配wait方法使用 |
上面介紹synchronized
關鍵字的時候,如果不對執行緒加鎖會產生非法監視異常,我們來驗證一下:
public class TestDemo12 { public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("執行完畢!"); }); thread.start(); System.out.println("wait前"); thread.wait(); System.out.println("wait後"); }}
看看執行結果:
果然丟擲了一個IllegalMonitorStateException
,因為wait
方法的執行步驟為:先釋放鎖,再使執行緒等待,你現在都沒有加鎖,那如何釋放鎖呢?所以會丟擲這個異常,但是執行notify
是無害的。
wait
方法常常搭配notify
方法搭配一起使用,前者能夠釋放鎖,使執行緒等待,後者能獲取鎖,使執行緒繼續執行,這套組合拳的流程圖如下:
現在有兩個任務由兩個執行緒執行,假設執行緒2比執行緒1先執行,請寫出一個多執行緒程式使任務1在任務2前面完成,其中執行緒1執行任務1,執行緒2執行任務2。
這個需求可以使用wait/notify
來實現。
class Task{ public void task(int i) { System.out.println("任務" + i + "完成!"); }}public class WiteNotify { //鎖物件 private static final Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { synchronized (lock) { Task task1 = new Task(); task1.task(1); //通知執行緒2執行緒1的任務完成 System.out.println("notify前"); lock.notify(); System.out.println("notify後"); } }); Thread thread2 = new Thread(() -> { synchronized (lock) { Task task2 = new Task(); //等待執行緒1的任務1執行完畢 System.out.println("wait前"); try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } task2.task(2); System.out.println("wait後"); } }); thread2.start(); Thread.sleep(10); thread1.start(); }}
執行結果:
推薦學習:《》
以上就是一起聊聊Java多執行緒之執行緒安全問題的詳細內容,更多請關注TW511.COM其它相關文章!