初識多線程及其原理-筆記

什麼情況下應該使用多線程?

  • 通過並行計算提高程序執行性能
  • 需要等待網絡、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是一種規範,

  • 目的是解決由於多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題。
  • 目的是保證併發編程場景中的原子性、可見性和有序性
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章