Java並發編程:Java中的鎖和線程同步機制


鎖的基礎知識

鎖的類型

鎖從宏觀上分類,只分為兩種:悲觀鎖與樂觀鎖。

樂觀鎖

樂觀鎖是一種樂觀思想,即認為讀多寫少,遇到並發寫的可能性低,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,采取在寫時先讀出當前版本號,然后加鎖操作(比較跟上一次的版本號,如果一樣則更新),如果失敗則要重復讀-比較-寫的操作。Java中的樂觀鎖基本都是通過CAS操作實現的,CAS是一種更新的原子操作,比較當前值跟傳入值是否一樣,一樣則更新,否則失敗。

悲觀鎖

悲觀鎖是就是悲觀思想,即認為寫多,遇到並發寫的可能性高,每次去拿數據的時候都認為別人會修改,所以每次在讀寫數據的時候都會上鎖,這樣別人想讀寫這個數據就會block直到拿到鎖。java中的悲觀鎖就是Synchronized,AQS框架下的鎖則是先嘗試CAS樂觀鎖去獲取鎖,獲取不到,才會轉換為悲觀鎖,如RetreenLock自旋鎖。

線程阻塞的代價

Java的線程是映射到操作系統原生線程之上的,如果要阻塞或喚醒一個線程就需要操作系統介入,需要在戶態與核心態之間切換,這種切換會消耗大量的系統資源,因為用戶態與內核態都有各自專用的內存空間,專用的寄存器等,用戶態切換至內核態需要傳遞給許多變量、參數給內核,內核也需要保護好用戶態在切換時的一些寄存器值、變量等,以便內核態調用結束后切換回用戶態繼續工作。

  1. 如果線程狀態切換是一個高頻操作時,這將會消耗很多CPU處理時間;
  2. 如果對於那些需要同步的簡單的代碼塊,獲取鎖掛起操作消耗的時間比用戶代碼執行的時間還要長,這種同步策略顯然非常糟糕的。

synchronized會導致爭用不到鎖的線程進入阻塞狀態,所以說它是java語言中一個重量級的同步操縱,被稱為重量級鎖,為了緩解上述性能問題,JVM從1.5開始,引入了輕量鎖與偏向鎖,默認啟用了自旋鎖,他們都屬於樂觀鎖。

明確java線程切換的代價,是理解java中各種鎖的優缺點的基礎之一。

Java中的鎖

自旋鎖

自旋鎖原理非常簡單,如果持有鎖的線程能在很短時間內釋放鎖資源,那么那些等待競爭鎖的線程就不需要做內核態和用戶態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的線程釋放鎖后即可立即獲取鎖,這樣就避免用戶線程和內核的切換的消耗。

但是線程自旋是需要消耗cup的,說白了就是讓cup在做無用功,如果一直獲取不到鎖,那線程也不能一直占用cup自旋做無用功,所以需要設定一個自旋等待的最大時間。

如果持有鎖的線程執行的時間超過自旋等待的最大時間扔沒有釋放鎖,就會導致其它爭用鎖的線程在最大等待時間內還是獲取不到鎖,這時爭用線程會停止自旋進入阻塞狀態。

自旋鎖的優缺點

自旋鎖盡可能的減少線程的阻塞,這對於鎖的競爭不激烈,且占用鎖時間非常短的代碼塊來說性能能大幅度的提升,因為自旋的消耗會小於線程阻塞掛起再喚醒的操作的消耗,這些操作會導致線程發生兩次上下文切換!

但是如果鎖的競爭激烈,或者持有鎖的線程需要長時間占用鎖執行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是占用cpu做無用功,占着XX不XX,同時有大量線程在競爭一個鎖,會導致獲取鎖的時間很長,線程自旋的消耗大於線程阻塞掛起操作的消耗,其它需要cup的線程又不能獲取到cpu,造成cpu的浪費。所以這種情況下我們要關閉自旋鎖;

自旋鎖時間閾值

自旋鎖的目的是為了占着CPU的資源不釋放,等到獲取到鎖立即進行處理。但是如何去選擇自旋的執行時間呢?如果自旋執行時間太長,會有大量的線程處於自旋狀態占用CPU資源,進而會影響整體系統的性能。因此自旋的周期選的額外重要!

