1. 程式人生 > 其它 >Java 記憶體模型與執行緒

Java 記憶體模型與執行緒

概述

在許多場景下,讓計算機同時去做幾件事情,不僅是因為計算機的運算能力強大了,還有一個很重要的原因是計算機的運算速度與它的儲存和通訊子系統的速度差距太大,
大量的時間都花費在磁碟I/O、網路通訊或者資料庫訪問上。如果不希望處理器在大部分時間裡都處於等待其他資源的空閒狀態,就必須使用一些手段去把處理器的運算能力“壓榨”出來,
否則就會造成很大的效能浪費,而讓計算機同時處理幾項任務則是最容易想到,也被證明是非常有效的“壓榨”手段。
衡量一個服務效能的高低好壞,每秒事務處理數(Transactions Per Second,TPS)是重要的指標之一,它代表著一秒內服務端平均能響應的請求總數。
對於計算量相同的任務,程式執行緒併發協調得越有條不紊,效率自然就會越高;反之,執行緒之間頻繁爭用資料,互相阻塞甚至死鎖,將會大大降低程式的併發能力

硬體的效率與一致性

物理機遇到的併發問題與虛擬機器中的情況有很多相似之處,所以物理機對併發的處理方案對虛擬機器的實現也有相當大的參考意義。
“讓計算機併發執行若干個運算任務”與“更充分地利用計算機處理器的效能”之間的因果關係,看起來理所當然,實際上它們之間的關係並沒有想象中那麼簡單,
其中一個重要的複雜性的來源是絕大多數的運算任務都不可能只靠處理器“計算”就能完成。處理器至少要與記憶體互動,如讀取運算資料、儲存運算結果等,
這個I/O操作就是很難消除的(無法僅靠暫存器來完成所有運算任務)。由於計算機的儲存裝置與處理器的運算速度有著幾個數量級的差距,
所以現代計算機系統都不得不加入一層或多層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝


將運算需要使用的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體速度之間的矛盾,但也帶來了更高的複雜度,它引入了一個新的問題:快取一致性(Cache Coherence)。
在多路處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致。
如果真的發生這種情況,那同步回到主記憶體時該以誰的快取資料為準呢?為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,
在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

圖1-1 處理器、快取記憶體、主記憶體間的關係

除了增加快取記憶體之外,為了使處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行優化
處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致。
與處理器的亂序執行優化類似,Java虛擬機器的即時編譯器中也有指令重排序(Instruction Reorder)優化。

Java記憶體模型

《Java虛擬機器規範》中曾試圖定義一種“Java記憶體模型”(Java Memory Model,JMM)來遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。
在此之前,主流程式語言(如C和C++等)直接使用物理硬體和作業系統的記憶體模型。因此,由於不同平臺上記憶體模型的差異,有可能導致程式在一套平臺上併發完全正常,
而在另外一套平臺上併發訪問卻經常出錯,所以在某些場景下必須針對不同的平臺來編寫程式。

主記憶體和工作記憶體

Java記憶體模型的主要目的是定義程式中各種變數的訪問規則,即關注在虛擬機器中把變數值儲存到記憶體和從記憶體中取出變數值這樣的底層細節。
此處的變數(Variables)僅包括了例項欄位、靜態欄位和構成陣列物件的元素,不包括區域性變數與方法引數,因為後者是執行緒私有的,不會存在競爭問題。

Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中。每條執行緒還有自己的工作記憶體(Working Memory,可與前面講的處理器快取記憶體類比),
執行緒的工作記憶體中儲存了被該執行緒使用的變數的主記憶體副本(變數引用、訪問到的欄位)。
執行緒對變數的所有操作(讀取、賦值等)必須在工作記憶體中進行,當執行緒賦值給欄位時,會將值指定給位於工作記憶體中的工作拷貝(assign),
指定完成後工作拷貝的內容便會複製到主記憶體(store->write),由JVM決定何時複製。
Java值傳遞時,也是複製一份變數引用,所以在方法中對形參進行重新賦值,並不會影響到實參。因為實參、形參已經是兩個單獨的變數。

不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成,執行緒、主記憶體、工作記憶體三者的互動關係如圖1-2。

圖1-2 執行緒、主記憶體、工作記憶體互動關係

這裡所講的主記憶體、工作記憶體與Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的對記憶體的劃分,這兩者基本上是沒有任何關係的。
如果兩者一定要勉強對應起來,那麼從變數、主記憶體、工作記憶體的定義來看,主記憶體主要對應於Java堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分割槽域。
從更基礎的層次上說,主記憶體對應於物理硬體的記憶體,而為了獲取更好的執行速度,虛擬機器可能會讓工作記憶體優先儲存於暫存器和快取記憶體中

記憶體間互動操作

