Java 併發編程解析 | 如何正確理解Java領域中的鎖機制,我們一般需要掌握哪些理論知識?

蒼穹之邊,浩瀚之摯,眰恦之美; 悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》

Picture-Navigation

寫在開頭

Picture-Header

提起Java領域中的鎖,是否有種“道不盡紅塵奢戀,訴不完人間恩怨“的”感同身受“之感?細數那些個“玩意兒”,你對Java的熱情是否還如初戀般“人生若只如初見”?

Java中對於鎖的實現真可謂是“百花齊放”,按照編程友好程度來說,美其名曰是Java提供了種類豐富的鎖,每種鎖因其特性的不同,在適當的場景下能夠展現出非常高的效率。

但是,從理解的難度上來講,其類型錯中複雜,主要原因是Java是按照是否含有某一特性來定義鎖的實現,如果不能正確理解其含義,瞭解其特性的話,往往都會深陷其中,難可自拔。

查詢過很多技術資料與相關書籍,對其介紹真可謂是“模棱兩可”,生怕我們搞懂了似的,但是這也是我們無法繞過去的一個“坎坎”,除非有其他的選擇。

作爲一名Java Developer來說,正確瞭解和掌握這些鎖的機制和原理,需要我們帶着一些實際問題,通過特性將鎖進行分組歸類,才能真正意義上理解和掌握。

比如,在Java領域中,針對於不同場景提供的鎖,都用於解決什麼問題?其實現方式是什麼?各自又有什麼特點,對應的應用有哪些?

帶着這些問題,今天我們就一起來盤一盤,Java領域中的鎖機制,盤點一下相關知識點,以及不同的鎖的適用場景,幫助我們更快捷的理解和掌握這項必備技術奧義。

關健術語

Picture-Keyword

本文用到的一些關鍵詞語以及常用術語,主要如下:

  • 線程調度(Thread Scheduling ):系統分配處理器使用權的過程,主要調度方式有兩種,分別是協同式線程調度(Cooperative Threads-Scheduling)和搶佔式線程調度(Preemptive Threads-Scheduling)。
  • 線程切換(Thread Switch ):主要是指在併發過程中,多線程之間會對上下文進行切換資源,並交叉執行的一種併發機制。
  • 指令重排(Command Reorder ): 指編譯器或處理器爲了優化性能而採取的一種手段,在不存在數據依賴性情況下(如寫後讀,讀後寫,寫後寫),調整代碼執行順序。
  • 內存屏障(Memory Barrier): 也稱內存柵欄,內存柵障,屏障指令等, 是一類同步屏障指令,是CPU或編譯器在對內存隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後纔可以開始執行此點之後的操作。

基本概述

Picture-Content

縱觀Java領域中“五花八門”的鎖,我們可以依據Java內存模型的工作機制,來具體分析一下對應問題的提出和表現,這也不失爲打開Java領域中鎖機制的“敲門磚”。

從本質上講,鎖是一種協調多個進程 或者多個線程對某一個資源的訪問的控制機制。

一.計算機運行模型

計算機運行模型主要是描述計算機系統體系結構的基本模型,一般主要是指CPU處理器結構。

v42Fw8.png

在計算機體系結構中,中央處理器(CPU,Central Processing Unit)是一塊超大規模的集成電路,是一臺計算機的運算核心(Core)和控制核心( Control Unit)。它的功能主要是解釋計算機指令以及處理計算機軟件中的數據。

一個計算能夠運行起來,主要是依靠CPU來負責執行我們的輸入指令的,通常情況下,我們都把這些指令統稱爲程序。

一般CPU決定着程序的運行速度,可以看出CPU對程序的執行有很重要的作用,但是一個計算機程序的運行快慢並不是完全由CPU決定,除了CPU還有內存、閃存等。

由此可見,一個CPU主要由控制單元,算術邏輯單元和寄存器單元等3個部分組成。其中:

v5OZGR.png

  • 控制單元( Control Unit): 屬於CPU的控制指揮中心,主要負責指揮CPU工作,通過向算術邏輯單元和寄存器單元來發送控制指令達到控制效果。
  • 算術邏輯單元(Arithmetic Logic Unit, ALU): 主要負責執行運算,一般是指算術運算和邏輯運算,主要是依據控制單元發送過來的指令進行處理。
  • 寄存器單元(Register Unit): 主要用於存儲臨時數據,保存着等待處理和已經處理的數據。

一般來說,寄存器單元是爲了減少CPU對內存的訪問次數,提升數據讀取性能而提出的,CPU中的寄存器單元主要分爲通用寄存器和專用寄存器兩個種,其中:

  • 通用寄存器:主要用於臨時存放CPU正在使用的數據。
  • 專用寄存器:主要用於臨時存放類似指令寄存器和程序計數器等CPU中專有用途的數據。其中:
    • 指令寄存器:用於存儲正在執行的指令
    • 程序計數器: 保存等待執行的指令地址

簡單來說,CPU與主存儲器主要是通過總線來進行通信,CPU通過控制單元來操作主存中的數據。而CPU與其他設備的通信都是由控制來實現。

綜上所述,我們便可以得到一個計算機內存模型的大致雛形,接下來,我們便來一起盤點解析是計算機內存模型的基本奧義。

二.計算機內存模型

計算機內存模型一般是指計算系統底層與編程語言之間的約束規範,主要是描述計算機程序與共享存儲器訪問的行爲特徵表現。

