Java 併發基礎總結

說實話,這麼長的時間以來,很多的併發編程的知識都是零零散散的積累和學習到的,可能是通過許多博客,或者是平時學習工作中代碼出現過的問題,也可能是聽老師和同學說起過(其實我的大學的老師在這方面都比較菜,畢竟他們的主要研究方向不在這,也不做業務)。所以很多時候,對知識的理解力、思維橫向性、總體把握能力都略感不足。因此需要一個系統的總結,將知識首尾貫穿,方能有心體通透、徹悟感嘆。

學習忌浮躁

(附思維導圖)
在這裏插入圖片描述

線程狀態

6 個狀態定義:java.lang.Thread.State
  1. New:尚未啓動的線程的線程狀態
  2. Runnable:可運行的線程狀態,等待 CPU 調度。
  3. Blocked:線程阻塞等待監視器鎖定的線程狀態。
  4. Waiting:等待線程的線程狀態。下列不帶超時的方式:
    Object.wait、Thread.join、LockSupport.park
  5. Timed Waiting:具有指定等待時間的等待線程的線程狀態。下列帶超時的方式:
    Thread.sleep、Object.wait、Thread.join、LockSupport.parkNanos、LockSupport.parkUntil
  6. Terminated:線程終止的狀態。線程正常完成執行或者出現異常。

可以說是 5 種狀態,也可以說是 6 種,畢竟 4、5 本質上是沒有多大區別的。
下面是這些狀態之間的關係:
在這裏插入圖片描述

線程中止

不正確的線程終止-stop:

終止線程,並且清除監控器鎖的信息,但是可能線程安全問題。所以現已被棄用。
Destroy:JDK 未實現該方法。
要讓一個線程結束,最好的方法是讓它執行完成自己所有的方法。對於不間斷重複運行的線程,我們一般都是設置一個標誌位,通過更改標誌位的方式讓線程結束。

通過狀態位來判斷
/** 通過狀態位來判斷 */
public class Demo4 extends Thread {
  public volatile static boolean flag = true;
  /** main方法入口 */
  public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
      try {
        while (flag) { // 判斷是否運行
          System.out.println("運行中");
          Thread.sleep(1000L);
        }
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }).start();
    // 3秒之後,將狀態標誌改爲False,代表不繼續運行
    Thread.sleep(3000L);
    flag = false;
    System.out.println("程序運行結束");
  }
}

線程封閉

ThreadLocal

多線程訪問共享可變數據時,涉及到線程間數據同步的問題。並不是所有時候,都要用到共享數據,所以線程封閉概念就提出來了。
數據都被封閉在各自的線程之中,就不需要同步,這種通過將數據封閉在線程中而避免使用同步的技術成爲 線程封閉
線程封閉的具體體現有:ThreadLocal、局部變量

ThreadLocal 是 Java 裏一種特殊的變量。
它是一個線程級別變量,每個線程都有一個 ThreadLocal 變量副本,競爭條件被徹底消除了,在併發模式下是絕對安全的變量。

線程封閉示例
/** 線程封閉示例 */
public class Demo5 {
	/** threadLocal變量,每個線程都有一個副本,互不干擾 */
	public static ThreadLocal<String> value = new ThreadLocal<>();

	public void threadLocalTest() throws Exception {
		// threadlocal線程封閉示例
		value.set("這是主線程設置的123"); // 主線程設置值
		String v = value.get();
		System.out.println("線程1執行之前,主線程取到的值:" + v);

		new Thread(new Runnable() {
			@Override
			public void run() {
				String v = value.get();
				System.out.println("線程1取到的值:" + v);
				// 設置 threadLocal
				value.set("這是線程1設置的456");

				v = value.get();
				System.out.println("重新設置之後,線程1取到的值:" + v);
				System.out.println("線程1執行結束");
			}
		}).start();

		Thread.sleep(5000L); // 等待所有線程執行結束

		v = value.get();
		System.out.println("線程1執行之後,主線程取到的值:" + v);

	}
	public static void main(String[] args) throws Exception {
		new Demo5().threadLocalTest();
	}
}

