啃碎併發(一):Java執行緒總述與概念

猿碼道發表於2018-01-30

1 前言

在JDK5之前,Java多執行緒以及其效能一直是個軟肋,只有synchronized、Thread.sleep()、Object.wait/notify這樣有限的方法,而synchronized的效率還特別地低,開銷比較大。

在JDK5之後,相對於前面版本有了重大改進,不僅在Java語法上有了很多改進,包括:泛型、裝箱、for迴圈、變參等,在多執行緒上也有了徹底提高,其引進了併發程式設計大師Doug Lea的java.util.concurrent包(後面簡稱J.U.C),支援了現代CPU的CAS原語,不僅在效能上有了很大提升,在自由度上也有了更多的選擇,此時 J.U.C的效率在高併發環境下的效率遠優於synchronized

在JDK6(Mustang 野馬)中,對synchronized的內在機制做了大量顯著的優化,加入了CAS的概念以及偏向鎖、輕量級鎖,使得synchronized的效率與J.U.C不相上下,並且官方說後面該關鍵字還有繼續優化的空間,所以在現 在JDK7時代,synchronized已經成為一般情況下的首選,在某些特殊場景:可中斷的鎖、條件鎖、等待獲得鎖一段時間如果失敗則停止,J.U.C是適用的,所以對於 多執行緒研究來說,瞭解其原理以及各自的適用場景是必要的

2 基本概念

2.1 執行緒

執行緒是依附於程式的,程式是分配資源的最小單位,一個程式可以生成多個執行緒,這些執行緒擁有共享的程式資源。就每個執行緒而言,只有很少的獨有資源,如:控制執行緒執行的執行緒控制塊,保留區域性變數和少數引數的棧空間等。執行緒有就緒、阻塞和執行三種狀態,並可以在這之間切換。也正因為多個執行緒會共享程式資源,所以當它們對同一個共享變數/物件進行操作的時候,執行緒的衝突和不一致性就產生了。

多執行緒併發環境下,本質上要解決地是這兩個問題:

  1. 執行緒之間如何通訊;
  2. 執行緒之間如何同步;

概括起來說就是:執行緒之間如何正確地通訊。雖然說的是在Java層面如何保證,但會涉及到 Java虛擬機器、Java記憶體模型,以及Java這樣的高階語言最終是要對映到CPU來執行(關鍵原因:如今的CPU有快取、並且是多核的),雖然有些難懂,但對於深刻把握多執行緒是至關重要的,所以需要多花一些時間。

2.2 鎖

當多個執行緒對同一個共享變數/物件進行操作,即使是最簡單的操作,如:i++,在處理上實際也涉及到讀取、自增、賦值這三個操作,也就是說 這中間存在時間差,導致多個執行緒沒有按照如程式編寫者所設想的去順序執行,出現錯位,從而導致最終結果與預期不一致

Java中的多執行緒同步是通過鎖的概念來體現。鎖不是一個物件、不是一個具體的東西,而是一種機制的名稱。鎖機制需要保證如下兩種特性:

  1. 互斥性:即在同一時間只允許一個執行緒持有某個物件鎖,通過這種特性來實現多執行緒中的協調機制,這樣在同一時間只有一個執行緒對需同步的程式碼塊(複合操作)進行訪問。互斥性我們也往往稱為操作的原子性
  2. 可見性:必須確保在鎖被釋放之前,對共享變數所做的修改,對於隨後獲得該鎖的另一個執行緒是可見的(即在獲得鎖時應獲得最新共享變數的值),否則另一個執行緒可能是在本地快取的某個副本上繼續操作從而引起不一致;

2.3 掛起、休眠、阻塞與非阻塞

掛起(Suspend):當執行緒被掛起的時候,其會失去CPU的使用時間,直到被其他執行緒(使用者執行緒或排程執行緒)喚醒。

休眠(Sleep):同樣是會失去CPU的使用時間,但是在過了指定的休眠時間之後,它會自動啟用,無需喚醒(整個喚醒表面看是自動的,但實際上也得有守護執行緒去喚醒,只是不需程式設計者手動干預)。