v5qgts.png

根據介紹計算機運行模型來看,計算機內存模型可以幫助以及指導我們理解Java內存模型,主要在如下的兩個方面:

  • 首先,系統底層希望能夠對程序進行更多的優化策略,一般主要是針對處理器和編譯器,從而提高運行性能。
  • 其次,爲編程語言帶來了更多的可編程性問題,主要是複雜的內存模型會有更多的約束,從而增加了程序設計的編程難度。

由此可見,內存模型用於定義處理器間的各層緩存與共享內存的同步機制,以及線程與內存之間交互的規則。

在操作系統層面,內存主要可以分爲物理內存與虛擬內存的概念,其中:

  • 物理內存(Physical Memory): 通常指通過安裝內存條而獲得的臨時儲存空間。主要作用是在計算機運行時爲操作系統和各種程序提供臨時儲存。常見的物理內存規格有256M、512M、1G、2G等。
  • 虛擬內存(Virtual Memory):計算機系統內存管理的一種技術。它使得應用程序認爲它擁有連續可用的內存(一個連續完整的地址空間),它通常是被分隔成多個物理內存碎片,還有部分暫時存儲在外部磁盤存儲器上,在需要時進行數據交換。

一般情況下,當物理內存不足時,可以用虛擬內存代替, 在虛擬內存出現之前,程序尋址用的都是物理地址。

從常見的存儲介質來看,主要有:寄存器(Register),高速緩存(Cache),隨機存取存儲器(RAM),只讀存儲器(ROM)等4種,按照讀取快慢的順序是:Register>Cache>RAM>ROM。其中:

  • 寄存器(Register): CPU處理器的一部分,主要分爲通用寄存器和專用寄存器。
  • 高速緩存(Cache):用於減少 CPU 處理器訪問內存所需平均時間的部件,一般是指L1/L2/L3層高級緩存。
  • 隨機存取存儲器(Random Access Memory,RAM):與CPU直接交換數據的內部存儲器,它可以隨時讀寫,而且速度很快,通常作爲操作系統或其他正在運行中的程序的臨時數據存儲媒介。
  • 只讀存儲器(Read-Only Memory,ROM):所存儲的數據通常都是裝入主機之前就寫好的,在工作的時候只能讀取而不能像隨機存儲器那樣隨便寫入。

由於CPU的運算速度比主存(物理內存)的存取速度快很多,爲了提高處理速度,現代CPU不直接和主存進行通信,而是在CPU和主存之間設計了多層的Cache(高速緩存),越靠近CPU的高速緩存越快,容
量也越小。

按照數據讀取順序和與CPU內核結合的緊密程度來看,大多數採用多層緩存策略,最經典的就三層高速緩存架構。

也就是我們常說的,CPU高速緩存有L1和L2高速緩存(即一級高速緩存和二級緩存高速),部分高端CPU還具有L3高速緩存(即三級高速緩存):

v5OPqU.png

CPU內核讀取數據時,先從L1高速緩存中讀取,如果沒有命中,再到L2、L3高速緩存中讀取,假如這些高速緩存都沒有命中,它就會到主存中讀取所需要的數據。

每一級高速緩存中所存儲的數據都是下一級高速緩存的一部分,越靠近CPU的高速緩存讀取越快,容量也越小。

當然,系統還擁有一塊主存(即主內存),由系統中的所有CPU共享。擁有L3高速緩存的CPU,CPU存取數據的命中率可達95%,也就是說只有不到5%的數據需要從主存中去存取。

因此,高速緩存大大縮小了高速CPU內核與低速主存之間的速度差距,基本體現在如下:

  • L1高速緩存:最接近CPU,容量最小、存取速度最快,每個核上都有一個L1高速緩存。
  • L2高速緩存:容量更大、速度低些,在一般情況下,每個內核上都有一個獨立的L2高速緩存。
  • L3高速緩存:最接近主存,容量最大、速度最低,由在同一個CPU芯片板上的不同CPU內核共享。

總結來說,CPU通過高速緩存進行數據讀取有以下優勢:

  • 寫緩衝區可以保證指令流水線持續運行,可以避免由於CPU停頓下來等待向內存寫入數據而產生的延遲。
  • 通過以批處理的方式刷新寫緩衝區,以及合併寫緩衝區中對同一內存地址的多次寫,減少對內存總線的佔用。

綜上所述,一般來說,對於單線程程序,編譯器和處理器的優化可以對編程開發足夠透明,對其優化的效果不會影響結果的準確性。

而在多線程程序來說,爲了提升性能優化的同時又達到兼顧執行結果的準確性,需要一定程度上內存模型規範。

由於經常會採用多層緩存策略,這就導致了一個比較經典的併發編程三大問題之一的共享變量的可見性問題,除了可見性問題之外,當然還有原子性問題和有序性問題。

由此來看,在計算機內存模型中,主要可以提出主存和工作內存的概念,其中:

  • 主存:一般指的物理內存,主要是指RAM隨機存取存儲器和ROM只讀存儲器等
  • 工作內存:一般指寄存器,還有以及我們說的三層高速緩存策略中的L1/L2/L3層高級緩存Cache等

在Java領域中,爲了解決這一系列問題,特此提出了Java內存模型,接下來,我們就來一看看Java內存模型的工作機制。

三.Java內存模型

Java內存模型主要是爲了解決併發編程的可見性問題,原子性問題和有序性問題等三大問題,具有跨平臺性。

