《深入理解java虛擬機》筆記6——高效併發

第五部分 高效併發

第十二章 Java內存模型與線程

併發處理的廣泛應用是使得Amdahl定律代替摩爾定律成爲計算機性能發展源動力的根本原因,也是人類“壓榨”計算機運算能力的最有力武器。

12.1 概述

  • 多任務處理在現代計算機操作系統中幾乎已是一項必備的功能了;
  • 除了充分利用計算機處理器的能力外,一個服務端同時對多個客戶端提供服務則是另一個更具體的併發應用場景;
  • 服務端是Java語言最擅長的領域之一,不過如何寫好併發應用程序卻又是服務端程序開發的難點之一,處理好併發方面的問題通常需要更多的編碼經驗來支持,幸好Java語言和虛擬機提供了許多工具,把併發編碼的門檻降低了不少;

12.2 硬件的效率與一致性

  • 絕大多數的運算任務不可能只靠處理器計算就能完成,處理器至少要與內存交互,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的高速緩存來作爲內存與處理器之間的緩衝:將運算需要使用到的數據複製到緩存中,讓運算能快速運行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了;
  • 基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是也爲計算機系統帶來更高的複雜度,因爲它引入了一個新的問題:緩存一致性;爲了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等;
  • 本章將會多次提到內存模型一詞,可以理解在特定的操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象;不同架構的物理機器可以擁有不一樣的內存模型,而Java虛擬機也有自己的內存模型,並且這裏介紹的內存訪問操作與硬件的緩存訪問具有很高的可比性;
  • 除了增加高速緩存之外,爲了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入代碼進行亂序執行優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的;

12.3 Java內存模型