阻塞(Block):線上程執行時,所需要的資源不能得到,則執行緒被掛起,直到滿足可操作的條件。

非阻塞(Block):線上程執行時,所需要的資源不能得到,則執行緒不是被掛起等待,而是繼續執行其餘事情,待條件滿足了之後,收到了通知(同樣是守護執行緒去做)再執行

掛起和休眠是獨立的作業系統的概念,而阻塞與非阻塞則是在資源不能得到時的兩種處理方式,不限於作業系統,當資源申請不到時,要麼掛起執行緒等待、要麼繼續執行其他操作,資源被滿足後再通知該執行緒重新請求。顯然非阻塞的效率要高於阻塞,相應的實現的複雜度也要高一些。

在Java中顯式的掛起之前是通過Thread的suspend方法來體現,現在此概念已經消失,原因是suspend/resume方法已經被廢棄,它們容易產生死鎖,在suspend方法的註釋裡有這麼一段話:當suspend的執行緒持有某個物件鎖,而resume它的執行緒又正好需要使用此鎖的時候,死鎖就產生了

所以,現在的JDK版本中,掛起是JVM的系統行為,程式設計師無需干涉。休眠的過程中也不會釋放鎖,但它一定會在某個時間後被喚醒,所以不會死鎖。現在我們所說的掛起,往往並非指編寫者的程式裡主動掛起,而是由作業系統的執行緒排程器去控制

所以,我們常常說的“執行緒在申請鎖失敗後會被掛起、然後等待排程”這樣有一定歧義,因為這裡的“掛起”是作業系統級別的掛起,其實是在申請資源失敗時的阻塞,和Java中的執行緒的掛起(可能已經獲得鎖,也可能沒有鎖,總之和鎖無關)不是一個概念,很容易混淆,所以在後文中說的掛起,一般指的是作業系統的操作,而不是Thread中的suspend()。

相應地有必要提下java.lang.Object的wait/notify,這兩個方法同樣是等待/通知,但它們的前提是已經獲得了鎖,且在wait(等待)期間會釋放鎖。在wait方法的註釋裡明確提到:執行緒要呼叫wait方法,必須先獲得該物件的鎖,在呼叫wait之後,當前執行緒釋放該物件鎖並進入休眠(這裡到底是進入休眠還是掛起?文件沒有細說,從該方法能指定等待時間來看,更可能是休眠,沒有指定等待時間的,則可能是掛起,不管如何,在休眠/掛起之前,JVM都會從當前執行緒中把該物件鎖釋放掉),只有以下幾種情況下會被喚醒:其他執行緒呼叫了該物件的notify或notifyAll、當前執行緒被中斷、呼叫wait時指定的時間已到

2.4 核心態與使用者態

這是兩個作業系統的概念,但理解它們對我們理解Java的執行緒機制有著一定幫助。

有一些系統級的呼叫,比如:清除時鐘、建立程式等這些系統指令,如果這些底層系統級指令能夠被應用程式任意訪問的話,那麼後果是危險的,系統隨時可能崩潰,所以 CPU將所執行的指令設定為多個特權級別,在硬體執行每條指令時都會校驗指令的特權,比如:Intel x86架構的CPU將特權分為0-3四個特權級,0級的許可權最高,3許可權最低。

而作業系統根據這系統呼叫的安全性分為兩種:核心態和使用者態核心態執行的指令的特權是0,使用者態執行的指令的特權是3

  1. 當一個任務(程式)執行系統呼叫而進入核心指令執行時,程式處於核心執行態(或簡稱為核心態);
  2. 當任務(程式)執行自己的程式碼時,程式就處於使用者態;

明白了核心態和使用者態的概念之後,那麼在這兩種狀態之間切換會造成什麼樣的效率影響?

在執行系統級呼叫時,需要將變數傳遞進去、可能要拷貝、計數、儲存一些上下文資訊,然後核心態執行完成之後需要再將引數傳遞到使用者程式中去,這個切換的代價相對來說是比較大的,所以應該是 儘量避免頻繁地在核心態和使用者態之間切換

那作業系統的這兩種形態和我們的執行緒主題有什麼關係呢?這裡是關鍵。Java並沒有自己的執行緒模型,而是使用了作業系統的原生執行緒

如果要實現自己的執行緒模型,那麼有些問題就特別複雜,難以解決,比如:如何處理阻塞、如何在多CPU之間合理地分配執行緒、如何鎖定,包括建立、銷燬執行緒這些,都需要Java自己來做,在JDK1.2之前Java曾經使用過自己實現的執行緒模型,後來放棄了,轉向使用作業系統的執行緒模型,因此建立、銷燬、排程、阻塞等這些事都交由作業系統來做,而 執行緒方面的事在作業系統來說屬於系統級的呼叫,需要在核心態完成,所以如果頻繁地執行執行緒掛起、排程,就會頻繁造成在核心態和使用者態之間切換,影響效率(當然,作業系統的執行緒操作是不允許外界(包括Java虛擬機器)直接訪問的,而是開放了叫“輕量級程式”的介面供外界使用,其與核心執行緒在Window和Linux上是一對一的關係,這裡不多敘述)。

前面說JDK5之前的synchronized效率低下,是 因為在阻塞時執行緒就會被掛起、然後等待重新排程,而執行緒操作屬於核心態,這頻繁的掛起、排程使得作業系統頻繁處於核心態和使用者態的轉換,造成頻繁的變數傳遞、上下文儲存等,從而效能較低

3 執行緒優勢

儘管面臨很多挑戰,多執行緒有一些優點使得它一直被使用。這些優點是:

  1. 資源利用率更好;
  2. 程式設計在某些情況下更簡單;
  3. 程式響應更快速;

3.1 資源利用率更好

CPU能夠在等待IO的時候做一些其他的事情。這個不一定就是磁碟IO。它也可以是網路的IO,或者使用者輸入。通常情況下,網路和磁碟的IO比CPU和記憶體的IO慢的多。

3.2 程式設計更簡單

在單執行緒應用程式中,如果你想編寫程式手動處理多個IO的讀取和處理的順序,你必須記錄每個檔案讀取和處理的狀態。相反,你可以啟動兩個執行緒,每個執行緒處理一個檔案的讀取和處理操作。執行緒會在等待磁碟讀取檔案的過程中被阻塞在等待的時候,其他的執行緒能夠使用CPU去處理已經讀取完的檔案。其結果就是,磁碟總是在繁忙地讀取不同的檔案到記憶體中。這會帶來磁碟和CPU利用率的提升。而且每個執行緒只需要記錄一個檔案,因此這種方式也很容易程式設計實現。

3.3 程式響應更快速

將一個單執行緒應用程式變成多執行緒應用程式的另一個常見的目的是 實現一個響應更快的應用程式。設想一個伺服器應用,它在某一個埠監聽進來的請求。當一個請求到來時,它去處理這個請求,然後再返回去監聽。

如果一個請求需要佔用大量的時間來處理,在這段時間內新的客戶端就無法傳送請求給服務端。只有伺服器在監聽的時候,請求才能被接收。

另一種設計是,監聽執行緒把請求傳遞給工作者執行緒池(worker thread pool),然後立刻返回去監聽。而工作者執行緒則能夠處理這個請求併傳送一個回覆給客戶端。

4 執行緒代價

使用多執行緒往往可以 獲得更大的吞吐率和更短的響應時間,但是,使用多執行緒不一定就比單執行緒程式跑的快,這取決於我們程式設計者的能力以及應用場景的不同。不要為了多執行緒而多執行緒,而應考慮具體的應用場景和開發實力,使用多執行緒就是希望能夠獲得更快的處理速度和利用閒置的處理能力,如果沒帶來任何好處還帶來了複雜性和一些定時炸彈,那就傻逼了?只有在使用多執行緒給我們帶來的好處遠大於我們付出的代價時,才考慮使用多執行緒。有時候可能引入多執行緒帶來的效能提升抵不過多執行緒而引入的開銷,一個沒有經過良好併發設計得程式也可能比使用單執行緒還更慢。

4.1 設計更復雜

