啃碎併發(一):Java線程總述與概念

1 前言

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

在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)是指同時發生的兩個併發事件,具備併發的含義,而併發則不必定並行。

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

上圖反映了一個包含8個操做的任務在一個有兩核心的CPU中建立四個線程運行的狀況。假設每一個核心有兩個線程,那麼每一個CPU中兩個線程會交替併發,兩個CPU之間的操做會並行運算。單就一個CPU而言兩個線程能夠解決線程阻塞形成的不流暢問題,其自己運行效率並無提升,多CPU的並行運算才真正解決了運行效率問題,這也正是併發和並行的區別

相關文章
相關標籤/搜索