1. 程式人生 > >slayerliu001的專欄

slayerliu001的專欄

1 併發理論簡介

1.1 java執行緒模型

java執行緒模型建立在兩個基本概念之上:

1.共享的,預設可見的可變狀態

2.搶佔式執行緒排程

也就是說,首先,統一程序中的所有執行緒應該可以很容易地共享程序中的物件;其次,能夠引用這些物件的所有執行緒都可以 修改 這些物件;第三,執行緒排程器應該可以在幾乎任何時候在cpu上調入或者調出執行緒

但是這種隨時的執行緒排程很有可能是在方法執行到一半的時候被中斷了,這樣可能會出現狀態不一致的物件,為了避免這樣的風險,必須還要滿足最後一點:物件可以被鎖住。

1.2 設計理念

  • 安全性
  • 活躍度
  • 效能
  • 重用性

安全性是指不管同時發生多少操作,都能保證物件保持一致。

活躍度是指,在一個活躍的系統中,所有做出嘗試的活動,最終 要麼取得進展,要麼失敗(不能卡死在某處)

效能可以理解為給定資源能做多少工作

可重用性,沒有人希望每次都要從頭開始寫併發程式碼吧,這時候設計一個好的可重用工具集就比較重要了

1.3  這些設計理念相互衝突的原因

  • 安全性跟活躍度互相對立:安全性確保壞事不發生,活躍度要求見到進展(就算是壞的結果)
  • 可重用系統傾向於對外開放核心,可能會引發安全性
  • 如果通過大量的鎖來保證安全性,那效能通常不會好

要想最終達到一種平衡狀態,既能靈活適用於各種問題,又安全,活躍度和效能也保持在一定的水平,做到這些還是挺困難的。

1.4 系統開銷之源

併發系統中的開銷來自:鎖與檢測、環境切換、執行緒的個數、排程、記憶體區域性性、演算法設計。

其他都比較好理解,這裡稍微帶兩句區域性性,現代的CPU有快取來加速記憶體讀取,其可以更快地讀取最近訪問過的記憶體毗鄰的記憶體。基於這一點,我們通過保證處理的資料排列在連續記憶體上,以提高記憶體區域性性,從而提高效能。

1.5 一個事務處理的例子

如下圖,用不同的執行緒池表示不同的程式處理階段,每個執行緒池逐一接收工作項,處理完後交給下一個執行緒池處理如下圖。

這種設計用java.util.concurrent包裡的類很容易實現。這個包裡有執行任務的執行緒池(Executor)、在不同執行緒池間傳遞工作的佇列、併發資料結構(concurrentHashMap)以及其他很多底層工具 folk-join框架,同步器比如cyclicBarrier countDownLatch Exchanger semaphore SynchronousQueue等等

2.塊結構併發(jdk1.5之前)

以前這種原始的,低階的通過synchronized,volatile等關鍵字來實現的多執行緒有何不妥之處?

2.1同步與鎖

使用synchronized鎖定的一些基本事實如下

  • 只能鎖定物件,不能鎖定基本型別
  • 被鎖定的物件陣列中的單個物件不會被鎖定
  • synchronized方法可以視為等同於用synchronized(this)包住的程式碼塊,但是他們二進位制程式碼是不一樣的
  • 靜態同步方法鎖定的是class物件,因為沒有例項物件
  • 如果要鎖定一個類物件,顯示鎖定和用getclass方法是不一樣的,我的理解是getclass永遠獲取的是執行例項的類物件
  • 內部類的同步是獨立於外部類的(因為編譯成位元組碼之後,外部類和內部類是兩個不同的class)
  • synchronized並不是方法簽名的組成部分,所以不能出現在介面的方法宣告中
  • java的執行緒鎖時可重入的,也就是說持有鎖的執行緒在遇到同一個鎖的同步點(比如說一個同步方法呼叫同一個類裡的另一個同步方法)時可以繼續執行。

2.2執行緒的狀態模型

如下圖

 使用new操作符新建立一個執行緒的時候,該執行緒還沒執行,處於上圖中的新建立狀態。

呼叫strat方法之後,就進入可執行狀態,可執行狀態的執行緒可以處於執行狀態也可能處於未執行的狀態,這個具體取決於作業系統是不是給了他時間片。這個圖之所以把其他其他地方見到的一些執行緒模型裡的「準備就緒」和「執行」兩個狀態畫在一起作為「可執行」狀態,是因為這兩個狀態在接收到通知或者請求鎖時,狀態變化是一樣的。

被阻塞和等待狀態

他們共同點在於執行緒處於這兩種狀態都是不活動的,不執行任何程式碼,消耗最少的資源知道執行緒排程器重新啟用他們。

  • 當執行緒試圖獲取一個內部物件鎖(而不是java.util.concurrent庫裡的Lock),而該鎖被其他執行緒持有,這時候執行緒進入被阻塞狀態(BLOCKED)。當其他所有持有該鎖的執行緒都釋放該鎖,並且執行緒排程器允許本執行緒持有該鎖的時候,狀態才恢復為非阻塞。
  • 當執行緒等待另一個執行緒通知排程器一個條件的時候,他自己進入等待(waiting)狀態,比如呼叫Object.wait,Thread.join,或者等待java.util.concurrent庫裡的Lock或者Condition的時候
  • 超時等待(timed waiting)和等待的區別就在於多了一個超時引數,當超時時間到了也會退出該狀態。帶超時引數的方法有Thread.sleep,Object.wait,Thread.join,Lock.tryLock,Condition.wait的計時版本。

當一個執行緒從被阻塞或者等待狀態恢復的時候,也不一定就立即能執行,排程器會去判斷優先順序,如果它優先順序高於某個正在執行的執行緒這個時候才會剝奪一個低優先順序的執行執行緒讓他來執行。

被終止可 能因為 1.run方法 自然退出 2.因為一個未捕獲的異常終止了run方法

為什麼是synchronized

為什麼java用來標識臨界區的關鍵字 是synchronized,不是critical locked之類的詞?

回答這個問題首先要知道的是,由於現在我們正處於多核時代,所以在考慮多執行緒程式時,必須把多個執行緒在同一時刻都在執行並且很可能在操作共享的資料考慮進去。為了提高效率,同時執行的每個執行緒可能都有他正在處理的資料的一個複本。

所以現在就有了主記憶體和執行緒本地記憶體的概念,臨界區程式碼塊執行完後,對被鎖定物件所做的任何修改,都會被重新整理到主記憶體中去。於是我們可以看到被synchronized的是在不同執行緒中表示被鎖定物件的記憶體塊。另外,當進入一個同步的程式碼塊,得到執行緒鎖之後,對被鎖定物件的任何修改都是從主記憶體中讀取出來的,所以在鎖定區域程式碼執行之前,持有鎖的執行緒就和鎖定物件主記憶體中synchronized了。

關鍵字volatile

一個volatile域遵循以下的的規則:

  • 執行緒所見的值在使用前總會從主記憶體讀出來
  • 執行緒所寫的值總會在指令完成前重新整理回主記憶體

可以把volatile域看成一個小小的同步塊,但是 只有寫入時不依賴於當前狀態的變數才應該被宣告為volatile,舉個例子,銀行賬戶的餘額,有多個執行緒在同併發地對其進行增減的時候,宣告為volatile並不能保證最終結果是正確的。

不可變性

不可變物件沒有狀態,或者只有final域(因此只能在構造方法中賦值)。因為不可變物件的狀態不可修改,因此不可能出現不一致的情況。

這邊插入一個設計模式相關的知識:構建器模式。假如我們要建立一個不可變物件,假設這個物件需要有很多個域,那如果過通過構造方法去構造物件,就必須把傳遞很多個引數給構造方法,這樣看起來又蠢又不便。有一個改進的方法是用工廠方法來生成需要的物件,但是還是避免不了傳遞大量的引數給工廠方法。

構建器模式可以解決這個問題:它由一個實現了構建器泛型介面的內部靜態類,一個構建不可變類例項的私有構造方法組成。

//構建器介面
public interface ObjeectBuilder<T> {
    T build();
}
//需要通過構建器構造物件的類,這裡的例子是一個不可變類
public class update() {
    //以下為final域,只能在構造方法中初始化
    private final Author author;
    private final String updateText;
    //私有的構造方法
    private Update(Builder builder) {
        author = builder.author;
        updateText = builder.updateText;
    }
    //內部靜態構造器類
    //靜態內部類與非靜態內部類之間存在一個最大的區別,我們知道非靜態內部類在編譯完成之後會隱含地
    //儲存著一個引用,該引用是指向建立它的外圍內,但是靜態內部類卻沒有。沒有這個引用就意味著:
    //  1、 它的建立是不需要依賴於外圍類的。
    //  2、 它不能使用任何外圍類的非static成員變數和方法。
    public static class Builder implements Object<Update> {
        private Author author;
        private String updateText;
        
        public Builder author(Author _author) {
            author = _author;
            return this;
        }
        
        public Builder updateText(String _updateText) {
            updateText = _updateText;
            return this;
        }
        
        @override
        public Update build() {
            return new Update(this);
        }    
    }

}
//最後呼叫的時候如下,可以鏈式呼叫,看起來很簡潔,而且需要生成的物件是鏈式呼叫的最後一步
//呼叫build方法的時候才生成,避免了有些情況下可能出現的同步問題
Update.Builder ub = new Update.Builer();
Update u = ub.updateText("Hello").author(someAuthor).build();

現代併發應用程式的構件

jdk1.5開始引入的java.util.cocurrent包包含了大量編寫多執行緒程式碼的新工具,下面簡單介紹一下。

原子類 java.util.concurrent.atomic

java.util.concurrent.atomic包裡有幾個以Atomic打頭的類,比如AtomicInteger,AtomicLong等等,他們語義上基本和volatile一樣,但是比volatile好,用這些類可以保證多執行緒都getandset同一個值也保持正確。

執行緒鎖 java.util.concurrent.locks

塊結構同步其實使用的是物件的內部鎖,這種鎖有一些缺點:

  • 這種鎖只有一種型別
  • 只能在同步方法或者同步程式碼塊開始結束的地方取得/釋放鎖
  • 執行緒要麼獲得鎖,要麼阻塞,沒有別的可能

如果想對上面的鎖進行改進,可以有如下幾點:

  • 支援不同型別的鎖,比如讀取鎖和寫入鎖
  • 可以在一個方法上鎖,在另外一個方法解鎖
  • 如果執行緒得不到鎖,允許執行緒繼續執行或者先做別的事(通過tryLock方法)
  • 獲取鎖應該有一個超時機制,超時獲取不到則放棄

java.util.concurrent.locks中的Lock介面有兩個實現類,一個是ReentrantLock,另一個是ReentrantReadWriteLock,讀執行緒很多,寫執行緒很少的時候用ReentrantReadWriteLock效能會比較好。

CountDownLatch

簡而言之,使用方法是先新建一個CountDownLatch,引數是一個整數表示需要countdown多少下,

CountDownLatch cdl = new CountDownLatch(num);

然後在子執行緒中  cdl .countDown();

然後在主執行緒中cdl.await();

這樣主執行緒就能保證在num個子執行緒執行完後才能繼續往下執行。

concurrentHashMap

concurrentHashMap是標準的HashMap的併發版本。下面就比較一下他們。

在java8中,HashMap和ConcurrentHashMap的實現都做了修改。

首先是java8之前(以java7為例),HashMap結構大體上如下圖,就是一個數組,陣列的每個元素是一個連結串列

然後是java7中的ConcurrentHashMap,基本思路和HashMap差不多,也是一個數組,數組裡面再放存放元素的結構,和HashMap不一樣的地方在於1,這個陣列不能擴容,一旦初始化的時候大小定了就不能再改變 ;2,這個陣列的元素是segment,繼承了ReentrantLock,其實這個segment和標準HashMap有點像,他是一個數組,元素是連結串列,只不過為了實現併發,需要加鎖操作。結構如下

到了java8中,HashMap程式碼進行了重寫,因為java7中的實現,對於每個陣列元素的連結串列查詢時,時間複雜度是O(n),這裡是有優化的餘地的,優化方法:每個連結串列的長度超過8以後,將連結串列轉為紅黑樹,紅黑樹的查詢時間複雜度是O(lgN),比O(N)的時間複雜度好出很多。

 java8中的ConcurrentHashMap放棄了分段鎖的做法,改用 CAS + synchronized 控制併發操作,在某些方面提升了效能。並且追隨 java8的 HashMap 底層實現,使用陣列+連結串列+紅黑樹進行資料儲存。

CopyOnWriteArrayList

CopyOnWriteArrayList通過增加寫時複製(copy-on-write)語義來實現執行緒安全。修改CopyOnWriteArrayList的任何操作都會建立一個back array的新複本。所以在用迭代器迭代CopyOnWriteArrayList的時候,一旦iterater生成了就不用擔心遇到意外的修改,所以不會在多執行緒操作list的時候產生ConcurrentModificationException。

CopyOnWriteArrayList的使用還是挺複雜的,因為他會多出一個backing array,如果資料量大的話,恐怕會觸發young GC甚至fullGC,那就會拖慢效能了。一般將其使用在寫的很少,讀取較多的場合,而且對資料實時一致性要求不高的場合,因為CopyOnWriteArrayList只能保證資料的最終一致性。書上的一個例子微博的時間線就不錯,時間線如果某個時候少了那麼一條正在插入的更新,影響並不大。

Queue

最基本的blockingQueue有兩個預設的實現:LinkedBlockingQueue和ArrayBlockingQueue,一般來說已知佇列大小且能確定合適的邊界的時候用ArrayBlockingQueue效能稍好一些。

BlockingQueue的兩個特性:佇列滿時put()被阻塞,佇列空時take()被阻塞

☆ Queue介面都是泛型的,但是一般不要直接這樣用:BlockingQueue<MyWork>,而是再封裝一層:BlockingQueue<QueueObject<MyWork>>,再封裝一層的好處有很多,如果測試或者有其他需要時,可以在QueueObject類中新增所需要的metadata,比如測試資訊,效能資料等等,而不需要修改實際的任務類MyWork。

BlockingQueue有個問題,如果用兩個執行緒一個處理生產,一個處理消費,生產者消費者的生產、消費速度差的有點多的話,很快佇列要麼一直是空的,一直是滿的。Java7開始有了TransferQueue來解決這個問題。

TransferQueue

之前說過,BlockingQueue當生產者向佇列新增元素但佇列已滿時,生產者會被阻塞;當消費者從佇列移除元素但佇列為空時,消費者會被阻塞。而TransferQueue繼承了BlockingQueue,所以一起BlockingQueue有的操作他都有,另外它還支援這樣的操作:生產者會一直阻塞直到所新增到佇列的元素被某一個消費者所消費(不僅僅是新增到佇列裡就完事)。新新增的transfer方法用來實現這種約束。

當然了,還有tryTransfer方法,包括帶timeout和不帶timeout的。

不帶timeout的:若當前存在一個正在等待獲取的消費者執行緒,則該方法會即刻轉移e,並返回true;若不存在則返回false,但是並不會將e插入到佇列中。這個方法不會阻塞當前執行緒,要麼快速返回true,要麼快速返回false。

帶timeout的:若當前存在一個正在等待獲取的消費者執行緒,會立即傳輸給它; 否則將元素e插入到佇列尾部,並且等待被消費者執行緒獲取消費掉。若在指定的時間內元素e無法被消費者執行緒獲取,則返回false,同時該元素從佇列中移除。

表示可執行程式碼片段的另一個介面

之前建立的執行緒都是用的java.lang.Runnable作為可執行的程式碼片段,使用Runnable是不能直接返回返回值的,而且Runnable的執行方法run()也不能丟擲異常。 現在有一個新的可執行程式碼片段介面:Callable,它位於java.util.concurrent包下,也只有一個方法:call(),  這是一個泛型介面,call()函式返回的型別就是傳遞進來的V型別。

Callable的使用一般需要和Future,FutureTask配合使用,Future是一個介面,FutureTask是一個類,他們之間的關係,手上沒有畫類圖的軟體,簡單描述一下吧,介面RunnableFuture extends Future介面和Runnable介面,而FutureTask   implements   RunnableFuture 介面,所以FutureTask既可以作為Runnable作為可執行程式碼段,也可以作為Future來接收Callable執行的結果。

分支/合併框架 

引入了一個新的executor服務,稱為ForkJoinPool,他可以處理比執行緒更小的併發單元:ForkJoinTask,而ForkJoinTask可以再被分為更小的ForkJoinTask,從而實現分而治之。

一個例子是多執行緒版本的mergesort,可以遞迴地實現,使用的是ForkJoinTask的子類RecursiveAction類

ForkJoinTask支援 工作竊取 機制,分之合併框架可以自動地將負荷滿的執行緒上的工作重新安排到負荷較輕的執行緒上去。

什麼情況下適用分支合併框架呢?有如下的這麼一個checklist

  • 問題的子任務是不是無需和其他子任務有協作或者同步也能工作?
  • 子任務是不是不會修改資料,只是經過計算得出一些結果?
  • 子任務是不是自然而然可以被分為更細小的子任務?

如果上面幾個問題回答都是“是”或者“大體上如此”的話,應該是屬於適用分支合併框架的情形,反之則可能不太適合,需要考慮其他的同步方式。

Java記憶體模型

JLS裡面對於java記憶體模型的定義非常學術化,但是概括來說有兩個基本的概念:Happeens-before和Synchronizes-with.

Happeens-before表示一段程式碼在開始執行之前另一段程式碼已經執行完成。

Synchronizes-with表示動作執行之前必須把它的物件檢視與主記憶體進行同步。

針對上面的兩個概念,JMM的主要規則如下:

  • 在監測物件上的解鎖操作與後續的鎖操作之間存在Synchronizes-with關係
  • 對volatile變數的寫入與後續讀取之前存在Synchronizes-with關係
  • 如果動作A Synchronizes-with 動作B,那麼動作A Happeens-before動作B
  • 如果執行緒中動作A出現在動作B之前,那麼A 必定Happeens-before B

還有一些關於敏感行為的規則:

  • 構造方法必須在物件的終結器開始執行之前完成
  • 開始一個執行緒的動作與這個新執行緒的第一個動作是Synchronizes-with關係
  • Thread.join()方法與這個執行緒的最後一個動作也是Synchronizes-with關係
  • Happeens-before 關係存在傳遞性,亦即A Happeens-before B,B Happeens-before C,則 A Happeens-before C