首先感謝優秀的極客時間專欄《Java併發編程實戰》,本篇文章都是學習了這個專欄以後的一些總結和本身的思考,附上我總結的專欄重點知識筆記:併發專欄重要知識點。知道這些理論基礎後,學習併發相關的其餘知識點就上手得很快了。這是第一篇文章,有不足之處還望讀者多多指出,你們共同進步。html
併發編程在各種開發語言中都屬於相對高階的地位,這意味着併發編程使用起來有必定門檻,並且極可能一個不當心寫出Bug還不知道哪裏出了問題。今天我就來講說併發編程,在知道它的一些本質原理以後,無論是本身在實際項目中寫併發編程的代碼,仍是面試中遇到併發編程程相關的問題,都能內心不慌,細細分析一波,找到可能出現Bug的地方。面試
若是如今有個需求,讓你實現本地文件批量上傳,你會怎麼設計?編程
反手來個線程池,把任務丟進去異步上傳。幾乎是條件反射,像文件上傳這麼耗時的操做固然開個子線程。尤爲在Android中,若是在主線程作耗時操做,很容易致使ANR。你們都知道爲何要用多線程,由於不能阻塞主線程,多幾個線程併發交替執行任務,提升執行效率。緩存
那麼問題來了,實際上多個線程併發執行,同一個時刻也只有一個線程在執行,只不過多個線程快速地交替執行而已。這樣看來多個線程各執行一個任務的消耗時間,跟單線程執行多個任務的消耗時間理論上是同樣的,並且多線程開發還多了線程上下文切換的時間,看起來更耗時啊。安全
終於引出了今天的第一個問題,那爲何還要用併發編程?bash
上面講的場景用單線程去執行多個任務確實更高效更安全,少了線程切換的時間,也不存在線程安全問題。可是若是任務中要去執行IO操做,狀況就不同了。網絡
若是要讀文件,CPU就發個命令讓設備驅動去幹活,也就是執行IO操做。CPU發完命令後就處於空閒狀態,只能乾等,等IO操做結束後,CPU再接着執行後續任務,這樣CPU的利用率就大大下降了。多線程
爲了在IO等待的時候不讓CPU閒着,咱們就把任務拆分交替執行。一個線程執行到IO操做時,CPU空閒了,另外一個線程正好能得到CPU時間片。放個圖方便理解:併發
知道併發編程的好處以後,咱們來看下一個問題。app
從全局的角度來看,併發編程能夠總結爲三個核心問題:分工、同步、互斥。
分工就是咱們前面介紹的,把任務拆解分配給線程,具備這樣特性的系統叫作分時操做系統。
分工以後,CPU利用率上來了,配合默契地協做能力在團隊工做中也是必不可少的,爲了對任務進行更好的組織編排,好比一個線程執行完了一個任務,再通知執行後續任務的線程開工,這就須要執行任務的線程之間就須要互相通訊。所以操做系統提供了一套線程通訊的方案,也就是線程同步。
有了分工和同步,就能夠愉快地編寫高效的併發程序了,但還有一個深坑,若是多線程對同一個資源進行讀寫,而且這個資源尚未保護措施,這時候就會引起線程安全問題,也就是說這個程序的執行結果是不肯定的。咱們必需要保證同一時刻只有一個線程訪問共享資源,也就是互斥。
高效地分工、在合適的時機同步、正確地互斥,任何併發編程的問題均可以從這三個方面考慮。說這個問題主要是讓你們創建一種全局觀,能從宏觀的角度去處理併發任務。
接下來看第三個問題,爲何在單線程下跑得好好的代碼,一到併發環境下就Bug頻出呢?究竟是什麼致使併發編程的Bug?
線程不安全的本質就是一個線程對變量A進行寫操做的時候(寫操做還未完成),另外一個線程對變量A進行了讀寫操做。這裏引出三個概念:原子性、可見性和有序性,他們仨就是罪魁禍首,具體表明什麼意思,等會再講。
爲了能充分協調CPU、內存和I/O設備三者的速度差別,計算機也是拼了老命在優化了,好比下面這些:
CPU增長了緩存,均衡CPU與內存的速度差別。
操做系統增長了進程、線程,以分時複用CPU,均衡CPU與I/O設備速度差別。
編譯程序優化指令執行次序,使得緩存可以獲得更加合理地利用。
雖然計算機的性能獲得了提高,但這也是併發編程Bug的源頭,以上的三個優化也帶了三個問題:
可見性,主要是針對共享變量而言,具有可見性意味着一個線程對共享變量的修改,另外一個線程可以馬上看到。正是由於CPU使用了緩存,會先從內存中讀取值存入緩存,下次用的時候直接從緩存中取,速度更快,可是多個線程可能在不一樣的CPU執行,這時候線程1對共享變量A的修改,對線程2而言就不具有可見性了。放張圖方便理解:
如圖所示,線程1和線程2一開始分別從內存中讀取了共享變量A的值存到CPU緩存裏,以後線程1對A作了修改,並把值刷新到了內存中,此時線程2再從CPU緩存中讀到的A值已經不是最新的值了。這就叫存在可見性問題。
一個或者多個操做在CPU執行的過程當中不被中斷的特性叫原子性。操做系統作任務切換,能夠發生在任何一條CPU指令執行完,而不是高級語言裏的一條語句。好比最多見的A = A + 1
就不具有原子性,由於完成這條語句須要三個動做, 取值 -> 加一 -> 賦值,那麼可能在執行第二個動做的時候,發生了線程切換,另一個線程修改了A的值,問題就來了。
程序按照代碼的前後順序執行就叫有序性。前面說了編譯程序爲了更好地利用緩存,會對代碼進行重排序。最經典的例子就是新建對象。
建立對象的new操做對應的CPU語句是:
正常代碼執行順序就是一、二、3,可是通過重排序後,2和3的順序可能會顛倒。(???順序顛倒能不出錯嗎?)若是在單線程中,是不會有問題的,由於無論你123,仍是321,最終執行完new操做後對象都是初始化好了的,編譯程序對代碼進行重排序也是爲了更好的利用計算機資源,它是可以保證程序的運行結果在單線程中是正確的。
可是在多線程中就可能有問題了,當線程1執行完語句3還未執行語句2時,切換線程,線程B判斷到instance已經不爲null,就直接使用了,實際上instance指向的對象尚未初始化,此時就可能觸發空指針異常。
知道了併發Bug的源頭,那Java自身又是怎麼設計的去避免這些問題呢?Java又提供了哪些語言特性讓開發者解決這些問題呢?
致使可見性的緣由是緩存,致使有序性的緣由是編譯優化,那解決可見性、有序性最直接的辦法就是禁用緩存和編譯優化,可是這樣問題雖然解決了,咱們程序的性能可就堪憂了。合理的方案應該是按需禁用緩存以及編譯優化。Java 內存模型規範了 JVM 如何提供按需禁用緩存和編譯優化的方法。具體來講,這些方法包括 volatile、synchronized 和 final 三個關鍵字,以及六項 Happens-Before 規則。其中volatile、synchronized 和 final的用法這裏不細說了,提及來能夠寫三篇文章了,詳細用法網絡上有不少文章能夠參考。我說一下Happens-Before規則。
Happens-Before規則大概內容以下:
第一次接觸Happens-Before規則時,個人心裏
這一大堆規則究竟是幹嗎的呢?是由於市面上有不少種編譯器,編譯器們能夠發揮本身的想象盡情優化程序,可是前提是優化後的程序必定要遵照全部的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規則的意義。
前面說過線程切換帶來了原子性,互斥鎖能夠鎖住一塊代碼區域,保證只有拿到鎖的線程能夠進入區域內,而且區域內同一時刻只容許一個線程進入,這種區域有個學名叫作臨界區。這種用鎖去保護資源的模型,在現實生活中也隨處可見。Java提供了synchronized關鍵字實現互斥鎖的功能,線程在synchronized塊中,即便發生了線程切換,線程持有的鎖也不會釋放。Java併發包還提供了Lock相關的併發工具類。所以咱們只要把對共享變量的相關操做都用鎖封裝起來,就能保證同一時刻只有一個線程對共享變量進行操做。模型如圖所示: