Java高併發與多線程(四)-----鎖

今天,我們開始Java高併發與多線程的第四篇,

之前的三篇,基本上都是在講一些概念性和基礎性的東西,東西有點零碎,但是像文科科目一樣,記住就好了。

但是本篇是高併發裏面真正的基石,需要大量的理解和實踐,一環扣一環,環環相扣,不難,但是需要認真去讀。

好了,現在開始。

   

--------------第一部分,咱們要談到java裏面的兩個用於保證線程之間有序性的關鍵字--------------

【synchronized】

synchronized是Java中解決併發問題的一種最常用的方法,也是最簡單的一種方法。

synchronized可以保證java代碼塊中的原子性,可見性和有序性

   

  • Java 內存模型中的可見性、原子性和有序性

可見性

是指線程之間的可見性,一個線程修改的狀態對另一個線程是可見的。

原子性

原子是世界上的最小單位,具有不可分割性
非原子操作都會存在線程安全問題,有時候需要我們使用同步技術(sychronized)來讓它變成一個原子操作。
java的concurrent包下就提供了一些原子類(AtomicInteger,AtomicLong,AtomicReference......),咱們之後會講。

有序性

java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性;
volatile 其本身包含"禁止指令重排序"的語義。

   

   

synchronized可以把任何一個非null對象作爲"鎖"

synchronized總共有三種用法:

- 當synchronized作用在實例方法時,監視器鎖(monitor)便是對象實例this);

- 當synchronized作用在靜態方法時,監視器鎖(monitor)便是對象的Class實例,因爲Class數據存在於永久代,因此靜態方法鎖相當於該類的一個全局鎖;

- 當synchronized作用在某一個對象實例時,監視器鎖(monitor)便是括號括起來的對象實例

一般來講,synchronized同步的範圍是越小越好

因爲若該方法耗時很久,那其它線程必須等到該持鎖線程執行完才能運行。

synchronized方法拋出異常,JVM會自動釋放鎖,不會導致死鎖問題。

   

鎖定對象的時候,不可以用String常量,以及基礎類型和基礎類型的封裝類

對象做鎖的時候,要加final,否則鎖對象發生變化,可能會造成問題。

   

【volatile】

Java語言提供了一種稍弱的同步機制,即volatile變量,用來確保將變量的更新操作通知到其他線程。

volatile變量保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。

   

由volatile變量修飾的共享變量進行寫操作的時候會使用CPU提供的Lock前綴指令

  1. 將當前處理器緩存行的數據寫回到系統內存
  2. 這個寫回內存的操作會告知在其他CPU你們拿到的變量是無效的,下一次使用的時候要重新從共享內存內拿。

   

當對非volatile 變量進行讀寫的時候,每個線程先從內存拷貝變量到CPU緩存中。如果計算機有多個CPU,每個線程可能在不同的CPU上被處理,這意味着每個線程可以拷貝到不同的CPU cache中。

   

當把變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序

volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,JVM 保證了每次讀變量都從內存中讀,跳過 CPU cache 這一步,因此在讀取volatile類型的變量時總會返回最新寫入的值

   

在訪問volatile變量時不會執行加鎖操作,因此也就不會使執行線程阻塞,因此volatile變量是一種比sychronized關鍵字更輕量級的同步機制。

volatile 的讀性能消耗與普通變量幾乎相同,但是寫操作稍慢,因爲它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。

   

被volatile修飾的變量在進行寫操作時,會生成一個特殊的彙編指令,該指令會觸發mesi協議「緩存一致性協議」,會存在一個總線嗅探機制的東西,簡單來說就是這個機制會不停檢測總線中該變量的變化,如果該變量一旦變化了,由於這個嗅探機制,其它cpu會立馬將該變量的cpu緩存數據清空掉,重新的去從主內存拿到這個數據。

volitale儘量去修飾簡單類型,不要去修飾引用類型,因爲volatile關鍵字對於基本類型的修改可以在隨後對多個線程的讀保持一致,但是對於引用類型如數組,實體bean,僅僅保證引用的可見性,但並不保證引用內容的可見性

   

保證線程可見性;(MESI/CPU的緩存一致性協議)

禁止指令重排序。

laodfence源語指令

