1. 程式人生 > >個人知識點總結——Java並發

個人知識點總結——Java並發

兩個 operation 單例 自己的 nes app 動規 嘗試 缺點

Java並發實在是一個非常深的問題,這裏僅僅簡單記錄一下Java並發的知識點。水太深。假設不花大量的時間感覺全然hold不住,可是眼下的精力全然不夠,興趣也不在這

什麽是線程安全性

某個類的行為和其規範全然一致
當多個線程訪問某個類時。不管運行時環境採用何種調度方式或者這些線程將怎樣交替運行。而且在主調代碼中不須要不論什麽額外的同步或協同,這個類都能表現出正確的行為,那麽就稱這個類是線程安全的

原子操作(Atomic Operation)

原子操作是指不會被線程調度機制打斷的操作,這樣的操作一旦開始。就會一直運行到結束,中間不會有不論什麽的上下文切換,它是不可切割的。

舉一個常見的樣例:a++。這個操作就不是一個原子性的操作,那麽在多個線程訪問調用的時候,a的終於結果就非常有可能不是我們的預期值。

由於實際上a++這個操作能夠分為已下三步:獲取a的值,更新a的值,寫回a的值。

緩存一致性

在共享內存的多處理器體系架構中,每一個處理器都擁有自己的緩存,而且定期地與主內存進行協調,在不同的處理器架構中提供了不同級別的緩存一致性。

這個緩存一致性能夠通過volatile關鍵字來加深理解。

Volatile關鍵字

Volatile是一種較弱的同步機制,用來確保將變量的更新操作通知到其它線程。當把變量聲明為volatile類型後。編譯器與運行時都會註意到這個變量是共享的,因此不會將該變量上的操作一起重排序。volatile變量不會被緩存在寄存器或者對其它處理器不可見的地方。因此在讀取volatile類型的變量時總會返回最新寫入的值。
可是有一點是須要註意的。被volatile修飾的變量的的操作也應該是原子性的。不然相同會出先問題。
比如:

Volatile int a = 0;

// 非原子性操作。使用volatile不能保證同步,改用Synchronized
a++;

而為什麽Volatile能夠實現這樣的功能呢?
這個要從它的實現原理說起,在x86處理器下通過工具獲取JIT編譯器生成的匯編指令來看Volatile的寫操作實際上做了什麽吧。

0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);

有Volatile變量修飾的共享變量進行寫操作的時候會多第二行代碼。lock指令修飾。
而lock指令會做什麽事呢?

  • 將當前處理器緩存行的數據寫回到系統內存
  • 該寫回內存的操作會引起在其它CPU裏緩存了該內存地址的數據無效

在上面介紹緩存一致性的時候提到了,在共享內存的多處理器體系架構中,每一個處理器都擁有自己的緩存。而且定期地與主內存進行協調,在不同的處理器架構中提供了不同級別的緩存一致性。
那麽這個時候也就有了以下的事情:
處理器為了提高處理速度,不直接和內存進行通訊,而是先將系統內存的數據讀到內部緩存中再進行操作,但操作完畢後不確定什麽時候會寫回到內存。假設對聲明了Volatile變量進行些操作,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。而由於緩存一致性協議,每一個處理器會通過嗅探在總線上傳播的數據在來檢查自己緩存的值是不是國旗了。當處理器發現自己緩存行相應的內存地址被改動。就會將當前處理器的緩存行設置為無效狀態。

當處理器要對這個數據進行改動操作的時候,就會強制從系統內存中又一次讀取數據到處理器緩存裏。
這也就是Volatile實現的原理。

重排序

剛才上面總結到了重排序的概念。那什麽是重排序呢?

簡單的理解就是。當程序在運行的時候,假設JVM覺得兩行代碼之間的結果互不影響。那麽在運行的過程中可能就會產生一個亂序的結果。
比如:

 a = 3; 
 b = 4;

正常情況下我們會覺得a = 3肯定是比b=4先運行的。由於它在b的上面,可是實際上並非這樣,由於b的運行結果並不依賴上一行a的結果。因此JVM就能夠對兩行代碼進行一個重排序,可能a先運行,也可能b先運行