vISuQA.png

JMM最初由JSR-133(Java Memory Model and ThreadSpecification)文檔描述,JMM定義了一組規則或規範,該規範定義了一個線程對共享變量寫入時,如何確保對另一個線程是可見的。

Java內存模型(Java Memory Model JMM)指的是Java HotSpot(TM) VM 虛擬機定義的一種統一的內存模型,將底層硬件以及操作系統的內存訪問差異進行封裝,使得Java程序在不同硬件以及操作系統上執行都能達到相同的併發效果。

Java內存模型對於內存的描述主要體現在三個方面:

  • 首先,描述程序各個變量之間關係,主要包括實例域,靜態域,數據元素等。
  • 其次,描述了在計算機系統中將變量存儲到內存以及從內存中獲取變量的底層細節,主要包括針對某個線程對於共享變量的進行操作時,如何通知其他線程(涉及線程間如何通信)
  • 最後,描述了多個線程對於主存中的共享資源的安全訪問問題。

一般來說,Java內存模型在對內存的描述上,我們可以依據是編譯時分配還是運行時分配,是靜態分配還是動態分配,是堆上分配還是棧上分配等角度來進行對比分析。

從Java HotSpot(TM) VM 虛擬機的整體結構上來看,內存區域可以分爲線程私有區,線程共享區,直接內存等內容,其中:

v42nln.png

  • 線程私有區(Thread Local):主要包括程序計數器、虛擬機棧、本地方法區,其中線程私有數據區域生命週期與線程相同, 依賴用戶線程的啓動/結束 而 創建/銷燬。
  • 線程共享區(Thread Shared):主要包括JAVA 堆、方法區,其中,線程共享區域隨虛擬機的啓動/關閉而創建/銷燬。
  • 直接內存(Driect Memory):不會受Java HotSpot(TM) VM 虛擬機中的GC影響,並不是JVM運行時數據區的成員。

根據線程私有區中包含的數據(程序計數器、虛擬機棧、本地方法區)來具體分析看,其中:

  • 程序計數器(Program Counter Register ):一塊較小的內存空間, 是當前線程所執行的字節碼的行號指示器,每條線程都要有一個獨立的程序計數器,而且是唯一一個在虛擬機中沒有規定任何OutOfMemoryError情況的區域。
  • 虛擬機棧(VM Stack):是描述Java方法執行的內存模型,在方法執行的同時都會創建一個棧幀用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。
  • 本地方法區(Native Method Stack):和Java Stack作用類似, 區別是虛擬機棧爲執行Java方法服務, 而本地方法棧則爲Native方法服務。

根據線程共享區中包含的數據(JAVA 堆、方法區)來具體分析看,其中:

  • JAVA 堆(Heap):是被線程共享的一塊內存區域,創建的對象和數組都保存在Java堆內存中,也是垃圾收集器進行垃圾收集的最重要的內存區域。
  • 方法區(Method Area):是指Java HotSpot(TM) VM 虛擬機把GC分代收集擴展至方法區,Java HotSpot(TM) VM 的垃圾收集器就可以像管理Java堆一樣管理這部分內存, 而不必爲方法區開發專門的內存管理器,其中這裏需要注意的是:
    • 在JDK1.8之前,使用永久代(Permanent Generation), 用於存儲被JVM加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據. , 即使用Java堆的永久代來實現方法區, 主要是因爲永久帶的內存回收的主要目標是針對常量池的回收和類型的卸載, 其收益一般很小。
    • 在JDK1.8之後,永久代已經被移除,被一個稱爲“元數據區(Metadata Area)”的區域所取代。元空間(Metadata Space)的本質和永久代類似,最大的區別在於:元空間並不在虛擬機中,而是使用本地內存。默認情況下,元空間的大小僅受本地內存限制。類的元數據放入 Native Memory, 字符串池和類的靜態變量放入Java堆中,這樣可以加載多少類的元數據由系統的實際可用空間來控制。

這裏對線程共享區和程私有區其細節,就暫時不做展開,但是我們可以簡單地看出,對於Java領域中的內存分配,這兩者之間已經幫助我們做了具體區分。

在繼續後續問題探索之前,我們一起來思考一個問題:按照線性思維來看,一個Java程序從程序編寫到編譯,編譯到運行,運行到執行等過程來說,究竟是先入堆還是先入棧呢 ?

這個問題,其實我在看Java HotSpot(TM) VM 虛擬機相關知識的時候,一直有這樣的個疑慮,但是其實這樣的表述是不準確的,這需要結合編譯原理相關的知識來具體分析。

按照編譯原理的觀點,從Java內存分配策略來看,程序運行時的內存分配有三種策略,其中:

vIpTH0.png

  • 靜態存儲分配:靜態存儲分配要求在編譯時能知道所有變量的存儲要求,指在編譯時,就能確定每個數據在運行時的存儲空間,因而在編譯時就可以給他們分配固定的內存空間。這種分配策略要求程序代碼中不允許有可變數據結構的存在,也不允許有嵌套或者遞歸的結構出現,因爲它們都會導致編譯程序無法計算準確的存儲空間需求。
  • 棧式存儲分配:棧式存儲分配要求在過程的入口處必須知道所有的存儲要求,也可稱爲動態存儲分配,是由一個類似於堆棧的運行棧來實現的。和靜態存儲分配相反,在棧式存儲方案中,程序對數據區的需求在編譯時是完全未知的,只有到運行的時候才能夠知道,也就是規定在運行中進入一個程序模塊時,必須知道該程序模塊所需的數據區大小才能夠爲其分配內存。棧式存儲分配按照先進後出的原則進行分配。
  • 堆式存儲分配:堆式存儲分配則專門負責在編譯時或運行時模塊入口處都無法確定存儲要求的數據結構的內存分配,比如可變長度串和對象實例。堆由大片的可利用塊或空閒塊組成,堆中的內存可以按照任意順序分配和釋放。