storefence源語指令

單例模式裏面的雙重檢查,要加volatile。(主要是因爲實例的初始化順序有可能被改變,這樣第二個線程訪問的時候可能訪問到了未初始化完成的實例)。

不過以上這種情況只會在超高併發的情況下才能出現,一般情況下是很難出現的,專門寫在這裏呢,是給準備面試的同學寫的。

   

  • synchronized 和 volatile

  volatile本質是在告訴jvm當前變量在寄存器(工作內存)中的值是不確定的,需要從主存中讀取;

synchronized則是鎖定當前變量,只有當前線程可以訪問該變量,其他線程被阻塞住。

   

volatile僅能使用在變量級別;

synchronized則可以使用在變量、方法、和類級別的。

   

volatile僅能實現變量的修改可見性,不能保證原子性;而synchronized則可以保證變量的修改可見性和原子性。

volatile不會造成線程的阻塞synchronized可能會造成線程的阻塞。

volatile標記的變量不會被編譯器優化;synchronized標記的變量可以被編譯器優化。

   

--------------第二部分,咱們說一說那些充斥在網絡博客裏面的各種鎖--------------

【悲觀鎖 · 樂觀鎖】

  • 悲觀鎖

  總是假設最壞的情況,每次使用數據都認爲別人會修改,所以每次在拿數據的時候都會上鎖

這樣別人想拿這個數據就會阻塞直到它拿到鎖(共享資源每次只給一個線程使用,其它線程阻塞,用完後再把資源轉讓給其它線程)。

傳統的關係型數據庫裏邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。

Java中synchronized和ReentrantLock等獨佔鎖就是悲觀鎖思想的實現。

  • 樂觀鎖

  總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號機制和CAS算法實現。

樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,像數據庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。

在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實現方式CAS實現的。

   

樂觀鎖適用於寫比較少的情況,因爲節省了很多鎖的開銷,但是如果寫比較多的話,可能會造成頻繁的retry操作,反倒會降低性能。

ABA 問題,修改過兩次之後,其實值已經被修改過了,但是沒有識別。「還要考慮到引用的情況,雖然指針沒有發生變化,但是引用內容已經變了」

ABA問題(主要是針對對象會有問題,基礎類型一般無所謂,可以通過加Version解決)

   

  • 什麼是CAS?

  Compare and Swap,即比較再交換

對CAS的理解,CAS是一種無鎖算法,CAS有3個操作數,內存值V,舊的預期值A,要修改的新值B。

當且僅當預期值A和內存值V相同時,將內存值V修改爲B,否則什麼都不做。

CAS的實現依賴於CPU原語支持(中途不會被打斷)。

   

如果不相等,說明共享數據已經被修改,放棄已經所做的操作,然後重新執行剛纔的操作。

當同步衝突出現的機會很少時,這種假設能帶來較大的性能提升。

   

  • Unsafe

  簡單講一下這個類。

Java無法直接訪問底層操作系統,而是通過本地(native)方法來訪問。不過儘管如此,JVM還是開了一個後門,那就是Unsafe類,它提供了硬件級別的原子操作

所有的CAS都是用Unsafe去實現的。

CAS直接操作java虛擬機裏面的內存。(可以直接通過偏移量,定位到內存裏面某個變量的值,然後修改)「這就是爲什麼cas我們可以認爲是原子的」

   

   

【自旋鎖】

線程的阻塞和喚醒需要CPU從用戶態轉爲核心態,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作,勢必會給系統的併發性能帶來很大的壓力。

同時我們發現在許多應用上面,對象鎖的鎖狀態只會持續很短一段時間,爲了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。

當一個線程嘗試獲取某個鎖時,如果該鎖已被其他線程佔用,就一直循環檢測鎖是否被釋放,而不是進入線程掛起或睡眠狀態。

   

自旋鎖佔用CPU,但是不訪問操作系統(不經過內核態),所以一直是用戶態。

自旋鎖適用於鎖保護的臨界區很小的情況,臨界區很小的話,鎖佔用的時間就很短。

自旋的默認次數爲10,可以通過參數-XX:PreBlockSpin來調整。

   

【適應性自旋鎖】

