1. 程式人生 > >初識多執行緒及其原理-筆記

初識多執行緒及其原理-筆記

什麼情況下應該使用多執行緒?

  • 通過平行計算提高程式執行效能
  • 需要等待網路、I/O響應導致耗費大量的執行時間,
    • 可以採用非同步執行緒的方式來減少阻塞

tomcat7 以前的io模型

  • 客戶端阻塞
  • 執行緒級別阻塞 BIO

如何應用多執行緒?

  • 在Java中,有多種方式來實現多執行緒。
  • 繼承Thread類、實現Runnable介面、
  • 使用ExecutorService、Callable、Future實現帶返回結果的多執行緒。

繼承Thread類建立執行緒

  • Thread類本質上是實現了Runnable介面的一個例項,代表一個執行緒的例項。
  • Thread類本質上是實現了Runnable介面的一個例項,代表一個執行緒的例項。
  • 啟動執行緒的唯一方法就是通過Thread類的start()例項方法。
  • start()方法是一個native方法,它會啟動一個新執行緒,並執行run()方法。

實現Runnable介面建立執行緒

  • 如果自己的類已經extends另一個類,就無法直接extends Thread,
  • 此時,可以實現一個Runnable介面

實現Callable介面通過FutureTask包裝器來建立Thread執行緒

  • 有的時候,我們可能需要讓一步執行的執行緒在執行完成以後,
  • 提供一個返回值給到當前的主執行緒,主執行緒需要依賴這個值進行後續的邏輯處理,
  • 那麼這個時候,就需要用到帶返回值的執行緒了。
public class CallableDemo implements Callable<String> {

	@Override
	public String call() throws Exception {
		int a = 1;
		int b = 2;
		System.out.println(a + b);
		return "執行結果:" + (a + b);
	}

	public static void main(String[] args) throws ExecutionException, InterruptedException {
		ExecutorService executorService = Executors.newFixedThreadPool(1);
		CallableDemo callableDemo = new CallableDemo();
		Future<String> future = executorService.submit(callableDemo);
		System.out.println(future.get());
		executorService.shutdown();
	}
}

Java 併發程式設計基礎

執行緒的狀態

  • 執行緒一共有6種狀態(
    • NEW、
    • RUNNABLE、
    • BLOCKED、
    • WAITING、
    • TIME_WAITING、
    • TERMINATED)
  • NEW:初始狀態,執行緒被構建,但是還沒有呼叫start方法
  • RUNNABLED:執行狀態,JAVA執行緒把作業系統中的就緒和執行兩種狀態統一稱為“執行中”
  • BLOCKED:阻塞狀態,表示執行緒進入等待狀態,也就是執行緒因為某種原因放棄了CPU使用權,阻塞也分為幾種情況
    • 等待阻塞:執行的執行緒執行wait方法,jvm會把當前執行緒放入到等待佇列
    • 同步阻塞:執行的執行緒在獲取物件的同步鎖時,
      • 若該同步鎖被其他執行緒鎖佔用了,那麼jvm會把當前的執行緒放入到鎖池中
    • 其他阻塞:執行的執行緒執行Thread.sleep或者t.join方法,
      • 或者發出了I/O請求時,JVM會把當前執行緒設定為阻塞狀態,
      • 當sleep結束、join執行緒終止、io處理完畢則執行緒恢復
  • TIME_WAITING:超時等待狀態,超時以後自動返回
  • TERMINATED:終止狀態,表示當前執行緒執行完畢

通過相應命令顯示執行緒狀態

  • 開啟終端或者命令提示符,鍵入“jps”,可以獲得相應程序的pid
  • 根據上一步驟獲得的pid,繼續輸入jstack pid
    • jstack是java虛擬機器自帶的一種堆疊跟蹤工具。
    • jstack用於打印出給定的java程序ID或core file或遠端除錯服務的Java堆疊資訊

執行緒的停止

  • stop、suspend、resume過期不建議使用
    • stop方法在結束一個執行緒時並不會保證執行緒的資源正常釋放,
    • 因此會導致程式可能出現一些不確定的狀態。

要優雅的去中斷一個執行緒,線上程中提供了一個interrupt方法

  • 當其他執行緒通過呼叫當前執行緒的interrupt方法,
  • 表示向當前執行緒打個招呼,告訴他可以中斷執行緒的執行了,至於什麼時候中斷,取決於當前執行緒自己。
  • 可以通過isInterrupted()來判斷是否被中斷
  • 這種通過標識位或者中斷操作的方式能夠使執行緒在終止時有機會去清理資源,而不是武斷地將執行緒停止,
    • 因此這種終止執行緒的做法顯得更加安全和優雅