JVM對於自旋周期的選擇,jdk1.5這個限度是一定的寫死的,在1.6引入了適應性自旋鎖,適應性自旋鎖意味着自旋的時間不在是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定,基本認為一個線程上下文切換的時間是最佳的一個時間,同時JVM還針對當前CPU的負荷情況做了較多的優化

  1. 如果平均負載小於CPUs則一直自旋

  2. 如果有超過(CPUs/2)個線程正在自旋,則后來線程直接阻塞

  3. 如果正在自旋的線程發現Owner發生了變化則延遲自旋時間(自旋計數)或進入阻塞

  4. 如果CPU處於節電模式則停止自旋

  5. 自旋時間的最壞情況是CPU的存儲延遲(CPU A存儲了一個數據,到CPU B得知這個數據直接的時間差)

  6. 自旋時會適當放棄線程優先級之間的差異

自旋鎖的開啟

JDK1.6中-XX:+UseSpinning開啟; 
-XX:PreBlockSpin=10 為自旋次數; 
JDK1.7后,去掉此參數,由jvm控制;

重量級鎖Synchronized

Synchronized的作用

在JDK1.5之前都是使用synchronized關鍵字保證同步的,Synchronized的作用相信大家都已經非常熟悉了;

它可以把任意一個非NULL的對象當作鎖。

  1. 作用於方法時,鎖住的是對象的實例(this);
  2. 當作用於靜態方法時,鎖住的是Class實例,又因為Class的相關數據存儲在永久帶PermGen(jdk1.8則是metaspace),永久帶是全局共享的,因此靜態方法鎖相當於類的一個全局鎖,會鎖所有調用該方法的線程;
  3. synchronized作用於一個對象實例時,鎖住的是所有以該對象為鎖的代碼塊。

Synchronized的實現

實現如下圖所示;

這里寫圖片描述

它有多個隊列,當多個線程一起訪問某個對象監視器的時候,對象監視器會將這些線程存儲在不同的容器中。

  1. Contention List:競爭隊列,所有請求鎖的線程首先被放在這個競爭隊列中;

  2. Entry List:Contention List中那些有資格成為候選資源的線程被移動到Entry List中;

  3. Wait Set:哪些調用wait方法被阻塞的線程被放置在這里;

  4. OnDeck:任意時刻,最多只有一個線程正在競爭鎖資源,該線程被成為OnDeck;

  5. Owner:當前已經獲取到所資源的線程被稱為Owner;

  6. !Owner:當前釋放鎖的線程。

JVM每次從隊列的尾部取出一個數據用於鎖競爭候選者(OnDeck),但是並發情況下,ContentionList會被大量的並發線程進行CAS訪問,為了降低對尾部元素的競爭,JVM會將一部分線程移動到EntryList中作為候選競爭線程。Owner線程會在unlock時,將ContentionList中的部分線程遷移到EntryList中,並指定EntryList中的某個線程為OnDeck線程(一般是最先進去的那個線程)。Owner線程並不直接把鎖傳遞給OnDeck線程,而是把鎖競爭的權利交給OnDeck,OnDeck需要重新競爭鎖。這樣雖然犧牲了一些公平性,但是能極大的提升系統的吞吐量,在JVM中,也把這種選擇行為稱之為“競爭切換”。

OnDeck線程獲取到鎖資源后會變為Owner線程,而沒有得到鎖資源的仍然停留在EntryList中。如果Owner線程被wait方法阻塞,則轉移到WaitSet隊列中,直到某個時刻通過notify或者notifyAll喚醒,會重新進去EntryList中。

處於ContentionList、EntryList、WaitSet中的線程都處於阻塞狀態,該阻塞是由操作系統來完成的(Linux內核下采用pthread_mutex_lock內核函數實現的)。

Synchronized是非公平鎖。 Synchronized在線程進入ContentionList時,等待的線程會先嘗試自旋獲取鎖,如果獲取不到就進入ContentionList,這明顯對於已經進入隊列的線程是不公平的,還有一個不公平的事情就是自旋獲取鎖的線程還可能直接搶占OnDeck線程的鎖資源。

偏向鎖