線程如果自旋成功了,那麼下次自旋的次數會更加多,因爲虛擬機認爲既然上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。

反之,如果對於某個鎖,很少有自旋能夠成功,那麼在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源

   

--------------第三部分,我們詳細闡述開發者和JVM對於鎖的一些優化設計和思路--------------

【鎖細化】

鎖的細化主要分爲兩個方面。

第一,synchronized鎖住的內容越少越好,所以有些情況下,可能會採取一些(比如說分段鎖,HASH鎖,弱引用鎖等)措施,提高同步效率。

第二,synchronized鎖住的代碼越少越好,代碼越少,臨界區的時長就越少,鎖等待時間也就越少。

   

【鎖粗化】

正常來講,對於開發者來說,鎖住的代碼範圍越小越好,但是在有些時候,我們需要將鎖進行粗化處理。

意思就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖

一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗

   

【鎖消除】

爲了保證數據的完整性,在進行操作時需要對這部分操作進行同步控制,但是在有些情況下,JVM檢測到不可能存在共享數據競爭,這是JVM會對這些同步鎖進行鎖消除

鎖消除的依據是逃逸分析的數據支持

如:StringBuffer的append方法。

StringBuffer.append()鎖就是sb對象。虛擬機觀察變量sb,很快就會發現它的動態作用域被限制在concatString()方法內部。

   

也就是sb的所有引用永遠不會"逃逸"到concatString()方法之外,其他線程無法訪問到它,所以這裏雖然有鎖,但是可以被安全地削除掉,在即時編譯之後,這段代碼就會忽略掉所有的同步而直接執行了。

進行逃逸分析之後,所有的對象都將由棧上分配,而非從JVM內存模型中的堆來分配。

   

逃逸分析和鎖消除分別可以使用參數-XX:+DoEscapeAnalysis-XX:+EliminateLocks(鎖消除必須在-server模式下)開啓。

   

【鎖升級】

對於鎖而言,一共有四種狀態,無鎖狀態,偏向鎖狀態,輕量級鎖狀態,重量級鎖狀態

1. 當沒有競爭出現時,默認使用偏向鎖。

     JVM會利用CAS操作,在對象頭上的Mark Word部分設置線程ID,以表示這個對象偏向於當前線程,所以並不涉及真正的互斥鎖。

  2. 如果有另外的線程試圖鎖定某個已經被偏向過的對象,JVM就需要撤銷(revoke)偏向鎖,並切換到輕量級鎖實現。

     輕量級鎖依賴 CAS 操作Mark Word來試圖獲取鎖,如果重試成功,就使用輕量級鎖;

  3. 否則在自旋一定次數後進一步升級爲重量級鎖。

   

  • 偏向鎖

  爲什麼要引入偏向鎖?

因爲經過HotSpot的作者大量的研究發現,大多數時候是不存在鎖競爭的,常常是一個線程多次獲得同一個鎖,因此如果每次都要競爭鎖會增大很多沒有必要付出的代價,爲了降低獲取鎖的代價,才引入的偏向鎖。

   

偏向鎖是在單線程執行代碼塊時使用的機制,如果在多線程併發的環境下(即線程A尚未執行完同步代碼塊,線程B發起了申請鎖的申請),則一定會轉化爲輕量級鎖或者重量級鎖。

現在幾乎所有的鎖都是可重入的,即已經獲得鎖的線程可以多次鎖住/解鎖監視對象

   

按照之前的HotSpot設計,每次加鎖/解鎖都會涉及到一些CAS操作(比如對等待隊列的CAS操作),CAS操作會延遲本地調用;

因此偏向鎖的想法是一旦線程第一次獲得了監視對象,之後讓監視對象"偏向"這個線程,之後的多次調用則可以避免CAS操作,說白了就是置個變量「對象頭上的Mark Word部分設置線程ID」,如果發現爲true則無需再走各種加鎖/解鎖流程

   

這樣做的假設是基於在很多應用場景中,大部分對象生命週期中最多會被一個線程鎖定,使用偏向鎖可以降低無競爭開銷。

   

偏向鎖是默認開啓的,而且開始時間一般是比應用程序啓動慢幾秒,如果不想有這個延遲,那麼可以使用-XX:BiasedLockingStartUpDelay=0