Thread.interrupted

  • 注意區別,這個是復位方法,與interrupt中斷方法對應
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {
                boolean ii = Thread.currentThread().isInterrupted();
                if (ii) {
                    System.out.println("before:" + ii);
                    Thread.interrupted();//對執行緒進行復位,中斷標識為false 
                    System.out.println("after:" + Thread.currentThread().isInterrupted());
                }
            }
        });
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        thread.interrupt();//設定中斷標識,中斷標識為true 
    }
}

其他的執行緒復位

  • 還有一種被動復位的場景,就是對丟擲InterruptedException異常的方法,
  • 在InterruptedException丟擲之前,JVM會先把執行緒的中斷標識位清除,
  • 然後才會丟擲InterruptedException,這個時候如果呼叫isInterrupted方法,將會返回false
public class InterruptDemo {
   public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    //丟擲該異常,會將復位標識設定為false 
                    e.printStackTrace();
                }
            }
        });
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        thread.interrupt();
        //設定復位標識為true 
        TimeUnit.SECONDS.sleep(1);
        System.out.println(thread.isInterrupted());//false
    }
}

首先我們來看看執行緒執行interrupt以後的原始碼是做了什麼?

  • 其實就是通過unpark去喚醒當前執行緒,並且設定一個標識位為true。
  • 並沒有所謂的中斷執行緒的操作,所以實際上,執行緒復位可以用來實現多個執行緒之間的通訊。

執行緒的停止方法之2

  • 定義一個volatile修飾的成員變數,來控制執行緒的終止
  • 這實際上是應用了volatile能夠實現多執行緒之間共享變數的可見性這一特點來實現的。
    public class VolatileDemo {
        private volatile static boolean stop = false;

        public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(() -> {
                int i = 0;
                while (!stop) {
                    i++;
                }
            });
            thread.start();
            System.out.println("begin start thread");
            Thread.sleep(1000);
            stop = true;
        }
    }

執行緒的安全性問題

  • 我們從原理層面去了解執行緒為什麼會存在安全性問題,並且我們應該怎麼去解決這類的問題。
  • 執行緒安全問題可以總結為: 可見性原子性有序性這幾個問題,
  • 我們搞懂了這幾個問題並且知道怎麼解決,那麼多執行緒安全性問題也就不是問題了

CPU快取記憶體

  • 執行緒是CPU排程的最小單元,執行緒涉及的目的最終仍然是更充分的利用計算機處理的效能,
  • 但是絕大部分的運算任務不能只依靠處理器“計算”就能完成,
    • 處理器還需要與記憶體互動,比如讀取運算資料、儲存運算結果,這個I/O操作是很難消除的。
  • 而由於計算機的儲存裝置與處理器的運算速度差距非常大,
    • 所以現代計算機系統都會增加一層讀寫速度儘可能接近處理器運算速度的快取記憶體來作為記憶體和處理器之間的緩衝:
    • 將運算需要使用的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步到記憶體之中。

  • 快取記憶體從下到上越接近CPU速度越快,同時容量也越小。
  • 現在大部分的處理器都有二級或者三級快取,從下到上依次為 L3 cache, L2 cache, L1 cache.
  • 快取又可以分為指令快取和資料快取,指令快取用來快取程式的程式碼資料快取用來快取程式的資料

L1 Cache,

  • 一級快取,本地core(cpu核心)的快取,
  • 分成32K的資料快取L1d和32k指令快取L1i,
  • 訪問L1需要3cycles,耗時大約1ns;

L2 Cache,

  • 二級快取,本地core(cpu核心)的快取,
  • 被設計為L1快取與共享的L3快取之間的緩衝,大小為256K,
  • 訪問L2需要12cycles,耗時大約3ns;

L3 Cache,

  • 三級快取,在同插槽的所有core(cpu核心)共享L3快取,分為多個2M的段,
  • 訪問L3需要38cycles,耗時大約12ns;

快取一致性問題

  • CPU-0讀取主存的資料,快取到CPU-0的快取記憶體中,
  • CPU-1也做了同樣的事情,而CPU-1把count的值修改成了2,並且同步到CPU-1的快取記憶體,
  • 但是這個修改以後的值並沒有寫入到主存中,
  • CPU-0訪問該位元組,由於快取沒有更新,所以仍然是之前的值,就會導致資料不一致的問題

引發這個問題的原因是

  • 因為多核心CPU情況下存在指令並行執行,
  • 而各個CPU核心之間的資料不共享從而導致快取一致性問題,
  • 為了解決這個問題,CPU生產廠商提供了相應的解決方案

匯流排鎖

  • 當一個CPU對其快取中的資料進行操作的時候,往匯流排中傳送一個Lock訊號。
  • 其他處理器的請求將會被阻塞,那麼該處理器可以獨佔共享記憶體。
  • 匯流排鎖相當於把CPU和記憶體之間的通訊鎖住了
    • 所以這種方式會導致CPU的效能下降,
    • 所以P6系列以後的處理器,出現了另外一種方式,就是快取鎖。