也就是說,在Java領域中,一個Java程序從程序編寫到編譯,編譯到運行,運行到執行等過程來說,單純考慮是先入堆還是入棧的問題,在這裏得到了答案。

從整體上來看,Java內存模型主要考慮的事情基本與主存,線程本地內存,共享變量,變量副本,線程等概念息息相關,其中:

  • 從主存與線程本地內存的關係來看 : 主存主要保存Java程序中的共享變量,其中主存不保存局部變量和方法參數列表;而線程本地內存主要保存Java程序中的共享變量的變量副本。
  • 從線程與線程本地內存的關係來看:每個線程都會維護一個自己專屬的本地內存,不同線程之間互相不可直接通信,其線程之間的通信就會涉及共享變量可見性的問題。

在Java內存模型中,一般來說主要提供volatile,synchronized,final以及鎖等4種方式來保證變量的可見性問題,其中:

  • 通過volatile關鍵詞實現: 利用volatile修飾聲明時,變量一旦有更改都會被立即同步到主存中,當線程需要使用這個變量時,需要從主存中刷新到工作內存中。
  • 通過synchronized關鍵詞實現:利用synchronized修飾聲明時,當一個線程釋放一個鎖,強制刷新工作內存中的變量到主存中,當另外一個線程需要使用此鎖時,會強制重新載入變量值。
  • 通過final關鍵詞實現:利用final修飾聲明時,變量一旦初始化完成,Java中的線程都可以看到這個變量。
  • 通過JDK中鎖實現:當一個線程釋放一個鎖,強制刷新工作內存中的變量到主存中,當另外一個線程需要使用此鎖時,會強制重新載入變量值。

實際上,相比之下,Java內存模型還引入了一個工作內存的概念來幫助我們提升性能,而且JMM提供了合理的禁用緩存以及禁止重排序的方法,所以其核心的價值在於解決可見性和有序性。

其中,需要特別注意的是,其主存和工作內存的區別:

  • 主存: 可以在計算機內存模型說是物理內存,對應到Java內存模型來講,是Java HotSpot(TM) VM 虛擬機中虛擬內存的一部分。
  • 工作內存:在計算機內存模型內是指CPU緩存,一般是指寄存器,還有以及我們說的三層高速緩存策略中的L1/L2/L3層高級緩存;對應到Java內存模型來講,主要是三層高速緩存Cache和寄存器。

綜上所述,我們對Java內存模型的探討算是水到渠成了,但是Java內存模型也提出了一些規範,接下來,我們就來看看Happen-Before 關係原則。

四.Java一致性模型指導原則

Java一致性模型指導原則是指制定一些規範來將複雜的物理計算機的系統底層封裝到JVM中,從而向上提供一種統一的內存模型語義規則,一般是指Happens-Before規則。

vIC2lQ.png

Happen-Before 關係原則,是 Java 內存模型中保證多線程操作可見性的機制,也是對早期語言規範中含糊的可見性概念的一個精確定義,其行爲依賴於處理器本身的內存一致性模型。

Happen-Before 關係原則主要規定了Java內存在多線程操作下的順序性,一般是指先發生操作的執行結果對後續發生的操作可見,因此稱其爲Java一致性模型指導原則。

由於Happen-Before 關係原則是向上提供一種統一的內存模型語義規則,它規範了Java HotSpot(TM) VM 虛擬機的實現,也能爲上層Java Developer描述多線程併發的可見性問題。

在Java領域中,Happen-Before 關係原則主要有8種,具體如下:

  • 單線程原則:線程內執行的每個操作,都保證 happen-before 後面的操作,這就保證了基本的程序順序規則,這是開發者在書寫程序時的基本約定。
  • 鎖原則:對於一個鎖的解鎖操作,保證 happen-before 加鎖操作。
  • volatile原則:對於 volatile 變量,對它的寫操作,保證 happen-before 在隨後對該變量的讀取操作。
  • 線程Start原則:類似線程內部操作的完成,保證 happen-before 其他 Thread.start() 的線程操作原則。
  • 線程Join原則:類似線程內部操作的完成,保證 happen-before 其他 Thread.join() 的線程操作原則。
  • 線程Interrupt原則:類似線程內部操作的完成,保證 happen-before 其他 Thread.interrupt() 的線程操作原則。
  • finalize原則: 對象構建完成,保證 happen-before 於 finalizer 的開始動作。
  • 傳遞原則: Happen-Before 關係是存在着傳遞性的,如果滿足 A happen-before B 和 B happen-before C,那麼 A happen-before C 也成立。

對於Happen-Before 關係原則來說,而不是簡單地線性思維的前後順序問題,是因爲它不僅僅是對執行時間的保證,也包括對內存讀、寫操作順序的保證。僅僅是時鐘順序上的先後,並不能保證線程交互的可見性。

