搞懂Java併發編程的四個問題

前言

首先感謝優秀的極客時間專欄《Java併發編程實戰》,本篇文章都是學習了這個專欄以後的一些總結和本身的思考,附上我總結的專欄重點知識筆記:併發專欄重要知識點。知道這些理論基礎後,學習併發相關的其餘知識點就上手得很快了。這是第一篇文章,有不足之處還望讀者多多指出,你們共同進步。html

併發編程在各種開發語言中都屬於相對高階的地位,這意味着併發編程使用起來有必定門檻,並且極可能一個不當心寫出Bug還不知道哪裏出了問題。今天我就來講說併發編程,在知道它的一些本質原理以後,無論是本身在實際項目中寫併發編程的代碼,仍是面試中遇到併發編程程相關的問題,都能內心不慌,細細分析一波,找到可能出現Bug的地方。面試

img

若是如今有個需求,讓你實現本地文件批量上傳,你會怎麼設計?編程

反手來個線程池,把任務丟進去異步上傳。幾乎是條件反射,像文件上傳這麼耗時的操做固然開個子線程。尤爲在Android中,若是在主線程作耗時操做,很容易致使ANR。你們都知道爲何要用多線程,由於不能阻塞主線程,多幾個線程併發交替執行任務,提升執行效率。緩存

那麼問題來了,實際上多個線程併發執行,同一個時刻也只有一個線程在執行,只不過多個線程快速地交替執行而已。這樣看來多個線程各執行一個任務的消耗時間,跟單線程執行多個任務的消耗時間理論上是同樣的,並且多線程開發還多了線程上下文切換的時間,看起來更耗時啊。安全

終於引出了今天的第一個問題,那爲何還要用併發編程?bash

爲何要用併發編程?

上面講的場景用單線程去執行多個任務確實更高效更安全,少了線程切換的時間,也不存在線程安全問題。可是若是任務中要去執行IO操做,狀況就不同了。網絡

若是要讀文件,CPU就發個命令讓設備驅動去幹活,也就是執行IO操做。CPU發完命令後就處於空閒狀態,只能乾等,等IO操做結束後,CPU再接着執行後續任務,這樣CPU的利用率就大大下降了。多線程

爲了在IO等待的時候不讓CPU閒着,咱們就把任務拆分交替執行。一個線程執行到IO操做時,CPU空閒了,另外一個線程正好能得到CPU時間片。放個圖方便理解:併發

image-20191017165055880

知道併發編程的好處以後,咱們來看下一個問題。app

怎麼寫好併發編程?

從全局的角度來看,併發編程能夠總結爲三個核心問題:分工、同步、互斥。

分工就是咱們前面介紹的,把任務拆解分配給線程,具備這樣特性的系統叫作分時操做系統。

分工以後,CPU利用率上來了,配合默契地協做能力在團隊工做中也是必不可少的,爲了對任務進行更好的組織編排,好比一個線程執行完了一個任務,再通知執行後續任務的線程開工,這就須要執行任務的線程之間就須要互相通訊。所以操做系統提供了一套線程通訊的方案,也就是線程同步

有了分工和同步,就能夠愉快地編寫高效的併發程序了,但還有一個深坑,若是多線程對同一個資源進行讀寫,而且這個資源尚未保護措施,這時候就會引起線程安全問題,也就是說這個程序的執行結果是不肯定的。咱們必需要保證同一時刻只有一個線程訪問共享資源,也就是互斥

高效地分工、在合適的時機同步、正確地互斥,任何併發編程的問題均可以從這三個方面考慮。說這個問題主要是讓你們創建一種全局觀,能從宏觀的角度去處理併發任務。

接下來看第三個問題,爲何在單線程下跑得好好的代碼,一到併發環境下就Bug頻出呢?究竟是什麼致使併發編程的Bug?

是什麼致使併發編程的Bug?

線程不安全的本質就是一個線程對變量A進行寫操做的時候(寫操做還未完成),另外一個線程對變量A進行了讀寫操做。這裏引出三個概念:原子性、可見性和有序性,他們仨就是罪魁禍首,具體表明什麼意思,等會再講。

爲了能充分協調CPU、內存和I/O設備三者的速度差別,計算機也是拼了老命在優化了,好比下面這些:

  • CPU增長了緩存,均衡CPU與內存的速度差別。

  • 操做系統增長了進程、線程,以分時複用CPU,均衡CPU與I/O設備速度差別。

  • 編譯程序優化指令執行次序,使得緩存可以獲得更加合理地利用。

    • 好比第1行: a=10; 第100行,a=a+1,經過重排序,把他們放在一塊兒執行,少了一步讀取a的操做了,直接拿10進行計算。

雖然計算機的性能獲得了提高,但這也是併發編程Bug的源頭,以上的三個優化也帶了三個問題:

1. 緩存致使了可見性問題

可見性,主要是針對共享變量而言,具有可見性意味着一個線程對共享變量的修改,另外一個線程可以馬上看到。正是由於CPU使用了緩存,會先從內存中讀取值存入緩存,下次用的時候直接從緩存中取,速度更快,可是多個線程可能在不一樣的CPU執行,這時候線程1對共享變量A的修改,對線程2而言就不具有可見性了。放張圖方便理解:

image-20191018183844133