快取鎖

  • 如果快取在處理器快取行中的記憶體區域在LOCK操作期間被鎖定,
  • 當它執行鎖操作回寫記憶體時,處理不在總線上宣告LOCK訊號,而是修改內部的快取地址
  • 然後通過快取一致性機制來保證操作的原子性,
    • 因為快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域的資料,
    • 當其他處理器回寫已經被鎖定的快取行的資料時會導致該快取行無效。

所以如果聲明瞭CPU的鎖機制,會生成一個LOCK指令,會產生兩個作用:

  • Lock字首指令會引起處理器快取回寫到記憶體,在P6以後的處理器中,LOCK訊號一般不鎖匯流排,而是鎖快取
  •  一個處理器的快取回寫到記憶體會導致其他處理器的快取無效

快取一致性協議

  • 處理器上有一套完整的協議,來保證Cache的一致性,比較經典的應該就是:MESI
  • MESI協議的方法是在CPU快取中儲存一個標記位,
    • 這個標記為有四種狀態:
      • M(Modified) 修改快取,當前CPU快取已經被修改,表示已經和記憶體中的資料不一致了
      • I(Invalid) 失效快取,說明CPU的快取已經不能使用了
      • E(Exclusive) 獨佔快取,當前cpu的快取和記憶體中資料保持一直,而且其他處理器沒有快取該資料
      • S(Shared) 共享快取,資料和記憶體中資料一致,並且該資料存在多個cpu快取中
  • 每個Core(cpu核心)的Cache控制器不僅知道自己的讀寫操作,也監聽其它Cache的讀寫操作,嗅探(snooping)協議

CPU的讀取會遵循幾個原則:

  • 如果快取的狀態是I,那麼就從記憶體中讀取,否則直接從快取讀取
  • 如果快取處於M或者E的CPU 嗅探到其他CPU有讀的操作,就把自己的快取寫入到記憶體,並把自己的狀態設定為S
  • 只有快取狀態是M或E的時候,CPU才可以修改快取中的資料,修改後,快取狀態變為M

CPU的優化執行

  • 除了增加快取記憶體以外,
  • 為了更充分利用處理器內內部的運算單元,處理器可能會對輸入的程式碼進行亂序執行優化,
  • 處理器會在計算之後將亂序執行的結果充足,保證該結果與順序執行的結果一致,
  • 但並不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致,這個是處理器的優化執行
  • 還有一個就是程式語言的編譯器也會有類似的優化,比如做指令重排來提升效能

併發程式設計的問題

  • 前面說的和硬體有關的概念你可能聽得有點蒙,還不知道他到底和軟體有啥關係
    • 其實原子性、可見性、有序性問題,是我們抽象出來的概念,
    • 他們的核心本質就是剛剛提到的快取一致性問題處理器優化問題  導致指令重排序問題
  • 比如快取一致性就導致可見性問題、
  • 處理器的亂序執行會導致原子性問題、
  • 指令重排會導致有序性問題。
  • 為了解決這些問題,所以在JVM中引入了JMM的概念

記憶體模型(JMM

  • 記憶體模型定義了共享記憶體系統中多執行緒程式讀寫操作行為的規範,
    • 來遮蔽各種硬體和作業系統的記憶體訪問差異,
    • 來實現Java程式在各個平臺下都能達到一致的記憶體訪問效果。
  • Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,
    • 也就是在虛擬機器中將變數儲存到記憶體以及從記憶體中取出變數
    • 這裡的變數,指的是共享變數,也就是例項物件、靜態欄位、陣列物件等儲存在堆記憶體中的變數
    • 而對於區域性變數這類的,屬於執行緒私有,不會被共享
  • 通過這些規則來規範對記憶體的讀寫操作,從而保證指令執行的正確性。
    • 它與處理器有關、與快取有關、與併發有關、與編譯器也有關。
    • 他解決了CP;多級快取、處理器優化、指令重排等導致的記憶體訪問問題,保證了併發場景下的可見性、原子性和有序性。
    • 記憶體模型解決併發問題主要採用兩種方式:限制處理器優化使用記憶體屏障

Java記憶體模型定義了執行緒和記憶體的互動方式,

  • 在JMM抽象模型中,分為主記憶體、工作記憶體。
  • 主記憶體是所有執行緒共享的,工作記憶體是每個執行緒獨有的。
  • 執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,不能直接讀寫主記憶體中的變數。
  • 並且不同的執行緒之間無法訪問對方工作記憶體中的變數,
  • 執行緒間的變數值的傳遞都需要通過主記憶體來完成,他們三者的互動關係如下:

所以,總的來說,JMM是一種規範,

  • 目的是解決由於多執行緒通過共享記憶體進行通訊時,存在的本地記憶體資料不一致、編譯器會對程式碼指令重排序、處理器會對程式碼亂序執行等帶來的問題。
  • 目的是保證併發程式設計場景中的原子性、可見性和有序性