【線程安全】 三類線程安全問題
什么是線程安全
《Java Concurrency In Practice》的作者 Brian Goetz 對線程安全是這樣理解的,當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行問題,也不需要進行額外的同步,而調用這個對象的行為都可以獲得正確的結果,那這個對象便是線程安全的。
通俗來講,就是在多線程并發的情況下,每個線程的執行結果始終都是預期的結果。那么這個線程就是線程安全的。
出現線程安全的三種情況
在理解了上面的概念后,如果平時開發的時候就會發現,我們會經常遇到線程不安全的情況,大概的羅列了以下三種
- 運行結果錯誤
- 發布和初始化導致線程安全問題
- 活躍性問題
運行結果錯誤
首先我們先看下面一段代碼
public class WrongResult {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
count++;
}
}
};
Thread t0 = new Thread(runnable);
Thread t1 = new Thread(runnable);
t0.start();
t1.start();
t0.join();
t1.join();
System.out.println(count);
}
}
上面這段代碼的邏輯,就是兩個線程對count進行自加一,每個線程都會循環自加1000次。那么預期的結果應該是2000。單其實執行的結果卻沒有2000,會始終比2000少,這究竟是什么原因導致這個種情況的發生呢。
這里就需要理解我們計算機CUP調度的分配了。在CUP的分配規則里面,是以時間為單位給每個線程去分配的,如果當前線程的所分配的時間執行完,就會切給另外一個線程去執行,這樣不好的地方就是,他沒有辦法保證i + +的原子性,我們可以先看下面這張圖。

通過上面的圖我們可以了解到,i + +在cup執行的步驟其實分為三步
- 第一步是讀取i的值
- 第二步是執行加一操作
- 第三步就是保存結果的值
這樣我們就可以仔細的思考一下,如果CUP給線程一分配的時間只足夠他執行到第二步操作,之后就被切到了線程二,那么這時就會導致線程二沒有獲取到最新的值,因為線程一還沒有執行到第三步去保存結果就被CPU給切掉了。那么這個時候線程二再去自增計算出來的值,就會和線程一獲得的結果一樣的。自然我們拿到的結果就會比預期的結果少了很多。像這種情況也就是最典型的線程安全問題。
發布和初始化導致線程安全問題
這種就比較好理解,比如在項目啟動的時候,去獲取一段初始數據,但是這個數據需要通過線程異步的初始化才會有。但是線程初始化數據需要時間,如果程序在線程還沒有初始化完成數據后就去獲取數據,這個時候就會導致線程安全問題。可以看下面這段代碼來理解。
public class WrongInit {
private List<String> info;
public WrongInit() {
info = new ArrayList<>();
Thread thread = new Thread(()->{
info.add("數據開始初始化");
try {
info.add("數據初始化中....");
//模擬數據初始化需要的時間
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
info.add("數據初始化完成!");
});
thread.start();
}
public static void main(String[] args) throws InterruptedException {
WrongInit init = new WrongInit();
init.info.forEach(System.out::println);
}
}
上面的代碼中,為了能模擬出數據沒有完全初始化的情況,在線程中休眠了1秒中,其實數據應該是要打印出【數據初始化完成!】然而結果去只打印到【數據初始化中....】,其實在這里還會有另外一種情況,如果創建線程的時間大于程序調用的時間,可能會直接報空指針異常。這種也是線程不安全的情況。
活躍性問題
線程的活躍性問題其實可以分為三種,分別為死鎖,活鎖和饑餓。
這三種都有個一個共同的特性,就是他們會讓線程卡住,死活也得不到運行結果,這種情況其實是最線程安全中最復雜的也是最嚴重的,如果線程卡住的太多,不僅會占用服務的資源,甚至還會導致服務假死或者宕機。
死鎖
死鎖比較常見,就是兩個線程互相等單對方的資源,單同時又互不相讓,都想自己先執行。可以看下面這段代碼
public class ThreadDeadMain {
private static Object o1 = new Object();
private static Object o2 = new Object();
public static void main(String[] args) {
Thread thread01 = new Thread(()->{
synchronized (o1){
System.out.println(Thread.currentThread().getName()+"獲取到o1的鎖了");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2){
System.out.println(Thread.currentThread().getName()+"獲取到o2的鎖了");
}
}
});
Thread thread02 = new Thread(()->{
synchronized (o2){
System.out.println(Thread.currentThread().getName()+"獲取到o2的鎖了");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1){
System.out.println(Thread.currentThread().getName()+"獲取到o1的鎖了");
}
}
});
thread01.start();
thread02.start();
}
}
上面這段代碼中啟動了兩個線程,每個線程中有兩個鎖,線程1啟動時會先獲取o1的鎖,然后再去獲取o2的鎖,之后才會執行完畢最后釋放o2和o1的鎖,線程2相對于線程1的邏輯則相反,顯示獲取o2的鎖,然后再去獲取o1的鎖,最后執行完畢釋放o2的鎖再去釋放o1的鎖。為了讓兩個線程能發生死鎖的情況,我在兩個線程都獲取第一個鎖的時候讓線程休眠的一秒種,這樣等到兩個線程同時去獲取第二個鎖的時候,就會發現,線程1的第二個鎖的o2在線程2的第一個鎖總沒有釋放,然而線程2的第二個鎖o1又被線程1占著,這樣就會發生兩者互不相讓,又同時占領著鎖。導致程序一直卡著。
活鎖
活鎖相對于死鎖有種相反的意思,死鎖是卡著資源,然后活鎖不一樣,他不占用鎖的資源,但是他會一直運行著,不過他會一直循環運行,但是一直沒有遇到正確的結果,導致線程一直在運行。但是他不會像死鎖一樣卡著不運行。
可以看到下面這段代碼
public class ThreadLiveMain implements Runnable {
private int num;
public ThreadLiveMain(int num) {
this.num = num;
}
@Override
public void run() {
System.out.println("中獎數字:"+num);
int i = 0;
do {
//隨機獲取1-10的數字
Random random = new Random();
i = random.nextInt(10)+1;
System.out.println("隨機數:"+i);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}while (num!=i);
System.out.println("抽中啦!");
}
public static void main(String[] args) {
ThreadLiveMain main = new ThreadLiveMain(11);
Thread thread = new Thread(main);
thread.start();
}
}
這是一個類似于抽獎的小程序,線程里面會隨機出1-10的數字來判斷自己是否中獎,如果我們給中獎數字在1-10內,這個時候程序就會得到正常的結果,因為他在隨機數范圍內。但是如果我們給了一個11,這個時候就超出隨機數的范圍了。線程會一直的去循環判斷,但是又遇不到正確的隨機數字,這樣線程就不會停止下來,這種情況就稱之為活鎖。
饑餓
饑餓就比較有趣了,他就真的是因為饑餓所以才導致線程拿不到結果,Java的線程有優先級的概念,有1-10的優先級劃分,如果一個線程的等級被設置到最低的1,那么這個線程可能永遠也拿不到線程的資源,線程吃不到飯,自然也沒力氣干活,沒力氣干活那也就拿不到結果。還有一種情況就是某個線程持有某個文件的鎖,如果其他線程想有修改文件就需要先獲得這個文件的鎖,那這個修改文件的線程就會陷入饑餓,沒法在繼續運行。

浙公網安備 33010602011771號