如果不想要偏向鎖,那麼可以通過-XX:-UseBiasedLocking = false來設置;

   

   

  • 輕量級鎖

  對於絕大部分的鎖,在整個同步週期內都是不存在競爭的,輕量級鎖使用CAS操作,避免使用互斥量

如果存在競爭,除了互斥量的開銷,還有CAS的操作,不僅沒有提升,反而性能會下降

   

輕量級鎖所適應的場景是線程交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,必然就會導致輕量級鎖膨脹爲重量級鎖。

   

  • 重量級鎖

  Synchronized是通過對象內部的一個叫做監視器鎖(Monitor)來實現的。

重量級鎖不佔用CPU。

但是監視器鎖本質又是依賴於底層的操作系統的Mutex Lock來實現的。

而操作系統實現線程之間的切換這就需要從用戶態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是爲什麼Synchronized效率低的原因。

因此,這種依賴於操作系統Mutex Lock所實現的鎖我們稱之爲 "重量級鎖"。

   

注:鎖是隻可以升級不可以降級的。

   

  • 對比

優點

缺點

適用場景

偏向鎖

加鎖和解鎖不需要額外的消耗,和執行非同步方法效率幾乎一樣

如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗。

適用於只有一個線程訪問同步塊的場景

輕量級鎖
「自旋鎖」

競爭的線程不會阻塞,提高了程序的響應速度。

如果始終得不到鎖競爭的線程,會一直消耗CPU。

追求響應時間;
同步塊(加鎖代碼)執行時間短,線程數少。

重量級鎖
「OS鎖」

線程競爭不會消耗CPU

線程阻塞,響應時間緩慢。

追求吞吐量;
同步塊執行時間長,線程數多。

   

   

--------------第三部分,我們加一些其他的沒說到的鎖的概念,補全鎖的內容--------------

【其他一些鎖概念】

  • 分段鎖

  分段鎖(SegmentLock)就是簡單的將鎖細粒度化,將一個鎖分成兩段或者多段,線程根據自己操作的段來加鎖解鎖。

這樣做可以避免線程之間互相無意義的等待,減少線程的等待時間。常見的應用有ConcurrentHashMap,它內部實現了Segment<K,V>繼承了ReentrantLock,分成了16段。

當然,其實在jdk1.8中,ConcurrentHashMap也開始使用CAS的方式。

   

  • 排它鎖 - 共享鎖

  排它鎖又叫互斥鎖、獨佔鎖、寫鎖,一個鎖在某一時刻只能被一個線程佔有,其它線程必須等待鎖被釋放之後纔可能獲取到鎖。如ReentrantLock。

共享鎖又稱讀鎖,就是允許多個線程同時獲取一個鎖,一個鎖可以同時被多個線程擁有。比如說ReadWriteLock。

   

  • 公平鎖

  公平鎖就是遵循了先到先得的原則,多個線程按照申請鎖的順序來獲取鎖。

   

  • 可重入鎖

  可重入鎖的意思就是,加入方法 A 獲得鎖並加鎖之後調用了方法 B,而方法 B 也需要鎖,這樣會導致死鎖,可重入鎖則會讓調用方法 B 的時候自動獲得鎖。

Java 中是通過lockedBy字段判斷加鎖的線程是不是同一個

synchronized是可重入鎖,也必須是可重入鎖,不然的話子類沒有辦法調用父類的方法

   

概念很多,但是一定要牢記,這些不光是在工作的過程中需要熟悉掌握,而且在面試的時候是一定會被問到的,所以近期在找工作的同學,鎖的內容是重中之重,一定要多看多練多記。

   

本篇我們講述了多線程中的鎖的內容,幾乎所有的鎖相關的東西都講到了,但是深度其實還遠遠不夠,我們在下一篇會聊一聊JUC下的各種同步鎖,再往後的時候,如果有時間,會盡量加一些源碼相關的分析,我們從實現層面看一看這些鎖是怎麼玩的。

作爲程序員,沒有興趣和熱情,實在是太難堅持下去了,希望這個系列以後的內容,儘量多的去透過現象看本質,現在寫的這些瑣裏瑣雜的東西,全都浮於表面,應付工作和麪試,無聊又無聊矣。

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