上篇文章中介紹了線程的概念以及使用,經過線程,咱們可以提升CPU的利用率以及項目的執行效率,可是使用線程有個很是大的問題,就是如何正確同步數據的問題。在介紹線程的問題以前,這裏首先對計算機內存模型作個介紹,理解了這個,才能理解線程的問題。java
計算機在執行程序時,每條指令都是在CPU中執行的,而執行指令過程當中,確定涉及到數據的讀取和寫入。CPU從主存中讀取數據,操做後再往主存中存取數據,每次執行都涉及的I/O操做,而因爲CPU的執行速度跟主存的讀取速度不是在一個量級的上,若是直接主存上進行讀/寫數據,因爲CPU執行速度更快,就會致使CPU須要進行等待主存執行後再進行,這樣就會減緩計算機的處理速度。爲了處理這種狀況,計算機在CPU與主存之間,增長了高速緩存的概念,《深刻了解計算機系統》一書中給出了一個存儲器層次結構的示例:c++
這裏咱們能夠簡單理解爲CPU-高速緩存-主存三個大層次的概念,當程序在運行過程當中,CPU會將運算須要的數據從主存複製一份到CPU的高速緩存當中,那麼CPU進行計算時就能夠直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束以後,再將高速緩存中的數據刷新到主存當中。程序員
經過這種方式可以大大增長計算機的響應速度,可是也會引入一個問題,這個問題咱們在平常開發中就會遇到,緩存的不一致性問題。緩存一致性的問題在單線程中是不存在的,可是考慮到多線程的狀況,在多核CPU中,每條線程可能運行於不一樣的CPU中,所以每一個線程運行時有本身的高速緩存,那麼在數據操做完畢後將數據返回主存的時候,該按照誰的緩存來存儲呢?如何去保證**緩存一致性**,針對該問題,計算機提供了兩種方式來解決:編程
經過在總線加LOCK#鎖的方式數組
經過緩存一致性協議緩存
這2種方式都是硬件層面上提供的方式。這種兩種方式介紹以下:安全
摘抄自百度百科多線程
在早期的CPU當中,是經過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。由於CPU和其餘部件進行通訊都是經過總線來進行的,若是對總線加LOCK#鎖的話,也就是說阻塞了其餘CPU對其餘部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。在總線上發出了LCOK#鎖的信號,那麼只有等待這段代碼徹底執行完畢以後,其餘CPU才能從其內存讀取變量,而後進行相應的操做。這樣就解決了緩存不一致的問題。併發
可是因爲在鎖住總線期間,其餘CPU沒法訪問內存,會致使效率低下。所以出現了第二種解決方案,經過緩存一致性協議來解決緩存一致性問題。最出名的就是Intel 的MESI協議,MESI協議保證了每一個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,若是發現操做的變量是共享變量,即在其餘CPU中也存在該變量的副本,會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,發現本身緩存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。app
畫個總結圖以下:
除了上面介紹的計算機內存模型,這裏也介紹一下重排序。**爲了讓CPU的內部運算單元可以儘可能的被充分利用,CPU會對輸入的代碼進行亂序執行(Out-Of-Order)優化,CPU在計算以後會將亂序執行的結果重組,保證與順序執行的結果一致,可是不能保證程序各個語句的前後計算順序與代碼輸入順序一致。**所以,若是存在一個計算任務依賴另外一個計算任務的中間結果,那麼順序性不能依靠代碼的前後順序來保證。
在Java中對於指令重排序發生的過程以下:
能夠看到,從咱們寫的代碼變成計算機可執行的指令的過程當中進行了一系列的指令重排序的工做,指令重排序的意義在於:JVM能根據處理器的特性,充分利用多級緩存,多核等進行適當的指令重排序,使程序在保證業務運行的同時,充分利用CPU的執行特色,最大的發揮機器的性能。那麼何時容許進行重排序呢?
當指令執行之間沒有數據依賴的時候,能夠容許JVM進行重排序。JVM中明確規定了爲了保證程序的執行結果不會改變,須要遵循as-if-serial語義,即編譯器和處理器不會對存在數據依賴關係的操做作重排序,由於這種重排序會改變執行結果。
as-if-serial語義的意思指:無論怎麼重排序(編譯器和處理器爲了提升並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵照as-if-serial語義。
上述說到了數據依賴這個概念,簡單而言就是當下一個指令的操做結果依賴於上面執行過的某一個指令時,這時候兩個指令就產生了數據依賴,宏觀的從代碼層面上表述以下:
a++; b=getCacheNum(a); c++;
代碼中b的值依賴於a的值,因此b=getCacheNum(a)
一系列指令必須等到a++
操做完成後才能進行,而c++
這句代碼並無須要依賴於b或者a,進行,那麼c++
這句代碼執行的指令是容許進行重排序的。數據依賴發生的條件以下:
針對單線程模型而言,即便JVM進行了指令重排序的優化工做,最後的輸出結果應該跟順序執行代碼的結果一致,而針對不一樣處理器之間和不一樣線程之間,數據依賴性的問題不被編譯器和處理器考慮的。在多線程環境下,指令重排序的優化可能會對程序的執行形成不可預期的影響,簡單看個Demo:
public class Test{ boolean over; int i=0; int a; public void testA(){ a=2; over=true; } public void testB(){ if(over){ i=a*2; } } }
這裏testA()
方法在線程A中調用,testB()
在線程B中調用,因爲testA()
中不涉及數據依賴的調用,因此容許指令重排序,因此testA()
可能執行的實際順序以下:
public void testA(){ over=true; a=2; }
那麼在testB()
中因爲over已經爲true了,可是a的值還沒被設置成2,因此獲得的結果並非咱們預期的結果。testB()
這裏還涉及一個控制依賴的問題,有興趣的能夠看這篇文章。
那麼,因爲指令重排序的引出的多線程中的問題,咱們須要怎麼解決呢?解決方案就是內存屏障的概念了。
內存屏障能夠禁止特定類型處理器的重排序,從而讓程序按咱們預想的流程去執行,經過阻止屏障兩邊的指令重排序來避免編譯器和硬件的不正確優化而提出的一種解決辦法。在Java層上的表現Volatile,Sychronized關鍵字,在硬件層上因爲不一樣的操做系統的不一樣,實現方式也不一樣,這裏也不深究啦。因爲內存屏障涉及的內容更可能是底層的知識,因此這裏就暫時介紹到這了
線程的安全性問題逃不開物理硬件,上面咱們描述了那麼多其實就是解釋了線程的使用問題:多線程如何保證數據在工做內存以及主存中傳遞的正確性。在Java層面上,要理解這個問題,咱們仍是從Java的內存模型開始看起。
上面討論了因爲因爲高速緩存的引入, 可能致使工做內存與主存的數據錯誤的問題,這個問題全部的編程語言都會遇到的,而在Java語言中,JVM爲了屏蔽掉各個操做系統的實現差別,也提出了Java內存模型(JMM)的概念。
在Java內存模型中規定全部的變量(這裏指實例變量,靜態字段,數組對象,但不包括局部對象和方法參數,這二者線程私有)存儲在主內存中(類比物理主存)。在每條工做線程當中還存在着工做內存(類比高速緩存),工做內存中保存了被該線程使用的變量的副本,線程對全部操做(讀取,賦值等)都經過工做內存進行,不直接讀取主存對象操做。不一樣線程直接也沒法訪問對象工做內存中的變量,線程間變量的值傳遞均經過主存來完成,三者的關係圖以下:
該模型跟物理計算機的模型基本一致
https://blog.csdn.net/weixin_36795183/article/details/79420771
首先這裏總結一下工做內存與主存的交互協議,即一個變量如何從主存拷貝到工做內存,再從工做內存同步回到主存中的細節。JMM在交互協議中定義了8種操做,這8中操做每一個都是原子性(long與double有例外狀況)的:
上述操做的一個基本流程以下展現(圖片出處):
當一個線程鎖定某個變量時候,最直接的體現就是使用sychronized,若是進行賦值的操做,那麼流程就如上所示。
除此以外,JMM中還規定上述操做的順序性(注意,不是連續性)的要求:
保證順序性而不是保證連續性,如主內存對變量a,b訪問時候,一種可能的狀況是read a,read b,load b,load a。JMM還規定了在執行這8種操做的時候,須要知足以下規則:
這些內容只要在網上查其實都能找到,這裏爲了方便往後查閱,便直接摘抄了一下,原文來自《深刻理解Java虛擬機》。然而這塊的東西有點略偏與底層的東西了,總的來說,JMM模型是圍繞着併發過程當中如何處理原子性,可見性和有序性三個特徵創建的,因此上述的內容旨在增強理解。接下來就看下可見性,原子性,有序性吧。
原子性表示操做不可拆分,即一個操做或者多個操做要麼所有執行而且執行的過程不會被任何因素打斷,要麼就都不執行。Java中的基本數據類型的訪問讀寫是原子性的(可是long和double不必定,在某些32位的系統上,long和double可能分紅兩部分連續的32位數據存儲。),除了基本數據類型,經過添加sychronized關鍵字可以保證一段操做的原子性,反編譯Java代碼可發現加入sychronized關鍵字的代碼段,其中會增長對應關鍵字monitorenter和monitorexit來保證,以下:
public void test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: aload_0 1: getfield #3 // Field obj:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter 7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream; 10: ldc #5 // String Heelo 12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 15: aload_1 16: monitorexit 17: goto 25 20: astore_2 21: aload_1 22: monitorexit 23: aload_2 24: athrow 25: return
這兩個字節碼指令對應底層的lock和unlock指令。
可見性指的是一個線程當前修改了共享變量的值,其餘線程可以當即得知這個修改。在Java內存模型當中的表示就是在線程A中修改了一個共享變量的值,修改後當即從A的工做內存中同步給了主內存更新值,同時其餘線程每次使用該共享變量值時,保證從主內存中獲取,可見性表明性的修飾符就是volatile,除此以外還有synchronized以及final。
synchronized就不談了,final關鍵字的可見性是指被final修飾的字段在構造器中一旦初始化完成,而且構造器沒有把"this"的引用傳遞出去(this引用逃逸是一件很危險的事情,其餘線程有可能經過這個引用訪問到「初始化了一半」的對象),那在其餘線程中就能看見final字段的值。
有序性總結一句話就是,若是本線程內觀察,全部的操做都是有序的;若是在一個線程中觀察另外一個線程,全部的操做都是無序的。前半句是指「線程內表現爲串行的語義」,後半句是指「指令重排序」現象和「工做內存與主內存同步延遲」現象。
上面講述了關於JMM中工做原理以及三個性質保證線程的安全性,因爲上述內容稍顯有點抽象,因此在JSR-133規範中定義了Happens-Before原則,即先行發生原則來程序員參考,屏蔽底層細節,根據先行發生原則,開發者能夠很容易的寫出線程安全的代碼。Happens-Before原則以下:
這些原則理解了就很簡單了,平常開發時在涉及多線程方面的工做時,根據先行發生原則,咱們可以應付諸多的併發問題了。
Ok,這裏本章節的內容就講解完畢了,這裏總結下。因爲高速緩存的引入致使了多線程在併發時候的緩存不一致的問題,咱們在開發的時候,線程安全的設計須要考慮知足有序性,原子性以及可見性,而且經過happen-before可以很好的判斷本身寫的代碼是否安全。