Java多線程之線程安全問題詳解
面試題:
- 什么是線程安全和線程不安全?
- 自增運算是不是線程安全的?如何保證多線程下 i++ 結(jié)果正確?
1. 什么是線程安全和線程不安全?
什么是線程安全呢?當多個線程并發(fā)訪問某個Java對象時,無論系統(tǒng)如何調(diào)度這些線程,也無論這些線程將如何交替操作,這個對象都能表現(xiàn)出一致的、正確的行為,那么對這個對象的操作是線程安全的。
如果這個對象表現(xiàn)出不一致的、錯誤的行為,那么對這個對象的操作不是線程安全的,發(fā)生了線程的安全問題。
2. 自增運算為什么不是線程安全的?
線程安全實驗:兩個線程對初始值為 0 的靜態(tài)變量一個做自增,一個做自減,各做 5000 次,結(jié)果是 0 嗎?具體的代碼如下
public class ThreadDemo { private static int i = 0; public static void main(String[] args) throws InterruptedException { // 線程1對變量i做5000次自增運算 Thread t1 = new Thread(()->{ for(int j=0;j<5000;j++){ i++; } }); Thread t2 = new Thread(()->{ for(int j=0;j<5000;j++){ i--; } }); t1.start(); t2.start(); // 主線程等待t1線程和t2線程執(zhí)行結(jié)束再繼續(xù)執(zhí)行 t1.join(); t2.join(); System.out.println(i);// 581 / -1830 / 0 } }
以上的結(jié)果可能是正數(shù)、負數(shù)、零。為什么呢?因為 Java 中對靜態(tài)變量的自增,自減并不是原子操作,要徹底理解,必須從字節(jié)碼來進行分析。
例如對于 i++ 而言,實際會產(chǎn)生如下的 JVM 字節(jié)碼指令:
getstatic i // 獲取靜態(tài)變量i的值 iconst_1 // 準備常量1 iadd // 自增 putstatic i // 將修改后的值存入靜態(tài)變量i
而對應(yīng) i-- 也是類似:
getstatic i // 獲取靜態(tài)變量i的值 iconst_1 // 準備常量1 isub // 自減 putstatic i // 將修改后的值存入靜態(tài)變量
而 Java 的內(nèi)存模型如下,完成靜態(tài)變量的自增,自減需要在主存和工作內(nèi)存中進行數(shù)據(jù)交換:
如果是單線程以上 8 行代碼是順序執(zhí)行(不會交錯)沒有問題:
但多線程下這 8 行代碼可能交錯運行:
出現(xiàn)負數(shù)的情況:
出現(xiàn)正數(shù)的情況:
因此,一個自增運算符是一個復(fù)合操作,至少包括三個JVM指令:“內(nèi)存取值”“寄存器增加1”和“存值到內(nèi)存”。這三個指令在JVM內(nèi)部是獨立進行的,中間完全可能會出現(xiàn)多個線程并發(fā)進行。“內(nèi)存取值”“寄存器增加1”和“存值到內(nèi)存”這三個JVM指令本身是不可再分的,它們都具備原子性,是線程安全的,也叫原子操作。但是,兩個或者兩個以上的原子操作合在一起進行操作就不再具備原子性了。比如先讀后寫,就有可能在讀之后,其實這個變量被修改了,出現(xiàn)讀和寫數(shù)據(jù)不一致的情況。
3. 臨界區(qū)資源和競態(tài)條件
在多個線程操作相同資源(如變量、數(shù)組或者對象)時就可能出現(xiàn)線程安全問題。一般來說,只在多個線程對這個資源進行寫操作的時候才會出現(xiàn)問題,如果是簡單的讀操作,不改變資源的話,顯然是不會出現(xiàn)問題的。
臨界區(qū)資源表示一種可以被多個線程使用的公共資源或共享數(shù)據(jù),但是每一次只能有一個線程使用它。一旦臨界區(qū)資源被占用,想使用該資源的其他線程則必須等待。在并發(fā)情況下,臨界區(qū)資源是受保護的對象。
臨界區(qū)代碼段是每個線程中訪問臨界資源的那段代碼,多個線程必須互斥地對臨界區(qū)資源進行訪問。線程進入臨界區(qū)代碼段之前,必須在進入?yún)^(qū)申請資源,申請成功之后執(zhí)行臨界區(qū)代碼段,執(zhí)行完成之后釋放資源。臨界區(qū)代碼段的進入和退出如圖所示:
競態(tài)條件可能是由于在訪問臨界區(qū)代碼段時沒有互斥地訪問而導(dǎo)致的特殊情況。如果多個線程在臨界區(qū)代碼段的并發(fā)執(zhí)行結(jié)果可能因為代碼的執(zhí)行順序不同而不同,我們就說這時在臨界區(qū)出現(xiàn)了競態(tài)條件問題。
比如下面代碼中的臨界區(qū)資源和臨界區(qū)代碼段:
public class SafeDemo { // 臨界區(qū)資源 private static int i = 0; // 臨界區(qū)代碼段 public void selfIncrement(){ for(int j=0;j<5000;j++){ i++; } } // 臨界區(qū)代碼段 public void selfDecrement(){ for(int j=0;j<5000;j++){ i--; } } // 這個不是臨界區(qū)代碼,因為雖然使用了共享資源,但是這個方法并沒有被多個線程同時訪問 public int getI(){ return i; } }
public class ThreadDemo { public static void main(String[] args) throws InterruptedException { SafeDemo safeDemo = new SafeDemo(); Thread t1 = new Thread(()->{ safeDemo.selfIncrement(); }); Thread t2 = new Thread(()->{ safeDemo.selfDecrement(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(safeDemo.getI()); } }
當多個線程訪問臨界區(qū)的selfIncrement()方法時,就會出現(xiàn)競態(tài)條件的問題。更標準地說,當兩個或多個線程競爭同一個資源時,對資源的訪問順序就變得非常關(guān)鍵。為了避免競態(tài)條件的問題,我們必須保證臨界區(qū)代碼段操作具備排他性。這就意味著當一個線程進入臨界區(qū)代碼段執(zhí)行時,其他線程不能進入臨界區(qū)代碼段執(zhí)行。
總結(jié):
(1) 一個程序運行多個線程本身是沒有問題的,問題出在多個線程訪問共享資源,多個線程讀共享資源其實也沒有問題,而在多個線程對共享資源讀寫操作時發(fā)生指令交錯,就會出現(xiàn)問題 ;
(2) 一段代碼塊內(nèi)如果存在對共享資源的多線程讀寫操作,稱這段代碼塊為臨界區(qū)代碼塊;
(3) 多個線程在臨界區(qū)內(nèi)執(zhí)行,由于代碼的執(zhí)行序列不同而導(dǎo)致結(jié)果無法預(yù)測,稱之為發(fā)生了競態(tài)條件;
在Java中,可以使用synchronized關(guān)鍵字,使用Lock顯式鎖實例,或者使用原子變量(AtomicVariables)對臨界區(qū)代碼段進行排他性保護。
本篇文章就到這里了,希望能夠給你帶來幫助,也希望您能夠多多關(guān)注腳本之家的更多內(nèi)容!
相關(guān)文章
java validation 后臺參數(shù)驗證的使用詳解
本篇文章主要介紹了java validation 后臺參數(shù)驗證的使用詳解,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-10-10springboot集成swagger3與knife4j的詳細代碼
這篇文章主要介紹了springboot集成swagger3與knife4j,本文通過實例代碼給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-08-08SpringBoot-JPA刪除不成功,只執(zhí)行了查詢語句問題
這篇文章主要介紹了SpringBoot-JPA刪除不成功,只執(zhí)行了查詢語句問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-12-12Java 多線程并發(fā)編程_動力節(jié)點Java學(xué)院整理
這篇文章主要介紹了Java 多線程并發(fā)編程的相關(guān)資料,非常不錯,具有參考借鑒價值,需要的朋友可以參考下2017-05-05Java FileDescriptor總結(jié)_動力節(jié)點Java學(xué)院整理
FileDescriptor 是“文件描述符”??梢员挥脕肀硎鹃_放文件、開放套接字等。接下來通過本文給大家分享Java FileDescriptor總結(jié),感興趣的朋友一起學(xué)習(xí)吧2017-05-05