在Java HotSpot(TM) VM 虛擬機內部的運行時數據區,但是真正程序執行,實際是要跑在具體的處理器內核上。簡單來說,把本地變量等數據從內存加載到緩存、寄存器,然後運算結束寫回主內存。

總的來說,JMM 內部的實現通常是依賴於內存屏障,通過禁止某些重排序的方式,提供內存可見性保證,也就是實現了各種 happen-before 規則。與此同時,更多複雜度在於,需要儘量確保各種編譯器、各種體系結構的處理器,都能夠提供一致的行爲。

五.Java指令重排

Java指令重排是指在執行程序時爲了提高性能,編譯器和處理器常常會對指令做重排序的一種防護措施機制。

v4226I.png

我們在實際開發工作中編寫代碼時候,是按照一定的代碼的思維和習慣去編排和組織代碼的,但是實際上,編譯器和CPU執行的順序可能會代碼順序產生不一致的情況。

畢竟,編譯器和CPU會對我們編寫的程序代碼自身做一定程度上的優化再去執行,以此來提高執行效率,因此提出了指令重排的機制。

一般來說,我們在程序中編寫的每一個行代碼其實就是程序指令,按照線性思維方式來看,這些指令按道理是一行行代碼存在的順序去執行的,只有上一行代碼執行完畢,下一行代碼纔會被執行,這就說明代碼的執行有一定的順序。

但是這樣的順序,對於程序的執行時間上來看是有一定的耗時的,爲了加快代碼的執行效率,一般會引入一種流水線技術的方式來解決這個問題,就像Jenkins 流水線部署機制的編寫那樣。

但是流水線技術的本質上,是把每一個指令拆成若干個部分,在同一個CPU的時間內使其可以執行多個指令的不同部分,從而達到提升執行效率的目的,主要體現在:

  • 獲取指令階段: 主要使用指令通道和指令寄存器,一般是在CPU處理器主導
  • 編譯指令階段:主要使用指令編譯器,一般是在編譯器主導
  • 執行指令階段:主要使用執行單元和數據通道,相對來說像是從內存在主導

一般來說,指令從排會涉及到CPU,編譯器,以及內存等,因此指令重排序的類型大致可以分爲 編譯器指令重排,CPU指令重排,內存指令重排,其中:

  • 編譯器指令重排:編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序
  • CPU指令重排:現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
  • 內存指令重排:由於處理器使用緩存和讀/寫緩衝區,其加載和存儲操作看上去類似亂序執行的情況。

在Java領域中,指令重排的原則是不能影響程序在單線程下的執行的準確性,但是在多線程的情況下,可能會導致程序執行出現錯誤的情況,主要是依據Happen-Before 關係原則來組織部重排序,其核心就是使用內存屏障來實現,通過內存屏障可以堆內存進行順序約束,而且作用於線程。

由於Java有不同的編譯器和運行時環境,對應起來看,Java指令重排主要發生在編譯階段和運行階段,而編譯階段對應的是編譯器,運行階段對應着CPU,其中:

vI0LnS.png

  • 編譯階段指令重排:
    • 1⃣️ 通用描述:源代碼->機器碼的指令重排: 源代碼經過編譯器變成機器碼,而機器碼可能被重排
    • 2⃣️ Java描述:Java源文件->Java字節碼的指令重排: Java源文件被javac編譯後變成Java字節碼,其字節碼可能被重排
  • 運行階段指令重排:
    • 1⃣️ 通用描述:機器碼->CPU處理器的指令重排:機器碼經過CPU處理時,可能會被CPU重排才執行
    • 2⃣️ Java描述:Java字節碼->Java執行器的指令重排: Java字節碼被Java執行器執行時,可能會被CPU重排才執行

既然設置內存屏障,可以確保多CPU的高速緩存中的數據與內存保持一致性, 不能確保內存與CPU緩存數據一致性的指令也不能重排,內存屏障正是通過阻止屏障兩邊的指令重排序來避免編譯器和硬件的不正確優化而提出的一種解決辦法。

但是內存屏障的是需要考慮CPU的架構方式,不同硬件實現內存屏障的方式不同,一般以常見Intel CPU來看,主要有:

  • 1⃣️ lfence屏障: 是一種Load Barrier 讀屏障。
  • 2⃣️ sfence屏障: 是一種Store Barrier 寫屏障 。
  • 3⃣️ mfence屏障:是一種全能型的屏障,具備ifence和sfence的能力 。
  • 4⃣️ Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock會對CPU總線和高速緩存加鎖,可以理解爲CPU指令級的一種鎖。

在Java領域中,Java內存模型屏蔽了這種底層硬件平臺的差異,由JVM來爲不同的平臺生成相應的機器碼。

v5r9u8.png

從廣義上的概念定義看,Java中的內存屏障一般主要有Load和Store兩類:

  • 1⃣️ 對Load Barrier來說,在讀指令前插入讀屏障,可以讓高速緩存中的數據失效,重新從主內存加載數據
  • 2⃣️ 對Store Barrier來說,在寫指令之後插入寫屏障,能讓寫入緩存的最新數據寫回到主內存

