十張圖告訴你Java多線程那些破事

這是我參與更文挑戰的第N天,活動詳情查看: 更文挑戰java

頭髮不少的程序員:『師父,這個批量處理接口太慢了,有什麼辦法能夠優化?』

架構師:『試試使用多線程優化』

次日

頭髮不少的程序員:『師父,我已經使用了多線程,爲何接口還變慢了?』

架構師:『去給我買杯咖啡,我寫篇文章告訴你』

……吭哧吭哧買咖啡去了
複製代碼

在實際工做中,錯誤使用多線程非但不能提升效率還可能使程序崩潰。以在路上開車爲例:git

在一個單向行駛的道路上,每輛汽車都遵照交通規則,這時候總體通行是正常的。『單向車道』意味着『一個線程』,『多輛車』意味着『多個job任務』。程序員

單線程順利同行
單線程順利同行

若是須要提高車輛的同行效率,通常的作法就是擴展車道,對應程序來講就是『加線程池』,增長線程數。這樣在同一時間內,通行的車輛數遠遠大於單車道。面試

多線程順利同行
多線程順利同行

然而成年人的世界沒有那麼完美,車道一旦多起來『加塞』的場景就會愈來愈多,出現碰撞後也會影響整條馬路的通行效率。這麼一對比下來『多車道』確實可能比『單車道』要慢。算法

多線程故障
多線程故障

防止汽車頻繁變道加塞能夠採起在車道間增長『護欄』,那在程序的世界該怎麼作呢?編程

程序世界中多線程遇到的問題概括起來就是三類:『線程安全問題』『活躍性問題』『性能問題』,接下來會講解這些問題,以及問題對應的解決手段。安全

線程安全問題

有時候咱們會發現,明明在單線程環境中正常運行的代碼,在多線程環境中可能會出現意料以外的結果,其實這就是你們常說的『線程不安全』。那到底什麼是線程不安全呢?往下看。markdown

原子性多線程

舉一個銀行轉帳的例子,好比從帳戶A向帳戶B轉1000元,那麼必然包括2個操做:從帳戶A減去1000元,往帳戶B加上1000元,兩個操做都成功才意味着一次轉帳最終成功。架構

試想一下,若是這兩個操做不具有原子性,從A的帳戶扣減了1000元以後,操做忽然終止了,帳戶B沒有增長1000元,那問題就大了。

銀行轉帳這個例子有兩個步驟,出現了意外後致使轉帳失敗,說明沒有原子性。

原子性:即一個操做或者多個操做 要麼所有執行而且執行的過程不會被任何因素打斷,要麼就都不執行。

原子操做:即不會被線程調度機制打斷的操做,沒有上下文切換。

在併發編程中不少操做都不是原子操做,出個小題目:

i = 0// 操做1
i++;   // 操做2
i = j; // 操做3
i = i + 1// 操做4
複製代碼

上面這四個操做中有哪些是原子操做,哪些不是的?不熟悉的人可能認爲這些都是原子操做,其實只有操做1是原子操做。

  • 操做1:對基本數據類型變量的賦值是原子操做;
  • 操做2:包含三個操做,讀取i的值,將i加1,將值賦給i;
  • 操做3:讀取j的值,將j的值賦給i;
  • 操做4:包含三個操做,讀取i的值,將i加1,將值賦給i;

在單線程環境下上述四個操做都不會出現問題,可是在多線程環境下,若是不經過加鎖操做,每每可能獲得意料以外的值。

在Java語言中經過可使用synchronize或者lock來保證原子性。

可見性

talk is cheap,先show一段代碼:

/**
* Author: leixiaoshuai
*/

class Test {
  int i = 50;
  int j = 0;
  
  public void update() {
    // 線程1執行
    i = 100;
  }
  
  public int get() {
    // 線程2執行
    j = i;
    return j;
  }
}
複製代碼

線程1執行update方法將 i 賦值爲100,通常狀況下線程1會在本身的工做內存中完成賦值操做,卻沒有及時將新值刷新到主內存中。

這個時候線程2執行get方法,首先會從主內存中讀取i的值,而後加載到本身的工做內存中,這個時候讀取到i的值是50,再將50賦值給j,最後返回j的值就是50了。本來指望返回100,結果返回50,這就是可見性問題,線程1對變量i進行了修改,線程2沒有當即看到i的新值。

可見性:指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。

如上圖每一個線程都有屬於本身的工做內存,工做內存和主內存間須要經過store和load等進行交互。