通過示例我們可以很清晰的看到每個線程中的 ThreadLocal 的值互相不受影響。
最常見的是用 ThreadLocal 來存儲數據庫連接,來保證安全。

線程池

線程是不是越多越好?

  • 線程在 java 中是一個對象,更是操作系統的資源,線程創建、銷燬需要時間。如果創建時間+銷燬時間 > 執行任務時間,就會很不划算。
  • java 對象佔用堆內存,操作系統線程佔用系統內存,根據 jvm 規範,一個線程默認最大棧大小 1M,這個棧空間是需要從系統內存中分配的。線程過多,會消耗很多的內存。
  • 操作系統需要頻繁切換線程上下文(大家都想被運行),影響性能。
線程池原理
  • 線程池管理器:用於創建並管理線程池,包括創建線程池,銷燬線程池,添加新任務;
  • 工作線程:線程池中線程,在沒有任務時處於等待狀態,可以循環的執行任務;
  • 任務接口:每個任務必須實現的接口,以供工作線程調度任務的執行,它主要規定了任務的入口,任務執行完後的收尾工作,任務的執行狀態等;
  • 任務隊列:用於存放沒有處理的任務。提供一種緩衝機制。

線程池裏的工作線程就像是貨車司機,沒有任務時就在等待獲取任務,來了任務之後就會運着我們的任務去 CPU 上執行。但是貨車司機總是有限的,如果大量的任務堆過來,我們的貨車不夠,那麼這些任務就會排着隊被放到倉庫裏面去,等到司機從 CPU 返回時,在從倉庫裏取出排在前面的任務,繼續執行。
在這裏插入圖片描述

線程池任務的提交過程
  • 是否達到核心線程數量?沒達到,創建一個工作線程來執行。
  • 工作隊列是否已滿?沒滿,則將新提交的任務存儲在工作隊列裏。
  • 是否達到線程池最大數量?沒達到,則創建一個新的工作線程來執行任務。
  • 最後,執行拒絕策略來處理這個任務。

在這裏插入圖片描述

內存屏障和CPU緩存

爲了提高程序運行的性能,現代 CPU 在很多方面對程序進行了優化。
例如:CPU 高速緩存。儘可能避免處理器訪問主內存的時間開銷,處理器大多會用緩存(Cache)以提高性能。

  • L1 Cache(一級緩存)是 CPU 第一層高速緩存,分爲數據緩存和指令緩存。一般服務器 CPU 的 L1 緩存通常在 32-4096KB。
  • L2 由於 L1 級高速緩存容量的限制,爲了再一次提高 CPU 運算速度,在 CPU 外部放置一高速存儲器,及二級緩存。
  • L3 現在的都是內置的。它的實際作用即是,L3 緩存的應用可以進一步降低內存延遲,同時提升大數據量計算時處理器的性能。具有較大 L3 緩存的處理器提供更有效的文件系統緩存行爲及較短消息和處理器隊列長度。一般是多核共享 L3 緩存!

CPU 在讀取數據時,先從 L1 尋找,再從 L2 尋找,再從 L3 尋找,然後是內存,再後是外存儲器。
CPU的三級緩存

緩存一致性協議

多 CPU 讀取同樣的數據進行緩存,進行不同運算之後,最終寫入主內存以哪個 CPU 爲準?
在這種高速緩存回寫的場景下,有一個緩存一致性協議多數 CPU 廠商對它進行了實現。
MESI協議,它規定每條緩存有個狀態位,同時定義了下面四個狀態:

  • 修改態(Modified)— 此 cache 行已被修改過(髒行),內容已不同於主存,爲此 cache 專-有;
  • 專有態(Exclusive)— 此 cache 行內容同於主存,但不出現於其它 cache 中;
  • 共享態(Shared)— 此 cache 行內容同於主存,但也出現於其它 cache 中;
  • 無效態(Invalid)— 此 cache 行內容無效(空行)。

