執行緒的安全性 – 並發基礎篇
作者:湯圓
個人部落格:javalover.cc
前言
官人們好啊,我是湯圓,今天給大家帶來的是《執行緒的安全性 – 並發基礎篇》,希望有所幫助,謝謝
文章純屬原創,個人總結難免有差錯,如果有,麻煩在評論區回復或後台私信,謝啦
簡介
當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼就說這個類是執行緒安全的
目錄
這次分三步走:關於相關知識點,放在文末的腦圖裡了,大家想看結論的,可直接下拉觀看哦
- 創建一個執行緒安全的類
- 創建一個執行緒不安全的類:有一個狀態變數
- 創建一個執行緒不安全的類:有多個狀態變數
正文
執行緒的安全性主要是針對對象的狀態(實例屬性或靜態屬性)而言的,如果在多執行緒中,訪問到的對象狀態不一致(比如常見的自增屬性),那麼就是執行緒不安全的
下面我們一步步來
先來個無狀態類
第一步:無狀態類
這裡我們寫一個簡單的執行緒安全類,簡單到什麼地步呢?如下所示
public class SafeDemo {
public int sum(int n, int m){
return n + m;
}
}
就是這麼簡單,我們說這個類是執行緒安全的
為啥安全呢?
因為這個類沒有狀態,即無狀態類;
只有局部變數n,m,而這些局部變數是存在於棧中的,棧是每個執行緒獨有的,不跟其他執行緒共享,堆才共享
所以每個執行緒操作sum時,對應的n,m只有自己可見,當然就安全了
好了,通過上面的例子,我們知道了什麼是執行緒安全類,那本節的內容就到此結束了,再見
上面的例子,我們舉了一個無狀態類,接下來我們添加一個狀態試試
第二步:加一個狀態變數
加一個狀態變數(靜態屬性),程式碼如下
public class UnSafeDemo {
static int a = 0;
public static void main(String[] args) throws InterruptedException {
// 執行緒1
new Thread(()-> {
for(int j=0;j<100000;j++){
a++;
}
}).start();
// 執行緒2
new Thread(()-> {
for(int j=0;j<100000;j++){
a++;
}
}).start();
Thread.sleep(3000);
// 這裡不是每次運行都會輸出200,000
System.out.println(a);
}
}
上面我們創建了兩個執行緒,每個執行緒都執行10萬次的自增操作
但是因為自增不是原子操作,實際分三步:讀-改-寫
此時如果兩個執行緒同時讀到相同的值,則累加次數就會少一次
這種在並發編程中,由於不恰當的執行時序而出現不正確的結果的情況,叫做競態條件
如下圖所示:
期望的是正常執行,每個執行緒交替執行
結果卻有可能是不正常的,如下
這時我們就可以說,上面加的這個狀態是不安全的,結果就是整個類也是不安全的
不安全的狀態有二:
-
可變狀態(變數):非final修飾的變數
-
共享狀態(變數):非局部變數
像上面這個例子,狀態就同時屬於可變狀態和共享狀態
那要怎麼確保安全:
-
同步:synchronized、volatile、顯式鎖、原子變數(比如AtomicInteger)
-
不可變變數:final(都不能改了,當然安全了)
-
不共享變數:不在多執行緒中共享變數(即局部變數)
PS:程式碼的封裝性越好,訪問可變變數的程式碼塊越少,越容易確保執行緒安全
這裡的自增我們就可以用同步中的原子變數來解決
關於原子變數的細節,後面章節再介紹,這裡只需要知道,原子變數內部的操作是原子操作就可以了
修改後的程式碼如下:
public class SafeDemo {
static final AtomicInteger a = new AtomicInteger(0);
// static int a = 0;
public static void main(String[] args) throws InterruptedException {
// 執行緒1
new Thread(()-> {
for(int j=0;j<100000;j++){
// 這裡的自增是原子操作
a.incrementAndGet();
}
}).start();
// 執行緒2
new Thread(()-> {
for(int j=0;j<100000;j++){
// 這裡的自增是原子操作
a.incrementAndGet();
}
}).start();
Thread.sleep(3000);
System.out.println(a.get());
}
}
可以看到,加了AtomicInteger.incrementAndGet()方法,這個方法是原子操作
這時,不管怎麼運行,都是輸出200,000
第三步:加多個狀態變數
上面我們加了一個狀態變數,可以用原子變數來保證執行緒安全
那如果是多個狀態變數呢?此時就算用了原子變數也不行了
因為原子變數只是保證它內部是原子操作,但是當多個原子變數放到一起組合操作時,他們之間又存在競態條件了,就又不是原子操作了
競態條件:並發編程中,由於不恰當的執行時序而出現不正確的結果的情況,就是競態條件(重複陳述ing,加深記憶)
程式碼如下:
public class UnSafeDemo2 {
static final AtomicInteger a = new AtomicInteger(0);
static final AtomicInteger b = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
for(int j=0;j<10000;j++){
a.incrementAndGet();
b.incrementAndGet();
if(a.get()!=b.get()){
// 理想狀態的話,不會運行到這裡,因為a和b是一起自增的
// 但是大部分時候都是不正常的,因為a和b各自是原子操作,但是放到一起就不是原子操作了
System.out.println(1);
}
}
}).start();
new Thread(()-> {
for(int j=0;j<10000;j++){
a.incrementAndGet();
b.incrementAndGet();
if(a.get()!=b.get()){
// 理想狀態的話,不會運行到這裡,因為a和b是一起自增的
// 但是大部分時候都是不正常的,因為a和b各自是原子操作,但是放到一起就不是原子操作了
System.out.println(2);
}
}
}).start();
}
}
上面多次運行,會發現基本上每次都會列印1和2,就是因為這兩個執行緒之間存在競態條件
那怎麼解決呢?
上鎖
程式碼如下:
public class UnSafeDemo2 {
static final AtomicInteger a = new AtomicInteger(0);
static final AtomicInteger b = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// 單獨創建一個對象,用來充當鎖
UnSafeDemo2 unSafeDemo2 = new UnSafeDemo2();
new Thread(()-> {
for(int j=0;j<10000;j++){
// 這裡加了鎖
synchronized (unSafeDemo2){
a.incrementAndGet();
b.incrementAndGet();
if(a.get()!=b.get()){
// 現在肯定是理想狀態,不會運行到這裡
System.out.println(1);
}
}
}
}).start();
new Thread(()-> {
for(int j=0;j<10000;j++){
// 這裡加了鎖
synchronized (unSafeDemo2){
a.incrementAndGet();
b.incrementAndGet();
if(a.get()!=b.get()){
// 現在肯定是理想狀態,不會運行到這裡
System.out.println(2);
}
}
}
}).start();
}
}
這裡用到的鎖為內置鎖,還有很多其他鎖,這裡就不展開了(後面章節再介紹)
這裡要注意:同步程式碼必須上同一個鎖才有用,比如上面的例子,兩個執行緒都是上的unsafeDemo2這個鎖
官人們可以試一下,一個上unsafeDemo2鎖,一個上Object鎖,看會輸出啥
內置鎖也叫監視器鎖
特點:
-
互斥性:即一個執行緒持有鎖,其他執行緒就要等待鎖釋放後才可以獲取鎖
-
可重入性:如果某個執行緒嘗試去獲取一個鎖,而這個鎖之前就是這個執行緒所持有的,那麼這個執行緒就可以再次獲取到鎖
-
好處:
- 避免了死鎖:比如一個子類繼承父類的synchronized方法,並顯示調用父類的synchronized方法,如果不可重入,那麼在子類中獲取的鎖,調用子類的fun方法是沒問題的,但是調用父類的fun方法時,會提示上了鎖,從而被阻塞,此時就會死鎖(自己持有鎖,還有再去獲取鎖,但是又獲取不到)
-
缺點:
- 跟狀態有關的方法都需要上鎖:操作麻煩,其實就是類的每個方法都需要上鎖,如果後面添加了一個方法,忘記加鎖,那還是有安全問題(比如被官人們遺棄的Vector)
- 性能問題:整個方法都上鎖,性能很低,尤其是一些耗時操作,比如網路IO這種容易阻塞的操作
-
解決:
- 縮小鎖的範圍
- 將耗時長的操作(前提是操作與狀態無關),放到同步之外的程式碼塊
-
好了,差不多先這些吧,後面還有太多東西了,慢慢來吧。
畢竟我們都一大把年紀了,身體要緊吶。
總結
懶了懶了,直接貼圖了(敲的腦仁疼),圖做的不是很好,不過應該能看懂,望見諒哈
參考內容:
- 《Java並發編程實戰》
- 《實戰Java高並發》
後記
最後,感謝大家的觀看,謝謝
原創不易,期待官人們的三連喲