爲了解決多線程可見性問題,Java語言提供了volatile這個關鍵字。當一個共享變量被volatile修飾時,它會保證修改的值會當即被更新到主存,當有其餘線程須要讀取時,它會去內存中讀取新值。而普通共享變量不能保證可見性,由於變量被修改後何時刷回到主存是不肯定的,另一個線程讀的可能就是舊值。

固然Java的鎖機制如synchronize和lock也是能夠保證可見性的,加鎖能夠保證在同一時刻只有一個線程在執行同步代碼塊,釋放鎖以前會將變量刷回至主存,這樣也就保證了可見性。

關於線程不安全的表現還有『有序性』,這個問題會在後面的文章中深刻講解。

活躍性問題

上面講到爲了解決可見性問題,咱們能夠採起加鎖方式解決,可是若是加鎖使用不當也容易引入其餘問題,好比『死鎖』。

在說『死鎖』前咱們先引入另一個概念:活躍性問題

活躍性是指某件正確的事情最終會發生,當某個操做沒法繼續下去的時候,就會發生活躍性問題。

概念是否是有點拗口,若是看不懂也不要緊,你能夠記住活躍性問題通常有這樣幾類:死鎖活鎖飢餓問題

(1)死鎖

死鎖是指多個線程由於環形的等待鎖的關係而永遠的阻塞下去。一圖勝千語,很少解釋。

(2)活鎖

死鎖是兩個線程都在等待對方釋放鎖致使阻塞。而活鎖的意思是線程沒有阻塞,還活着呢。

當多個線程都在運行而且修改各自的狀態,而其餘線程彼此依賴這個狀態,致使任何一個線程都沒法繼續執行,只能重複着自身的動做和修改自身的狀態,這種場景就是發生了活鎖。

![](/Users/ray/Library/Application Support/typora-user-images/image-20210408232019843.png)

若是你們還有疑惑,那我再舉一個生活中的例子,你們平時在走路的時候,迎面走來一我的,兩我的互相讓路,可是又同時走到了一個方向,若是一直這樣重複着避讓,這倆人就是發生了活鎖,學到了吧,嘿嘿。

(3)飢餓

若是一個線程無其餘異常卻遲遲不能繼續運行,那基本是處於飢餓狀態了。

常見有幾種場景:

  • 高優先級的線程一直在運行消耗CPU,全部的低優先級線程一直處於等待;
  • 一些線程被永久堵塞在一個等待進入同步塊的狀態,而其餘線程老是能在它以前持續地對該同步塊進行訪問;

有一個很是經典的飢餓問題就是哲學家用餐問題,以下圖所示,有五個哲學家在用餐,每一個人必需要同時拿兩把叉子才能夠開始就餐,若是哲學家1和哲學家3同時開始就餐,那哲學家二、四、5就得餓肚子等待了。

性能問題

前面講到了線程安全和死鎖、活鎖這些問題會影響多線程執行過程,若是這些都沒有發生,多線程併發必定比單線程串行執行快嗎,答案是不必定,由於多線程有建立線程線程上下文切換的開銷。

建立線程是直接向系統申請資源的,對操做系統來講建立一個線程的代價是十分昂貴的,須要給它分配內存、列入調度等。

線程建立完以後,還會遇到線程上下文切換

CPU是很寶貴的資源速度也很是快,爲了保證雨露均沾,一般爲給不一樣的線程分配時間片,當CPU從執行一個線程切換到執行另外一個線程時,CPU 須要保存當前線程的本地數據,程序指針等狀態,並加載下一個要執行的線程的本地數據,程序指針等,這個開關被稱爲『上下文切換』。

通常減小上下文切換的方法有:無鎖併發編程CAS 算法使用協程等。

有態度的總結

多線程用好了可讓程序的效率成倍提高,用很差可能比單線程還要慢。

用一張圖總結一下上面講的:

image-20210412234350204

文章講了多線程併發會遇到的問題,你可能也發現了,文章中並無給出具體的解決方案,由於這些問題在Java語言設計過程當中大神都已經爲你考慮過了。

Java併發編程學起來有必定難度,但這也是從初級程序員邁向中高級程序員的必經道路,接下來的文章會帶領你們逐個擊破!

做者:雷小帥

Github 『Java八股文』開源項目做者,專一Java面試套路,Java進階學習,打破內卷拿大廠Offer,升職加薪!

做者簡介:

☕讀過幾年書:華中科技大學碩士畢業;

😂浪過幾個大廠:華爲、網易、百度……

😘一直堅信技術能改變世界,願保持初心,加油技術人!

相關文章
相關標籤/搜索