多處理器時,單個 CPU 對緩存中數據進行了改動,需要通知給其他 CPU。
也就是意味着,CPU 處理要控制自己的讀寫操作,還要監聽其他 CPU 發出的通知,從而保證 最終一致性

運行時指令重排

指令重排的場景:當 CPU 寫緩存時 發現緩存區塊正被其他 CPU 佔用,爲了提高 CPU 處理性能,可能將後面的 讀緩存命令優先執行
但也並非隨便重排,需要遵循 as-if-serial 語義
as-if-serial 語義的意思是指:不管怎麼重排序(編譯器和處理器爲了提高並行速度),(單線程)程序的執行結果不能被改變。編譯器,runtime 處理器都必須遵守 as-if-serial 語義。
也就是說:編譯器和處理器 不會對存在數據依賴關係的操作做重排序

CPU 緩存和指令重排帶來的問題
  • 緩存中的數據與主內存的數據並不是時時同步的,各 CPU(或 CPU 核心)間緩存的數據也不是實時同步。在同一個時間點,各 CPU 所看到的同一內存地址的數據的值可能是不一致的。
  • 雖然遵守了 as-if-serial 語義,但僅在但 CPU 自己執行的情況下能保證結果正確。
    多核多線程中,指令邏輯無法分辨因果關聯,可能出現亂序執行,導致程序運行結果錯誤。
內存屏障

處理器提供了兩個內存屏障指令(Memory Barrier)用於解決上述兩個問題。

  • 寫內存屏障(Store Memory Barrier):
    在指令後面插入 Store Barrier,能讓寫入緩存中的最新數據更新寫入主內存,讓其他線程可見。
    強制寫入主內存,這種顯示調用,CPU 就不會因爲性能考慮二區對指令重排。
  • 讀內存屏障(Load Memory Barrier):
    在指令前插入 Load Barrier,可以讓高速緩存中的數據失效,強制重新從主內存加載數據。
    強制讀取主內存內容,讓 CPU 緩存與主內存保持一致,避免了緩存導致的一致性問題。

實際上 Java 的 volatile 關鍵字用的就是這個原理。

線程可見性

volatile

當一個變量定義爲 volatile 之後,它將具備兩種特性:

  • 第一是保證此變量對所有線程的 可見性,是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。
  • 第二是 禁止指令重排序優化

可能很多開發人員認爲,volatile 變量在各個線程中是一致的,所以基於 volatile 變量的運算在併發下是安全的。
事實上並不能保證,volatile 的可見性只保證了,對於單個 volatile 變量的寫,在下一次對 volatile 變量的讀可見。對於一些非原子性操作,如 i++;就不能保證線程安全。

除了 volatile 之外,Java 還有兩個關鍵字能實現可見性:synchronized 和 final

final

被final修飾的字段在構造器中一旦初始化完成,並且構造器沒有把 “this” 的引用傳遞出去,那在其他線程中就能看見final字段的值

線程安全

對線程安全的實現的最有效直接的方式就是加鎖。
鎖的種類有很多,可以分爲悲觀鎖、樂觀鎖,重量級鎖、輕量級鎖,可重入鎖、不可重入鎖,公平鎖、非公平鎖,阻塞鎖、自旋鎖…

Java 中要加鎖方法有很多:

  • synchronized 關鍵字
  • ReentrantLock
  • ReadWriteLock
  • Automic 原子類

這些都可以實現線程安全。

題外話

這些僅僅是對 java 多線程基礎知識的一個整理和總結,其中每一小塊知識點都可以引申和涉及到相當多的知識。由於文章篇幅和時間限制,在此處不可能一一敘述。
而且,java 年齡已高,發展許久,如今已有非常龐大而牢固的生態圈。與其相關的內容可謂數不勝數,讀者不可拘泥於此,方須知世間之大,官博廣闊,方能參悟透徹。
所以,在瀏覽文章之時,發現概念不清晰,掌握不透徹的點,一定要去查閱資料,深入探究方可。筆者若閒暇之餘,也會對其中的部分內容詳細撰寫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章