為什麽要採用重排序?
重排序一般是編譯器或運行時環境為了優化程序性能而採取的。

它能夠分為兩類:編譯器重排序和運行時重排序。

順序一致性模型:理想的模型是。各種指令運行的順序是唯一且有序的,這個順序就是它們被編寫在代碼中的順序,與處理器或其它因素無關

順序一致性模型的缺點:效率過低

編譯器重排序的典型就是通過調整指令順序,在不改變程序語義的前提下。盡可能的減少寄存器的讀取、存儲次數、充分服用寄存器的存儲值。

假設第一條指令計算一個值賦給變量A並存放在寄存器中。第二條指令與A無關但須要占用寄存器(假設它將占用A所在的那個寄存器),第三條指令使用A的值且與第二條指令無關。那麽假設依照順序一致性模型。A在第一條指令運行過後被放入寄存器。在第二條指令運行時A不再存在。第三條指令運行時A又一次被讀入寄存器,而這個過程中,A的值沒有發生變化。

通常編譯器都會交換第二和第三條指令的位置,這樣第一條指令結束時A存在於寄存器中,接下來能夠直接從寄存器中讀取A的值,減少了反復讀取的開銷。

並發時的亂序問題

上面總結的重排序能夠引起亂序,相同的,在並發時對局部變量進行操作也有可能會產生亂序的問題。由於在每一個線程中都擁有一個獨立的棧,也就是獨立的線程空間,當它運行時,會從主內存中讀取該變量的值並存放到自己的線程棧中,對變量操作完畢後就會把值寫回主內存空間。
可是這裏就有一個問題了,那就是變量的寫回操作發生的時間並不能夠確定。

就算是線程A比線程B先讀取數據,仍然有可能線程B先把值寫回主內存,終於相同會造成一個得到的結果並非我們想要的值。

Happens-before(先行發生)

Java內存模型(JMM)為程序中全部的操作定義了一個偏序關系。稱之為Happens-before。假設想要保證運行操作B的線程看到操作A的結果(不管A和B是否在同一個線程中運行)。那麽在A和B之間必須滿足Happens-Before關系。

假設兩個操作時間缺乏Happens-Before關系。那麽JVM能夠對它們隨意地重排序。

當一個變量被多個線程讀取而且至少被一個線程寫入時。假設在讀寫操作之間沒有依照Happens-Before來排序,那麽就會產生數據競爭的問題。

Happens-Before規則包括:

  • 程序順序規則:假設程序中操作A在操作B之前,那麽在線程中A操作將在B操作之前運行
  • 監視器鎖規則:在監視器鎖上的解鎖操作必須在同一個監視器鎖上的加鎖操作之前運行
  • volatile變量規則:對volatile變量的寫入操作必須在對該變量的讀操作之前運行
  • 線程啟動規則:在線程上對Thread.Start的調用必須在該縣城中運行不論什麽操作之前運行
  • 線程結束規則:縣城中的不論什麽操作都必須在其它線程檢測到該線程已經結束之前運行,或者在Thread.join中成功返回。或者在調用THread.isAlive時返回false
  • 中斷規則:當一個線程在還有一個線程上調用interrupt時。必須在被中斷線程檢測到interrupt調用之前運行
  • 終結器規則:對象的構造函數必須在啟動終結器之前運行
  • 傳遞性:操作A在B之前。B在C之前,那麽A就必須在C之前運行

比如線程A:y=1 -》 lock M -》 x=1 -》unlock M
線程B: lock M -》 i=x -》 unlock M -》 j=y

當兩個線程使用同一個鎖進行同步時,在它們之間的happens-Before關系就是:A的unlock M運行完畢之後才幹運行B的lock M方法,假設這兩個線程是在不同的鎖上進行同步的,那麽就不能判斷它們之間的動作順序,由於在這兩個線程的操作之間並不存在Happens-Before關系

Lock / Synchronized / ReentrantLock(獨占鎖 / 悲觀鎖)