如圖所示,線程1和線程2一開始分別從內存中讀取了共享變量A的值存到CPU緩存裏,以後線程1對A作了修改,並把值刷新到了內存中,此時線程2再從CPU緩存中讀到的A值已經不是最新的值了。這就叫存在可見性問題。

2. 線程切換帶來了原子性問題

一個或者多個操做在CPU執行的過程當中不被中斷的特性叫原子性。操做系統作任務切換,能夠發生在任何一條CPU指令執行完,而不是高級語言裏的一條語句。好比最多見的A = A + 1就不具有原子性,由於完成這條語句須要三個動做, 取值 -> 加一 -> 賦值,那麼可能在執行第二個動做的時候,發生了線程切換,另一個線程修改了A的值,問題就來了。

image

3. 編譯優化致使了有序性問題

程序按照代碼的前後順序執行就叫有序性。前面說了編譯程序爲了更好地利用緩存,會對代碼進行重排序。最經典的例子就是新建對象。

建立對象的new操做對應的CPU語句是:

  1. 分配一塊內存M
  2. 在內存M上初始化對象
  3. 將M的地址賦值給對象變量instance

正常代碼執行順序就是一、二、3,可是通過重排序後,2和3的順序可能會顛倒。(???順序顛倒能不出錯嗎?)若是在單線程中,是不會有問題的,由於無論你123,仍是321,最終執行完new操做後對象都是初始化好了的,編譯程序對代碼進行重排序也是爲了更好的利用計算機資源,它是可以保證程序的運行結果在單線程中是正確的。

可是在多線程中就可能有問題了,當線程1執行完語句3還未執行語句2時,切換線程,線程B判斷到instance已經不爲null,就直接使用了,實際上instance指向的對象尚未初始化,此時就可能觸發空指針異常。

image

知道了併發Bug的源頭,那Java自身又是怎麼設計的去避免這些問題呢?Java又提供了哪些語言特性讓開發者解決這些問題呢?

Java如何保證併發安全?

1. Java內存模型:保證可見性和有序性

致使可見性的緣由是緩存,致使有序性的緣由是編譯優化,那解決可見性、有序性最直接的辦法就是禁用緩存和編譯優化,可是這樣問題雖然解決了,咱們程序的性能可就堪憂了。合理的方案應該是按需禁用緩存以及編譯優化。Java 內存模型規範了 JVM 如何提供按需禁用緩存和編譯優化的方法。具體來講,這些方法包括 volatile、synchronized 和 final 三個關鍵字,以及六項 Happens-Before 規則。其中volatile、synchronized 和 final的用法這裏不細說了,提及來能夠寫三篇文章了,詳細用法網絡上有不少文章能夠參考。我說一下Happens-Before規則。

Happens-Before規則

Happens-Before規則大概內容以下:

  1. 程序的順序性規則:指在一個線程中,按照程序順序,前面的操做Happens-Before於後續的任意操做。
  2. volatile變量規則:指對一個volatile變量的寫操做,Happens-Before於後續對這個volatile變量的讀操做。
  3. 傳遞性:指若是A Happens-Before B,B Happens-Before C,那麼A Happens-Before C。
  4. 管程中鎖的規則:指對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。
  5. 線程start()規則:指主線程 A 啓動子線程 B 後,子線程 B 可以看到主線程在啓動子線程 B 前的操做。
  6. 線程join()規則:指主線程 A 等待子線程 B 完成(主線程 A 經過調用子線程 B 的 join() 方法實現),當子線程 B 完成後(主線程 A 中 join() 方法返回),主線程可以看到子線程的操做。

第一次接觸Happens-Before規則時,個人心裏

image

這一大堆規則究竟是幹嗎的呢?是由於市面上有不少種編譯器,編譯器們能夠發揮本身的想象盡情優化程序,可是前提是優化後的程序必定要遵照全部的Happens-Before 規則。Java提供了這樣一堆規則去約束編譯器的行爲,以保證併發程序的正確性。實際上Happens-Before語義本質上就是一種可見性,A Happens-Before B 意味着 A 事件對 B 事件來講是可見的,不管 A 事件和 B 事件是否發生在同一個線程裏。

舉一個規則4的代碼例子,能理解更清楚點:

sychronized(obj){ //加鎖
    //對共享變量進行修改
    a = 123;
}//隱式解鎖
複製代碼

規則4的意思就是,若是線程A進入了sychronized塊,對共享變量進行了修改,而後又退出了sychronized塊,接着線程B進入sychronized塊,此時可以保證線程B讀取到的共享變量的值是a=123,也就是說能看到線程A在sychronized中對共享變量的修改。 若是隻是一段未加鎖的代碼,是不能保證可見性的。這就是Happens-Before規則的意義。

2. 互斥鎖:保證原子性

前面說過線程切換帶來了原子性,互斥鎖能夠鎖住一塊代碼區域,保證只有拿到鎖的線程能夠進入區域內,而且區域內同一時刻只容許一個線程進入,這種區域有個學名叫作臨界區。這種用鎖去保護資源的模型,在現實生活中也隨處可見。Java提供了synchronized關鍵字實現互斥鎖的功能,線程在synchronized塊中,即便發生了線程切換,線程持有的鎖也不會釋放。Java併發包還提供了Lock相關的併發工具類。所以咱們只要把對共享變量的相關操做都用鎖封裝起來,就能保證同一時刻只有一個線程對共享變量進行操做。模型如圖所示:

image
相關文章
相關標籤/搜索