使用多線程可能會遇到的問題


圖解線程

在實際開發過程中,錯誤的使用多線程非但不能提高效率還可能會導致程序崩潰,以在路上開車為例:

在一個單向行駛的道路上,每輛車都遵守交通規則,這時候整體通行是正常的,[單向通道]意味着[一個線程],[多輛車]意味着[多個job]
單線程順利執行

如果需要提升車輛的同行效率,一般的做法就是拓展車道,對應程序而言就是[加線程池],增加線程數,這樣在同一時間內,通行的車輛數遠遠大於單車道
多車道順利同行

然而並不會這么完美,車到一旦多起來,[加塞]的場景就會出現,出現碰撞的概覽也會變大,從而影響整條馬路的通行效率,這么一對比,[多車道]確實可能比[單車道]要慢
多線程故障
防止車輛加塞,可以增加護欄,而再程序中如何使用
程序中來解決一共可以分為三類:[線程安全問題],[線程活躍問題],[性能問題]

線程安全問題

有時候我們會發現,明明單線程環境中運行的代碼,在多線程環境中可能會出現意料之外的結果,其實這就是大家說的[線程不安全問題]

原子性

舉一個銀行轉賬的栗子,比如從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:包含3個操作,讀取i的值,將i加1,將值賦給i
  • 操作3:包含2個操作,讀取j的值,將j的值賦給i
  • 操作4:包含3個操作,讀取i的值,將i加1,將值賦給i

在單線程環境中這4個操作都不會出現問題,但是在多線程環境中,如果不通過加鎖操作,往往很可能會出現意料之外的值

在java中可以通過synchronized或者lock來保證原子性

可見性

先上一段代碼:

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了,原本期望是返回100,這就是可見性問題,線程1對變量i進行了修改,但是線程2沒有立即看到i的新值

可見性:指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即得到這個修改的值
可見性問題

如上圖所示,每個線程都有自己的工作內存,工作內存和主存間要通過store和load進行交互
為了解決多線程的可見性問題,java提供了volatile關鍵字,當一個共享變量被volatile修飾時,他會保證修改的值會立即更新到主存,當有其他線程需要讀取時,他會去主存中讀取新值,而普通共享變量不能保證其可見性,因為變量被修改后刷回到主存的時間是不確定的

當然java的鎖機制,如synchronized和lock也是可以保證其可見性的,加鎖可以保證在同一時刻只有一個線程在執行同步代碼塊,釋放鎖之前會將變量刷回主存,這樣就保證了數據的可見性
關於線程不安全的表現還有[安全性]

上面講到為了解決可見性問題,我們可以采取加鎖的方式解決,但是如果加鎖使用不當也容易引起其他問題,比如死鎖
在說死鎖問題之前,我們先引入一個概念:活躍性問題

活躍性是指某件正確的事情終究會發生,當某個操作無法繼續進行下去的時候,就會發生活躍性問題

概念比較拗口,看不懂也沒關系,可以記住活躍性問題一般有這樣幾類:死鎖,活鎖,飢餓鎖

死鎖

死鎖是因為循環依賴而導致程序永遠無法繼續下去的問題,如圖所示:
死鎖問題

活鎖

死鎖是兩個線程都在等待對方釋放鎖導致阻塞,而活鎖的意思是線程沒有阻塞,還活着呢
當多個線程都在運行並且修改各自的狀態,而其他線程彼此依賴這個狀態,導致任何一個線程都無法繼續執行,只能重復着自身的動作和修改自身的狀態,這種場景就是發生了活鎖
其實也可以這么理解:馬路中間有條小橋,只能容納一輛車經過,橋兩頭開來兩輛車A和B,A比較禮貌,示意B先過,B也比較禮貌,示意A先過,結果兩人一直謙讓誰也過不去

飢餓

如果一個線程無其他異常,但是卻遲遲不能繼續運行,那基本是處於飢餓狀態了
常見的場景:

  • 高優先級的線程一直在運行,消耗cpu資源,所有的低優先級線程一直處於等待
  • 一些線程被永久阻塞在一個等待進入同步快的狀態,而其他線程總能在他之前持續的對同步塊進行訪問

有一個經典的飢餓問題:哲學家用餐問題:如圖所示:有5個哲學家在用餐,每個人必須要同時拿兩把叉子才可以開始用餐,如果1和3同時開始用餐,那么2,4,5就需要餓肚子等待了
哲學家就餐問題

性能問題

前面講了線程安全和死鎖,活鎖這些問題,會影響多線程的執行,如果這些都沒有發生,多線程並發一定會快嗎?其實不一定,因為多線程有線程創建線程上下文切換的開銷
創建線程是直接向操作系統申請資源,對操作系統來說創建一個線程的代價是十分昂貴的,需要給他分配內存,列入調度任務等
線程創建完了之后,還會遇到線程上下文切換
cpu執行
CPU是很寶貴的資源,速度也非常快,為了保證均衡,通常會給不同的線程分配時間片,當CPU從一個線程切換到另外一個線程的時候,CPU需要保存當前線程的本地數據,程序指針等狀態,並加載下一個要執行的線程的本地數據,程序指針等,這個切換稱之為上下文切換
一般減少上下文切換的方法有:無鎖並發編程CAS算法使用協程等方式

總結

多線程用好了可以成倍的增加效率,用不好可能比單線程還慢
用一張圖總結:
總結


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM