1. 程式人生 > >Java併發指南開篇:Java併發程式設計學習大綱

Java併發指南開篇:Java併發程式設計學習大綱

Java併發程式設計一直是Java程式設計師必須懂但又是很難懂的技術內容。

這裡不僅僅是指使用簡單的多執行緒程式設計,或者使用juc的某個類。當然這些都是併發程式設計的基本知識,除了使用這些工具以外,Java併發程式設計中涉及到的技術原理十分豐富。為了更好地把併發知識形成一個體系,也鑑於本人沒有能力寫出這類文章,於是參考幾位併發程式設計專家的部落格和書籍,做一個簡單的整理和複習。

本文只是簡要的介紹和總結。詳細的內容歡迎來我的專欄閱讀,會有更多的系列文章。

併發基礎和多執行緒 

首先需要學習的就是併發的基礎知識,什麼是併發,為什麼要併發,多執行緒的概念,執行緒安全的概念等。

然後學會使用Java中的Thread或是其他執行緒實現方法,瞭解執行緒的狀態轉換,執行緒的方法,執行緒的通訊方式等。

JMM記憶體模型 

任何語言最終都是執行在處理器上,JVM虛擬機器為了給開發者一個一致的程式設計記憶體模型,需要制定一套規則,這套規則可以在不同架構的機器上有不同實現,並且向上為程式設計師提供統一的JMM記憶體模型。

所以瞭解JMM記憶體模型也是瞭解Java併發原理的一個重點,其中瞭解指令重排,記憶體屏障,以及可見性原理尤為重要。

JMM只保證happens-before和as-if-serial規則,所以在多執行緒併發時,可能出現原子性,可見性以及有序性這三大問題。

下面的內容則會講述Java是如何解決這三大問題的。

synchronized,volatile,final等關鍵字

對於併發的三大問題,volatile可以保證原子性和可見性,synchronized三種特性都可以保證(允許指令重排)。

synchronized是基於作業系統的mutex lock指令實現的,volatile和final則是根據JMM實現其記憶體語義。

此處還要了解CAS操作,它不僅提供了類似volatile的記憶體語義,並且保證操作原子性,因為它是由硬體實現的。

JUC中的Lock底層就是使用volatile加上CAS的方式實現的。synchronized也會嘗試用cas操作來優化器重量級鎖。

瞭解這些關鍵字是很有必要的。

JUC包 

在瞭解完上述內容以後,就可以看看JUC的內容了。

JUC提供了包括Lock,原子操作類,執行緒池,同步容器,工具類等內容。

這些類的基礎都是AQS,所以瞭解AQS的原理是很重要的。

除此之外,還可以瞭解一下Fork/Join,以及JUC的常用場景,比如生產者消費者,阻塞佇列,以及讀寫容器等。

實踐 

上述這些內容,除了JMM部分的內容比較不好實現之外,像是多執行緒基本使用,JUC的使用都可以在程式碼實踐中更好地理解其原理。多嘗試一些場景,或者在網上找一些比較經典的併發場景,或者參考別人的例子,在實踐中加深理解,還是很有必要的。 

補充 

 由於很多Java新手可能對併發程式設計沒什麼概念,在這裡放一篇不錯的總結,簡要地提幾個併發程式設計中比要重要的點,也是比較基本的點嗎,算是拋磚引玉,開個好頭,在大致瞭解了這些基礎內容以後,才能更好地開展後面詳細內容的學習。

 

1.併發程式設計三要素 

2. 執行緒的五大狀態 

  • 原子性 原子,即一個不可再被分割的顆粒。在Java中原子性指的是一個或多個操作要麼全部執行成功要麼全部執行失敗。
  • 有序性 程式執行的順序按照程式碼的先後順序執行。(處理器可能會對指令進行重排序)
  • 可見性 當多個執行緒訪問同一個變數時,如果其中一個執行緒對其作了修改,其他執行緒能立即獲取到最新的值。
  • 建立狀態 當用 new 操作符建立一個執行緒的時候
  • 就緒狀態 呼叫 start 方法,處於就緒狀態的執行緒並不一定馬上就會執行 run 方法,還需要等待CPU的排程
  • 執行狀態 CPU 開始排程執行緒,並開始執行 run 方法
  • 阻塞狀態 執行緒的執行過程中由於一些原因進入阻塞狀態 比如:呼叫 sleep 方法、嘗試去得到一個鎖等等​​
  • 死亡狀態 run 方法執行完 或者 執行過程中遇到了一個異常

