何為線程安全?
要談及何為線程安全,總得說來,我們可以用一句話來概況:
如果在多線程環境下代碼運行結果和我們預期是相符的,即和單線程環境下的運行結果相同,那么我們就稱這個程序是線程安全的,反之則不安全,即和預期不符;
為了大家更好地理解這句話,大家可以看一下下面這個例子
public class Demo21 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for(int i = 0; i < 5000; i++) {count++;}});Thread t2 = new Thread(() -> {for(int i = 0; i < 5000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
對于上述代碼,我們預期的結果應該是輸出10000,因為count經過了10000次的累加,然而事實上結果是....?
7019...??這樣一個令人摸不著頭腦的數字,甚至每次運行的結果都不一樣,這是為什么呢?
原因分析:
實際上,count++的操作是分為三步的
- load從內存中讀取數據到cpu的寄存器
- add把寄存器中的值+1
- save把寄存器中的值寫回內存中
而由于線程是隨機調度的,所以有可能t1剛執行到第1一步,cpu資源就被調度走了,那么count值就不會和預想結果一樣,也可能t1和t2同時執行第一步,那么它們讀取到的數據都是count = 0,而事實上count應該執行的操作是 + 2,因為這樣隨機調度的不確定性,就使得這樣的代碼是線程不安全的!!
線程不安全的原因
1) 根本原因
線程不安全的根本原因就是線程的隨機調度,這樣的隨機帶來了很多不確定性,使得線程的執行順序是不確定的~~
2) 多個線程修改同一個變量
通過例子我們可以發現,t1和t2都在針對count這一個變量進行修改的操作,這樣的操作就會引起線程安全問題,為了解決這樣的問題,我們就會引入"鎖"這樣的概念,具體的解釋我們會在解決安全問題篇講述~~
3) 修改操作不是原子的
何為原子的?
原子的即原子性的操作,即這個操作是不可再分的。
我們上文提到了,雖然我們肉眼看起來count++這個操作就是對count進行了一個加法操作,但事實上,count++這個操作是包含了三部分的,所以這個操作并不是一個原子性的操作~~因此引發了線程安全問題
4) 內存可見性問題
在講述這個原因之前,我們要先引入另外一個例子
import java.util.Scanner;public class Demo22 {public static int flg = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while (flg == 0) {}System.out.println("t1線程結束");});Thread t2 = new Thread(() -> {System.out.println("請輸入flg的值:");Scanner scan = new Scanner(System.in);flg = scan.nextInt();});t1.start();t2.start();}
}
上述代碼我們想實現的結果是,用戶輸入一個非0的數字后t1線程結束,然而真正的運行結果卻無法結束t1線程
原因分析:
造成這樣結果的原因就是因為t2修改了這個變量內存,但t1內卻沒有接收到這個變量的變化,這樣的問題我們就稱為 "內存可見性" 問題,造成這樣的問題主要有以下兩個要點:
- JVM識別到load加載的flg的值幾百萬次都一樣[while循環的執行速度是很快的,可能一秒幾百萬次]
- load操作的花費開銷是很大的,遠遠超過了其它操作
因此在很多次的執行之后,JVM就會覺得,反之每次結果都一樣,那還有什么執行的必要嗎??因此JVM就自動地優化了代碼,將load操作變成了直接使用寄存器中之前"緩存"的值,而非每次去內存中重新獲取,大大降低了花費,因此就造成了即使后面修改了flg的值也無法被t2感知到的結果
5) 指令重排序問題
指令重排序實際上也是編譯器優化代碼的一種方式,保證邏輯不變的前提下,調整原有代碼的執行順序,提高程序的效率,自然,指令順序都發生了改變,安全也無法保證
在描述解決這些問題之前,我們先來講一下"鎖"的概念
線程加鎖
加鎖的關鍵字
形如上圖的由synchronized關鍵字修飾的代碼塊就是相當于對一個Object對象加鎖,當兩個線程競爭同一把鎖的時候,就會引發阻塞,一個進程拿到鎖之后,另外一個進程就會因為拿不到鎖而陷入阻塞狀態,這樣就不會因為隨機的變化而造成線程安全問題
我們舉個例子來理解一下~~
比如你想要追求你的crush~~她有對象的時候,就相當于她加上鎖了,按理來說,你就不能再追求她了,但是如果她分手了,又回歸了單身狀態,那就是鎖解除啦,然后你就可以追求她了,你們在一起之后,就相當于你競爭到了這把鎖,那其它人就不能追求你的crush啦,除非你倆又分手了,她又回歸了單身狀態,那么別人就可以又來競爭這把鎖,此時"男朋友"這個身份就是那把鎖~~~
加鎖例子
我們來完善一下開頭的例子,看下我們加上鎖之后的結果
public class Demo21 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(() -> {for(int i = 0; i < 5000; i++) {synchronized (locker) { //共同競爭Locker這把鎖count++;}}});Thread t2 = new Thread(() -> {for(int i = 0; i < 5000; i++) {synchronized (locker) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
可以看到,此時結果就是10000了
提示
當對同一個線程多次加同一把鎖的時候,是無效的,只會算一把鎖,因為鎖有可重入性~~
解決線程安全問題
對于問題1)
這個問題是無法解決的,這是系統的底層邏輯
對于問題2)、 3)
想要解決問題2和問題3,就是要對操作加鎖,詳情請參看鎖篇章~~
對于問題4)、5)
想要解決這兩個問題,我們要引入另一個關鍵字 volatile
volatile可以強制關閉JVM的代碼優化機制,確保每次循環都要重新從內存中讀取數據,雖然這增加了開銷,但可以增加代碼的準確性~~
同樣的例子,我們對flg加上volatile關鍵字
import java.util.Scanner;public class Demo22 {public static volatile int flg = 0; //加上volatilepublic static void main(String[] args) {Thread t1 = new Thread(() -> {while (flg == 0) {}System.out.println("t1線程結束");});Thread t2 = new Thread(() -> {System.out.println("請輸入flg的值:");Scanner scan = new Scanner(System.in);flg = scan.nextInt();});t1.start();t2.start();Object locker = new Object();synchronized (locker) {}}}
運行則可以發現,t1此時就可以正常結束了~~
?? 覺得博主寫的有幫助的話,請點個贊 b( ̄▽ ̄)d ,謝謝~~~ ??
?? 你的喜歡是我更新的最大動力~~~ ??