從具體的使用方式來看,Java中的內存屏障主要有以下幾種方式:

  • 1⃣️ 通過 synchronized關鍵字包住的代碼區域:當線程進入到該區域讀取變量信息時,保證讀到的是最新的值。
    - a. 在同步區內對變量的寫入操作,在離開同步區時就將當前線程內的數據刷新到內存中。
    - b. 對數據的讀取也不能從緩存讀取,只能從內存中讀取,保證了數據的讀有效性.這也是會插入StoreStore屏障的緣故。
  • 2⃣️ 通過volatile關鍵字修飾變量:當對變量的寫操作,會插入StoreLoad屏障。
  • 3⃣️ 其他的設置方式,一般需要通過Unsafe這個類來執行,主要是:
    - a. Unsafe.putOrderedObject():類似這樣的方法,會插入StoreStore內存屏障
    - b. Unsafe.putVolatiObject() 類似這樣的方法,會插入StoreLoad屏障

綜上所述,一般來說volatile關健字能保證可見性和防止指令重排序,也是我們最常見提到的方式。

六.Java併發編程的三宗罪

Java併發編程的三宗罪主要是指原子性問題、可見性問題和有序性問題等三大問題。

v5DojK.png

在介紹Java內存模型時,我們都說其核心的價值在於解決可見性和有序性,以及還有原子性等,那麼對其總結來說,就是Java併發編程的三宗罪,其中:

  • 原子性問題:就是“不可中斷的一個或一系列操作”,是指不會被線程調度機制打斷的操作。這種操作一旦開始,就一直運行到結束,中間不會有任何線程的切換。
  • 可見性問題:一個線程對共享變量的修改,另一個線程能夠立刻可見,我們稱該共享變量具備內存可見性。
  • 有序性問題:指程序按照代碼的先後順序執行。如果程序執行的順序與代碼的先後順序不同,並導致了錯誤的結果,即發生了有序性問題。

但是,這裏我們需要知道,Java內存模型是如何解決這些問題的?主要體現如下幾個方面:

  • 解決原子性問題:Java內存模型通過read、load、assign、use、store、write來保證原子性操作,此外還有lock和unlock,直接對應着synchronized關鍵字的monitorenter和monitorexit字節碼指令。
  • 解決可見性問題:Java保證可見性通過volatile、final以及synchronized,鎖來實現。
  • 解決有序性問題:由於處理器和編譯器的重排序導致的有序性問題,Java主要可以通過volatile、synchronized來保證。

一定意義上來講,一般在Java併發編程中,其實加鎖可以解決一部分問題,除此之外,我們還需要考慮線程飢餓問題,數據競爭問題,競爭條件問題以及死鎖問題,通過綜合分析才能得到意想不到的結果。

綜上所述,我們在理解Java領域中的鎖時,可以以此作爲一個考量標準之一,來幫助和方便我們更快理解和掌握併發編程技術。

七.Java線程飢餓問題

Java線程飢餓問題是指長期無法獲取共享資源或搶佔CPU資源而導致線程無法執行的現象。

v5rkNj.png

在Java併發編程的過程中,特別是開啓線程數過多,會遇到某些線程貪婪地把CPU資源佔滿,導致某些線程分配不到CPU而沒有辦法執行。

在Java領域中,對於線程飢餓問題,可以從以下幾個方面來看:

  • 互斥鎖synchronized飢餓問題:在使用synchronized對資源進行加鎖時,不斷有大量的線程去競爭獲取鎖,那麼就可能會引發線程飢餓問題,主要是synchronized只是加鎖,沒有要求公平性導致的。
  • 線程優先級飢餓問題:Java中每個線程都有自己的優先級,一般情況下使用默認優先級,但是由於線程優先級不同,也會引起線程飢餓問題。
  • 線程自旋飢餓問題: 主要是在Java併發操作中,會使用自旋鎖,由於鎖的核心的自旋操作,會導致大量線程自旋,也會引起線程飢餓問題。
  • 等待喚醒飢餓問題: 主要是因爲JVM中wait和notify實現不同,比方說Java HotSpot(TM) VM 虛擬機是一種先入先出結構,也會引起線程飢餓問題。

針對上述的飢餓問題,爲了解決它,JDK內部實現一些具備公平性質的鎖,可以直接使用。所以,解決線程飢餓問題,一般是引入隊列,也就是排隊處理,最典型的有ReentrantLock。

綜上所述,這不就是爲我們掌握和理解Java中的鎖機制時,需要考慮Java線程飢餓問題。

八.Java數據競爭問題

Java數據競爭問題是指至少存在兩個線程去讀寫某個共享內存,其中至少一個線程對其共享內存進行寫操作。

v5sreU.png

對於數據競爭問題,最簡單的理解就是,多個線程在同時對於共享內存的進行寫操作時,在寫的過程中,其他的線程讀到數據是內存數據中非正確預期的。

產生數據競爭的原因,一個CPU在任意時刻只能執行一條指令,但是對其某個內存中的寫操作可能會用到若干條件機器指令,從而導致在寫的過程中還沒完全修改完內存,其他線程去讀取數據,從而導致結果不可預知。從而引發數據競爭問題,這個情況有點像MySQL數據中併發事務引起的髒讀情況。

在Java領域中,解決數據競爭問題的方式一般是把共享內存的更新操作進行原子化,同時也保證內存的可見性。

針對上述的飢餓問題,爲了解決它,JDK內部實現一系列的原子類,比如AtomicReference類等,但是主要可以採用CAS+自旋鎖的方式來實現。

綜上所述,這不就是爲我們掌握和理解Java中的鎖機制時,需要考慮Java數據競爭問題。

九.Java競爭條件問題

Java競爭條件問題是指代碼在執行臨界區產生競爭條件,主要是因爲多個線程不同的執行順序以及線程併發的交叉執行導致執行結果與預期不一致的情況。