3.悲觀鎖與樂觀鎖 

  • 悲觀鎖:每次操作都會加鎖,會造成執行緒阻塞。
  • 樂觀鎖:每次操作不加鎖而是假設沒有衝突而去完成某項操作,如果因為衝突失敗就重試,直到成功為止,不會造成執行緒阻塞。

4.執行緒之間的協作 

4.1 wait/notify/notifyAll 

這一組是 Object 類的方法 需要注意的是:這三個方法都必須在同步的範圍內呼叫​

  • wait 阻塞當前執行緒,直到 notify 或者 notifyAll 來喚醒

wait有三種方式的呼叫
wait()
必要要由 notify 或者 notifyAll 來喚醒​​​​
wait(long timeout)
在指定時間內,如果沒有notify或notifAll方法的喚醒,也會自動喚醒。
wait(long timeout,long nanos)
本質上還是呼叫一個引數的方法
public final void wait(long timeout, int nanos) throws InterruptedException {
      if (timeout < 0) {
             throw new IllegalArgumentException("timeout value is negative");
       }
      if (nanos < 0 || nanos > 999999) {
              throw new IllegalArgumentException(
             "nanosecond timeout value out of range");
       }
       if (nanos > 0) {
             timeout++;
       }
       wait(timeout);
}
       

4.2 sleep/yield/join 

  • notify 只能喚醒一個處於 wait 的執行緒
  • notifyAll 喚醒全部處於 wait 的執行緒

這一組是 Thread 類的方法

  • sleep 讓當前執行緒暫停指定時間,只是讓出CPU的使用權,並不釋放鎖

  • yield 暫停當前執行緒的執行,也就是當前CPU的使用權,讓其他執行緒有機會執行,不能指定時間。會讓當前執行緒從執行狀態轉變為就緒狀態,此方法在生產環境中很少會使用到,​​​官方在其註釋中也有相關的說明

/**
* A hint to the scheduler that the current thread is willing to yield
* its current use of a processor. The scheduler is free to ignore this
* hint.
*
* <p> Yield is a heuristic attempt to improve relative progression
* between threads that would otherwise over-utilise a CPU. Its use
* should be combined with detailed profiling and benchmarking to
* ensure that it actually has the desired effect.
*
* <p> It is rarely appropriate to use this method. It may be useful
* for debugging or testing purposes, where it may help to reproduce
* bugs due to race conditions. It may also be useful when designing
* concurrency control constructs such as the ones in the
* {@link java.util.concurrent.locks} package.
*/​​
​​​​

 join 等待呼叫 join 方法的執行緒執行結束,才執行後面的程式碼 其呼叫一定要在 start 方法之後(看原始碼可知)​ 使用場景:當父執行緒需要等待子執行緒執行結束才執行後面內容或者需要某個子執行緒的執行結果會用到 join 方法

5.valitate 關鍵字 

5.1 定義 

java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致的更新,執行緒應該確保通過排他鎖單獨獲得這個變數。Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個欄位被宣告成volatile,java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。

valitate是輕量級的synchronized,不會引起執行緒上下文的切換和排程,執行開銷更小。

5.2 原理 

1. 使用volitate修飾的變數在彙編階段,會多出一條lock字首指令 2. 它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成 3. 它會強制將對快取的修改操作立即寫入主存 4. 如果是寫操作,它會導致其他CPU裡快取了該記憶體地址的資料無效。

5.3 作用

記憶體可見性 多執行緒操作的時候,一個執行緒修改了一個變數的值 ,其他執行緒能立即看到修改後的值 防止重排序 即程式的執行順序按照程式碼的順序執行(處理器為了提高程式碼的執行效率可能會對程式碼進行重排序)

並不能保證操作的原子性(比如下面這段程式碼的執行結果一定不是100000)

public class testValitate {
 public volatile int inc = 0;
 public void increase() {
     inc = inc + 1;
 }
 public static void main(String[] args) {
     final testValitate test = new testValitate();
     for (int i = 0; i < 100; i++) {
         new Thread() {
             public void run() {
                 for (int j = 0; j < 1000; j++)
                     test.increase();
             }
         }.start();
     }
     while (Thread.activeCount() > 2) {  //保證前面的執行緒都執行完
         Thread.yield();
     }
     System.out.println(test.inc);
  }
}

6. synchronized 關鍵字 

確保執行緒互斥的訪問同步程式碼

6.1 定義 

synchronized 是JVM實現的一種鎖,其中鎖的獲取和釋放分別是 monitorenter 和 monitorexit 指令,該鎖在實現上分為了偏向鎖、輕量級鎖和重量級鎖,其中偏向鎖在 java1.6 是預設開啟的,輕量級鎖在多執行緒競爭的情況下會膨脹成重量級鎖,有關鎖的資料都儲存在物件頭中。

6.2 原理

加了 synchronized 關鍵字的程式碼段,生成的位元組碼檔案會多出 monitorenter 和 monitorexit 兩條指令(利用javap -verbose 位元組碼檔案可看到關,關於這兩條指令的文件如下:

       加了 synchronized 關鍵字的方法,生成的位元組碼檔案中會多一個 ACC_SYNCHRONIZED 標誌位,當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件。 其實本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過位元組碼來完成。

  • monitorenter Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows: • If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor. • If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count. • If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.​

  • monitorexit The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref. The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

6.3 關於使用 

  • 修飾普通方法 同步物件是例項物件
  • 修飾靜態方法 同步物件是類本身
  • 修飾程式碼塊 可以自己設定同步物件

6.4 缺點 

會讓沒有得到鎖的資源進入Block狀態,爭奪到資源之後又轉為Running狀態,這個過程涉及到作業系統使用者模式和核心模式的切換,代價比較高。Java1.6為 synchronized 做了優化,增加了從偏向鎖到輕量級鎖再到重量級鎖的過度,但是在最終轉變為重量級鎖之後,效能仍然較低。

7. CAS

AtomicBoolean,AtomicInteger,AtomicLong以及 Lock 相關類等底層就是用 CAS實現的,在一定程度上效能比 synchronized 更高。

7.1 什麼是CAS

CAS全稱是Compare And Swap,即比較替換,是實現併發應用到的一種技術。操作包含三個運算元 —— 記憶體位置(V)、預期原值(A)和新值(B)。 如果記憶體位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新為新值 。否則,處理器不做任何操作。

7.2 為什麼會有CAS

如果只是用 synchronized 來保證同步會存在以下問題 synchronized 是一種悲觀鎖,在使用上會造成一定的效能問題。在多執行緒競爭下,加鎖、釋放鎖會導致比較多的上下文切換和排程延時,引起效能問題。一個執行緒持有鎖會導致其它所有需要此鎖的執行緒掛起。

7.3 實現原理

Java不能直接的訪問作業系統底層,是通過native方法(JNI)來訪問。CAS底層通過Unsafe類實現原子性操作。

7.4 存在的問題

  • ABA問題 什麼是ABA問題?比如有一個 int 型別的值 N 是 1 此時有三個執行緒想要去改變它: 執行緒A ​​:希望給 N 賦值為 2 執行緒B: 希望給 N 賦值為 2 執行緒C: 希望給 N 賦值為 1​​ 此時執行緒A和執行緒B同時獲取到N的值1,執行緒A率先得到系統資源,將 N 賦值為 2,執行緒 B 由於某種原因被阻塞住,執行緒C線上程A執行完後得到 N 的當前值2 此時的執行緒狀態 執行緒A成功給 N 賦值為2 執行緒B獲取到 N 的當前值 1 希望給他賦值為 2,處於阻塞狀態 執行緒C獲取當好 N 的當前值 2 ​​​​​希望給他賦值為1 ​​ 然後執行緒C成功給N賦值為1 ​最後執行緒B得到了系統資源,又重新恢復了執行狀態,​在阻塞之前執行緒B獲取到的N的值是1,執行compare操作發現當前N的值與獲取到的值相同(均為1),成功將N賦值為了2。 ​ 在這個過程中執行緒B獲取到N的值是一箇舊值​​,雖然和當前N的值相等,但是實際上N的值已經經歷了一次 1到2到1的改變 上面這個例子就是典型的ABA問題​ 怎樣去解決ABA問題 給變數加一個版本號即可,在比較的時候不僅要比較當前變數的值 還需要比較當前變數的版本號。Java中AtomicStampedReference 就解決了這個問題
  • 迴圈時間長開銷大 在併發量比較高的情況下,如果許多執行緒反覆嘗試更新某一個變數,卻又一直更新不成功,迴圈往復,會給CPU帶來很大的壓力。

CAS只能保證一個共享變數的原子操作

8. AbstractQueuedSynchronizer(AQS) 

AQS抽象的佇列式同步器,是一種基於狀態(state)的連結串列管理方式。state 是用CAS去修改的。它是 java.util.concurrent 包中最重要的基石,要學習想學習 java.util.concurrent 包裡的內容這個類是關鍵。 ReentrantLock​、CountDownLatcher、Semaphore 實現的原理就是基於AQS。想知道他怎麼實現以及實現原理 可以參看這篇文章

9. Future 

在併發程式設計我們一般使用Runable去執行非同步任務,然而這樣做我們是不能拿到非同步任務的返回值的,但是使用Future 就可以。使用Future很簡單,只需把Runable換成FutureTask即可。使用上比較簡單,這裡不多做介紹。

10. 執行緒池

如果我們使用執行緒的時候就去建立一個執行緒,雖然簡單,但是存在很大的問題。如果併發的執行緒數量很多,並且每個執行緒都是執行一個時間很短的任務就結束了,這樣頻繁建立執行緒就會大大降低系統的效率,因為頻繁建立執行緒和銷燬執行緒需要時間。執行緒池通過複用可以大大減少執行緒頻繁建立與銷燬帶來的效能上的損耗。

Java中執行緒池的實現類 ThreadPoolExecutor,其建構函式的每一個引數的含義在註釋上已經寫得很清楚了,這裡幾個關鍵引數可以再簡單說一下

  • corePoolSize :核心執行緒數即一直保留線上程池中的執行緒數量,即使處於閒置狀態也不會被銷燬。要設定 allowCoreThreadTimeOut 為 true,才會被銷燬。
  • maximumPoolSize:執行緒池中允許存在的最大執行緒數
  • keepAliveTime :非核心執行緒允許的最大閒置時間,超過這個時間就會本地銷燬。
  • workQueue:用來存放任務的佇列。
    • SynchronousQueue:這個佇列會讓新新增的任務立即得到執行,如果執行緒池中所有的執行緒都在執行,那麼就會去建立一個新的執行緒去執行這個任務。當使用這個佇列的時候,maximumPoolSizes一般都會設定一個最大值 Integer.MAX_VALUE
    • LinkedBlockingQueue:這個佇列是一個無界佇列。怎麼理解呢,就是有多少任務來我們就會執行多少任務,如果執行緒池中的執行緒小於corePoolSize ,我們就會建立一個新的執行緒去執行這個任務,如果執行緒池中的執行緒數等於corePoolSize,就會將任務放入佇列中等待,由於佇列大小沒有限制所以也被稱為無界佇列。當使用這個佇列的時候 maximumPoolSizes 不生效(執行緒池中執行緒的數量不會超過corePoolSize),所以一般都會設定為0。
    • ArrayBlockingQueue:這個佇列是一個有界佇列。可以設定佇列的最大容量。當執行緒池中執行緒數大於或者等於 maximumPoolSizes 的時候,就會把任務放到這個佇列中,噹噹前佇列中的任務大於佇列的最大容量就會丟棄掉該任務交由 RejectedExecutionHandler 處理。

最後,本文主要對Java併發程式設計開發需要的知識點作了簡單的講解,這裡每一個知識點都可以用一篇文章去講解,由於篇幅原因不能對每一個知識點都詳細介紹,我相信通過本文你會對Java的併發程式設計會有更近一步的瞭解。如果您發現還有缺漏或者有錯誤的地方,可以在評論區補充,謝謝。