Java偏向鎖(Biased Locking)是Java6引入的一項多線程優化。 
偏向鎖,顧名思義,它會偏向於第一個訪問鎖的線程,如果在運行過程中,同步鎖只有一個線程訪問,不存在多線程爭用的情況,則線程是不需要觸發同步的,這種情況下,就會給線程加一個偏向鎖。 
如果在運行過程中,遇到了其他線程搶占鎖,則持有偏向鎖的線程會被掛起,JVM會消除它身上的偏向鎖,將鎖恢復到標准的輕量級鎖。

它通過消除資源無競爭情況下的同步原語,進一步提高了程序的運行性能。

偏向鎖的實現

偏向鎖獲取過程:
  1. 訪問Mark Word中偏向鎖的標識是否設置成1,鎖標志位是否為01,確認為可偏向狀態。

  2. 如果為可偏向狀態,則測試線程ID是否指向當前線程,如果是,進入步驟5,否則進入步驟3。

  3. 如果線程ID並未指向當前線程,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中線程ID設置為當前線程ID,然后執行5;如果競爭失敗,執行4。

  4. 如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全局安全點(safepoint)時獲得偏向鎖的線程被掛起,偏向鎖升級為輕量級鎖,然后被阻塞在安全點的線程繼續往下執行同步代碼。(撤銷偏向鎖的時候會導致stop the word)

  5. 執行同步代碼。

注意:第四步中到達安全點safepoint會導致stop the word,時間很短。

偏向鎖的釋放:

偏向鎖的撤銷在上述第四步驟中有提到。偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態,撤銷偏向鎖后恢復到未鎖定(標志位為“01”)或輕量級鎖(標志位為“00”)的狀態。

偏向鎖的適用場景

始終只有一個線程在執行同步塊,在它沒有執行完釋放鎖之前,沒有其它線程去執行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就升級為輕量級鎖,升級為輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖的時候會導致stop the word操作; 
在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向所的時候會導致進入安全點,安全點會導致stw,導致性能下降,這種情況下應當禁用;

查看停頓–安全點停頓日志

要查看安全點停頓,可以打開安全點日志,通過設置JVM參數 -XX:+PrintGCApplicationStoppedTime 會打出系統停止的時間,添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 這兩個參數會打印出詳細信息,可以查看到使用偏向鎖導致的停頓,時間非常短暫,但是爭用嚴重的情況下,停頓次數也會非常多;

注意:安全點日志不能一直打開: 
1. 安全點日志默認輸出到stdout,一是stdout日志的整潔性,二是stdout所重定向的文件如果不在/dev/shm,可能被鎖。 
2. 對於一些很短的停頓,比如取消偏向鎖,打印的消耗比停頓本身還大。 
3. 安全點日志是在安全點內打印的,本身加大了安全點的停頓時間。

所以安全日志應該只在問題排查時打開。 
如果在生產系統上要打開,再再增加下面四個參數: 
-XX:+UnlockDiagnosticVMOptions -XX: -DisplayVMOutput -XX:+LogVMOutput -XX:LogFile=/dev/shm/vm.log 
打開Diagnostic(只是開放了更多的flag可選,不會主動激活某個flag),關掉輸出VM日志到stdout,輸出到獨立文件,/dev/shm目錄(內存文件系統)。

這里寫圖片描述

此日志分三部分: 
第一部分是時間戳,VM Operation的類型 
第二部分是線程概況,被中括號括起來 
total: 安全點里的總線程數 
initially_running: 安全點開始時正在運行狀態的線程數 
wait_to_block: 在VM Operation開始前需要等待其暫停的線程數

第三部分是到達安全點時的各個階段以及執行操作所花的時間,其中最重要的是vmop

  • spin: 等待線程響應safepoint號召的時間;
  • block: 暫停所有線程所用的時間;
  • sync: 等於 spin+block,這是從開始到進入安全點所耗的時間,可用於判斷進入安全點耗時;
  • cleanup: 清理所用時間;
  • vmop: 真正執行VM Operation的時間。

可見,那些很多但又很短的安全點,全都是RevokeBias, 高並發的應用會禁用掉偏向鎖。

jvm開啟/關閉偏向鎖

    • 開啟偏向鎖:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
    • 關閉偏向鎖:-XX:-UseBiasedLocking