v5yPYj.png

對於競爭條件問題,其中臨界區是一塊代碼區域,其實說白了就是我們自己寫的邏輯代碼,由於沒有考慮位,從而引發的多個線程不同的執行順序以及線程併發的交叉執行導致執行結果與預期不一致的情況。

產生競爭條件問題的主要原因,一般主要有線程執行順序的不確定性和併發機制導致上下文切換等兩個原因導致競爭條件問題,其中:

  • 線程執行順序的不確定性:這個線程調度的工作方式有關,現在大部分計算機的操作系統都是搶佔方式的調度方式,所有的任務調度由操作系統來完全控制,線程的執行順序不一定是按照編碼順序的,主要有操作系統調度算法決定。
  • 併發機制導致上下文切換:在併發的多線程的程序中,多個線程會導致進行上下文的資源切換,並且交叉執行,從而併發機制自身也會引起競爭條件問題。

在Java領域中,解決競爭條件問題的方式一般是把臨界區進行原子化,保證臨界區的源自性,保證了臨界區捏只有一個線程,從而避免競爭產生。

針對上述的飢餓問題,爲了解決它,JDK內部實現一系列的原子類或者說直接使用synchronized來聲明,均可實現。

綜上所述,這不就是爲我們掌握和理解Java中的鎖機制時,需要考慮Java競爭條件問題。

十.Java死鎖問題

Java死鎖問題主要是指一種有兩個或者兩個以上的線程或者進程構成一個無限互相等待的環形狀態的情況,不是一種鎖概念,而是一種線程狀態的表徵描述。

v5yt0O.png

一般爲了保證線程安全問題,我們都會想着給會使用加鎖機制來確保線程安全,但如果過度地使用加鎖,則可能導致鎖順序死鎖(Lock-Ordering Deadlock)。

或者有的場景我們使用線程池和信號量等來限制資源的使用,但這些被限制的行爲可能會導致資源死鎖(Resource DeadLock)。

Java死鎖問題的主要體現在以下幾個方面:

  • 1⃣️ Java應用程序不具備MySQL數據庫服務器的本地事務,無法檢測一組事務中是否有死鎖的發生。
  • 2⃣️ 在Java程序中,如果過度地使用加鎖,輕則導致程序響應時間變長,系統吞吐量變小,重則導致應用中的某一個功能直接失去響應能力無法提供服務。

當然,死鎖問題的產生也必須具備以及同時滿足以下幾個條件:

  • 互斥條件:資源具有排他性,當資源被一個線程佔用時,別的線程不能使用,只能等待。
  • 阻塞不釋放條件: 某個線程或者線程請求某個資源而進入阻塞狀態,不會釋放已經獲取的資源。
  • 佔有並等待條件: 某個線程或者線程應該至少佔有一個資源,等待獲取另外一個資源,該資源被其他線程或者線程霸佔。
  • 非搶佔條件: 不可搶佔,資源請求者不能強制從資源佔有者手中搶奪資源,資源只能由佔有者主動釋放。
  • 環形條件: 循環等待,多個線程存在環路的鎖依賴關係而永遠等待下去。

對於死鎖問題,一般都是需要編程開發人員人爲去幹預和防止的,只是需要一些措施區規範處理,主要可以分爲事前預防和事後處理等2種方式,其中:

  • 事前預防: 一般是保證鎖的順序化,資源合併處理,以及避免嵌套鎖等。
  • 事後處理: 一般是對鎖設置超時機制,在死鎖發生時搶佔鎖資源,以及撤銷線程機制等。

除了有死鎖的問題,當然還有活鎖問題,主要是因爲某些邏輯導致一直在做無用功,使得線程無法正確執行的情況。

應用分析

在Java領域中,我們可以將鎖大致分爲基於Java語法層面(關鍵詞)實現的鎖和基於JDK層面實現的鎖。

Picture-Content

單純從Java對其實現的方式上來看,我們大體上可以將其分爲基於Java語法層面(關鍵詞)實現的鎖和基於JDK層面實現的鎖。其中:

  • 基於Java語法層面(關鍵詞)實現的鎖,主要是根據Java語義來實現,最典型的應用就是synchronized。
  • 基於JDK層面實現的鎖,主要是根據統一的AQS基礎同步器來實現,最典型的有ReentrantLock。

需要特別注意的是,在Java領域中,基於JDK層面的鎖通過CAS操作解決了併發編程中的原子性問題,而基於Java語法層面實現的鎖解決了併發編程中的原子性問題和可見性問題。

單純從Java對其實現的方式上來看,我們大體上可以將其分爲基於Java語法層面(關鍵詞)實現的鎖和基於JDK層面實現的鎖。其中:

  • 基於Java語法層面(關鍵詞)實現的鎖,主要是根據Java語義來實現,最典型的應用就是synchronized。
  • 基於JDK層面實現的鎖,主要是根據統一的AQS基礎同步器來實現,最典型的有ReentrantLock。

需要特別注意的是,在Java領域中,基於JDK層面的鎖通過CAS操作解決了併發編程中的原子性問題,而基於Java語法層面實現的鎖解決了併發編程中的原子性問題和可見性問題。

而從具體到對應的Java線程資源來說,我們按照是否含有某一特性來定義鎖,主要可以從如下幾個方面來看:

vIfOBR.png

  • 從加鎖對象角度方面上來看,線程要不要鎖住同步資源 ? 如果是需要加鎖,鎖住同步資源的情況下,一般稱其爲悲觀鎖;否則,如果是不需要加鎖,且不用鎖住同步資源的情況就屬於爲樂觀鎖。
  • 從獲取鎖的處理方式上來看,假設鎖住同步資源,其對該線程是否進入睡眠狀態或者阻塞狀態?如果會進入睡眠狀態或者阻塞狀態,一般稱其爲互斥鎖,否則,不會進入睡眠狀態或者阻塞狀態屬於一種非阻塞鎖,即就是自旋鎖。
  • 從鎖的變化狀態方面來看,多個線程在競爭資源的流程細節上是否有差別?
  • 1⃣️ 對於不會鎖住資源,多個線程只有一個線程能修改資源成功,其他線程會依據實際情況進行重試,即就是不存在競爭的情況,一般屬於無鎖。
  • 2⃣️ 對於同一個線程執行同步資源會自動獲取鎖資源,一般屬於偏向鎖。
  • 3⃣️ 對於多線程競爭同步資源時,沒有獲取到鎖資源的線程會自旋等待鎖釋放,一般屬於輕量級鎖。
  • 4⃣️ 對於多線程競爭同步資源時,沒有獲取到鎖資源的線程會阻塞等待喚醒,一般屬於重量級鎖。
  • 從鎖競爭時公平性上來看,多個線程在競爭資源時是否需要排隊等待?如果是需要排隊等待的情況,一般屬於公平鎖;否則,先插隊,然後再嘗試排隊的情況屬於非公平鎖。
  • 從獲取鎖的操作頻率次數來看,一個線程中的多個流程是否可以獲取同一把鎖?如果是可以多次進行加鎖操作的情況,一般屬於可重入鎖,否則,可以多次進行加鎖操作的情況屬於非可重入鎖。
  • 從獲取鎖的佔有方式上來看,多個線程能不能共享一把鎖?如果是可以共享鎖資源的情況,一般屬於共享鎖;否則,獨佔鎖資源的情況屬於排他鎖。

針對於上述描述的各種情況,這裏就不做展開和贅述,看到這裏只需要在腦中形成一個概念就行,後續會有專門的內容來對其進行分析和探討。

寫在最後

Picture-Footer

在上述的內容中,一般常規的概念中,我們很難會依據上述這些問題去認識和看待Java中的鎖機制,主要是在學習和查閱資料的時,大多數的論調都是零散和細分的,很難在我們的腦海中形成知識體系。

從本質上講,我們對鎖應該有一個認識,其主要是一種協調多個進程 或者多個線程對某一個資源的訪問的控制機制,是併發編程中最關鍵的一環。

接下來,對於上述內容做一個簡單的總結:

  • 1⃣️ 計算機運行模型主要是描述計算機系統體系結構的基本模型,一般主要是指CPU處理器結構。
  • 2⃣️ 計算機內存模型一般是指計算系統底層與編程語言之間的約束規範,主要是描述計算機程序與共享存儲器訪問的行爲特徵表現。
  • 3⃣️ Java內存模型主要是爲了解決併發編程的可見性問題,原子性問題和有序性問題等三大問題,具有跨平臺性。
  • 4⃣️ Java一致性模型指導原則是指制定一些規範來將複雜的物理計算機的系統底層封裝到JVM中,從而向上提供一種統一的內存模型語義規則,一般是指Happens-Before規則。
  • 5⃣️ Java指令重排是指在執行程序時爲了提高性能,編譯器和處理器常常會對指令做重排序的一種防護措施機制。
  • 6⃣️ Java併發編程的三宗罪主要是指原子性問題、可見性問題和有序性問題等三大問題。
  • 7⃣️ Java線程飢餓問題是指長期無法獲取共享資源或搶佔CPU資源而導致線程無法執行的現象。
  • 8⃣️ Java數據競爭問題是指至少存在兩個線程去讀寫某個共享內存,其中至少一個線程對其共享內存進行寫操作。
  • 9⃣️ Java競爭條件問題是指代碼在執行臨界區產生競爭條件,主要是因爲多個線程不同的執行順序以及線程併發的交叉執行導致執行結果與預期不一致的情況。
  • 🔟 Java死鎖問題主要是指一種有兩個或者兩個以上的線程或者進程構成一個無限互相等待的環形狀態的情況,不是一種鎖概念,而是一種線程狀態的表徵描述。

單純從Java對其實現的方式上來看,我們大體上可以將其分爲基於Java語法層面(關鍵詞)實現的鎖和基於JDK層面實現的鎖,其中:

  • 1⃣️ 基於Java語法層面(關鍵詞)實現的鎖,主要是根據Java語義來實現,最典型的應用就是synchronized。
  • 2⃣️ 基於JDK層面實現的鎖,主要是根據統一的AQS基礎同步器來實現,最典型的有ReentrantLock。

綜上所述,我相信看到這裏的時候,對Java領域中的鎖機制已經有一個基本的輪廓,後面會專門寫一篇內容來詳細介紹,敬請期待。

最後,技術研究之路任重而道遠,願我們熬的每一個通宵,都撐得起我們想在這條路上走下去的勇氣,未來仍然可期,與君共勉!

版權聲明:本文爲博主原創文章,遵循相關版權協議,如若轉載或者分享請附上原文出處鏈接和鏈接來源。

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