Java虛擬機規範中視圖定義一種Java內存模型(JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。

12.3.1 主內存與工作內存

  • Java內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節;此處的變量與Java編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但不包括局部變量與方法參數,因爲後者是線程私有的,不會被共享;
  • Java內存模型規定了所有的變量都存儲在主內存中,每個線程還有自己的工作內存,線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量;
  • 這裏所講的主內存、工作內存與第二章所講的Java內存區域中的Java堆、棧、方法區等並不是同一個層次的內存劃分,這兩者基本上是沒有關係的;線程、主內存和工作內存的關係如下所示:

12.3.2 內存間交互操作

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

  • lock
  • unlock
  • read
  • load
  • use
  • assign
  • store
  • write

基於理解難度和嚴謹性考慮,最新的JSR-133文檔中,已經放棄採用這八種操作去定義Java內存模型的訪問協議了,後面將會介紹一個等效判斷原則 -- 先行發生原則,用來確定一個訪問在併發環境下是否安全;

12.3.3 對於volatile型變量的特殊規則

  • 關鍵字volatile可以說是Java虛擬機提供的最輕量級的同步機制;
  • 當一個變量定義爲volatile之後,它將具備兩種特性:第一是保證此變量對所有線程的可見性,這裏的可見性是指當一個線程修改了這個變量的值,新的值對於其他線程來說是可以立即得知的,而普通的變量的值在線程間傳遞均需要通過主內存來完成;另外一個是禁止指令重排序優化,普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致;
  • volatile變量在各個線程的工作內存中不存在一致性問題,但是Java裏面的運算並非原子操作,導致volatile變量的運算在併發下一樣是不安全的;
  • 在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖來保證原子性:運算結果並不依賴變量的當前值或者能夠確保只有單一的線程修改變量的值、變量不需要與其他的狀態變量共同參與不變約束;
  • volatile變量讀操作的性能消耗與普通變量幾乎沒有任何差別,但是寫操作則可能會慢一些;不過大多數場景下volatile的總開銷仍然要比鎖低,我們在volatile與鎖之中選擇的唯一依據僅僅是volatile的語義能否滿足使用場景的需求;

12.3.4 對於long和double型變量的特殊規則

  • 允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分爲兩次32位的操作來進行,即允許虛擬機實現選擇可以不保證64位數據類型的load、store、read和write這4個操作的原子性,這點就是所謂的long和double的非原子性協定;
  • 但允許虛擬機選擇把這些操作實現爲具有原子性的操作,目前各種平臺下的商用虛擬機幾乎都選擇把64位數據的讀寫操作作爲原子操作來對待;

12.3.5 原子性、可見性與有序性

  • 原子性(Atomicity):由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write;在synchronized塊之間的操作也具備原子性;
  • 可見性(Visibility):是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改;除了volatile之外,Java還有synchronized和final關鍵字能實現可見性;
  • 有序性(Ordering):如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的;Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性;

12.3.6 先行發生原則

  • 先行發生是Java內存模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,影響包括了修改了內存中共享變量的值、發送了消息、調用了方法等;
  • 下面是Java內存模型下一些天然的先行發生關係:程序次序規則、管程鎖定規則、volatile變量規則、線程啓動規則、線程終止規則、線程中斷規則、對象終結規則、傳遞性;
  • 時間先後順序與先行發生原則之間基本沒有太大的關係,所以我們衡量併發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則爲準;

12.4 Java與線程

12.4.1 線程的實現

  • 線程是比進程更輕量級的調度執行單位,線程的引入可以把一個進程的資源分配和執行調度分開,各個線程既可以共享進程資源又可以獨立調度;
  • Thread類與大部分的Java API有顯著的差別,它的所有關鍵方法都是聲明爲Native的;
  • 實現線程主要有三種方式:使用內核線程實現(系統調用代價相對較高、一個系統支持輕量級進程的數量是有限的)、使用用戶線程實現(優勢在於不需要系統內核支援,劣勢在於所有線程操作都需要用戶程序自己處理)和使用用戶線程加輕量級進程混合實現(用戶線程是完全建立在用戶空間中,因此用戶線程的創建、切換等操作依然廉價,並且可以支持大規模的用戶線程併發;而操作系統提供支持的輕量級進程則作爲用戶線程和內核線程之間的橋樑,這樣可以使用內核提供的線程調度功能及處理器映射,並且用戶線程的系統調用要通過輕量級線程來完成,大大降低了整個進程被完全阻塞的風險);
  • 對於Sun JDK來說,它的Windows版與Linux版都是使用一對一的線程模型實現的,一條Java線程就映射到一條輕量級進程之中,因爲Windows和Linux系統提供的線程模式就是一對一的;

12.4.2 Java線程調度

  • 線程調度是指系統爲線程分配處理器使用權的過程,主要調度方式有兩種,分別是協同式線程調度(線程的執行時間由線程本身來控制)和搶佔式線程調度(線程由系統來分配執行時間,線程的切換不由線程本身來決定);
  • Java語言一共設置了10個級別的線程優先級,不過線程優先級並不是太靠譜,原因就是操作系統的線程優先級不見得總是與Java線程的優先級一一對應,另外優先級還可能被系統自行改變;

12.4.3 狀態轉換

  • Java語言定義了五種線程狀態,在任意一個時間點,一個線程只能有且只有其中一種狀態,分別是新建(New)、運行(Runnable)、無限期等待(Waiting)、限期等待(Timed Waiting)、阻塞(Blocled)、結束(Terminated)。它們之間相互的轉換關係如下所示:

12.5 本章小結

本章我們首先了解了虛擬機Java內存模型的結構及操作,然後講解了原子性、可見性、有序性在Java內存模型中的體現,最後介紹了先行發生原則的規則及使用。另外,我們還了解了線程在Java語言之中是如何實現的。

在本章主要介紹了虛擬機如何實現併發,而在下一章我們主要關注點將是虛擬機如何實現高效,以及虛擬機對我們編寫的併發代碼提供了什麼樣的優化手段。

第十三章 線程安全與鎖優化

13.1 概述

  • 首先需要保證併發的正確性,然後在此基礎上實現高效;

13.2 線程安全

Brian Goetz對線程安全有一個比較恰當的定義:當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行爲都可以獲得正確的結果,那這個對象是線程安全的。

13.2.1 Java語言中的線程安全

  • 我們可以將Java語言中各個操作共享的數據分爲以下五類:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立;
  • 不可變:不可變帶來的安全性是最簡單和最純粹的,如final的基本數據類型;如果共享的數據是一個對象,那就需要保證對象的行爲不會對其狀態產生任何影響才行,比如String類的substring、replace方法;Number類型的大部分子類都符合不可變要求的類型,但是AtomicInteger和AtomicLong則並非不可變的;
  • 線程絕對安全:Java API中標註自己是線程安全的類,大多數都不是絕對的線程安全;比如java.util.Vector,不意味着調用它的是時候永遠都不再需要同步手段了;
  • 線程相對安全:是我們通常意義上所講的線程安全,在Java語言中,大部分的線程安全類都屬於這種類型;
  • 線程兼容:指對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在併發環境中可以安全地使用;我們說一個類不是線程安全的,絕大多數時候指的是這一種情況;
  • 線程對立:無論調用端是否採取了同步措施,都無法在多線程環境中併發使用的代碼,Java語言中很少出現;

13.2.2 線程安全的實現方法

  • 互斥同步:同步是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻只被一個線程使用,而互斥是實現同步的一種手段,臨界區、互斥量和信號量都是主要的互斥實現方式;Java中最基本的互斥同步手段就是synchronized關鍵字,它對同一個線程來說是可重入的且會阻塞後面其他線程的進入;另外還可以使用java.util.concurrent包中的重入鎖(ReentrantLock)來實現同步,相比synchronized關鍵字ReentrantLock增加了一些高級功能:等待可中斷、可實現公平鎖以及鎖可以綁定多個條件;
  • 非阻塞同步:互斥同步最主要的問題就是進行線程阻塞和喚醒帶來的性能問題,其屬於一種悲觀的併發策略;隨着硬件指令集的發展,我們有了另外一個選擇即基於衝突檢測的樂觀併發策略,就是先進行操作,如果沒有其他線程爭用共享數據那就操作成功了,如果有爭用產生了衝突,那就再採取其他的補償措施(最常見的就是不斷重試直至成功),這種同步操作稱爲非阻塞同步;Java併發包的整數原子類,其中的compareAndSet和getAndIncrement等方法都使用了Unsafe類的CAS操作;
  • 無同步方案:要保證線程安全,並不是一定就要進行同步;有一些代碼天生就是線程安全的,比如可重入代碼和線程本地存儲的代碼;

13.3 鎖優化

13.3.1 自旋鎖與自適應自旋

  • 互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要轉入內核態中完成,這些操作給系統的併發性能帶來了很大的壓力;另外在共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛起和恢復線程並不值得,如果讓兩個或以上的線程同時並行執行,讓後面請求鎖的那個線程稍等一下,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖;爲了讓線程等待,我們只需讓線程執行一個忙循環,這些技術就是所謂的自旋鎖;
  • 在JDK 1.6已經默認開啓自旋鎖;如果鎖被佔用的時間很短自旋等待的效果就會非常好,反之則會白白消耗處理器資源;
  • 在JDK 1.6中引入了自適應的自旋鎖,這意味着自旋的時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定;

13.3.2 鎖消除

  • 鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除;
  • 鎖消除的主要判斷依據來源於逃逸分析的數據支持;

13.3.3 鎖粗化

  • 原則上總是推薦將同步塊的作用範圍限制得儘量小 -- 只有在共享數據的實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變小,如果存在鎖競爭,那等待鎖的線程也能儘快拿到鎖;
  • 但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗;

13.3.4 輕量級鎖

  • 輕量級鎖是JDK 1.6之中加入的新型鎖機制,它是相對於使用操作系統互斥量來實現的傳統鎖而言的;它並不是用來代替重量級鎖的,它的本意是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗;
  • 要理解輕量級鎖,以及後面會講到的偏向鎖的原理和運作過程,必須從HotSpot虛擬機的對象的內存佈局開始介紹;HotSpot虛擬機的對象頭分爲兩部分信息:第一部分用於存儲對象自身的運行時數據,如哈希碼、GC分代年齡等,這部分官方稱之爲Mark Word,是實現輕量級鎖和偏向鎖的關鍵,另外一部分用於存儲指向方法區對象類型數據的指針; Mark Word被設計成一個非固定的數據結構以便在極小的空間存儲儘量多的信息,在32位的HotSpot虛擬機中對象未被鎖定的狀態下,25bit用於存儲對象哈希碼,4bit用於存儲對象分代年齡,2bit用於存儲鎖標誌位,1bit固定爲0;在其他狀態(輕量級鎖定、重量級鎖定、GC標誌、可偏向)下對象的存儲內容如下:
  • 在代碼進入同步塊的時候,如果此同步對象沒有被鎖定,虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲對象目前的Mark Word的拷貝(官方稱之爲Displaced Mark Word);然後虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,如果更新成功了那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位將轉變爲“00”,即表示此對象處於輕量級鎖定狀態;如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶佔了;如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹爲重量級鎖,鎖標誌的狀態值變爲“10”,Mark Word中存儲的就是指向重量級鎖的指針,後面等待鎖的線程也要進行阻塞狀態;
  • 輕量級鎖能提升程序同步性能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗數據;

13.3.5 偏向鎖

  • 偏向鎖也是JDK 1.6中引入的一項鎖優化,它的目的是消除數據在無競爭情況下的同步原語,進一步提高程序的運行性能;如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了;
  • 偏向鎖會偏向於第一個獲得它的線程,如果在接下來的執行過程中,該鎖沒有被其他的線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步;
  • 假設當前虛擬機啓動了偏向鎖,那麼當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設爲“01”,即偏向模式;同時使用CAS操作把獲取到這個鎖的線程ID記錄在對象的Mark Word之中;如果CAS操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作;當有另外一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束,根據鎖對象目前是否被鎖定的狀態,撤銷偏向後恢復到未鎖定或輕量級鎖定的狀態,後續的同步操作就如上面介紹的輕量級鎖那樣執行;偏向鎖、輕量級鎖的狀態轉化以及對象Mark Work的關係如下圖所示:
  • 偏向鎖可以提高帶有同步但無競爭的程序性能,它同樣是一個帶有效益權衡性質的優化;

本章小結

本章介紹了線程安全所涉及的概念和分類、同步實現的方式及虛擬機的底層運行原理,並且介紹了虛擬機爲了實現高效併發所採取的一系列鎖優化措施。

參考資料

原文博客:

《深入理解java虛擬機》筆記6——高效併發

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