輕量級鎖

輕量級鎖是由偏向所升級來的,偏向鎖運行在一個線程進入同步塊的情況下,當第二個線程加入鎖爭用的時候,偏向鎖就會升級為輕量級鎖; 
輕量級鎖的加鎖過程:

  1. 在代碼進入同步塊的時候,如果同步對象鎖狀態為無鎖狀態(鎖標志位為“01”狀態,是否為偏向鎖為“0”),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,官方稱之為 Displaced Mark Word。這時候線程堆棧與對象頭的狀態如圖: 
      這里寫圖片描述

  2. 拷貝對象頭中的Mark Word復制到鎖記錄中;

  3. 拷貝成功后,虛擬機將使用CAS操作嘗試將對象的Mark Word更新為指向Lock Record的指針,並將Lock record里的owner指針指向object mark word。如果更新成功,則執行步驟4,否則執行步驟5。

  4. 如果這個更新動作成功了,那么這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標志位設置為“00”,即表示此對象處於輕量級鎖定狀態,這時候線程堆棧與對象頭的狀態如圖所示。 
      這里寫圖片描述

  5. 如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行。否則說明多個線程競爭鎖,輕量級鎖就要膨脹為重量級鎖,鎖標志的狀態值變為“10”,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程也要進入阻塞狀態。 而當前線程便嘗試使用自旋來獲取鎖,自旋就是為了不讓線程阻塞,而采用循環去獲取鎖的過程。

輕量級鎖的釋放

釋放鎖線程視角:由輕量鎖切換到重量鎖,是發生在輕量鎖釋放鎖的期間,之前在獲取鎖的時候它拷貝了鎖對象頭的markword,在釋放鎖的時候如果它發現在它持有鎖的期間有其他線程來嘗試獲取鎖了,並且該線程對markword做了修改,兩者比對發現不一致,則切換到重量鎖。

因為重量級鎖被修改了,所有display mark word和原來的markword不一樣了。

怎么補救,就是進入mutex前,compare一下obj的markword狀態。確認該markword是否被其他線程持有。

此時如果線程已經釋放了markword,那么通過CAS后就可以直接進入線程,無需進入mutex,就這個作用。

嘗試獲取鎖線程視角:如果線程嘗試獲取鎖的時候,輕量鎖正被其他線程占有,那么它就會修改markword,修改重量級鎖,表示該進入重量鎖了。

還有一個注意點:等待輕量鎖的線程不會阻塞,它會一直自旋等待鎖,並如上所說修改markword。

這就是自旋鎖,嘗試獲取鎖的線程,在沒有獲得鎖的時候,不被掛起,而轉而去執行一個空循環,即自旋。在若干個自旋后,如果還沒有獲得鎖,則才被掛起,獲得鎖,則執行代碼。

總結

這里寫圖片描述

synchronized的執行過程: 
1. 檢測Mark Word里面是不是當前線程的ID,如果是,表示當前線程處於偏向鎖 
2. 如果不是,則使用CAS將當前線程的ID替換Mard Word,如果成功則表示當前線程獲得偏向鎖,置偏向標志位1 
3. 如果失敗,則說明發生競爭,撤銷偏向鎖,進而升級為輕量級鎖。 
4. 當前線程使用CAS將對象頭的Mark Word替換為鎖記錄指針,如果成功,當前線程獲得鎖 
5. 如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。 
6. 如果自旋成功則依然處於輕量級狀態。 
7. 如果自旋失敗,則升級為重量級鎖。

上面幾種鎖都是JVM自己內部實現,當我們執行synchronized同步塊的時候jvm會根據啟用的鎖和當前線程的爭用情況,決定如何執行同步操作;

在所有的鎖都啟用的情況下線程進入臨界區時會先去獲取偏向鎖,如果已經存在偏向鎖了,則會嘗試獲取輕量級鎖,啟用自旋鎖,如果自旋也沒有獲取到鎖,則使用重量級鎖,沒有獲取到鎖的線程阻塞掛起,直到持有鎖的線程執行完同步塊喚醒他們;

