文章插圖

文章插圖
多核的機器,現在已經非常常見了 。即使是一塊手機,也都配備了強勁的多核處理器 。通過多進程和多線程的手段,就可以讓多個CPU同時工作,來加快任務的執行 。
多線程,是編程中一個比較高級的話題 。由于它涉及到共享資源的操作,所以在編碼時非常容易出現問題 。Java的concurrent包,提供了非常多的工具,來幫助我們簡化這些變量的同步,但學習應用之路依然充滿了曲折 。
本篇文章,將簡單的介紹一下Java中多線程的基本知識 。然后著重介紹一下初學者在多線程編程中一些最容易出現問題的地方,很多都是血淚經驗 。規避了這些坑,就相當于規避了90%兇殘的多線程bug 。
1. 多線程基本概念1.1 輕量級進程
在JVM中,一個線程,其實是一個輕量級進程(LWP) 。所謂的輕量級進程,其實是用戶進程調用系統內核,所提供的一套接口 。實際上,它還要調用更加底層的內核線程(KLT) 。
實際上,JVM的線程創建銷毀以及調度等,都是依賴于操作系統的 。如果你看一下Thread類里面的多個函數,你會發現很多都是native的,直接調用了底層操作系統的函數 。
下圖是JVM在Linux上簡單的線程模型 。
可以看到,不同的線程在進行切換的時候,會頻繁在用戶態和內核態進行狀態轉換 。這種切換的代價是比較大的,也就是我們平常所說的上下文切換(Context Switch) 。
1.2 JMM
在介紹線程同步之前,我們有必要介紹一個新的名詞,那就是JVM的內存模型JMM 。
JMM并不是說堆、metaspace這種內存的劃分,它是一個完全不同的概念,指的是與線程相關的Java運行時線程內存模型 。
由于Java代碼在執行的時候,很多指令都不是原子的,如果這些值的執行順序發生了錯位,就會獲得不同的結果 。比如,i++的動作就可以翻譯成以下的字節碼 。
getfield//Fieldvalue:Iiconst_1iaddputfield//Fieldvalue:I這還只是代碼層面的 。如果再加上CPU每核的各級緩存,這個執行過程會變得更加細膩 。如果我們希望執行完i++之后,再執行i–,僅靠初級的字節碼指令,是無法完成的 。我們需要一些同步手段 。上圖就是JMM的內存模型,它分為主存儲器(Main Memory)和工作存儲器(Working Memory)兩種 。我們平常在Thread中操作這些變量,其實是操作的主存儲器的一個副本 。當修改完之后,還需要重新刷到主存儲器上,其他的線程才能夠知道這些變化 。
1.3 Java中常見的線程同步方式
為了完成JMM的操作,完成線程之間的變量同步,Java提供了非常多的同步手段 。
Java的基類Object中,提供了wait和notify的原語,來完成monitor之間的同步 。不過這種操作我們在業務編程中很少遇見使用synchronized對方法進行同步,或者鎖住某個對象以完成代碼塊的同步使用concurrent包里面的可重入鎖 。這套鎖是建立在AQS之上的使用volatile輕量級同步關鍵字,實現變量的實時可見性使用Atomic系列,完成自增自減使用ThreadLocal線程局部變量,實現線程封閉使用concurrent包提供的各種工具,比如LinkedBlockingQueue來實現生產者消費者 。本質還是AQS使用Thread的join,以及各種await方法,完成并發任務的順序執行
從上面的描述可以看出,多線程編程要學的東西可實在太多了 。幸運的是,同步方式雖然千變萬化,但我們創建線程的方式卻沒幾種 。
第一類就是Thread類 。大家都知道有兩種實現方式 。第一可以繼承Thread覆蓋它的run方法;第二種是實現Runnable接口,實現它的run方法;而第三種創建線程的方法,就是通過線程池 。
其實,到最后,就只有一種啟動方式,那就是Thread 。線程池和Runnable,不過是一種封裝好的快捷方式罷了 。
多線程這么復雜,這么容易出問題,那常見的都有那些問題,我們又該如何避免呢?下面,我將介紹10個高頻出現的坑,并給出解決方案 。
2. 避坑指南
2.1. 線程池打爆機器
首先,我們聊一個非常非常低級,但又產生了嚴重后果的多線程錯誤 。
通常,我們創建線程的方式有Thread,Runnable和線程池三種 。隨著Java1.8的普及,現在最常用的就是線程池方式 。
有一次,我們線上的服務器出現了僵死,就連遠程ssh,都登錄不上,只能無奈的重啟 。大家發現,只要啟動某個應用,過不了幾分鐘,就會出現這種情況 。最終定位到了幾行讓人啼笑皆非的代碼 。
有位對多線程不太熟悉的同學,使用了線程池去異步處理消息 。通常,我們都會把線程池作為類的靜態變量,或者是成員變量 。但是這位同學,卻將它放在了方法內部 。也就是說,每當有一個請求到來的時候,都會創建一個新的線程池 。當請求量一增加,系統資源就被耗盡,最終造成整個機器的僵死 。
voidrealJob(){ThreadPoolExecutorexe=newThreadPoolExecutor(...);exe.submit(newRunnable(){...})}這種問題如何去避免?只能通過代碼review 。所以多線程相關的代碼,哪怕是非常簡單的同步關鍵字,都要交給有經驗的人去寫 。即使沒有這種條件,也要非常仔細的對這些代碼進行review 。2.2. 鎖要關閉
相比較synchronized關鍵字加的獨占鎖,concurrent包里面的Lock提供了更多的靈活性 ??梢愿鶕枰?,選擇公平鎖與非公平鎖、讀鎖與寫鎖 。
但Lock用完之后是要關閉的,也就是lock和unlock要成對出現,否則就容易出現鎖泄露,造成了其他的線程永遠了拿不到這個鎖 。
如下面的代碼,我們在調用lock之后,發生了異常,try中的執行邏輯將被中斷,unlock將永遠沒有機會執行 。在這種情況下,線程獲取的鎖資源,將永遠無法釋放 。
privatefinalLocklock=newReentrantLock();voiddoJob(){try{lock.lock();//發生了異常lock.unlock();}catch(Exceptione){}}正確的做法,就是將unlock函數,放到finally塊中,確保它總是能夠執行 。由于lock也是一個普通的對象,是可以作為函數的參數的 。如果你把lock在函數之間傳來傳去的,同樣會有時序邏輯混亂的情況 。在平時的編碼中,也要避免這種把lock當參數的情況 。
2.3. wait要包兩層
Object作為Java的基類,提供了四個方法wait wait(timeout) notify notifyAll ,用來處理線程同步問題,可以看出wait等函數的地位是多么的高大 。在平常的工作中,寫業務代碼的同學使用這些函數的機率是比較小的,所以一旦用到很容易出問題 。
但使用這些函數有一個非常大的前提,那就是必須使用synchronized進行包裹,否則會拋出IllegalMonitorStateException 。比如下面的代碼,在執行的時候就會報錯 。
finalObjectcondition=newObject();publicvoidfunc(){condition.wait();}類似的方法,還有concurrent包里的Condition對象,使用的時候也必須出現在lock和unlock函數之間 。為什么在wait之前,需要先同步這個對象呢?因為JVM要求,在執行wait之時,線程需要持有這個對象的monitor,顯然同步關鍵字能夠完成這個功能 。
但是,僅僅這么做,還是不夠的,wait函數通常要放在while循環里才行,JDK在代碼里做了明確的注釋 。
重點:這是因為,wait的意思,是在notify的時候,能夠向下執行邏輯 。但在notify的時候,這個wait的條件可能已經是不成立的了,因為在等待的這段時間里條件條件可能發生了變化,需要再進行一次判斷,所以寫在while循環里是一種簡單的寫法 。
finalObjectcondition=newObject();publicvoidfunc(){synchronized(condition){while(<條件成立>){condition.wait();}}}帶if條件的wait和notify要包兩層,一層synchronized,一層while,這就是wait等函數的正確用法 。2.4. 不要覆蓋鎖對象
使用synchronized關鍵字時,如果是加在普通方法上的,那么鎖的就是this對象;如果是加載static方法上的,那鎖的就是class 。除了用在方法上,synchronized還可以直接指定要鎖定的對象,鎖代碼塊,達到細粒度的鎖控制 。
如果這個鎖的對象,被覆蓋了會怎么樣?比如下面這個 。
Listlisteners=newArrayList();voidadd(Listenerlistener,booleanupsert){synchronized(listeners){Listresults=newArrayList();for(Listenerler:listeners){...}listeners=results;}}上面的代碼,由于在邏輯中,強行給鎖listeners對象進行了重新賦值,會造成鎖的錯亂或者失效 。為了保險起見,我們通常把鎖對象聲明成final類型的 。
finalListlisteners=newArrayList();或者直接聲明專用的鎖對象,定義成普通的Object對象即可 。finalObjectlistenersLock=newObject();2.5. 處理循環中的異常在異步線程里處理一些定時任務,或者執行時間非常長的批量處理,是經常遇到的需求 。我就不止一次看到小伙伴們的程序執行了一部分就停止的情況 。
排查到這些中止的根本原因,就是其中的某行數據發生了問題,造成了整個線程的死亡 。
我們還是來看一下代碼的模板 。
volatilebooleanrun=true;voidloop(){while(run){for(Tasktask:taskList){//do.sthinta=1/0;}}}在loop函數中,執行我們真正的業務邏輯 。當執行到某個task的時候,發生了異常 。這個時候,線程并不會繼續運行下去,而是會拋出異常直接中止 。在寫普通函數的時候,我們都知道程序的這種行為,但一旦到了多線程,很多同學都會忘了這一環 。值得注意的是,即使是非捕獲類型的NullPointerException,也會引起線程的中止 。所以,時刻把要執行的邏輯,放在try catch中,是個非常好的習慣 。
volatilebooleanrun=true;voidloop(){while(run){for(Tasktask:taskList){try{//do.sthinta=1/0;}catch(Exceptionex){//log}}}}2.6. HashMap正確用法HashMap在多線程環境下,會產生死循環問題 。這個問題已經得到了廣泛的普及,因為它會產生非常嚴重的后果:CPU跑滿,代碼無法執行,jstack查看時阻塞在get方法上 。
至于怎么提高HashMap效率,什么時候轉紅黑樹轉列表,這是陽春白雪的八股界話題,我們下里巴人只關注怎么不出問題 。
網絡上有詳細的文章描述死循環問題產生的場景,大體因為HashMap在進行rehash時,會形成環形鏈 。某些get請求會走到這個環上 。JDK并不認為這是個bug,雖然它的影響比較惡劣 。
如果你判斷你的集合類會被多線程使用,那就可以使用線程安全的ConcurrentHashMap來替代它 。
HashMap還有一個安全刪除的問題,和多線程關系不大,但它拋出的是ConcurrentModificationException,看起來像是多線程的問題 。我們一塊來看看它 。
Map<String,String>map=newHashMap<>();map.put("xjjdog0","狗1");map.put("xjjdog1","狗2");for(Map.Entry<String,String>entry:map.entrySet()){Stringkey=entry.getKey();if("xjjdog0".equals(key)){map.remove(key);}}上面的代碼會拋出異常,這是由于HashMap的Fail-Fast機制 。如果我們想要安全的刪除某些元素,應該使用迭代器 。【java有哪些常見的線程池 java線程池有幾種線程】
Iterator<Map.Entry<String,String>>iterator=map.entrySet().iterator();while(iterator.hasNext()){Map.Entry<String,String>entry=iterator.next();Stringkey=entry.getKey();if("xjjdog0".equals(key)){iterator.remove();}}2.7. 線程安全的保護范圍使用了線程安全的類,寫出來的代碼就一定是線程安全的么?答案是否定的 。
線程安全的類,只負責它內部的方法是線程安全的 。如我我們在外面把它包了一層,那么它是否能達到線程安全的效果,就需要重新探討 。
比如下面這種情況,我們使用了線程安全的ConcurrentHashMap來存儲計數 。雖然ConcurrentHashMap本身是線程安全的,不會再出現死循環的問題 。但addCounter函數,明顯是不正確的,它需要使用synchronized函數包裹才行 。
privatefinalConcurrentHashMap<String,Integer>counter;publicintaddCounter(Stringname){Integercurrent=counter.get(name);intnewValue=http://www.mnbkw.com/jxjc/189345/++current;counter.put(name,newValue);returnnewValue;}這是開發人員常踩的坑之一 。要達到線程安全,需要看一下線程安全的作用范圍 。如果更大維度的邏輯存在同步問題,那么即使使用了線程安全的集合,也達不到想要的效果 。2.8. volatile作用有限
volatile關鍵字,解決了變量的可見性問題,可以讓你的修改,立馬讓其他線程給讀到 。
雖然這個東西在面試的時候問的挺多的,包括ConcurrentHashMap中隊volatile的那些優化 。但在平常的使用中,你真的可能只會接觸到boolean變量的值修改 。
volatilebooleanclosed;publicvoidshutdown(){closed=true;}千萬不要把它用在計數或者線程同步上,比如下面這樣 。volatilecount=0;voidadd(){++count;}這段代碼在多線程環境下,是不準確的 。這是因為volatile只保證可見性,不保證原子性,多線程操作并不能保證其正確性 。直接用Atomic類或者同步關鍵字多好,你真的在乎這納秒級別的差異么?
2.9. 日期處理要小心
很多時候,日期處理也會出問題 。這是因為使用了全局的Calendar,SimpleDateFormat等 。當多個線程同時執行format函數的時候,就會出現數據錯亂 。
SimpleDateFormatformat=newSimpleDateFormat("yyyy-MM-ddhh:mm:ss");DategetDate(Stringstr){returnformat(str);}為了改進,我們通常將SimpleDateFormat放在ThreadLocal中,每個線程一份拷貝,這樣可以避免一些問題 。當然,現在我們可以使用線程安全的DateTimeFormatter了 。staticDateTimeFormatterFOMATTER=DateTimeFormatter.ofPattern("MM/dd/yyyyHH:mm:ss");publicstaticvoidmain(String[]args){ZonedDateTimezdt=ZonedDateTime.now();System.out.println(FOMATTER.format(zdt));}2.10. 不要在構造函數中啟動線程在構造函數,或者static代碼塊中啟動新的線程,并沒有什么錯誤 。但是,強烈不推薦你這么做 。
因為Java是有繼承的,如果你在構造函數中做了這種事,那么子類的行為將變得非常魔幻 。另外,this對象可能在構造完畢之前,出遞到另外一個地方被使用,造成一些不可預料的行為 。
所以把線程的啟動,放在一個普通方法,比如start中,是更好的選擇 。它可以減少bug發生的機率 。
End
wait和notify是非常容易出問題的地方,
編碼格式要求非常嚴格 。synchronized關鍵字相對來說比較簡單,但同步代碼塊的時候依然有許多要注意的點 。這些經驗,在concurrent包所提供的各種API中依然實用 。我們還要處理多線程邏輯中遇到的各種異常問題,避免中斷,避免死鎖 。規避了這些坑,基本上多線程代碼寫起來就算是入門了 。
許多java開發,都是剛剛接觸多線程開發,在平常的工作中應用也不是很多 。如果你做的是crud的業務系統,那么寫一些多線程代碼的時候就更少了 。但總有例外,你的程序變得很慢,或者排查某個問題,你會直接參與到多線程的編碼中來 。
我們的各種工具軟件,也在大量使用多線程 。從Tomcat,到各種中間件,再到各種數據庫連接池緩存等,每個地方都充斥著多線程的代碼 。
即使是有經驗的開發,也會陷入很多多線程的陷阱 。因為異步會造成時序的混亂,必須要通過強制的手段達到數據的同步 。多線程運行,首先要保證準確性,使用線程安全的集合進行數據存儲;還要保證效率,畢竟使用多線程的目標就是如此 。
希望本文中的這些實際案例,讓你對多線程的理解,更上一層樓 。
- 伊麗莎白二世是蜥蜴人 有蜥蜴人嗎
- 真正的美人魚視頻 有真正的美人魚嗎
- 有靈異體質的人幾月生
- 有沒有蜥蜴人的電影 有沒有蜥蜴人
- 獨角獸有沒有真實存在過 有沒有真的獨角獸
- 存在外星人的有力證據 有沒有外星人存在的證據
- SaaS軟件廠商都有哪些? saas軟件行業發展前景
- 活佛有什么特異功能 有沒有什么特異功能
- 世界上有沒有ufo圖片 有沒有ufo的圖片
- 山東ufo不明飛行物 有沒有ufo不明飛行物的新聞