Synchronized
內置鎖,當它用來修飾一個方法或者一個代碼塊的時候,能夠保證在同一時刻最多僅僅有一個線程運行該段代碼。還有一個線程必須等待當前線程運行完這個代碼快以後才幹運行該代碼塊(未運行之前。該線程被堵塞)。同一時候,它也是一個可重入鎖(Lock均可重入)

可是這裏有一個非常關鍵的地方:當一個線程訪問object的一個synchronized(this)同步代碼塊時。還有一個線程仍然能夠訪問該object中的非synchronized(this)同步代碼塊。

之前在單例模式中總結到了雙重檢查鎖定模式,可是由於雙重檢查鎖定模式在一定情況下存在非常嚴重的Bug。就沒有在該博客中寫出。

這裏就對雙重檢查鎖定模式進行一個分析

public static Singleton getInstance() {  
        if (singleton == null) {    
            synchronized (Singleton.class) {    
               if (singleton == null) {    
                  singleton = new Singleton();   
               }    
            }    
        }    
        return singleton;   
    } 

由於在new Singleton()的過程中。實際上是能夠分為非常多步的,可大致分為三件事情:

  • 給Singleton的實例分配內存
  • 調用Singleton的構造函數。初始化字段
  • 將singleton對象指向分配的內存空間(singleton != null)

可是,Java編譯器是同意處理器亂序運行的。全部有可能讓第二步和第三步亂序運行,也就是說假設在第二步被亂序(安排到了最後一步運行),當他還沒運行的時候切換到了線程B,這個時候就會由於singleton已經不為null而直接跳出if判斷,這樣的話在我們以後的代碼運行過程中使用的就是一個未經構造函數初始化的一個對象。【如今好像有在JDK層面有改進因此能夠正常使用。具體的不清楚,以後再改動】

Lock

Lock是一個接口,它裏面主要包括了以下的幾個方法:
P.S.源代碼裏的凝視太多,這裏就不貼了

void lock();
void lockInterruptibly();
boolean tryLock();
boolean tryLock(long time, TimeUnit unit);
void unlock();
Condition newCondition();

Lock它與內置加鎖機制不同,它提供的是一種無條件的、可輪訓的、定時的以及可中斷的鎖獲取操作,全部的加鎖和解鎖的方法都是顯式的。

一句話總結:Synchronized算是Lock的簡化版本號。功能比之較少可是在程序運行完畢後會自己主動釋放鎖,而Lock必須手動釋放鎖

ReentrantLock
ReentrantLock它實現了Lock接口。並提供了與synchronized相同的相互排斥性和內存可見性。
那為什麽還要提供一種機制跟內置鎖十分類似的新加鎖機制呢?
由於內置鎖在一定情況下存在局限性。比如無法中斷一個正在等待獲取鎖的進程,或者無法在請求獲取一個鎖時無限地等待下去,。無法實現非堵塞結構的加鎖規則。

而在ReentrantLock中。它能夠實現輪詢鎖、定時鎖、中斷鎖等多種加鎖方式,這也讓它的應用場景變的很多其它。

同一時候在性能上:假設有越多的資源被耗費在鎖的管理和調度上,那麽應用程序得到的資源就越少。鎖的實現方式越好,就須要越少的系統調用和上下文切換,而且在共享內存總線上的內存同步通信量也越少。
在Java5中。ReentrantLock的性能比內置鎖高了非常多,可是在Java6中內置鎖採取了一種類似與ReentrantLock中使用的算法來管理內置鎖,有效地提高了可伸縮性,因此在Java6中。它們的吞吐量就非常接近了。

在公平性上,ReentrantLock能夠創建一個非公平鎖(默認)也能夠創建一個公平鎖。


公平鎖:線程依照發出請求的順序來獲得鎖(先到先得。不準插隊)
非公平鎖:當一個線程請求非公平鎖時,假設在發出請求的同一時候該鎖的狀態變為可用,那麽就跳過隊列中全部的等待隊列立馬獲得鎖(也就是同意插隊。申請的時候鎖為可用狀態就直接獲取)

而對於公平鎖和非公平鎖來說,它們的效率也是顯而易見的:
公平性將由於在掛起線程和恢復線程時存在的開銷而極大的減少效率。
而非公平性由於是在請求時鎖已經為可用狀態就直接獲取,不須要進行什麽額外的操作。因此效率更高。
實際上:確保被堵塞的線程能終於獲得鎖就已經夠用了,而且實際開銷也小非常多。

當在一個激烈競爭的情況下,恢復一個被掛起的線程與這個線程真正開始運行之間存在著嚴重的延遲,這樣的話就影響了效率。而假設我們採用非公平鎖(也就是ReentrantLock的默認方式)。線程A釋放鎖時,B被喚醒然後嘗試獲取鎖,與此同一時候C也請求這個鎖。那麽C非常有可能會在B被全然喚醒之前獲得、使用以及釋放這個鎖。也就有可能會造成B獲得鎖的時刻並沒有推遲,C也更早的獲得了鎖

那什麽時候應該使用公平鎖呢?
當持有鎖的時間相對較長,或者請求鎖的平均時間間隔較長,那麽就應該使用公平鎖。

在Synchronize和ReentrantLock中怎樣選擇
上面總結了這麽多,好像ReentrantLock的長處比Synchronize好太多,那為什麽不直接取消掉Synchronize呢?我們自己該怎麽選擇呢?

Synchronize非常重要的幾個長處就是:

  • 無需手動釋放鎖,程序自己主動完畢。

    假設在使用Lock的過程中忘記在finally中釋放鎖,那麽盡管代碼表面上能正式運行,可是實際上已經出了大問題,還有可能傷到其它代碼。所以一般僅僅是在做一些內置鎖不能完畢的需求時才考慮使用ReentrantLock,比如中斷鎖、輪詢鎖等等

  • 調試的問題:Synchronized在線程存儲中能夠給出在哪些調用幀中獲得了哪些鎖。並能夠檢測和識別發生死鎖的進程。

    而ReentrantLock它僅僅是一個對象。JVM不知道哪些線程持有這個。

非堵塞同步機制(樂觀鎖)

加鎖機制始終會存在一個掛起喚醒的操作,假設有多個線程同一時候請求鎖,那麽JVM就須要借助操作系統的功能,而在掛起和恢復線程等過程中存在著非常大的開銷,而且通常存在著較長時間的中斷。假設在競爭激烈的時候,調度開銷與工作開銷的比值會非常高。

此外,假設一個線程正在等待鎖時,它不能做不論什麽其它事情,同一時候假設被堵塞線程的優先級較高,而持有鎖的線程優先級較低,那麽問題更嚴重。也就是發生了優先級反轉。即:高優先級的線程必須等到低優先級的線程釋放鎖,從而導致它的優先級會減少至低優先級線程的級別。

而近期的非常多並發算法研究都側重於非堵塞同步的機制,比如:Lock-free算法

Lock-free算法(無鎖)

這個算法中主要使用到了一個CAS機制(Compare and swap),它包括了3個值,須要讀寫的內存位置V,須要進行比較的值A, 要寫入的新值B。
它的原理就是:

當且僅當V的值等於A時,CAS才會通過原子方式用新值B來更新V的值,否則不運行不論什麽操作

而當多個線程常識使用CAS同一時候更新同一個變量時,僅僅有當中一個線程能更新變量的值。而其它的線程都將失敗。

可是失敗的線程並不會被掛起。而是被告知在這次競爭中失敗,並能夠嘗試再次嘗試。由於一個線程在競爭CAS時失敗不會堵塞,因此它能夠決定是否又一次嘗試,或者運行一些恢復操作,再或者不運行不論什麽操作。這樣的靈活性就大大減少了與鎖相關的活躍性風險。

結語

並發的水實在太深,不花精力實在難以hold住,這裏簡單記錄一下Java並發的知識點

參考

  • 《Java並發編程實戰》
  • 聊聊並發系列博客
  • JAVA中JVM的重排序具體介紹 ——也就是Java中為什麽要使用重排序

個人知識點總結——Java並發