偏向鎖是在無鎖爭用的情況下使用的,也就是同步開在當前線程沒有執行完之前,沒有其它線程會執行該同步塊,一旦有了第二個線程的爭用,偏向鎖就會升級為輕量級鎖,如果輕量級鎖自旋到達閾值后,沒有獲取到鎖,就會升級為重量級鎖;

如果線程爭用激烈,那么應該禁用偏向鎖。

鎖優化建議

雖然對於以上介紹的鎖都不是我們代碼中能夠控制的,但是借鑒上面的思想,我們可以優化我們自己線程的加鎖操作。

減少鎖的時間

不需要同步執行的代碼,能不放在同步快里面執行就不要放在同步快內,可以讓鎖盡快釋放。

減少鎖的粒度

它的思想是將物理上的一個鎖,拆成邏輯上的多個鎖,增加並行度,從而降低鎖競爭。它的思想也是用空間來換時間。

鎖粗化

大部分情況下我們是要讓鎖的粒度最小化,鎖的粗化則是要增大鎖的粒度。
在以下場景下需要粗化鎖的粒度: 
假如有一個循環,循環內的操作需要加鎖,我們應該把鎖放到循環外面,否則每次進出循環,都進出一次臨界區,效率是非常差的。

使用讀寫鎖

ReentrantReadWriteLock 是一個讀寫鎖,讀操作加讀鎖,可以並發讀,寫操作使用寫鎖,只能單線程寫。

讀寫分離

CopyOnWriteArrayList 、CopyOnWriteArraySet 
CopyOnWrite容器即寫時復制的容器。通俗的理解是當我們往一個容器添加元素的時候,不直接往當前容器添加,而是先將當前容器進行Copy,復制出一個新的容器,然后新的容器里添加元素,添加完元素之后,再將原容器的引用指向新的容器。這樣做的好處是我們可以對CopyOnWrite容器進行並發的讀,而不需要加鎖,因為當前容器不會添加任何元素。所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器。 
 CopyOnWrite並發容器用於讀多寫少的並發場景,因為,讀的時候沒有鎖,但是對其進行更改的時候是會加鎖的,否則會導致多個線程同時復制出多個副本,各自修改各自的。

使用CAS

如果需要同步的操作執行速度非常快,並且線程競爭並不激烈,這時候使用CAS效率會更高,因為加鎖會導致線程的上下文切換,如果上下文切換的耗時比同步操作本身更耗時,且線程對資源的競爭不激烈,使用volatiled+cas操作會是非常高效的選擇;

消除緩存行的偽共享

除了我們在代碼中使用的同步鎖和jvm自己內置的同步鎖外,還有一種隱藏的鎖就是緩存行,它也被稱為性能殺手。 
在多核cup的處理器中,每個cup都有自己獨占的一級緩存、二級緩存,甚至還有一個共享的三級緩存,為了提高性能,cpu讀寫數據是以緩存行為最小單元讀寫的;32位的cpu緩存行為32字節,64位cup的緩存行為64字節,這就導致了一些問題。 
例如,多個不需要同步的變量因為存儲在連續的32字節或64字節里面,當需要其中的一個變量時,就將它們作為一個緩存行一起加載到某個cup-1私有的緩存中(雖然只需要一個變量,但是cpu讀取會以緩存行為最小單位,將其相鄰的變量一起讀入),被讀入cpu緩存的變量相當於是對主內存變量的一個拷貝,也相當於變相的將在同一個緩存行中的幾個變量加了一把鎖,這個緩存行中任何一個變量發生了變化,當cup-2需要讀取這個緩存行時,就需要先將cup-1中被改變了的整個緩存行更新回主存(即使其它變量沒有更改),然后cup-2才能夠讀取,而cup-2可能需要更改這個緩存行的變量與cpu-1已經更改的緩存行中的變量是不一樣的,所以這相當於給幾個毫不相關的變量加了一把同步鎖; 
為了防止偽共享,不同jdk版本實現方式是不一樣的: 
1. 在jdk1.7之前會 將需要獨占緩存行的變量前后添加一組long類型的變量,依靠這些無意義的數組的填充做到一個變量自己獨占一個緩存行; 
2. 在jdk1.7因為jvm會將這些沒有用到的變量優化掉,所以采用繼承一個聲明了好多long變量的類的方式來實現; 
3. 在jdk1.8中通過添加sun.misc.Contended注解來解決這個問題,若要使該注解有效必須在jvm中添加以下參數: 
-XX:-RestrictContended

