天天看點

讀書筆記 | Java 記憶體模型與線程一、概述二、正式開始前的開胃菜三、Java 記憶體模型四、從虛拟機的角度看線程五、參考

一、概述

本篇文章是基于《深入了解Java虛拟機》一書的讀書筆記,針對Java虛拟機在并發情況下的記憶體模型以及線程做了介紹,本文屬于對正文的内容提煉,文章結構如下所示:

  • 正式開始前的開胃菜
  • Java記憶體模型
  • 從虛拟機的角度看線程

二、正式開始前的開胃菜

1. 多任務處理的必要性

多任務處理在現代計算機作業系統中幾乎是一項必備的功能。讓計算機同時去做幾件事有以下的因素:

  • 現在的計算機運算能力越來越強大。
  • 計算機的運算速度與它的存儲和通信子系統速度差距過大,運算能力較強的處理器不得不長時間處于等待狀态造成了資源的浪費,多任務處理可以更加有效地利用這些處于空閑的處理器。
  • 便于服務端向多個用戶端提供服務。每秒事務處理數(Transactions Per Second, TPS)是用于衡量伺服器性能好壞的一個重要名額,它與程式的并發能力有着密切的關系。

2. 硬體的效率與一緻性

Java虛拟機的并發問題與實體計算機中的并發問題具有很高的相似性,通過實體機遇到的問題和解決方式,可以為我們接下來的正文做參考
  • 并發目的:讓計算機并發執行若幹個運算任務,進而提高計算機處理器的效能。
  • 問題:絕大多數的運算任務不可能隻靠處理器完成,處理器至少要與記憶體進行互動,如讀取運算資料、存儲運算結果等,這個 IO 操作是很難消除的。但計算機的儲存設備于CPU的運算速度有幾個數量級的差距。
  • 解決辦法:加入一層讀寫速度盡可能接近處理器運算速度的告訴緩存作為記憶體與處理器之間的緩沖,将運算需要使用到的資料複制到緩存中,讓運算能快速進行。當運算結束後再從緩存同步回記憶體之中。這樣處理器無需等待緩慢的記憶體讀寫了。
  • 帶來的新問題:緩存一緻性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主記憶體(Main Memory)。當多個處理器的運算任務都涉及到同一塊主記憶體區域時,将導緻各自的緩存資料不一緻,那麼同步回主記憶體的時候,将會造成資料的不一緻性。
  • 解決方案:為了解決資料的不一緻性問題,需要各個處理器通路緩存時都遵守一些協定,這類協定有 MSI、MESI、MOSI(Illinois Protocol)、Synapse、Firefly 及 Dragon Protocol 等。

是以在現代計算機中,處理器、高速緩存及其主記憶體的關系圖如下所示:

讀書筆記 | Java 記憶體模型與線程一、概述二、正式開始前的開胃菜三、Java 記憶體模型四、從虛拟機的角度看線程五、參考

三、Java 記憶體模型

定義 Java記憶體模型(Java Memory Model, JMM)的目的:

屏蔽掉各種硬體和作業系統的記憶體通路差異,以實作讓 Java 程式在各種平台下都達到一緻的記憶體通路效果。

1. 主記憶體和工作記憶體

Java 記憶體模型将記憶體劃分成了記憶體和工作記憶體,如下圖所示:

讀書筆記 | Java 記憶體模型與線程一、概述二、正式開始前的開胃菜三、Java 記憶體模型四、從虛拟機的角度看線程五、參考

可以看出 Java 記憶體模型和實體機的記憶體模型具有非常高的可比性,它的特點如下:

  • 每條線程有自己的工作記憶體,線程的工作記憶體儲存了被該線程使用到的變量的主記憶體副本拷貝。
  • 線程對變量的所有操作(讀取、指派等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變量。
  • 不同的線程之間也法直接通路對方工作記憶體中的變量,線程間的變量值的傳遞均需要通過主記憶體來完成。
需要注意的是這裡的變量與 Java 程式設計中說的變量有所不同,它包括了執行個體字段、靜态字段和構成數組對象的元素,但不包括局部變量與方法參數,因為後者是線程私有的,不會被共享。

2. 記憶體間互動操作

Java記憶體模型中定義了以下8種操作來完成主記憶體與工作記憶體之間具體的互動協定,虛拟機實作時必須保證這8種操作都是原子的、不可再分的:

操作 作用對象 作用
lock(鎖定) 主記憶體中的變量 把一個變量辨別為一條線程獨占的狀态
unlock(解鎖) 主記憶體中的變量 把一個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定
read(讀取) 主記憶體中的變量 把一個變量的值從主記憶體傳輸到線程的工作記憶體中,以便随後的load動作使用
load(載入) 工作記憶體的變量 把read操作從主記憶體中得到的變量值放入到工作記憶體的變量副本中
use(使用) 工作記憶體的變量 把工作記憶體中一個變量的值傳遞給執行引擎,每當虛拟機遇到一個需要使用到變量的位元組碼指令時将會執行這個操作
assign(指派) 工作記憶體的變量 把一個從執行引擎接收到的值賦給工作記憶體的變量,每當虛拟機遇到一個給變量指派的位元組碼指令時執行這個操作
store(存儲) 工作記憶體的變量 把工作記憶體中一個變量的值傳送到主記憶體中,以便随後的write操作使用
wriite(寫入) 主記憶體中的變量 把store操作從工作記憶體中得到的變量的值放入到主記憶體的變量中
把一個變量從主記憶體複制到工作記憶體,要順序執行 read 和 load 操作,如果要把變量從工作記憶體同步回主記憶體,要順序執行 store 和 write 操作。順序執行的兩個操作之間可以不連續。例如從主記憶體中同時讀入兩個變量 A 和 B 到工作記憶體中,順序可能為 read A、read B、load B、load A。

執行上述 8 種基本操作必須滿足如下規則:

  • 不允許 read 和 load、store 和 write 操作之一單獨出現。
  • 不允許一個線程丢棄它最近的assign操作,即變量在工作記憶體中改變了之後必須把該變化同步回主記憶體。
  • 不允許一個線程無原因地(沒有發生過任何 assign 操作)把資料從線程的工作記憶體同步回主記憶體中。
  • 一個新的變量隻能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化過的變量。
  • 一個變量在同一時刻隻允許一條線程對其進行 lock 操作,但 lock 操作可以被同一條線程重複執行多次,多次執行 lock 後,隻有執行相同次數的 unlock 操作,變量才會解鎖。
  • 如果對一個變量執行 lock 操作,那将會清空工作記憶體中此變量的值,在執行引擎使用這個變量前,需要重新執行 load 或 assign 操作初始化變量的值。
  • 如果一個變量事先沒有被 lock 操作鎖定,那就不允許對它執行 unlock 操作,也不允許去 unlock 一個被其它線程鎖定住的變量。
  • 對一個變量執行 unlock 操作之前,必須先把此變量同步回主記憶體中(執行store、write操作)。

3. Java 記憶體模型的特性

Java 記憶體模型是圍繞着并發過程中如何處理原子性、可見性和有序性這 3 個特征來建立的,下面是對它們的詳細介紹:

  • 原子性(Atomicity):一個操作要麼都執行要麼都不執行。
    • 由 Java記憶體模型來直接保證原子性變量操作包括 read、load、assign、use、store 和 write,我們可以大緻認為基本資料類型的通路讀寫是具備原子性*(long、double除外)。
    • Java記憶體模型還提供了 lock 和 unlock 來保證更大範圍的原子性保證,這兩個指令并未開放給使用者使用,但是缺提供了更高層次的位元組碼

      monitorenter

      monitorexit

      來隐式使用這兩個操作,這兩個位元組碼指令反映到 Java代碼中就是

      synchronized

      關鍵字,是以在

      synchronized

      塊中的操作也具備原子性。
  • 可見性(Visibility):當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。
    • Java 記憶體模型是通過在變量修改後将新值同步回主記憶體,在變量讀取前從主記憶體重新整理變量值這種依賴主記憶體作為傳遞媒介的方式實作可見性的,有以下三點實作方式:
      • volatile:通過 volatile 的特殊規則保證新值能夠立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。是以 volatile 保證了多線程操作時變量的可見性,普通變量則不能保證這一點。
      • synchronized:同步塊的可見性是由“對一個變量執行 unlock 操作之前,必須先把此變量同步回主記憶體中”這條規則獲得的。
      • final:被 final 關鍵字修飾的字段在構造器中一旦初始化完成,并且構造器沒有把 “this” 的引用傳遞出去,那在其他線程就能看見 final 字段的值。
  • 有序性(Ordering):在本線程内觀察,所有的操作都是有序的;在一個線程中觀察另一個線程,所有的操作都是無序的。
    • 造成這種差異主要是因為指令重排序以及工作記憶體與主記憶體的同步延遲。Java提供了以下兩種方式保證線程之間操作的有序性:
      • volatile:關鍵字 volatile 可以禁止指令重排序優化。
      • synchronized:在同步塊中,一個變量在同一時刻隻允許一條線程對其進行 lock 操作。

4. 先行發生原則

先行發生是 Java記憶體模型中定義的兩項操作之間的偏序關系,是用于判斷資料是否存在競争、線程是否安全的主要依據。Java記憶體模型有如下一些“天然的”先行發生關系:

  • 程式次序規則(Program Order Rule):在一個線程内,按照程式代碼順序,書寫在前面的操作先行發生于書寫在後面的操作。
  • 管程鎖定規則(Monitor Lock Rule):一個 unlock 操作先行發生于後面對同一個鎖的 lock 操作。
  • volatile變量規則(Volatile Variable Rule):對一個 volatile 變量的寫操作先行發生于後面對這個變量的讀操作。
  • 線程啟動規則(Thread Start Rule):Thread 對象的 start() 方法先行發生于此線程的每一個動作。
  • 線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生于對此線程的終止檢測。
  • 線程中斷規則(Thread Interruption Rule):對線程 interrupt() 方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生。
  • 對象終結規則(Finalizer Rule):一個對象的初始化完成先行發生于它的 finalize() 方法的開始。
  • 傳遞性(Transitivity):如果操作 A 先行發生于操作 B,操作 B 先行發生于操作 C,那麼操作 A 先行發生與操作 C。

例子:

public class Demo {

    static class Value{
        private int value = 0;

        public int getValue() {
            return value;
        }

        public void setValue(int value) {
            this.value = value;
        }
    }

    public static void main(String[] args) {

        Value v = new Value();
        
        Thread A = new Thread(() -> {
            v.setValue(1);
        });
        
        Thread B = new Thread(() -> {
            System.out.print(v.getValue());
        });
        
        A.start();
        B.start();
    }
    
}
           

這段程式的列印結果是不确定的,可能為 0,也可能為 1。根據先行發生的 8 大規則逐一分析:

  • 程式次序規則:由于指派和取值的操作不在同一個線程中,是以該規則不适用。
  • 管程鎖定規則:兩個線程所調用的方法均沒有同步塊,是以不适應。
  • volatile變量規則:變量 value 沒有用關鍵字 volatile 修飾,同樣不适用。
  • 線程啟動、終止、中斷規則和對象終結規則:例子中和這幾個規則都沒有關系,是以都不适用。
  • 傳遞性:因為沒有一個适用與上述先行發生規則的關系出現,是以這條規則更是不适用。

結論:

  1. 以上先發先行原則均不使用于這段代碼,是以該操作是線程不安全的。
  2. 一個操作時間上的先發生不代表這個操作會先行發生。

解決方法:

  1. 對于 setter 和 getter 方法均用 synchronized關鍵字修飾,這樣我們這段代碼就符合管程鎖定規則。
  2. 由于這裡的 setter 方法對 value 的修改不依賴 value 的原值,是以用 volatile 關鍵字修飾變量 value 同樣能保證線程安全,此時這段程式就符合 volatile變量規則。
注意:時間先後順序與先行發生原則之間基本沒有太大的關系,在衡量并發安全問題是不要收到時間順序的幹擾,一切要以先行發生原則為準。

四、從虛拟機的角度看線程

1. 線程實作的三種方式

  • 使用核心線程實作
    • 核心線程(Kernel-Level Thread, KTL):直接由作業系統核心(Kernel)支援的線程。
    • 原理:核心線程由核心完成線程切換,核心通過操縱排程器(Scheduler)對線程進行排程,并負責将線程的任務映射到各個處理器上。每個核心線程都可以視為核心的一個分身。程式一般不直接使用核心線程,而是使用輕量級程序。
    • 輕量級程序(Light Weight Process, LWP):通常意義上所講的線程,每個輕量級程序都由一個核心線程支援,與核心線程之間是 1 : 1 的關系。
      • 優點:由于核心線程的支援,每個輕量級程序都可以成為一個獨立的排程單元,即使有一個輕量級程序在系統調用中阻塞了,也不會影響整個程序繼續工作。
      • 缺點:由于基于核心線程的實作,建立、析構及同步等線程操作都需要進行代價較為高昂的系統調用,是以輕量級程序需要消耗一定的核心資源,同時輕量級程序數量有限。
    • 輕量級程序和核心線程是一對一的對于關系,如下圖所示:
      讀書筆記 | Java 記憶體模型與線程一、概述二、正式開始前的開胃菜三、Java 記憶體模型四、從虛拟機的角度看線程五、參考
  • 使用使用者線程實作
    • 使用者線程(User Thread, UT):完全建立在使用者空間的線程庫上,系統核心不能感覺線程存在的實作。
    • 優點:使用者線程的建立、同步、銷毀和排程完全在使用者态中完成,不需要消耗核心資源,是以操作是非常快速且低消耗的;可以支援規模更大的線程數量。
    • 缺點:所有線程操作都需要使用者程式自己處理,線程的建立、切換和排程等都是需要考慮的問題;處理諸如“阻塞如何處理”、“多處理器系統中将線程映射到其他處理器上”這類問題異常困難,甚至不可能完成。
    • 程序與使用者線程之間為 1: N 的關系,如下圖所示:
      讀書筆記 | Java 記憶體模型與線程一、概述二、正式開始前的開胃菜三、Java 記憶體模型四、從虛拟機的角度看線程五、參考
  • 使用使用者線程加輕量級程序混合實作
    • 混合實作定義:将核心線程和使用者線程一起使用的實作方式,既存在使用者線程,也存在輕量級程序。
    • 優點:使用者線程還是完全建立在使用者空間中,是以使用者線程的建立、切換、析構等操作依然廉價,并可支援大規模的使用者線程并發。而輕量級程序則作為使用者線程和核心線程之間的橋梁,進而可以利用核心提供的線程排程功能及處理器映射,并且使用者線程的系統調用要通過輕量級線程完成,大大降低整個程序被完全阻塞的風險。
    • 混合實作的使用者線程和輕量級程序的數量比是不定的,為 N : M 的關系,即多對多的線程模型,如下圖所示:
      讀書筆記 | Java 記憶體模型與線程一、概述二、正式開始前的開胃菜三、Java 記憶體模型四、從虛拟機的角度看線程五、參考

2. Java線程的實作

  • 在 JDK1.2 之前,是基于使用者線程的方式實作的;
  • 在 JDK1.2 中,線程模型替換為基于作業系統原生線程模型來實作;
  • 目前的 JDK 版本答案是不确定。在不同的平台上,Java虛拟機對于線程的實作都是不同的,因為不同的作業系統所支援的線程模型不同,而線程模型很大長度上決定了 Java虛拟機的線程是怎麼映射的。線程模型隻對線程的并發規模和操作成本産生影響,對 Java程式的編碼和運作來說,這些差異都是透明的。

3. Java線程排程的兩種方式

  • 同式線程排程(Cooperative Threads-Scheduling)
    • 原理:線程的執行時間由線程本身來控制,線程把自己的工作執行完之後,要主動通知系統切換到另一個線程上。
    • 優點:實作簡單,并且切換操作對線程自己是可知的,不存線上程同步的問題。
    • 缺點:線程執行時間不可控,如果有線程一直不告知系統進行線程切換,那麼程式會一直阻塞在那裡。
  • 搶占式線程排程(Preemptive Threads-Scheduling)
    • 原理:線程通過系統來配置設定執行時間,線程的切換不由線程本身決定。Java 采用的線程排程方式就是搶占式線程排程。
    • 優點:線程的執行時間是系統可控的,不會存在一個線程導緻整個程序阻塞的問題。

由于 Java的線程排程是由系統自動完成的,理論上線程是不可控的。但我們可以通過給系統某種暗示來給某些線程多一些執行時間,另外一些線程則可以少配置設定一點——這項操作可以通過設定線程優先級來完成。

但是線程優先級并不是太靠譜,因為Java的線程是通過映射到系統的原生線程上來實作的,是以線程的排程最終還是取決于作業系統。雖然現在很多作業系統都提供了線程優先級的概念,但是并不見得與Java線程的優先級一一對應。例如對于優先級比Java線程少的系統,就不得不出現幾個優先級相同的情況了。

4. 線程的五種狀态

  • 建立(New):建立後尚未啟動的線程處于建立狀态。
  • 運作(Runnable):包括了作業系統線程狀态中的 Running 和 Ready,處于此狀态的線程有可能正在執行,也有可能正在等待 CPU 為其配置設定時間片。
  • 無限期等待(Waiting):該狀态下的線程不會被配置設定 CPU 執行時間,需要等待被其他線程顯示喚醒。可通過下列方法讓線程進入無限期等待狀态:
    • 沒有設定 Timeout 參數的 Object#wait() 方法。
    • 沒有設定 Timeout 參數的 Thread#join() 方法。
    • LockSupport.park() 方法。
  • 限期等待(Timed Waiting):該狀态下的線程同樣不會被配置設定 CPU 執行時間,但在一定時間之後它們會由系統自行喚醒。可通過下列方法讓線程進入限期等待狀态:
    • Thread.sleep() 方法
    • 設定了 Timeout 參數的 Object#wait() 方法。
    • 設定了 Timeout 參數的 Thread#join() 方法。
    • LockSupport.parkNanos() 方法。
    • LockSupport.parkUntil() 方法。
  • 阻塞(Blocked):處于阻塞狀态的線程是在等待擷取一個排他鎖,在程式等待進入同步塊時,線程處于這種狀态。
  • 結束(Terminated):已終止線程的線程狀态,線程已經結束執行。

線程之間的狀态切換如下圖所示:

讀書筆記 | Java 記憶體模型與線程一、概述二、正式開始前的開胃菜三、Java 記憶體模型四、從虛拟機的角度看線程五、參考

五、參考

本篇文章參考自:

  • 《深入了解Java虛拟機》
    • 第五部分 高效并發
      • 第12章 Java記憶體模型與線程

電子書資源:

連結:https://pan.baidu.com/s/1SDginxQBu90hvPeG_85epw

提取碼:bftc

希望這篇文章對您有所幫助~