多執行緒程式在訪問共享可變資料的時候往往需要我們很小心的處理,否則就會出現難以發現的BUG,一般地,多執行緒程式往往比單執行緒程式設計會更加複雜(儘管有些單執行緒處理程式可能比多執行緒程式要複雜),而且錯誤很難重現(因為執行緒排程的無序性,某些bug的出現依賴於某種特定的執行緒執行時序)。

4.2 上下文切換開銷

當CPU從執行一個執行緒切換到執行另外一個執行緒的時候,需要先儲存當前執行緒的本地的資料,程式指標等,然後載入另一個執行緒的本地資料,程式指標等,最後才開始執行。這種切換稱為 “上下文切換”(“context switch”)。CPU會在一個上下文中執行一個執行緒,然後切換到另外一個上下文中執行另外一個執行緒。

上下文切換並不廉價。如果沒有必要,應該減少上下文切換的發生。

4.3 增加資源消耗

執行緒在執行的時候需要從計算機裡面得到一些資源。除了CPU,執行緒還需要一些記憶體來維持它本地的堆疊。它也需要佔用作業系統中一些資源來管理執行緒。我們可以嘗試編寫一個程式,讓它建立100個執行緒,這些執行緒什麼事情都不做,只是在等待,然後看看這個程式在執行的時候佔用了多少記憶體。

5 建立執行

編寫執行緒執行時執行的程式碼有兩種方式:一種是建立Thread子類的一個例項並重寫run方法,第二種是建立類的時候實現Runnable介面

5.1 建立Thread的子類

建立Thread子類的一個例項並重寫run方法,run方法會在呼叫start()方法之後被執行。例子如下:

public class MyThread extends Thread {
   public void run(){
     System.out.println("MyThread running");
   }
}
複製程式碼

可以用如下方式建立並執行上述Thread子類:

MyThread myThread = new MyThread();
myTread.start();
複製程式碼

一旦執行緒啟動後start方法就會立即返回,而不會等待到run方法執行完畢才返回。就好像run方法是在另外一個cpu上執行一樣。當run方法執行後,將會列印出字串MyThread running。

5.2 實現Runnable介面

第二種編寫執行緒執行程式碼的方式是新建一個實現了java.lang.Runnable介面的類的例項,例項中的方法可以被執行緒呼叫。下面給出例子:

public class MyRunnable implements Runnable {
   public void run(){
    System.out.println("MyRunnable running");
   }
}
複製程式碼

為了使執行緒能夠執行run()方法,需要在Thread類的建構函式中傳入 MyRunnable的例項物件。示例如下:

Thread thread = new Thread(new MyRunnable());
thread.start();
複製程式碼

當執行緒執行時,它將會呼叫實現了Runnable介面的run方法。上例中將會列印出”MyRunnable running”。

5.3 建立子類還是實現Runnable介面?

對於這兩種方式哪種好並沒有一個確定的答案,它們都能滿足要求。就個人意見,更傾向於實現Runnable介面這種方法。因為執行緒池可以有效的管理實現了Runnable介面的執行緒,如果執行緒池滿了,新的執行緒就會排隊等候執行,直到執行緒池空閒出來為止。而如果執行緒是通過實現Thread子類實現的,這將會複雜一些

有時我們要同時融合實現Runnable介面和Thread子類兩種方式。例如,實現了Thread子類的例項可以執行多個實現了Runnable介面的執行緒。一個典型的應用就是執行緒池。

5.4 常見錯誤:呼叫run()方法而非start()方法

建立並執行一個執行緒所犯的常見錯誤是呼叫執行緒的run()方法而非start()方法,如下所示:

Thread newThread = new Thread(MyRunnable());
newThread.run();  //should be start();
複製程式碼

起初你並不會感覺到有什麼不妥,因為run()方法的確如你所願的被呼叫了。但是,事實上,run()方法並非是由剛建立的新執行緒所執行的,而是被建立新執行緒的當前執行緒所執行了。也就是被執行上面兩行程式碼的執行緒所執行的。想要讓建立的新執行緒執行run()方法,必須呼叫新執行緒的start()方法

5.5 執行緒名

當建立一個執行緒的時候,可以給執行緒起一個名字。它有助於我們區分不同的執行緒。例如:如果有多個執行緒寫入System.out,我們就能夠通過執行緒名容易的找出是哪個執行緒正在輸出。例子如下:

MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable, "New Thread");
thread.start();
System.out.println(thread.getName());
複製程式碼

需要注意的是,因為MyRunnable並非Thread的子類,所以MyRunnable類並沒有getName()方法。可以通過以下方式得到當前執行緒的引用:

Thread.currentThread();
複製程式碼

因此,通過如下程式碼可以得到當前執行緒的名字:

String threadName = Thread.currentThread().getName();
複製程式碼

首先輸出執行main()方法執行緒名字。這個執行緒JVM分配的。然後開啟10個執行緒,命名為1~10。每個執行緒輸出自己的名字後就退出。

public class ThreadExample {
  public static void main(String[] args){
     System.out.println(Thread.currentThread().getName());
      for(int i=0; i<10; i++){
         new Thread("" + i){
            public void run(){
             System.out.println("Thread: " + getName() + "running");
            }
         }.start();
      }
  }
}
複製程式碼

需要注意的是,儘管啟動執行緒的順序是有序的,但是執行的順序並非是有序的。也就是說,1號執行緒並不一定是第一個將自己名字輸出到控制檯的執行緒。這是因為執行緒是並行執行而非順序的。JVM和作業系統一起決定了執行緒的執行順序,他和執行緒的啟動順序並非一定是一致的

5.6 Main執行緒與子執行緒關係

  1. Main執行緒是個非守護執行緒,不能設定成守護執行緒

    這是因為,Main執行緒是由Java虛擬機器在啟動的時候建立的。main方法開始執行的時候,主執行緒已經建立好並在執行了。對於執行中的執行緒,呼叫Thread.setDaemon()會丟擲異常Exception in thread "main" java.lang.IllegalThreadStateException

  2. Main執行緒結束,其他執行緒一樣可以正常執行

    主執行緒,只是個普通的非守護執行緒,用來啟動應用程式,不能設定成守護執行緒;除此之外,它跟其他非守護執行緒沒有什麼不同。主執行緒執行結束,其他執行緒一樣可以正常執行

    這樣其實是很合理的,按照作業系統的理論,程式是資源分配的基本單位,執行緒是CPU排程的基本單位。對於CPU來說,其實並不存在java的主執行緒和子執行緒之分,都只是個普通的執行緒。程式的資源是執行緒共享的,只要程式還在,執行緒就可以正常執行,換句話說執行緒是強依賴於程式的。也就是說:

    執行緒其實並不存在互相依賴的關係,一個執行緒的死亡從理論上來說,不會對其他執行緒有什麼影響

  3. Main執行緒結束,其他執行緒也可以立刻結束,當且僅當這些子執行緒都是守護執行緒

    Java虛擬機器(相當於程式)退出的時機是:虛擬機器中所有存活的執行緒都是守護執行緒。只要還有存活的非守護執行緒虛擬機器就不會退出,而是等待非守護執行緒執行完畢;反之,如果虛擬機器中的執行緒都是守護執行緒,那麼不管這些執行緒的死活java虛擬機器都會退出

6 再聊併發與並行

併發和並行的區別就是一個處理器同時處理多個任務多個處理器或者是多核的處理器同時處理多個不同的任務前者是邏輯上的同時發生(simultaneous),而後者是物理上的同時發生

併發性(concurrency),又稱共行性,是指能處理多個同時性活動的能力,併發事件之間不一定要同一時刻發生。

並行(parallelism)是指同時發生的兩個併發事件,具有併發的含義,而併發則不一定並行。

來個比喻:併發和並行的區別就是一個人同時吃三個饅頭和三個人同時吃三個饅頭

啃碎併發(一):Java執行緒總述與概念

上圖反映了一個包含8個操作的任務在一個有兩核心的CPU中建立四個執行緒執行的情況。假設每個核心有兩個執行緒,那麼每個CPU中兩個執行緒會交替併發,兩個CPU之間的操作會並行運算。單就一個CPU而言兩個執行緒可以解決執行緒阻塞造成的不流暢問題,其本身執行效率並沒有提高,多CPU的並行運算才真正解決了執行效率問題,這也正是併發和並行的區別

相關文章