sun.misc.Contended注解會在變量前面添加128字節的padding將當前變量與其他變量進行隔離; 

volatile關鍵字

volatile是Java中的輕量級同步機制,使用volatile可以保持內存可見性和防止指令重排序。

保持內存可見性

內存可見性是指所有線程都能看到共享內存的最新狀態。

Java內存模型

在Java內存模型中,分為棧內存(線程私有)和堆內存(線程共享),Java中的內存模型依賴於硬件的存儲模型。

Java內存模型和硬件存儲模型的大致關系如下圖所示(圖片來源於網絡):

而對於多個線程共享的變量,Java內存模型規定,變量存儲在主內存當中,每個線程都有自己獨立的工作內存(比如CPU的寄存器),線程只能訪問自己的工作內存,不可以訪問其它線程的工作內存。

工作內存中保存了主內存共享變量的副本,線程要操作這些共享變量,只能通過操作工作內存中的副本來實現,操作完畢之后再同步回到主內存當中。

如何保證多個線程操作主內存的數據完整性是一個難題,Java內存模型也規定了工作內存與主內存之間交互的協議,定義了8種原子操作:

(1) lock:將主內存中的變量鎖定,為一個線程所獨占

(2) unclock:將lock加的鎖定解除,此時其它的線程可以有機會訪問此變量

(3) read:將主內存中的變量值讀到工作內存當中

(4) load:將read讀取的值保存到工作內存中的變量副本中

(5) use:將值傳遞給線程的代碼執行引擎

(6) assign:將執行引擎處理返回的值重新賦值給變量副本

(7) store:將變量副本的值存儲到主內存中

(8) write:將store存儲的值寫入到主內存的共享變量當中

大致過程如下圖所示(圖片來源於網絡):

可見性帶來的數據失效問題

通過上面Java內存模型的描述,我們會注意到這么一個問題,每個線程在獲取鎖之后會在自己的工作內存來操作共享變量,操作完成之后將工作內存中的副本回寫到主內存,並且在其它線程從主內存將變量同步回自己的工作內存之前,共享變量的改變對其是不可見的。即其他線程的本地內存中的變量已經是過時的,並不是更新后的值,也就是說其他線程本地內存中保持的是失效數據。

比如如下代碼是一個可變整數類:

MutableInteger.java

public class MutableInteger {
    
    private int value;

    public int get() {
        return value;
    }

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

MutableInteger不是線程安全的,因為getset方法都是在沒有同步的情況下進行的。如果線程1調用了set方法,那么正在調用的get的線程2可能會看到更新后的value值,也有可能看不到,這就是數據失效問題。

要解決這個問題其實也很簡單,只需要將value聲明為volatile變量即可。

private volatile int value;

volatile如何保持內存可見性

volatile的特殊規則就是:

  • read、load、use動作必須連續出現。
  • assign、store、write動作必須連續出現。

所以,使用volatile變量能夠保證:

