這是Java建設者的第 110 篇原創文章html
我一直沒有急於寫併發的緣由是我參不透操做系統
,現在,我已經把操做系統刷了一遍,此次試着寫一些併發,看看能不能寫清楚,卑微小編在線求鼓勵...... 我打算採起操做系統和併發同時結合講起來的方式。java
併發歷史
在計算機最先期的時候,沒有操做系統,執行程序只須要一個過程,那就是從頭至尾依次執行。任何資源都會爲這個程序服務,這必然就會存在 浪費資源
的狀況。數據庫
❝這裏說的浪費資源指的是資源空閒,沒有充分使用的狀況。編程
❞
操做系統爲咱們的程序帶來了 併發性
,操做系統使咱們的程序同時運行多個程序,一個程序就是一個進程,也就至關於同時運行了多個進程。緩存
操做系統是一個併發系統
,併發性是操做系統很是重要的特徵,操做系統具備同時處理和調度多個程序的能力,好比多個 I/O 設備同時在輸入輸出;設備 I/O 和 CPU 計算同時進行;內存中同時有多個系統和用戶程序被啓動交替、穿插地執行。操做系統在協調和分配進程的同時,操做系統也會爲不一樣進程分配不一樣的資源。安全
操做系統實現多個程序同時運行解決了單個程序沒法作到的問題,主要有下面三點服務器
-
資源利用率
,咱們上面說到,單個進程存在資源浪費的狀況,舉個例子,當你在爲某個文件夾賦予權限的時候,輸入程序沒法接受外部的輸入字符,只能等到權限賦予完畢後才能接受外部輸入。綜合來說,就是在等待程序時沒法執行其餘工做。若是在等待程序的同時能夠運行另外一個程序,那麼將會大大提升資源的利用率。(資源並不會以爲累)由於它不會划水~微信 -
公平性
,不一樣的用戶和程序對於計算機上的資源有着一樣的使用權。一種高效的運行方式是爲不一樣的程序劃分時間片使用資源,可是有一點須要注意,操做系統能夠決定不一樣進程的優先級,雖然每一個進程都有可以公平享有資源的權利,可是每次前一個進程釋放資源後的同時有一個優先級更高的進程搶奪資源,就會形成優先級低的進程沒法得到資源,長此以往會致使進程飢餓。多線程 -
便利性
,單個進程是沒法通訊的,通訊這一點我認爲實際上是一種避雷針
策略,通訊的本質就是信息交換
,及時進行信息交換可以避免信息孤島
,作重複性的工做;任何併發能作的事情,順序編程也可以實現,只不過這種方式效率很低,它是一種阻塞式
的。併發
可是,順序編程(也稱爲串行編程
)也不是一無可取
的,串行編程的優點在於其「直觀性和簡單性」,客觀來說,串行編程更適合咱們人腦的思考方式,可是咱們並不會知足於順序編程,「we want it more!!!」 。資源利用率、公平性和便利性促使着進程出現的同時也促使着線程
的出現。
若是你還不是很理解進程和線程的區別的話,那麼我就以我多年操做系統的經驗(吹牛逼,實則半年)來爲你解釋一下:「進程是一個應用程序,而線程是應用程序中的一條順序流」。
或者阮一峯
老師也給出了你通俗易懂的解釋
摘自 https://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html
線程會共享進程範圍內的資源,例如內存和文件句柄,可是每一個線程也有本身私有的內容,好比程序計數器、棧以及局部變量。下面彙總了進程和線程共享資源的區別
線程被描述爲一種輕量級
的進程,輕量級體如今線程的建立和銷燬要比進程的開銷小不少。
❝注意:任何比較都是相對的。
❞
在大多數現代操做系統中,都以線程爲基本的調度單位,因此咱們的視角着重放在對線程的探究。
線程
優點和劣勢
合理使用線程是一門藝術,合理編寫一道準確無誤的多線程程序更是一門藝術,若是線程使用得當,可以有效的下降程序的開發和維護成本。
在 GUI 中,線程能夠提升用戶界面的響應靈敏度,在服務器應用程序中,併發能夠提升資源利用率以及系統吞吐率。
Java 很好的在用戶空間實現了開發工具包,並在內核空間提供系統調用來支持多線程編程,Java 支持了豐富的類庫 java.util.concurrent
和跨平臺的內存模型
,同時也提升了開發人員的門檻,併發一直以來是一個高階的主題,可是如今,併發也成爲了主流開發人員的必備素質。
雖然線程帶來的好處不少,可是編寫正確的多線程(併發)程序是一件極困難的事情,併發程序的 Bug 每每會詭異地出現又詭異的消失,在當你認爲沒有問題的時候它就出現了,難以定位
是併發程序的一個特徵,因此在此基礎上你須要有紮實的併發基本功。那麼,併發爲何會出現呢?
爲何是併發
計算機世界的快速發展離不開 CPU、內存和 I/O 設備的高速發展,可是這三者一直存在速度差別性問題,咱們能夠從存儲器的層次結構能夠看出
CPU 內部是寄存器的構造,寄存器的訪問速度要高於高速緩存
,高速緩存的訪問速度要高於內存,最慢的是磁盤訪問。
程序是在內存中執行的,程序裏大部分語句都要訪問內存,有些還須要訪問 I/O 設備,根據漏桶理論來講,程序總體的性能取決於最慢的操做也就是磁盤訪問速度。
由於 CPU 速度太快了,因此爲了發揮 CPU 的速度優點,平衡這三者的速度差別,計算機體系機構、操做系統、編譯程序都作出了貢獻,主要體現爲:
-
CPU 使用緩存來中和和內存的訪問速度差別
-
操做系統提供進程和線程調度,讓 CPU 在執行指令的同時分時複用線程,讓內存和磁盤不斷交互,不一樣的
CPU 時間片
可以執行不一樣的任務,從而均衡這三者的差別 -
編譯程序提供優化指令的執行順序,讓緩存可以合理的使用
咱們在享受這些便利的同時,多線程也爲咱們帶來了挑戰,下面咱們就來探討一下併發問題爲何會出現以及多線程的源頭是什麼
線程帶來的安全性問題
線程安全性是很是複雜的,在沒有采用同步機制
的狀況下,多個線程中的執行操做每每是不可預測的,這也是多線程帶來的挑戰之一,下面咱們給出一段代碼,來看看安全性問題體如今哪
public class TSynchronized implements Runnable{ static int i = 0; public void increase(){ i++; } @Override public void run() { for(int i = 0;i < 1000;i++) { increase(); } } public static void main(String[] args) throws InterruptedException { TSynchronized tSynchronized = new TSynchronized(); Thread aThread = new Thread(tSynchronized); Thread bThread = new Thread(tSynchronized); aThread.start(); bThread.start(); System.out.println("i = " + i); } }
這段程序輸出後會發現,i 的值每次都不同,這不符合咱們的預測,那麼爲何會出現這種狀況呢?咱們先來分析一下程序的運行過程。
TSynchronized
實現了 Runnable 接口,並定義了一個靜態變量 i
,而後在 increase
方法中每次都增長 i 的值,在其實現的 run 方法中進行循環調用,共執行 1000 次。
可見性問題
在單核 CPU 時代,全部的線程共用一個 CPU,CPU 緩存和內存的一致性問題容易解決,CPU 和 內存之間
若是用圖來表示的話我想會是下面這樣
在多核時代,由於有多核的存在,每一個核都可以獨立的運行一個線程,每顆 CPU 都有本身的緩存,這時 CPU 緩存與內存的數據一致性就沒那麼容易解決了,當多個線程在不一樣的 CPU 上執行時,這些線程操做的是不一樣的 CPU 緩存
由於 i 是靜態變量,沒有通過任何線程安全措施的保護,多個線程會併發修改 i 的值,因此咱們認爲 i 不是線程安全的,致使這種結果的出現是因爲 aThread 和 bThread 中讀取的 i 值彼此不可見,因此這是因爲 可見性
致使的線程安全問題。
原子性問題
看起來很普通的一段程序卻由於兩個線程 aThread
和 bThread
交替執行產生了不一樣的結果。可是根源不是由於建立了兩個線程致使的,多線程只是產生線程安全性的必要條件,最終的根源出如今 i++
這個操做上。
這個操做怎麼了?這不就是一個給 i 遞增的操做嗎?也就是 「i++ => i = i + 1」,這怎麼就會產生問題了?
由於 i++
不是一個 原子性
操做,仔細想一下,i++ 其實有三個步驟,讀取 i 的值,執行 i + 1 操做,而後把 i + 1 得出的值從新賦給 i(將結果寫入內存)。
當兩個線程開始運行後,每一個線程都會把 i 的值讀入到 CPU 緩存中,而後執行 + 1 操做,再把 + 1 以後的值寫入內存。由於線程間都有各自的虛擬機棧和程序計數器,他們彼此之間沒有數據交換,因此當 aThread 執行 + 1 操做後,會把數據寫入到內存,同時 bThread 執行 + 1 操做後,也會把數據寫入到內存,由於 CPU 時間片的執行週期是不肯定的,因此會出現當 aThread 尚未把數據寫入內存時,bThread 就會讀取內存中的數據,而後執行 + 1操做,再寫回內存,從而覆蓋 i 的值,致使 aThread 所作的努力白費。
爲何上面的線程切換會出現問題呢?
咱們先來考慮一下正常狀況下(即不會出現線程安全性問題的狀況下)兩條線程的執行順序
能夠看到,當 aThread 在執行完整個 i++ 的操做後,操做系統對線程進行切換,由 aThread -> bThread,這是最理想的操做,一旦操做系統在任意 讀取/增長/寫入
階段產生線程切換,都會產生線程安全問題。例如以下圖所示
最開始的時候,內存中 i = 0,aThread 讀取內存中的值並把它讀取到本身的寄存器中,執行 +1 操做,此時發生線程切換,bThread 開始執行,讀取內存中的值並把它讀取到本身的寄存器中,此時發生線程切換,線程切換至 aThread 開始運行,aThread 把本身寄存器的值寫回到內存中,此時又發生線程切換,由 aThread -> bThread,線程 bThread 把本身寄存器的值 +1 而後寫回內存,寫完後內存中的值不是 2 ,而是 1, 內存中的 i 值被覆蓋了。
咱們上面提到 原子性
這個概念,那麼什麼是原子性呢?
❝併發編程的原子性操做是徹底獨立於任何其餘進程運行的操做,原子操做多用於現代操做系統和並行處理系統中。
原子操做一般在內核中使用,由於內核是操做系統的主要組件。可是,大多數計算機硬件,編譯器和庫也提供原子性操做。
在加載和存儲中,計算機硬件對存儲器字進行讀取和寫入。爲了對值進行匹配、增長或者減少操做,通常經過原子操做進行。在原子操做期間,處理器能夠在同一數據傳輸期間完成讀取和寫入。這樣,其餘輸入/輸出機制或處理器沒法執行存儲器讀取或寫入任務,直到原子操做完成爲止。
❞
簡單來說,就是「原子操做要麼所有執行,要麼所有不執行」。數據庫事務的原子性也是基於這個概念演進的。
有序性問題
在併發編程中還有帶來讓人很是頭疼的 有序性
問題,有序性顧名思義就是順序性,在計算機中指的就是指令的前後執行順序。一個很是顯而易見的例子就是 JVM 中的類加載
這是一個 JVM 加載類的過程圖,也稱爲類的生命週期,類從加載到 JVM 到卸載一共會經歷五個階段 「加載、鏈接、初始化、使用、卸載」。這五個過程的執行順序是必定的,可是在鏈接階段,也會分爲三個過程,即 「驗證、準備、解析」 階段,這三個階段的執行順序不是肯定的,一般交叉進行,在一個階段的執行過程當中會激活另外一個階段。
有序性問題通常是編譯器帶來的,編譯器有的時候確實是 「好心辦壞事」,它爲了優化系統性能,每每更換指令的執行順序。
活躍性問題
多線程還會帶來活躍性
問題,如何定義活躍性問題呢?活躍性問題關注的是 「某件事情是否會發生」。
「若是一組線程中的每一個線程都在等待一個事件,而這個事件只能由該組中的另外一個線程觸發,這種狀況會致使死鎖」。
簡單一點來表述一下,就是每一個線程都在等待其餘線程釋放資源,而其餘資源也在等待每一個線程釋放資源,這樣沒有線程搶先釋放本身的資源,這種狀況會產生死鎖,全部線程都會無限的等待下去。
換句話說,死鎖線程集合中的每一個線程都在等待另外一個死鎖線程佔有的資源。可是因爲全部線程都不能運行,它們之中任何一個資源都沒法釋放資源,因此沒有一個線程能夠被喚醒。
若是說死鎖很癡情
的話,那麼活鎖
用一則成語來表示就是 弄巧成拙
。
某些狀況下,當線程意識到它不能獲取所須要的下一個鎖時,就會嘗試禮貌的釋放已經得到的鎖,而後等待很是短的時間再次嘗試獲取。能夠想像一下這個場景:當兩我的在狹路相逢的時候,都想給對方讓路,相同的步調會致使雙方都沒法前進。
如今假想有一對並行的線程用到了兩個資源。它們分別嘗試獲取另外一個鎖失敗後,兩個線程都會釋放本身持有的鎖,再次進行嘗試,這個過程會一直進行重複。很明顯,這個過程當中沒有線程阻塞,可是線程仍然不會向下執行,這種情況咱們稱之爲 活鎖(livelock)
。
若是咱們指望的事情一直不會發生,就會產生活躍性問題,好比單線程中的無限循環
while(true){...} for(;;){}
在多線程中,好比 aThread 和 bThread 都須要某種資源,aThread 一直佔用資源不釋放,bThread 一直得不到執行,就會形成活躍性問題,bThread 線程會產生飢餓
,咱們後面會說。
性能問題
與活躍性問題密切相關的是 性能
問題,若是說活躍性問題關注的是最終的結果,那麼性能問題關注的就是形成結果的過程,性能問題有不少方面:好比服務時間過長,吞吐率太低,資源消耗太高,在多線程中這樣的問題一樣存在。
在多線程中,有一個很是重要的性能因素那就是咱們上面提到的 線程切換
,也稱爲 上下文切換(Context Switch)
,這種操做開銷很大。
❝在計算機世界中,老外都喜歡用 context 上下文這個詞,這個詞涵蓋的內容不少,包括上下文切換的資源,寄存器的狀態、程序計數器等。context switch 通常指的就是這些上下文切換的資源、寄存器狀態、程序計數器的變化等。
❞
在上下文切換中,會保存和恢復上下文,丟失局部性,把大量的時間消耗在線程切換上而不是線程運行上。
爲何線程切換會開銷如此之大呢?線程間的切換會涉及到如下幾個步驟
將 CPU 從一個線程切換到另外一線程涉及掛起當前線程,保存其狀態,例如寄存器,而後恢復到要切換的線程的狀態,加載新的程序計數器,此時線程切換實際上就已經完成了;此時,CPU 不在執行線程切換代碼,進而執行新的和線程關聯的代碼。
引發線程切換的幾種方式
線程間的切換通常是操做系統層面須要考慮的問題,那麼引發線程上下文切換有哪幾種方式呢?或者說線程切換有哪幾種誘因呢?主要有下面幾種引發上下文切換的方式
-
當前正在執行的任務完成,系統的 CPU 正常調度下一個須要運行的線程
-
當前正在執行的任務遇到 I/O 等阻塞操做,線程調度器掛起此任務,繼續調度下一個任務。
-
多個任務併發搶佔鎖資源,當前任務沒有得到鎖資源,被線程調度器掛起,繼續調度下一個任務。
-
用戶的代碼掛起當前任務,好比線程執行 sleep 方法,讓出CPU。
-
使用硬件中斷的方式引發上下文切換
線程安全性
在 Java 中,要實現線程安全性,必需要正確的使用線程和鎖,可是這些只是知足線程安全的一種方式,要編寫正確無誤的線程安全的代碼,其核心就是對狀態訪問操做進行管理。最重要的就是最 共享(Shared)
的 和 可變(Mutable)
的狀態。只有共享和可變的變量纔會出現問題,私有變量不會出現問題,參考程序計數器
。
對象的狀態能夠理解爲存儲在實例變量或者靜態變量中的數據,共享意味着某個變量能夠被多個線程同時訪問、可變意味着變量在生命週期內會發生變化。一個變量是不是線程安全的,取決於它是否被多個線程訪問。要使變量可以被安全訪問,必須經過同步機制來對變量進行修飾。
若是不採用同步機制的話,那麼就要避免多線程對共享變量的訪問,主要有下面兩種方式
-
不要在多線程之間共享變量
-
將共享變量置爲不可變的
咱們說了這麼屢次線程安全性,那麼什麼是線程安全性呢?
什麼是線程安全性
根據上面的探討,咱們能夠得出一個簡單的定義:「當多個線程訪問某個類時,這個類始終都能表現出正確的行爲,那麼就稱這個類是線程安全的」。
單線程就是一個線程數量爲 1 的多線程,單線程必定是線程安全的。讀取某個變量的值不會產生安全性問題,由於無論讀取多少次,這個變量的值都不會被修改。
原子性
咱們上面提到了原子性的概念,你能夠把原子性操做想象成爲一個不可分割
的總體,它的結果只有兩種,要麼所有執行,要麼所有回滾。你能夠把原子性認爲是 婚姻關係
的一種,男人和女人只會產生兩種結果,好好的
和 說散就散
,通常男人的一輩子均可以把他當作是原子性的一種,固然咱們不排除時間管理(線程切換)
的個例,咱們知道線程切換必然會伴隨着安全性問題,男人要出去浪也會形成兩種結果,這兩種結果分別對應安全性的兩個結果:線程安全(好好的)和線程不安全(說散就散)。
競態條件
有了上面的線程切換的功底,那麼競態條件也就好定義了,它指的就是「兩個或多個線程同時對一共享數據進行修改,從而影響程序運行的正確性時,這種就被稱爲競態條件(race condition)」 ,線程切換是致使競態條件出現的誘導因素,咱們經過一個示例來講明,來看一段代碼
public class RaceCondition { private Signleton single = null; public Signleton newSingleton(){ if(single == null){ single = new Signleton(); } return single; } }
在上面的代碼中,涉及到一個競態條件,那就是判斷 single
的時候,若是 single 判斷爲空,此時發生了線程切換,另一個線程執行,判斷 single 的時候,也是空,執行 new 操做,而後線程切換回以前的線程,再執行 new 操做,那麼內存中就會有兩個 Singleton 對象。
加鎖機制
在 Java 中,有不少種方式來對共享和可變的資源進行加鎖和保護。Java 提供一種內置的機制對資源進行保護:synchronized
關鍵字,它有三種保護機制
-
對方法進行加鎖,確保多個線程中只有一個線程執行方法;
-
對某個對象實例(在咱們上面的探討中,變量可使用對象來替換)進行加鎖,確保多個線程中只有一個線程對對象實例進行訪問;
-
對類對象進行加鎖,確保多個線程只有一個線程可以訪問類中的資源。
synchronized 關鍵字對資源進行保護的代碼塊俗稱 同步代碼塊(Synchronized Block)
,例如
synchronized(lock){ // 線程安全的代碼 }
每一個 Java 對象均可以用作一個實現同步的鎖,這些鎖被稱爲 內置鎖(Instrinsic Lock)
或者 監視器鎖(Monitor Lock)
。線程在進入同步代碼以前會自動得到鎖,而且在退出同步代碼時自動釋放鎖,而不管是經過正常執行路徑退出仍是經過異常路徑退出,得到內置鎖的惟一途徑就是進入這個由鎖保護的同步代碼塊或方法。
synchronized 的另外一種隱含的語義就是 互斥
,互斥意味着獨佔
,最多隻有一個線程持有鎖,當線程 A 嘗試得到一個由線程 B 持有的鎖時,線程 A 必須等待或者阻塞,直到線程 B 釋放這個鎖,若是線程 B 不釋放鎖的話,那麼線程 A 將會一直等待下去。
線程 A 得到線程 B 持有的鎖時,線程 A 必須等待或者阻塞,可是獲取鎖的線程 B 能夠重入,重入的意思能夠用一段代碼表示
public class Retreent { public synchronized void doSomething(){ doSomethingElse(); System.out.println("doSomething......"); } public synchronized void doSomethingElse(){ System.out.println("doSomethingElse......"); }
獲取 doSomething() 方法鎖的線程能夠執行 doSomethingElse() 方法,執行完畢後能夠從新執行 doSomething() 方法中的內容。鎖重入也支持子類和父類之間的重入,具體的咱們後面會進行介紹。
volatile
是一種輕量級的 synchronized
,也就是一種輕量級的加鎖方式,volatile 經過保證共享變量的可見性來從側面對對象進行加鎖。可見性的意思就是當一個線程修改一個共享變量時,另一個線程可以 看見
這個修改的值。volatile 的執行成本要比 synchronized
低不少,由於 volatile 不會引發線程的上下文切換。
關於 volatile 的具體實現,咱們後面會說。
咱們還可使用原子類
來保證線程安全,原子類其實就是 rt.jar
下面以 atomic
開頭的類
除此以外,咱們還可使用 java.util.concurrent
工具包下的線程安全的集合類來確保線程安全,具體的實現類和其原理咱們後面會說。
本文分享自微信公衆號 - Java建設者(javajianshe)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。