Java多線程基本概念以及避坑指南
前言
多核的機(jī)器,現(xiàn)在已經(jīng)非常常見(jiàn)了。即使是一塊手機(jī),也都配備了強(qiáng)勁的多核處理器。通過(guò)多進(jìn)程和多線程的手段,就可以讓多個(gè)CPU同時(shí)工作,來(lái)加快任務(wù)的執(zhí)行。
多線程,是編程中一個(gè)比較高級(jí)的話題。由于它涉及到共享資源的操作,所以在編碼時(shí)非常容易出現(xiàn)問(wèn)題。Java的concurrent包,提供了非常多的工具,來(lái)幫助我們簡(jiǎn)化這些變量的同步,但學(xué)習(xí)應(yīng)用之路依然充滿了曲折。
本篇文章,將簡(jiǎn)單的介紹一下Java中多線程的基本知識(shí)。然后著重介紹一下初學(xué)者在多線程編程中一些最容易出現(xiàn)問(wèn)題的地方,很多都是血淚經(jīng)驗(yàn)。規(guī)避了這些坑,就相當(dāng)于規(guī)避了90%兇殘的多線程bug。
1. 多線程基本概念
1.1 輕量級(jí)進(jìn)程
在JVM中,一個(gè)線程,其實(shí)是一個(gè)輕量級(jí)進(jìn)程(LWP)。所謂的輕量級(jí)進(jìn)程,其實(shí)是用戶進(jìn)程調(diào)用系統(tǒng)內(nèi)核,所提供的一套接口。實(shí)際上,它還要調(diào)用更加底層的內(nèi)核線程(KLT)。
實(shí)際上,JVM的線程創(chuàng)建銷(xiāo)毀以及調(diào)度等,都是依賴于操作系統(tǒng)的。如果你看一下Thread類(lèi)里面的多個(gè)函數(shù),你會(huì)發(fā)現(xiàn)很多都是native的,直接調(diào)用了底層操作系統(tǒng)的函數(shù)。
下圖是JVM在Linux上簡(jiǎn)單的線程模型。
可以看到,不同的線程在進(jìn)行切換的時(shí)候,會(huì)頻繁在用戶態(tài)和內(nèi)核態(tài)進(jìn)行狀態(tài)轉(zhuǎn)換。這種切換的代價(jià)是比較大的,也就是我們平常所說(shuō)的上下文切換(Context Switch)。
1.2 JMM
在介紹線程同步之前,我們有必要介紹一個(gè)新的名詞,那就是JVM的內(nèi)存模型JMM。
JMM并不是說(shuō)堆、metaspace這種內(nèi)存的劃分,它是一個(gè)完全不同的概念,指的是與線程相關(guān)的Java運(yùn)行時(shí)線程內(nèi)存模型。
由于Java代碼在執(zhí)行的時(shí)候,很多指令都不是原子的,如果這些值的執(zhí)行順序發(fā)生了錯(cuò)位,就會(huì)獲得不同的結(jié)果。比如,i++的動(dòng)作就可以翻譯成以下的字節(jié)碼。
getfield // Field value:I iconst_1 iadd putfield // Field value:I
這還只是代碼層面的。如果再加上CPU每核的各級(jí)緩存,這個(gè)執(zhí)行過(guò)程會(huì)變得更加細(xì)膩。如果我們希望執(zhí)行完i++之后,再執(zhí)行i--,僅靠初級(jí)的字節(jié)碼指令,是無(wú)法完成的。我們需要一些同步手段。
上圖就是JMM的內(nèi)存模型,它分為主存儲(chǔ)器(Main Memory)和工作存儲(chǔ)器(Working Memory)兩種。我們平常在Thread中操作這些變量,其實(shí)是操作的主存儲(chǔ)器的一個(gè)副本。當(dāng)修改完之后,還需要重新刷到主存儲(chǔ)器上,其他的線程才能夠知道這些變化。
1.3 Java中常見(jiàn)的線程同步方式
為了完成JMM的操作,完成線程之間的變量同步,Java提供了非常多的同步手段。
- Java的基類(lèi)Object中,提供了wait和notify的原語(yǔ),來(lái)完成monitor之間的同步。不過(guò)這種操作我們?cè)跇I(yè)務(wù)編程中很少遇見(jiàn)
- 使用synchronized對(duì)方法進(jìn)行同步,或者鎖住某個(gè)對(duì)象以完成代碼塊的同步
- 使用concurrent包里面的可重入鎖。這套鎖是建立在AQS之上的
- 使用volatile輕量級(jí)同步關(guān)鍵字,實(shí)現(xiàn)變量的實(shí)時(shí)可見(jiàn)性
- 使用Atomic系列,完成自增自減
- 使用ThreadLocal線程局部變量,實(shí)現(xiàn)線程封閉
- 使用concurrent包提供的各種工具,比如LinkedBlockingQueue來(lái)實(shí)現(xiàn)生產(chǎn)者消費(fèi)者。本質(zhì)還是AQS
- 使用Thread的join,以及各種await方法,完成并發(fā)任務(wù)的順序執(zhí)行
從上面的描述可以看出,多線程編程要學(xué)的東西可實(shí)在太多了。幸運(yùn)的是,同步方式雖然千變?nèi)f化,但我們創(chuàng)建線程的方式卻沒(méi)幾種。
第一類(lèi)就是Thread類(lèi)。大家都知道有兩種實(shí)現(xiàn)方式。第一可以繼承Thread覆蓋它的run方法;第二種是實(shí)現(xiàn)Runnable接口,實(shí)現(xiàn)它的run方法;而第三種創(chuàng)建線程的方法,就是通過(guò)線程池。
其實(shí),到最后,就只有一種啟動(dòng)方式,那就是Thread。線程池和Runnable,不過(guò)是一種封裝好的快捷方式罷了。
多線程這么復(fù)雜,這么容易出問(wèn)題,那常見(jiàn)的都有那些問(wèn)題,我們又該如何避免呢?下面,我將介紹10個(gè)高頻出現(xiàn)的坑,并給出解決方案。
2. 避坑指南
2.1. 線程池打爆機(jī)器
首先,我們聊一個(gè)非常非常低級(jí),但又產(chǎn)生了嚴(yán)重后果的多線程錯(cuò)誤。
通常,我們創(chuàng)建線程的方式有Thread,Runnable和線程池三種。隨著Java1.8的普及,現(xiàn)在最常用的就是線程池方式。
有一次,我們線上的服務(wù)器出現(xiàn)了僵死,就連遠(yuǎn)程ssh,都登錄不上,只能無(wú)奈的重啟。大家發(fā)現(xiàn),只要啟動(dòng)某個(gè)應(yīng)用,過(guò)不了幾分鐘,就會(huì)出現(xiàn)這種情況。最終定位到了幾行讓人啼笑皆非的代碼。
有位對(duì)多線程不太熟悉的同學(xué),使用了線程池去異步處理消息。通常,我們都會(huì)把線程池作為類(lèi)的靜態(tài)變量,或者是成員變量。但是這位同學(xué),卻將它放在了方法內(nèi)部。也就是說(shuō),每當(dāng)有一個(gè)請(qǐng)求到來(lái)的時(shí)候,都會(huì)創(chuàng)建一個(gè)新的線程池。當(dāng)請(qǐng)求量一增加,系統(tǒng)資源就被耗盡,最終造成整個(gè)機(jī)器的僵死。
void realJob(){ ThreadPoolExecutor exe = new ThreadPoolExecutor(...); exe.submit(new Runnable(){...}) }
這種問(wèn)題如何去避免?只能通過(guò)代碼review。所以多線程相關(guān)的代碼,哪怕是非常簡(jiǎn)單的同步關(guān)鍵字,都要交給有經(jīng)驗(yàn)的人去寫(xiě)。即使沒(méi)有這種條件,也要非常仔細(xì)的對(duì)這些代碼進(jìn)行review。
2.2. 鎖要關(guān)閉
相比較synchronized關(guān)鍵字加的獨(dú)占鎖,concurrent包里面的Lock提供了更多的靈活性??梢愿鶕?jù)需要,選擇公平鎖與非公平鎖、讀鎖與寫(xiě)鎖。
但Lock用完之后是要關(guān)閉的,也就是lock和unlock要成對(duì)出現(xiàn),否則就容易出現(xiàn)鎖泄露,造成了其他的線程永遠(yuǎn)了拿不到這個(gè)鎖。
如下面的代碼,我們?cè)谡{(diào)用lock之后,發(fā)生了異常,try中的執(zhí)行邏輯將被中斷,unlock將永遠(yuǎn)沒(méi)有機(jī)會(huì)執(zhí)行。在這種情況下,線程獲取的鎖資源,將永遠(yuǎn)無(wú)法釋放。
private final Lock lock = new ReentrantLock(); void doJob(){ try{ lock.lock(); //發(fā)生了異常 lock.unlock(); }catch(Exception e){ } }
正確的做法,就是將unlock函數(shù),放到finally塊中,確保它總是能夠執(zhí)行。
由于lock也是一個(gè)普通的對(duì)象,是可以作為函數(shù)的參數(shù)的。如果你把lock在函數(shù)之間傳來(lái)傳去的,同樣會(huì)有時(shí)序邏輯混亂的情況。在平時(shí)的編碼中,也要避免這種把lock當(dāng)參數(shù)的情況。
2.3. wait要包兩層
Object作為Java的基類(lèi),提供了四個(gè)方法wait wait(timeout) notify notifyAll ,用來(lái)處理線程同步問(wèn)題,可以看出wait等函數(shù)的地位是多么的高大。在平常的工作中,寫(xiě)業(yè)務(wù)代碼的同學(xué)使用這些函數(shù)的機(jī)率是比較小的,所以一旦用到很容易出問(wèn)題。
但使用這些函數(shù)有一個(gè)非常大的前提,那就是必須使用synchronized進(jìn)行包裹,否則會(huì)拋出IllegalMonitorStateException。比如下面的代碼,在執(zhí)行的時(shí)候就會(huì)報(bào)錯(cuò)。
final Object condition = new Object(); public void func(){ condition.wait(); }
類(lèi)似的方法,還有concurrent包里的Condition對(duì)象,使用的時(shí)候也必須出現(xiàn)在lock和unlock函數(shù)之間。
為什么在wait之前,需要先同步這個(gè)對(duì)象呢?因?yàn)镴VM要求,在執(zhí)行wait之時(shí),線程需要持有這個(gè)對(duì)象的monitor,顯然同步關(guān)鍵字能夠完成這個(gè)功能。
但是,僅僅這么做,還是不夠的,wait函數(shù)通常要放在while循環(huán)里才行,JDK在代碼里做了明確的注釋。
重點(diǎn):這是因?yàn)?,wait的意思,是在notify的時(shí)候,能夠向下執(zhí)行邏輯。但在notify的時(shí)候,這個(gè)wait的條件可能已經(jīng)是不成立的了,因?yàn)樵诘却倪@段時(shí)間里條件條件可能發(fā)生了變化,需要再進(jìn)行一次判斷,所以寫(xiě)在while循環(huán)里是一種簡(jiǎn)單的寫(xiě)法。
final Object condition = new Object(); public void func(){ synchronized(condition){ while(<條件成立>){ condition.wait(); } } }
帶if條件的wait和notify要包兩層,一層synchronized,一層while,這就是wait等函數(shù)的正確用法。
2.4. 不要覆蓋鎖對(duì)象
使用synchronized關(guān)鍵字時(shí),如果是加在普通方法上的,那么鎖的就是this對(duì)象;如果是加載static方法上的,那鎖的就是class。除了用在方法上,synchronized還可以直接指定要鎖定的對(duì)象,鎖代碼塊,達(dá)到細(xì)粒度的鎖控制。
如果這個(gè)鎖的對(duì)象,被覆蓋了會(huì)怎么樣?比如下面這個(gè)。
List listeners = new ArrayList(); void add(Listener listener, boolean upsert){ synchronized(listeners){ List results = new ArrayList(); for(Listener ler:listeners){ ... } listeners = results; } }
上面的代碼,由于在邏輯中,強(qiáng)行給鎖listeners對(duì)象進(jìn)行了重新賦值,會(huì)造成鎖的錯(cuò)亂或者失效。
為了保險(xiǎn)起見(jiàn),我們通常把鎖對(duì)象聲明成final類(lèi)型的。
final List listeners = new ArrayList();
或者直接聲明專(zhuān)用的鎖對(duì)象,定義成普通的Object對(duì)象即可。
final Object listenersLock = new Object();
2.5. 處理循環(huán)中的異常
在異步線程里處理一些定時(shí)任務(wù),或者執(zhí)行時(shí)間非常長(zhǎng)的批量處理,是經(jīng)常遇到的需求。我就不止一次看到小伙伴們的程序執(zhí)行了一部分就停止的情況。
排查到這些中止的根本原因,就是其中的某行數(shù)據(jù)發(fā)生了問(wèn)題,造成了整個(gè)線程的死亡。
我們還是來(lái)看一下代碼的模板。
volatile boolean run = true; void loop(){ while(run){ for(Task task: taskList){ //do . sth int a = 1/0; } } }
在loop函數(shù)中,執(zhí)行我們真正的業(yè)務(wù)邏輯。當(dāng)執(zhí)行到某個(gè)task的時(shí)候,發(fā)生了異常。這個(gè)時(shí)候,線程并不會(huì)繼續(xù)運(yùn)行下去,而是會(huì)拋出異常直接中止。在寫(xiě)普通函數(shù)的時(shí)候,我們都知道程序的這種行為,但一旦到了多線程,很多同學(xué)都會(huì)忘了這一環(huán)。
值得注意的是,即使是非捕獲類(lèi)型的NullPointerException,也會(huì)引起線程的中止。所以,時(shí)刻把要執(zhí)行的邏輯,放在try catch中,是個(gè)非常好的習(xí)慣。
volatile boolean run = true; void loop(){ while(run){ for(Task task: taskList){ try{ //do . sth int a = 1/0; }catch(Exception ex){ //log } } } }
2.6. HashMap正確用法
HashMap在多線程環(huán)境下,會(huì)產(chǎn)生死循環(huán)問(wèn)題。這個(gè)問(wèn)題已經(jīng)得到了廣泛的普及,因?yàn)樗鼤?huì)產(chǎn)生非常嚴(yán)重的后果:CPU跑滿,代碼無(wú)法執(zhí)行,jstack查看時(shí)阻塞在get方法上。
至于怎么提高HashMap效率,什么時(shí)候轉(zhuǎn)紅黑樹(shù)轉(zhuǎn)列表,這是陽(yáng)春白雪的八股界話題,我們下里巴人只關(guān)注怎么不出問(wèn)題。
網(wǎng)絡(luò)上有詳細(xì)的文章描述死循環(huán)問(wèn)題產(chǎn)生的場(chǎng)景,大體因?yàn)镠ashMap在進(jìn)行rehash時(shí),會(huì)形成環(huán)形鏈。某些get請(qǐng)求會(huì)走到這個(gè)環(huán)上。JDK并不認(rèn)為這是個(gè)bug,雖然它的影響比較惡劣。
如果你判斷你的集合類(lèi)會(huì)被多線程使用,那就可以使用線程安全的ConcurrentHashMap來(lái)替代它。
HashMap還有一個(gè)安全刪除的問(wèn)題,和多線程關(guān)系不大,但它拋出的是ConcurrentModificationException,看起來(lái)像是多線程的問(wèn)題。我們一塊來(lái)看看它。
Map<String, String> map = new HashMap<>(); map.put("xjjdog0", "狗1"); map.put("xjjdog1", "狗2"); for (Map.Entry<String, String> entry : map.entrySet()) { String key = entry.getKey(); if ("xjjdog0".equals(key)) { map.remove(key); } }
上面的代碼會(huì)拋出異常,這是由于HashMap的Fail-Fast機(jī)制。如果我們想要安全的刪除某些元素,應(yīng)該使用迭代器。
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, String> entry = iterator.next(); String key = entry.getKey(); if ("xjjdog0".equals(key)) { iterator.remove(); } }
2.7. 線程安全的保護(hù)范圍
使用了線程安全的類(lèi),寫(xiě)出來(lái)的代碼就一定是線程安全的么?答案是否定的。
線程安全的類(lèi),只負(fù)責(zé)它內(nèi)部的方法是線程安全的。如我我們?cè)谕饷姘阉艘粚?,那么它是否能達(dá)到線程安全的效果,就需要重新探討。
比如下面這種情況,我們使用了線程安全的ConcurrentHashMap來(lái)存儲(chǔ)計(jì)數(shù)。雖然ConcurrentHashMap本身是線程安全的,不會(huì)再出現(xiàn)死循環(huán)的問(wèn)題。但addCounter函數(shù),明顯是不正確的,它需要使用synchronized函數(shù)包裹才行。
private final ConcurrentHashMap<String,Integer> counter; public int addCounter(String name) { Integer current = counter.get(name); int newValue = ++current; counter.put(name,newValue); return newValue; }
這是開(kāi)發(fā)人員常踩的坑之一。要達(dá)到線程安全,需要看一下線程安全的作用范圍。如果更大維度的邏輯存在同步問(wèn)題,那么即使使用了線程安全的集合,也達(dá)不到想要的效果。
2.8. volatile作用有限
volatile關(guān)鍵字,解決了變量的可見(jiàn)性問(wèn)題,可以讓你的修改,立馬讓其他線程給讀到。
雖然這個(gè)東西在面試的時(shí)候問(wèn)的挺多的,包括ConcurrentHashMap中隊(duì)volatile的那些優(yōu)化。但在平常的使用中,你真的可能只會(huì)接觸到boolean變量的值修改。
volatile boolean closed; public void shutdown() { closed = true; }
千萬(wàn)不要把它用在計(jì)數(shù)或者線程同步上,比如下面這樣。
volatile count = 0; void add(){ ++count; }
這段代碼在多線程環(huán)境下,是不準(zhǔn)確的。這是因?yàn)関olatile只保證可見(jiàn)性,不保證原子性,多線程操作并不能保證其正確性。
直接用Atomic類(lèi)或者同步關(guān)鍵字多好,你真的在乎這納秒級(jí)別的差異么?
2.9. 日期處理要小心
很多時(shí)候,日期處理也會(huì)出問(wèn)題。這是因?yàn)槭褂昧巳值腃alendar,SimpleDateFormat等。當(dāng)多個(gè)線程同時(shí)執(zhí)行format函數(shù)的時(shí)候,就會(huì)出現(xiàn)數(shù)據(jù)錯(cuò)亂。
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); Date getDate(String str){ return format(str); }
為了改進(jìn),我們通常將SimpleDateFormat放在ThreadLocal中,每個(gè)線程一份拷貝,這樣可以避免一些問(wèn)題。當(dāng)然,現(xiàn)在我們可以使用線程安全的DateTimeFormatter了。
static DateTimeFormatter FOMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss"); public static void main(String[] args) { ZonedDateTime zdt = ZonedDateTime.now(); System.out.println(FOMATTER.format(zdt)); }
2.10. 不要在構(gòu)造函數(shù)中啟動(dòng)線程
在構(gòu)造函數(shù),或者static代碼塊中啟動(dòng)新的線程,并沒(méi)有什么錯(cuò)誤。但是,強(qiáng)烈不推薦你這么做。
因?yàn)镴ava是有繼承的,如果你在構(gòu)造函數(shù)中做了這種事,那么子類(lèi)的行為將變得非常魔幻。另外,this對(duì)象可能在構(gòu)造完畢之前,出遞到另外一個(gè)地方被使用,造成一些不可預(yù)料的行為。
所以把線程的啟動(dòng),放在一個(gè)普通方法,比如start中,是更好的選擇。它可以減少bug發(fā)生的機(jī)率。
End
wait和notify是非常容易出問(wèn)題的地方,
編碼格式要求非常嚴(yán)格。synchronized關(guān)鍵字相對(duì)來(lái)說(shuō)比較簡(jiǎn)單,但同步代碼塊的時(shí)候依然有許多要注意的點(diǎn)。這些經(jīng)驗(yàn),在concurrent包所提供的各種API中依然實(shí)用。我們還要處理多線程邏輯中遇到的各種異常問(wèn)題,避免中斷,避免死鎖。規(guī)避了這些坑,基本上多線程代碼寫(xiě)起來(lái)就算是入門(mén)了。
許多java開(kāi)發(fā),都是剛剛接觸多線程開(kāi)發(fā),在平常的工作中應(yīng)用也不是很多。如果你做的是crud的業(yè)務(wù)系統(tǒng),那么寫(xiě)一些多線程代碼的時(shí)候就更少了。但總有例外,你的程序變得很慢,或者排查某個(gè)問(wèn)題,你會(huì)直接參與到多線程的編碼中來(lái)。
我們的各種工具軟件,也在大量使用多線程。從Tomcat,到各種中間件,再到各種數(shù)據(jù)庫(kù)連接池緩存等,每個(gè)地方都充斥著多線程的代碼。
即使是有經(jīng)驗(yàn)的開(kāi)發(fā),也會(huì)陷入很多多線程的陷阱。因?yàn)楫惒綍?huì)造成時(shí)序的混亂,必須要通過(guò)強(qiáng)制的手段達(dá)到數(shù)據(jù)的同步。多線程運(yùn)行,首先要保證準(zhǔn)確性,使用線程安全的集合進(jìn)行數(shù)據(jù)存儲(chǔ);還要保證效率,畢竟使用多線程的目標(biāo)就是如此。
希望本文中的這些實(shí)際案例,讓你對(duì)多線程的理解,更上一層樓。
到此這篇關(guān)于Java多線程基本概念以及避坑指南的文章就介紹到這了,更多相關(guān)Java多線程概念及避坑內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
JAVA NIO實(shí)現(xiàn)簡(jiǎn)單聊天室功能
這篇文章主要為大家詳細(xì)介紹了JAVA NIO實(shí)現(xiàn)簡(jiǎn)單聊天室功能,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-11-11SpringMVC實(shí)現(xiàn)RESTful風(fēng)格:@PathVariable注解的使用方式
這篇文章主要介紹了SpringMVC實(shí)現(xiàn)RESTful風(fēng)格:@PathVariable注解的使用方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-11-11springboot 傳參校驗(yàn)@Valid及對(duì)其的異常捕獲方式
這篇文章主要介紹了springboot 傳參校驗(yàn)@Valid及對(duì)其的異常捕獲方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-10-10Java Web端程序?qū)崿F(xiàn)文件下載的方法分享
這篇文章主要介紹了Java Web端程序?qū)崿F(xiàn)文件下載的方法分享,包括一個(gè)包含防盜鏈功能的專(zhuān)門(mén)針對(duì)圖片下載的程序代碼示例,需要的朋友可以參考下2016-05-05編譯大型Java項(xiàng)目class沖突導(dǎo)致報(bào)錯(cuò)的解決方案
這篇文章給大家盤(pán)點(diǎn)編譯大型項(xiàng)目class沖突導(dǎo)致報(bào)錯(cuò)的解決方案,文中通過(guò)代碼示例介紹的非常詳細(xì),具有一定的參考價(jià)值,需要的朋友可以參考下2023-10-10java如何實(shí)現(xiàn)抽取json文件指定字段值
這篇文章主要介紹了java如何實(shí)現(xiàn)抽取json文件指定字段值,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。2022-06-06