  • 每次讀取前必須先從主內存刷新最新的值。
  • 每次寫入后必須立即同步回主內存當中。

也就是說,使用volatile關鍵字修飾的變量看到的隨時都是自己的最新值。線程1中對變量value的最新修改,對線程2是可見的。這是因為每當修改本地內存變量的值時,在將更新同步到主內存的同時還會根據MESI清除其他線程中本地變量副本值,迫使變量副本重新同步為主內存的最新值。

防止指令重排

指令重排序

指令重排序是JVM為了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,盡可能地提高並行度。編譯器、處理器也遵循這樣一個目標。注意是單線程。多線程的情況下指令重排序就會給程序員帶來問題。

不同的指令間可能存在數據依賴。比如下面計算圓的面積的語句:

double r = 2.5d; //(1)

double pi =3.1415926; //(2)

double area = pi* r * r; //(3)

area的計算依賴於r與pi兩個變量的賦值指令。而r與pi無依賴關系。

as-if-serial語義是指:不管如何重排序(編譯器與處理器為了提高並行度),(單線程)程序的結果不能被改變。這是編譯器、Runtime、處理器必須遵守的語義。

雖然,(1) - happensbefore -> (2),(2) - happens before -> (3),但是計算順序(1)(2)(3)與(2)(1)(3) 對於r、pi、area變量的結果並無區別。編譯器、Runtime在優化時可以根據情況重排序(1)與(2),而絲毫不影響程序的結果。

指令重排序包括編譯器重排序和運行時重排序。

指令重排序帶來的問題

例子1:A線程指令重排導致B線程出錯

對於在同一個線程內,這樣的改變是不會對邏輯產生影響的,但是在多線程的情況下,指令重排序會帶來一些問題。

我們來看下面這個情景:

在線程A中:

context = loadContext();

init = true;

在線程B中:

while(!init) {  // 根據線程A中對init變量的修改決定是否使用context變量

   sleep(1000);

}

doSomethingWithConfig(context);

假設此時在線程A中發生了指令重排序:

init = true;

context = loadContext();

那么B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發程序錯誤。

例子2:指令重排導致單例模式失效

如下,是一個懶加載的單例模式,在單線程中這個單例是沒有問題的,但是在多線程中,競態條件會導致instance引用被多次賦值,使用戶得到兩個不同的單例。

class Singleton {
private static Singleton instance;
private Singleton(){}
public static Singleton getInstance() { if ( instance == null ) { // 這里存在競態條件 instance = new Singleton(); } return instance; } }

為了解決這個問題,可以使用synchronized關鍵字將getInstance方法改為同步方法;但這樣串行化的單例效率是很低下的。所以就有前輩設計了DCL(Double Check Lock,雙重檢查鎖)機制,使得大部分請求都不會進入阻塞代碼塊。

class Singleton {
    
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) { // 當instance不為null時,仍可能指向一個“被部分初始化的對象”
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

“看起來”非常完美:既減少了阻塞,又避免了競態條件。不錯,但實際上仍然存在一個問題——當instance不為null時,仍可能指向一個"被部分初始化的對象"

問題出在這行簡單的賦值語句:

instance = new Singleton();

它並不是一個原子操作。事實上,它可以”抽象“為下面幾條JVM指令:

memory = allocate();    // 1:分配對象的內存空間
initInstance(memory);   // 2:初始化對象
instance = memory;      // 3:設置instance指向剛分配的內存地址

上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM可以以“優化”為目的對它們進行重排序,經過重排序后如下:

memory = allocate();    //1:分配對象的內存空間
instance = memory;      //3:設置instance指向剛分配的內存地址(此時對象還未初始化)
ctorInstance(memory);   //2:初始化對象

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向內存memory時,而這段嶄新的內存還沒有初始化,即引用instance指向了一個"被部分初始化的對象"。此時,如果另一個線程調用getInstance方法,由於instance已經指向了一塊內存空間,從而if條件判為false,方法返回instance引用,用戶得到了沒有完成初始化的“半個”單例。

解決這個問題也很簡單,只需要將instance聲明為volatile變量即可。

private static volatile Singleton instance;

volatile如何防止指令重排

volatile關鍵字是通過提供“內存屏障”的方式來防止指令被重排序,為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。

大多數的處理器都支持內存屏障的指令。但對於編譯器來說,發現一個最優布置來最小化插入屏障的總數是不太可能的,為此,Java內存模型采取了保守的策略。

下面是基於保守策略的JMM內存屏障插入策略:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。
  • 在每個volatile寫操作的后面插入一個StoreLoad屏障。
  • 在每個volatile讀操作的后面插入一個LoadLoad屏障。
  • 在每個volatile讀操作的后面插入一個LoadStore屏障。

注意要點

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

2.由於使用volatile屏蔽掉了JVM中必要的代碼優化,所以在效率上比較低,因此一定在必要時才使用此關鍵字。在兩個或者更多的線程需要訪問的成員變量上使用volatile。當要訪問的變量已在synchronized代碼塊中,或者為常量時,沒必要使用volatile。

3.加鎖機制(即同步機制)既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性,原因是聲明為volatile的簡單變量如果當前值與該變量以前的值相關,那么volatile關鍵字不起作用,也就是說如下的表達式都不是原子操作:“count++”、“count = count+1”。

4.在需要同步的時候,第一選擇應該是synchronized關鍵字,這是最安全的方式,嘗試其他任何方式都是有風險的。尤其在、jdK1.5之后,對synchronized同步機制做了很多優化,如:自適應的自旋鎖、鎖粗化、鎖消除、輕量級鎖等,使得它的性能明顯有了很大的提升。

Volatile與Synchronized的區別

1、volatile不需要加鎖,比synchronized更輕量級,不會阻塞線程;並且volatile只能修飾變量,而synchronized可以修飾方法,以及代碼塊。

2、從內存可見性角度看,volatile讀相當於加鎖,volatile寫相當於解鎖。

3、synchronized既能保證可見性,又能保證原子性,而volatile只能保證可見性,無法保證原子性。

4、關鍵字volatile解決的是變量在多個線程之間的可見性問題,而synchronized關鍵字解決的是多個線程之間訪問資源的同步性。

sleep和wait的區別

1、sleep是Thread的靜態方法、wait是Object的方法;
2、sleep不釋放鎖對象,wait放棄鎖對象;
3、sleep暫停線程,但監控狀態仍然保持,結束后自動恢復;
4、wait、notify和notifyAll只能在同步控制方法控制塊里面使用,而sleep可以在任意地方使用;
5、wait方法導致線程放棄對象鎖,只有針對此對象發出notify(或notifyAll)后才進入對象鎖定池准備重新獲得對象鎖,然后進入就緒狀態,准備運行。

sleep方法屬於Thread類中方法,表示讓一個線程進入睡眠狀態,等待一定的時間之后,自動醒來進入到可運行狀態,不會馬上進入運行狀態,因為線程調度機制恢復線程的運行也需要時間,一個線程對象調用了sleep方法之后,並不會釋放他所持有的所有對象鎖,所以也就不會影響其他進程對象的運行。但在sleep的過程中有可能被其他對象調用它的interrupt(),產生InterruptedException異常,如果你的程序不捕獲這個異常,線程就會異常終止,進入TERMINATED狀態,如果你的程序捕獲了這個異常,那么程序就會繼續執行catch語句塊(可能還有finally語句塊)以及以后的代碼。
注意sleep()方法是一個靜態方法,也就是說他只對當前對象有效,通過t.sleep()讓t對象進入sleep,這樣的做法是錯誤的,它只會是使當前線程被sleep 而不是t線程。

wait屬於Object的成員方法,一旦一個對象調用了wait方法,必須要采用notify()或notifyAll()方法喚醒該進程;如果線程擁有某個或某些對象的同步鎖,那么在調用了wait()后,這個線程就會釋放它持有的所有同步資源,而不限於這個被調用了wait()方法的對象。wait()方法也同樣會在wait的過程中有可能被其他對象調用interrupt()方法而產生InterruptedException異常。

其實兩者都可以讓線程暫停一段時間,但是本質的區別是sleep是線程的運行狀態控制,wait是線程之間的通訊問題。sleep()是讓某個線程暫停運行一段時間,其控制范圍是由當前線程決定,也就是說,在線程里面決定。好比如說,我要做的事情是"點火->燒水->煮面",而當我點完火之后我不立即燒水,我要休息一段時間再燒。對於運行的主動權是由我的流程來控制。而wait(),首先,這是由某個確定的對象來調用的,將這個對象理解成一個傳話的人,當這個人在某個線程里面說"暫停!",也是 thisOBJ.wait(),這里的暫停是阻塞。還是"點火->燒水->煮飯",thisOBJ就好比一個監督我的人站在我旁邊,本來該線程應該執行1后執行2,再執行3,而在2處被那個對象喊暫停,那么我就會一直等在這里而不執行3,但正個流程並沒有結束,我一直想去煮飯,但還沒被允許,直到那個對象在某個地方說"通知暫停的線程啟動!",也就是thisOBJ.notify()的時候,那么我就可以煮飯了,這個被暫停的線程就會從暫停處繼續執行。

 

源碼下載

碼雲:https://gitee.com/liuge1988/java-demo.git


作者:朝雨憶輕塵
出處:https://www.cnblogs.com/xifengxiaoma/ 
版權所有,歡迎轉載,轉載請注明原文作者及出處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM