線程安全問題就在我身邊 !
串行和并行
線程安全常見錯誤
運行結(jié)果錯誤
發(fā)布和初始化導(dǎo)致線程安全問題
活躍性問題
需要用到線程安全的場景
訪問共享變量或資源
依賴時序的操作
不同數(shù)據(jù)之間存在綁定關(guān)系
對方?jīng)]有聲明自己是線程安全的
總結(jié)
尾語
串行和并行
提到多線程這里要說兩個概念,就是串行和并行,搞清楚這個我們才能更好的理解多線程。
串行其實是相對于單條線程來執(zhí)行多個任務(wù)來說的,我們就拿下載文件來舉個例子,我們下載多個文件,在串行中它是按照一定的順序去進行下載的,也就是說必須等下載完A之后,才能開始下載B,它們在時間上是不可能發(fā)生重疊的。

并行:下載多個文件,開啟多條線程,多個文件同時進行下載,這里是嚴格意義上的在同一時刻發(fā)生的,并行在時間上是重疊的。

線程安全常見錯誤
線程安全是Java面試中的???,而在Java中有一些類本身是線程安全的,這些類就是線程安全類,例如ConcurrentHashMap。但是有時候錯誤地使用線程安全類反而會出現(xiàn)線程不安全的情況。
對象線程安全的定義是:當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環(huán)境下的調(diào)度和交替執(zhí)行問題,也不需要進行額外的同步,而調(diào)用這個對象的行為都可以獲得正確的結(jié)果。
如果某個對象是線程安全的,那么對于使用者而言,在使用時就不需要考慮方法間的協(xié)調(diào)問題,比如不需要考慮不能同時寫入或讀寫不能并行的問題,也不需要考慮任何額外的同步問題。
在擁有共享數(shù)據(jù)的多條線程并行執(zhí)行的程序中,線程安全的代碼會通過同步機制如自己加 synchronized 鎖,保證各個線程都可以正常且正確的執(zhí)行,不會出現(xiàn)數(shù)據(jù)污染等意外情況。
而我們在實際開發(fā)中經(jīng)常會遇到線程不安全的情況,那么一共有哪 3 種典型的線程安全問題呢?
運行結(jié)果錯誤; 發(fā)布和初始化先后順序混亂導(dǎo)致線程安全問題; 活躍性問題,例如死鎖和活鎖。
運行結(jié)果錯誤
首先,來看多線程同時操作一個變量導(dǎo)致的運行結(jié)果錯誤。
public?class?WrongResult?{
?
???volatile?static?int?i;
???public?static?void?main(String[]?args)?throws?InterruptedException?{
???????Runnable?r?=?new?Runnable()?{
???????????@Override
???????????public?void?run()?{
???????????????for?(int?j?=?0;?j?10000;?j++)?{
???????????????????i++;
???????????????}
???????????}
???????};
???????Thread?thread1?=?new?Thread(r);
???????thread1.start();
???????Thread?thread2?=?new?Thread(r);
???????thread2.start();
???????thread1.join();
???????thread2.join();
???????System.out.println(i);
????}
}
理論上得到的結(jié)果應(yīng)該是20000,但實際結(jié)果呢?
你可以去試驗10000次,應(yīng)該沒有一次是20000。
結(jié)果可能是16326,也可能是12362,每次的結(jié)果都還不一樣,這是為什么呢?
是因為在多線程下,CPU 的調(diào)度是以時間片為單位進行分配的,這也意味著在短時間內(nèi)很有可能有多個線程對某一個變量進行操作。
i++ 操作,表面上看只是一行代碼,但實際上它并不是一個原子操作,它的執(zhí)行步驟主要分為三步,而且在每步操作之間都有可能被打斷。
從內(nèi)存中讀取變量; 增加變量; 將變量進行保存。 
線程 1 首先拿到 i=1 的結(jié)果,然后進行 i+1 操作,但此時 i+1 的結(jié)果并沒有保存下來,線程 1 就被切換走了,于是 CPU 開始執(zhí)行線程 2,它所做的事情和線程 1 是一樣的 i++ 操作。
實際上和線程 1 拿到的 i 的結(jié)果一樣都是 1,為什么呢?因為線程 1 雖然對 i 進行了 +1 操作,但結(jié)果沒有保存,所以線程 2 看不到修改后的結(jié)果。
然后假設(shè)等線程 2 對 i 進行 +1 操作后,又切換到線程 1,讓線程 1 完成未完成的操作,即將 i+1 的結(jié)果 2 保存下來,然后又切換到線程 2 完成 i=2 的保存操作,雖然兩個線程都執(zhí)行了對 i 進行 +1 的操作,但結(jié)果卻最終保存了 i=2 的結(jié)果,而不是我們期望的 i=3,這樣就發(fā)生了線程安全問題,導(dǎo)致了數(shù)據(jù)結(jié)果錯誤,這也是最典型的線程安全問題。
發(fā)布和初始化導(dǎo)致線程安全問題
第二種是對象發(fā)布和初始化時導(dǎo)致的線程安全問題。如果我們發(fā)布和初始化的順序沒有同步,容易導(dǎo)致線程安全問題。
public?class?WrongInit?{
?
????private?Map?students;
?
????public?WrongInit()?{
????????new?Thread(new?Runnable()?{
????????????@Override
????????????public?void?run()?{
????????????????students?=?new?HashMap<>();
????????????????students.put(1,?"張三");
????????????????students.put(2,?"李四");
????????????????students.put(3,?"周五");
????????????????students.put(4,?"趙六");
????????????}
????????}).start();
?????}
?
????public?Map?getStudents()? {
????????return?students;
????}
?
????public?static?void?main(String[]?args)?throws?InterruptedException?{
????????WrongInit?multiThreadsError6?=?new?WrongInit();
????????System.out.println(multiThreadsError6.getStudents().get(1));
?
????}
}
在類中,定義一個類型為 Map 的成員變量 students,Integer 是學(xué)號,String 是姓名。然后在構(gòu)造函數(shù)中啟動一個新線程,并在線程中為 students 賦值。
學(xué)號:1,姓名:張三; 學(xué)號:2,姓名:李四; 學(xué)號:3,姓名:周五; 學(xué)號:4,姓名:趙六。
只有當線程運行完 run() 方法中的全部賦值操作后,4 名同學(xué)的全部信息才算是初始化完畢,可是我們看在主函數(shù) mian() 中,初始化 WrongInit 類之后并沒有進行任何休息就直接打印 1 號同學(xué)的信息。
試想這個時候程序會出現(xiàn)什么情況?實際上會發(fā)生空指針異常。
Exception?in?thread?"main"?java.lang.NullPointerException
at?lesson6.WrongInit.main(WrongInit.java:32)
這又是為什么呢?
因為 students 這個成員變量是在構(gòu)造函數(shù)中新建的線程中進行的初始化和賦值操作,而線程的啟動需要一定的時間,但是我們的 main 函數(shù)并沒有進行等待就直接獲取數(shù)據(jù),導(dǎo)致 getStudents 獲取的結(jié)果為 null,這就是在錯誤的時間或地點發(fā)布或初始化造成的線程安全問題。這說明初始化和發(fā)布操作順序必須一致,才能避免此類問題。
活躍性問題
第三種線程安全問題統(tǒng)稱為活躍性問題,最典型的有三種,分別為死鎖、活鎖和饑餓。
什么是活躍性問題呢,活躍性問題就是程序始終得不到運行的最終結(jié)果,相比于前面兩種線程安全問題帶來的數(shù)據(jù)錯誤或報錯,活躍性問題帶來的后果可能更嚴重,比如發(fā)生死鎖會導(dǎo)致程序完全卡死,無法向下運行。
死鎖
最常見的活躍性問題是死鎖,死鎖是指兩個線程之間相互等待對方資源,但同時又互不相讓,都想自己先執(zhí)行,如代碼所示。
?public?class?MayDeadLock?{
????Object?o1?=?new?Object();
????Object?o2?=?new?Object();
?
????public?void?thread1()?throws?InterruptedException?{
????????synchronized?(o1)?{
????????????Thread.sleep(1000);
????????????synchronized?(o2)?{
????????????????System.out.println("線程1成功拿到兩把鎖");
???????????}
????????}
????}
?
????public?void?thread2()?throws?InterruptedException?{
????????synchronized?(o2)?{
????????????Thread.sleep(1000);
????????????synchronized?(o1)?{
????????????????System.out.println("線程2成功拿到兩把鎖");
????????????}
????????}
????}
?
????public?static?void?main(String[]?args)?{
????????MayDeadLock?mayDeadLock?=?new?MayDeadLock();
????????new?Thread(new?Runnable()?{
????????????@Override
????????????public?void?run()?{
????????????????try?{
????????????????????mayDeadLock.thread1();
????????????????}?catch?(InterruptedException?e)?{
????????????????????e.printStackTrace();
????????????????}
????????????}
????????}).start();
????????new?Thread(new?Runnable()?{
????????????@Override
????????????public?void?run()?{
????????????????try?{
????????????????????mayDeadLock.thread2();
????????????????}?catch?(InterruptedException?e)?{
????????????????????e.printStackTrace();
????????????????}
????????????}
????????}).start();
????}
}
首先,代碼中創(chuàng)建了兩個 Object 作為 synchronized 鎖的對象,線程 1 先獲取 o1 鎖,sleep(1000) 之后,獲取 o2 鎖;線程 2 與線程 1 執(zhí)行順序相反,先獲取 o2 鎖,sleep(1000) 之后,獲取 o1 鎖。
假設(shè)兩個線程幾乎同時進入休息,休息完后,線程 1 想獲取 o2 鎖,線程 2 想獲取 o1 鎖,這時便發(fā)生了死鎖,兩個程序既不會主動停止申請,也不會釋放自己本身持有的資源,處于循環(huán)等待的過程中。
在死鎖發(fā)生時,必然存在一個“進程-資源環(huán)形鏈”,即:{p0,p1,p2,…pn},進程p0(或線程)等待p1占用的資源,p1等待p2占用的資源,pn等待p0占用的資源。(最直觀的理解是,p0等待p1占用的資源,而p1而在等待p0占用的資源,于是兩個進程就相互等待)
這種死鎖的出現(xiàn)解決方案是什么?
申請鎖的時候執(zhí)行順序要一致,每個線程的申請順序都固定為先申請o1,再申請o2。這樣可以避免死鎖情況的發(fā)生。
活鎖
活鎖指的是任務(wù)或者執(zhí)行者沒有被阻塞,由于某些條件沒有滿足,導(dǎo)致一直重復(fù)嘗試—失敗—嘗試—失敗的過程。
假設(shè)有一個消息隊列,隊列里放著各種各樣需要被處理的消息,而某個消息由于自身被寫錯了導(dǎo)致不能被正確處理,執(zhí)行時會報錯,可是隊列的重試機制會重新把它放在隊列頭進行優(yōu)先重試處理,但這個消息本身無論被執(zhí)行多少次,都無法被正確處理,每次報錯后又會被放到隊列頭進行重試,周而復(fù)始,最終導(dǎo)致線程一直處于忙碌狀態(tài),便發(fā)生了活鎖問題。
饑餓
饑餓是指線程需要某些資源時始終得不到。在 Java 中有線程優(yōu)先級的概念,Java 中優(yōu)先級分為 1 到 10,1 最低,10 最高。如果我們把某個線程的優(yōu)先級設(shè)置為 1,這是最低的優(yōu)先級,在這種情況下,這個線程就有可能始終分配不到 CPU 資源,而導(dǎo)致長時間無法運行?;蛘呤悄硞€線程始終持有某個文件的鎖,而其他線程想要修改文件就必須先獲取鎖,這樣想要修改文件的線程就會陷入饑餓,長時間不能運行。
需要用到線程安全的場景
訪問共享變量或資源
第一種場景是訪問共享變量或共享資源的時候,典型的場景有訪問共享對象的屬性,訪問 static 靜態(tài)變量,訪問共享的緩存,等等。因為這些信息不僅會被一個線程訪問到,還有可能被多個線程同時訪問,那么就有可能在并發(fā)讀寫的情況下發(fā)生線程安全問題。比如多線程同時 i++ 的例子:
/**
?*?描述:?????共享的變量或資源帶來的線程安全問題
?*/
public?class?ThreadNotSafe1?{
????static?int?i;
????public?static?void?main(String[]?args)?throws?InterruptedException?{
????????Runnable?r?=?new?Runnable()?{
????????????@Override
????????????public?void?run()?{
????????????????for?(int?j?=?0;?j?10000;?j++)?{
????????????????????i++;
????????????????}
????????????}
????????};
????????Thread?thread1?=?new?Thread(r);
????????Thread?thread2?=?new?Thread(r);
????????thread1.start();
????????thread2.start();
????????thread1.join();
????????thread2.join();
????????System.out.println(i);
????}
}
如代碼所示,兩個線程同時對 i 進行 i++ 操作,最后的輸出可能是 15875 等小于20000的數(shù),而不是我們期待的20000,這便是非常典型的共享變量帶來的線程安全問題。
依賴時序的操作
第二個需要我們注意的場景是依賴時序的操作,如果我們操作的正確性是依賴時序的,而在多線程的情況下又不能保障執(zhí)行的順序和我們預(yù)想的一致,這個時候就會發(fā)生線程安全問題
if?(map.containsKey(key))?{
????map.remove(obj)
}
代碼中首先檢查 map 中有沒有 key 對應(yīng)的元素,如果有則繼續(xù)執(zhí)行 remove 操作。
此時,這個組合操作就是危險的,因為它是先檢查后操作,而執(zhí)行過程中可能會被打斷。
如果此時有兩個線程同時進入 if() 語句,然后它們都檢查到存在 key 對應(yīng)的元素,于是都希望執(zhí)行下面的 remove 操作,隨后一個線程率先把 obj 給刪除了,而另外一個線程它剛已經(jīng)檢查過存在 key 對應(yīng)的元素,if 條件成立,所以它也會繼續(xù)執(zhí)行刪除 obj 的操作,但實際上,集合中的 obj 已經(jīng)被前面的線程刪除了,這種情況下就可能導(dǎo)致線程安全問題。
不同數(shù)據(jù)之間存在綁定關(guān)系
第三種需要我們注意的線程安全場景是不同數(shù)據(jù)之間存在相互綁定關(guān)系的情況。
有時候,我們的不同數(shù)據(jù)之間是成組出現(xiàn)的,存在著相互對應(yīng)或綁定的關(guān)系,最典型的就是 IP 和端口號。有時候我們更換了 IP,往往需要同時更換端口號,如果沒有把這兩個操作綁定在一起,就有可能出現(xiàn)單獨更換了 IP 或端口號的情況,而此時信息如果已經(jīng)對外發(fā)布,信息獲取方就有可能獲取一個錯誤的 IP 與端口綁定情況,這時就發(fā)生了線程安全問題。在這種情況下,我們也同樣需要保障操作的原子性。
對方?jīng)]有聲明自己是線程安全的
第四種值得注意的場景是在我們使用其他類時,如果對方?jīng)]有聲明自己是線程安全的,那么這種情況下對其他類進行多線程的并發(fā)操作,就有可能會發(fā)生線程安全問題。舉個例子,比如說我們定義了 ArrayList,它本身并不是線程安全的,如果此時多個線程同時對 ArrayList 進行并發(fā)讀/寫,那么就有可能會產(chǎn)生線程安全問題,造成數(shù)據(jù)出錯,而這個責(zé)任并不在 ArrayList,因為它本身并不是并發(fā)安全的,正如源碼注釋所寫的:
Note?that?this?implementation?is?not?synchronized.?
If?multiple?threads?access?an?ArrayList?instance?concurrently,?
and?at?least?one?of?the?threads?modifies?the?list?structurally,?it?must?be?synchronized?externally.
這段話的意思是說,如果我們把 ArrayList 用在了多線程的場景,需要在外部手動用 synchronized 等方式保證并發(fā)安全。
所以 ArrayList 默認不適合并發(fā)讀寫,是我們錯誤地使用了它,導(dǎo)致了線程安全問題。所以,我們在使用其他類時如果會涉及并發(fā)場景,那么一定要首先確認清楚,對方是否支持并發(fā)操作。
總結(jié)
線程安全問題是多線程并發(fā)的重中之重,這里只是講了幾個有可能存在線程安全的例子,就此也打開了線程并發(fā)的學(xué)習(xí)。接下來將講述一些關(guān)于Java中的一些鎖機制和案例,敬請期待。
END
有熱門推薦??
1.?在 IDEA 中用了熱部署神器 JRebel 之后,開發(fā)效率提升10倍!
2.?想讓進程后臺運行,試試Linux的nohup命令,3分鐘學(xué)會。
最近面試BAT,整理一份面試資料《Java面試BATJ通關(guān)手冊》,覆蓋了Java核心技術(shù)、JVM、Java并發(fā)、SSM、微服務(wù)、數(shù)據(jù)庫、數(shù)據(jù)結(jié)構(gòu)等等。
獲取方式:點“在看”,關(guān)注公眾號并回復(fù)?Java?領(lǐng)取,更多內(nèi)容陸續(xù)奉上。
文章有幫助的話,在看,轉(zhuǎn)發(fā)吧。
謝謝支持喲 (*^__^*)