關於主記憶體與工作記憶體之間具體的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體這一類的實現細節,Java記憶體模型中定義了6種操作來完成。
Java虛擬機器實現時必須保證下面提及的每一種操作都是原子的、不可再分的(對於double和long型別的變數來說,read和write操作在某些平臺上允許有例外)。

  1. lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。
  2. unlock(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  3. read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體拷貝到執行緒的工作記憶體中
  4. use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
  5. assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  6. write(寫入):作用於主記憶體的變數,它把從工作記憶體中得到的變數的值放入主記憶體的變數中

對於volatile型變數的特殊規則

關鍵字volatile是Java虛擬機器提供的最輕量級的同步機制,但是它並不容易被正確、完整地理解。
當一個變數被定義成volatile之後,它將具備兩項特性:
第一項是保證此變數對所有執行緒的可見性,這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的。
但被volatile修飾並不代表此變數就是執行緒安全的。如下例程式所示:

    public class VolatileTest {
        public static volatile int race = 0;

        public static void increase() {
            race++;
        }

        private static final int THREADS_COUNT = 20;

        public static void main(String[] args) {
            Thread[] threads = new Thread[THREADS_COUNT];
            for (int i = 0; i < THREADS_COUNT; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int i = 0; i < 10000; i++) {
                            increase();
                        }
                    }
                });
                threads[i].start();
            }
            // 等待所有累加執行緒都結束
            while (Thread.activeCount() > 1) {
                Thread.yield();
            }
            System.out.println(race);
        }
    }

這段程式碼發起了20個執行緒,每個執行緒對race變數進行10000次自增操作,如果這段程式碼能夠正確併發的話,最後輸出的結果應該是200000。
但執行完這段程式碼之後,並不會獲得期望的結果,而且會發現每次執行程式,輸出的結果都不一樣,d但都是一個小於200000的數字。
問題就出在自增運算“race++”之中,這個操作並不是一個原子操作。當多個執行緒同時修改時,會存在前後覆蓋的情況。

由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized、java.util.concurrent中的鎖或原子類)來保證原子性:

  • 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
  • 變數不需要與其他的狀態變數共同參與不變約束。

第二項是禁止指令重排序優化,普通的變數僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。
有volatile修飾的變數,賦值後多執行了一個“lock addl$0x0,(%esp)”操作,這個操作的作用相當於一個記憶體屏障
(Memory Barrier或Memory Fence,指重排序時不能把後面的指令重排序到記憶體屏障之前的位置),只有一個處理器訪問記憶體時,並不需要記憶體屏障;
但如果有兩個或更多處理器訪問同一塊記憶體,且其中有一個在觀測另一個,就需要記憶體屏障來保證一致性了。

這句指令中的“addl$0x0,(%esp)”(把ESP暫存器的值加0)顯然是一個空操作,這裡的關鍵在於lock字首,查詢IA32手冊可知,
它的作用是將本處理器的快取寫入了記憶體,該寫入動作也會引起別的處理器或者別的核心無效化(Invalidate)其快取,這種操作相當於對快取中的變數做了一次write操作。
所以通過這樣一個空操作,可讓前面volatile變數的修改對其他處理器立即可見

針對long和double型變數的特殊規則

Java記憶體模型要求lock、unlock、read、assign、use、write這6種操作都具有原子性,但是對於64位的資料型別(long和double),
在模型中特別定義了一條寬鬆的規定:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行。
即允許虛擬機器實現自行選擇是否要保證64位資料型別的read和write這四個操作的原子性,這就是所謂的“long和double的非原子性協定”(Non-Atomic Treatment of double and long Variables)。
如果有多個執行緒共享一個並未宣告為volatile的long或double型別的變數,並且同時對它們進行讀取和修改操作,那麼某些執行緒可能會讀取到一個既不是原值,也不是其他執行緒修改值的代表了“半個變數”的數值。

原子性、可見性與有序性

  • 原子性:指一個操作是不可中斷的,要麼全部成功,要麼全部失敗,在多執行緒執行時,一個執行緒原子操作開始,不會被其他執行緒所幹擾。
    Java中基本資料型別的訪問、讀寫都是具備原子性的(例外就是long和double的非原子性協定)。
    如果應用場景需要一個更大範圍的原子性保證,Java記憶體模型還提供了lock和unlock操作來滿足這種需求,儘管虛擬機器未把lock和unlock操作直接開放給使用者使用,
    但是卻提供了更高層次的位元組碼指令monitorenter和monitorexit來隱式地使用這兩個操作。
    這兩個位元組碼指令反映到Java程式碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。

  • 可見性:可見性就是指當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改。Java記憶體模型是通過在變數修改後將新值同步回主記憶體,
    在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性的,無論是普通變數還是volatile變數都是如此。
    普通變數與volatile變數的區別是,volatile的特殊規則保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理
    除了volatile之外,Java還有兩個關鍵字能實現可見性,它們是synchronized和final。
    同步塊的可見性是由對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行write操作)這條規則獲得的。
    而final關鍵字的可見性是指:被final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有把“this”的引用傳遞出去,那麼在其他執行緒中就能看見final欄位的值。
    如如下程式碼所示,變數i與j都具備可見性,它們無須同步就能被其他執行緒正確訪問。

public static final int i;
public final int j;
static {
 i = 0;
 // 省略後續動作
}
{
 // 也可以選擇在建構函式中初始化
 j = 0;
 // 省略後續動作
  • 有序性:Java記憶體模型的有序性在前面講解volatile時也比較詳細地討論過了,Java程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;
    如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內似表現為序列的語義”(Within-Thread As-If-SerialSemantics),
    後半句是指指令重排序現象和工作記憶體與主記憶體同步延遲現象。Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,
    而synchronized則是由一個變數在同一個時刻只允許一條執行緒對其進行lock操作這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能序列地進入。

Java與執行緒

執行緒的實現

執行緒是比程序更輕量級的排程執行單位,執行緒的引入,可以把一個程序的資源分配和執行排程分開,各個執行緒既可以共享程序資源(記憶體地址、檔案I/O等),又可以獨立排程。
目前執行緒是Java裡面進行處理器資源排程的最基本單位,如果日後Loom專案能成功為Java引入纖程(Fiber)的話,可能就會改變這一點。
主流的作業系統都提供了執行緒實現,Java語言則提供了在不同硬體和作業系統平臺下對執行緒操作的統一處理,每個已經呼叫過start()方法且還未結束的java.lang.Thread類的例項就代表著一個執行緒。
實現執行緒主要有三種方式:使用核心執行緒實現(1:1實現),使用使用者執行緒實現(1:N實現),使用使用者執行緒加輕量級程序混合實現(N:M實現)。

1 核心執行緒實現
使用核心執行緒實現的方式也被稱為1:1實現。核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心(Kernel,下稱核心)支援的執行緒
這種執行緒由核心來完成執行緒切換,核心通過操縱排程器(Scheduler)對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上
每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就稱為多執行緒核心(Multi-Threads Kernel)。
程式一般不會直接使用核心執行緒,而是使用核心執行緒的一種高階介面——輕量級程序(LightWeight Process,LWP),輕量級程序就是我們通常意義上所講的執行緒,
由於每個輕量級程序都由一個核心執行緒支援,因此只有先支援核心執行緒,才能有輕量級程序。這種輕量級程序與核心執行緒之間1:1的關係稱為一對一的執行緒模型,如圖1-3所示:

圖1-3 輕量級程序與核心執行緒之間1:1的關係

由於核心執行緒的支援,每個輕量級程序都成為一個獨立的排程單元,即使其中某一個輕量級程序在系統呼叫中被阻塞了,也不會影響整個程序繼續工作。
輕量級程序也具有它的侷限性:

  • 由於是基於核心執行緒實現的,所以各種執行緒操作,如建立、析構及同步,都需要進行系統呼叫。而系統呼叫的代價相對較高,需要在使用者態(User Mode)和核心態(Kernel Mode)中來回切換。
  • 每個輕量級程序都需要有一個核心執行緒的支援,因此輕量級程序要消耗一定的核心資源(如核心執行緒的棧空間),因此一個系統支援輕量級程序的數量是有限的

2 使用者執行緒實現
使用使用者執行緒實現的方式被稱為1:N實現。廣義上來講,一個執行緒只要不是核心執行緒,都可以認為是使用者執行緒(User Thread,UT)的一種,因此從這個定義上看,
輕量級程序也屬於使用者執行緒,但輕量級程序的實現始終是建立在核心之上的,許多操作都要進行系統呼叫,因此效率會受到限制,並不具備通常意義上的使用者執行緒的優點。

而狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知到使用者執行緒的存在及如何實現的。使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助
如果程式實現得當,這種執行緒不需要切換到核心態,因此操作可以是非常快速且低消耗的,也能夠支援規模更大的執行緒數量,部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。
這種程序與使用者執行緒之間1:N的關係稱為一對多的執行緒模型,如圖1-4所示:

圖1-4 程序與使用者執行緒之間1:N 的關係

使用者執行緒的優勢在於不需要系統核心支援,劣勢也在於沒有系統核心的支援,所有的執行緒操作都需要由使用者程式自己去處理。執行緒的建立、銷燬、切換和排程都是使用者必須考慮的問題,
而且由於作業系統只把處理器資源分配到程序,那諸如“阻塞如何處理”“多處理器系統中如何將執行緒對映到其他處理器上”這類問題解決起來將會異常困難。
Java、Ruby等語言都曾經使用過使用者執行緒,最終又都放棄了使用它。近年來許多新的、以高併發為賣點的程式語言又普遍支援了使用者執行緒,譬如Golang、Erlang等。

3 Java執行緒的實現:
Java執行緒在早期的Classic虛擬機器上(JDK 1.2以前),是基於一種被稱為“綠色執行緒”(Green Threads)的使用者執行緒實現的,但從JDK 1.3起,
“主流”平臺上的“主流”商用Java虛擬機器的執行緒模型普遍都被替換為基於作業系統原生執行緒模型來實現,即採用1:1的執行緒模型。
以HotSpot為例,它的每一個Java執行緒都是直接對映到一個作業系統原生執行緒來實現的,而且中間沒有額外的間接結構,所以HotSpot是不會去幹涉執行緒排程的。
執行緒排程全由作業系統去處理,包括何時凍結或喚醒執行緒、該給執行緒分配多少處理器執行時間、該把執行緒安排給哪個處理器核心去執行等。

java執行緒排程

執行緒排程是指系統為執行緒分配處理器使用權的過程,排程主要方式有兩種,分別是協同式(Cooperative Threads-Scheduling)執行緒排程和搶佔式(Preemptive Threads-Scheduling)執行緒排程。

  • 協同式排程的多執行緒系統,執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上去
    協同式多執行緒的最大好處是實現簡單,而且由於執行緒要把自己的事情幹完後才會進行執行緒切換,切換操作對執行緒自己是可知的,所以一般沒有什麼執行緒同步的問題。
    它的壞處也很明顯:執行緒執行時間不可控制,甚至如果一個執行緒的程式碼編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡
    Windows 3.x系統就是使用協同式來實現多程序多工的,那是相當不穩定的,只要有一個程序堅持不讓出處理器執行時間,就可能會導致整個系統崩潰

  • 搶佔式排程的多執行緒系統,那麼每個執行緒將由系統來分配執行時間,執行緒的切換不由執行緒本身來決定。譬如在Java中,有Thread::yield()方法可以主動讓出執行時間,
    但是如果想要主動獲取執行時間,執行緒本身是沒有什麼辦法的。在這種實現執行緒排程的方式下,執行緒的執行時間是系統可控的,也不會有一個執行緒導致整個程序甚至整個系統阻塞的問題。
    Java使用的執行緒排程方式就是搶佔式排程。雖然說Java執行緒排程是系統自動完成的,但我們可以設定執行緒優先級別來給系統提供排程建議。
    Java一共設定了10個級別的執行緒優先順序(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在兩個執行緒同時處於Ready狀態時,優先順序越高的執行緒越容易被系統選擇執行。

Java與協程

在Java時代的早期,Java語言抽象出來隱藏了各種作業系統執行緒差異性的統一執行緒介面,這曾經是它區別於其他程式語言的一大優勢。
在此基礎上,湧現過無數多執行緒的應用與框架,譬如在網頁訪問時,HTTP請求可以直接與Servlet API中的一條處理執行緒繫結在一起,以“一對一服務”的方式處理由瀏覽器發來的資訊。
時至今日,這種便捷的併發程式設計方式和同步的機制依然在有效地運作著,但是在某些場景下,卻也已經顯現出了疲態,如微服務高併發。

核心執行緒的侷限
現代B/S系統中一次對外部業務請求的響應,往往需要分佈在不同機器上的大量服務共同協作來實現,這種服務細分的架構在減少單個服務複雜度、增加複用性的同時,
也不可避免地增加了服務的數量,縮短了留給每個服務的響應時間。這要求每一個服務都必須在極短的時間內完成計算,這樣組合多個服務的總耗時才不會太長;
也要求每一個服務提供者都要能同時處理數量更龐大的請求,這樣才不會出現請求由於某個服務被阻塞而出現等待。

Java目前1:1的核心執行緒模型,對映到作業系統上的執行緒天然的缺陷是切換、排程成本高昂,系統能容納的執行緒數量也很有限
在每個請求本身的執行時間變得很短、數量變得很多的前提下,使用者執行緒切換的開銷甚至可能會接近用於計算本身的開銷,這就會造成嚴重的浪費。

java解決方案
OpenJDK在2018年建立了Loom專案,這是Java用來應對微服務高併發場景的官方解決方案。Loom專案背後的意圖是重新提供對使用者執行緒的支援,
但與過去的綠色執行緒不同,這些新功能不是為了取代當前基於作業系統的執行緒實現,而是會有兩個併發程式設計模型在Java虛擬機器中並存,可以在程式中同時使用。