Java并發(fā)機制的底層實現原理分析
Java代碼在編譯后會變成Java字節(jié)碼,字節(jié)碼被類加載器加載到JVM里,JVM執(zhí)行字節(jié)碼,最終需要轉換為匯編指令在CPU上執(zhí)行,Java中所使用的并發(fā)機制依賴于JVM的實現和CPU的指令。
volatile的應用
在多線程并發(fā)編程中synchronized和volatile都扮演著重要的角色,volatile是輕量級的synchronized,它在多處理器開發(fā)中保證了共享變量的可見性。
可見性的意思是當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。
如果volatile變量修飾符使用恰當的話,它比synchronized的使用和執(zhí)行成本更低,因為它不會引起線程上下文的切換和調度。
volatile的定義與實現原理
Java編程語言允許線程訪問共享變量,為了確保共享變量能被準確和一致地更新,線程應該確保通過排他鎖單獨獲得這個變量。
Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個字段被聲明成volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。
一些CPU術語的定義:
| 術語 | 英文單詞 | 術語描述 |
|---|---|---|
| 內存屏障 | memory barriers | 是一組處理器指令,用于實現內存操作的順序限制 |
| 緩沖行 | cache line | CPU告訴緩存中可以分配的最小存儲單位。處理器填寫緩存行時會加載整個緩存行,現代CPU需要執(zhí)行幾百次CPU指令 |
| 原子操作 | atomic operations | 不可中斷的一個或一系列操作 |
| 緩存行填充 | cache line fill | 當處理器識別到從內存中讀取操作數是可緩存的,處理器讀取整個高速緩存行到適當的緩存(L1,L2,L3的或所有) |
| 緩存命中 | cache hit | 如果進行高速緩存填充操作的內存位置仍然是下次處理器訪問的地址時,那么在下次時,處理器從緩存中讀取操作數,而不是從內存中讀取 |
| 寫命中 | writer hit | 當處理器將操作數寫回到一個內存緩存的區(qū)域時,它首先會檢查這個緩存的內存地址是否在緩存行中,如果存在一個有效的緩存行,則處理器將這個操作數寫回到緩存,而不是寫回到內存,這個操作被稱為寫命中 |
| 寫缺失 | writer misses the cache | 一個有效的緩存行被寫入到不存在的內存區(qū)域 |
java代碼中定義變量時使用volatile,JIT編譯器生成的匯編指令會多出一行,這行內容中帶有 lock add1,
lock前綴的指令在多核處理器下會引發(fā)兩件事情:
- 將當前處理器緩存行的數據寫回到系統(tǒng)內存
- 這個寫回內存的操作會使在其他CPU里緩存了該內存地址的數據無效(一個處理器的緩存回寫到內存會導致其他處理器的緩存無效),這樣在下次訪問相同內存地址時,強制執(zhí)行緩存行填充,處理器就能從緩存中得到最新的數據(為了提高處理速度,處理器不直接和內存進行通信,而是將系統(tǒng)內存的數據讀到內部緩存(L1,L2或其他)后再進行操作)
volatile的使用優(yōu)化
著名的Java并發(fā)編程大師Doug Lea 在JDK7的并發(fā)包里新增一個隊列集合類LinkedTransferQueue,它在使用volatile變量時,用一種追加字節(jié)的方式來優(yōu)化隊列出隊和入隊的性能。
就是增加15個變量,再加上父類的value變量,一個對象的引用占用4字節(jié),共64字節(jié)。一些處理器的高速緩存行是64個字節(jié)寬,這就意味著一個隊列的頭節(jié)點和尾節(jié)點不會在同一個緩存行。
當然有些處理器的緩存行并非64字節(jié)寬,或者共享變量不會被頻繁地寫,java7就會智能的不使用這種追加字節(jié)的方式。(java8中我并未在LinkedTransferQueue中找到追加字節(jié)對應的代碼,可能只在java7中有)
synchronized的實現原理與應用
synchronized一直被稱為重量級鎖,但java1.6對它進行了優(yōu)化,有些情況下它并不那么重了。
下面介紹java1.6中為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖的存儲結構和升級過程。
先來看下利用synchronized實現同步的基礎:Java中的每一個對象都可以作為鎖。
具體表現為一下三種形式:
- 對于普通同步方法,鎖是當前實例對象
- 對于靜態(tài)同步方法,鎖是當前類的Class對象
- 對于同步方法塊,鎖是synchronized括號里配置的對象
當一個線程試圖訪問同步代碼塊時,它首先必須得到鎖,退出或拋出異常時必須釋放鎖。那么鎖到底存在哪里呢?鎖里面會存儲什么信息呢?
從JVM規(guī)范中可以看到synchronized在JVM里的實現原理,JVM基于進入和退出Monitor對象來實現方法同步和代碼塊同步,但兩者的實現細節(jié)不一樣。
代碼塊同步是使用monitorenter和monitorexit指令實現的,而方法同步是使用另外一種方式實現的,細節(jié)在JVM規(guī)范里并沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實現。
monitorenter指令是在編譯后插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。
任何對象都有一個monitor與之關聯,并且一個monitor被持有后,它將處于鎖定狀態(tài)。
線程執(zhí)行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。
Java對象頭
synchronized用的鎖是存在Java對象頭里的。如果對象是數組類型,則虛擬機用3個字寬(Word)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。
在32位虛擬機中,1字寬等于4字節(jié),即32bit,Java對象頭的長度如下所示:
| 長度 | 內容 | 說明 |
|---|---|---|
| 32/64bit | Mark Word | 存儲對象的hashCode或鎖信息等 |
| 32/64bit | Class Metadata Address | 存儲到對象類型數據的指針 |
| 32/64bit | Array Length | 數組的長度(如果當前對象是數組) |
Java對象頭里的Mark Word里默認存儲對象的HashCode、分代年齡和鎖標記位。
32位JVM的Mark Word的默認存儲結構如下所示(Java對象頭的存儲結構):
| 鎖狀態(tài) | 25bit | 4bit | 1bit是否是偏向鎖 | 2bit鎖標志位 |
|---|---|---|---|---|
| 無鎖狀態(tài) | 對象的hashcode | 對象分代年齡 | 0 | 01 |
在運行期間,Mark Word里存儲的數據會隨著鎖標志位的變化而變化。
Mark Word可能變化為存儲以下4種數據,Mark Word的狀態(tài)變化如下所示:

在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下所示:

鎖的升級與對比
Java1.6為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了"偏向鎖"和"輕量級鎖",在Java1.6中,鎖一共有4種狀態(tài),級別從低到高依次是:無鎖狀態(tài)、偏向鎖狀態(tài)、輕量級鎖狀態(tài)和重量級鎖狀態(tài),這幾個狀態(tài)會隨著競爭情況逐漸升級。
鎖可以升級但不能降級,意味著偏向鎖升級為輕量級鎖后不能再降級為偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。
偏向鎖
HotSpot(Java虛擬機的一種實現)的作者經過研究發(fā)現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。
當一個線程訪問同步塊并獲得鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后以后該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需要簡單地測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖。
如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置為1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
(1)偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。
偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執(zhí)行的字節(jié)碼)。
它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活著,如果線程不處于活動狀態(tài),則將對象頭設置成無鎖狀態(tài);如果線程仍然活著,擁有偏向鎖的棧會被執(zhí)行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要么重新偏向于其他線程,要么恢復到無鎖或者標記對象不適合作為偏向鎖,最后喚醒暫停的線圖。下圖中的線程1演示了偏向鎖初始化的流程,線程2演示了偏向鎖撤銷的流程:

(2)關閉偏向鎖
編向鎖在java6和Java7里是默認啟用的,但是它在應用程序啟動幾秒鐘之后才激活,
如有必要可以使用IVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。
如果確定應用程序里所有的鎖通常情況下處于競爭狀態(tài),可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態(tài)。
輕量級鎖
(1)輕量級鎖加鎖
線程在執(zhí)行同步塊之前,JVM會先在當前線程的棧楨中創(chuàng)建用于存儲鎖記錄的空間,并將對象頭中的 Mark Word 復制到鎖記錄中,官方稱為 Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word 替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
(2)輕量級鎖解鎖
輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word 替換回到對象頭,如果成功,則表示沒有競爭發(fā)生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。
下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖:

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態(tài)。
當鎖處于這個狀態(tài)下,其他線程試圖獲聯鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之后會喚配這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。
鎖的優(yōu)缺點對比
| 鎖 | 優(yōu)點 | 缺點 | 使用場景 |
|---|---|---|---|
| 偏向鎖 | 加鎖和解鎖不需要額外的消耗,和執(zhí)行非同步方法相比僅存在納秒級的差距 | 如果線程間存在鎖竟爭會帶來額外的鎖撤銷的消耗 | 適用于只有一個線程訪問同步塊場景 |
| 輕量級鎖 | 競爭的線程不會阻塞,提高了程序的響應速度 | 如果始終得不到鎖竟爭的線程,使用自旋會消耗CPU | 追求響應時間同步塊執(zhí)行速度非???/td> |
| 重量級鎖 | 線程競爭不使用自旋,不會消耗CPU | 線程阻塞,響應時間緩慢 | 追求吞吐量同步塊執(zhí)行速度較慢 |
原子操作的實現原理
原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為“不可被中斷的一個或一系列操作”。
在多處理器上實現原子操作就變得有點復雜。讓我們一起來聊一聊在Intel處理器和Java里是如何實現原子操作的。
術語定義
在了解原子操作的實現原理前,先要了解一下相關的術語,如下所示:
| 術語名稱 | 英文 | 解 釋 |
|---|---|---|
| 緩存行 | Cache line | 緩存的最小操作單位 |
| 比較并交換 | Compare and Swap | CAS操作需要輸入兩個數值,一個舊值(期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發(fā)生變化,如果沒有發(fā)生變化,才交換成新值,發(fā)生了變化則不交換 |
| CPU 流水線 | CPU pipeline | CPU流水線的工作方式就像工業(yè)生產上的裝配流水線,在CP中由5-6個不同功能的電路單元組成一條指令處理流水線,然后將一條X86指令分成5~6步后再由這些電路單元分別執(zhí)行:這樣就能實現在一個CPU時鐘周期完成一條指令,因此提高CP的運算速度 |
| 內存順序沖突 | Memory order violation | 內存順序沖突一般是由假共享引起的,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當出現這個內存順序沖突時,CPU必須清空流水線 |
處理器如何實現原子操作
32位IA-32處理器使用基于對緩存加鎖或總線加鎖的方式來實現多處理器之間的原子操作。首先處理器會自動保證基本的內存操作的原子性。
處理器保證從系統(tǒng)內存中讀取或者寫入一個字節(jié)是原子的,意思是當一個處理器讀取一個字節(jié)時,其他處理器不能訪問這個字節(jié)的內存地址。
Pentium 6和最新的處理器能自動保證單處理器對同一個緩存行里進行16/32/64位的操作是原子的,但是復雜的內存操作處理器是不能自動保證其原子性的,比如跨總線寬度、跨多個緩存行和跨頁表的訪問。但是,處理器提供總線鎖定和緩存鎖定兩個機制來保證復雜內存操作的原子性。
(1)使用總線鎖保證原子性
第一個機制是通過總線鎖保證原子性。如果多個處理器同時對共享變量進行讀改寫操作(i++就是經典的讀改寫操作),那么共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,作完之后共享變量的值會和期望的不一致。
舉個例子,如果i=1,我們進行兩次i+操作,我們期望的結果是3,但是有可能結果是2,如下圖所示:

原因可能是多個處理器同時從各自的緩存中讀取變量i,分別進行加1操作,然后分別寫入系統(tǒng)內存中。那么,想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量內存地址的緩存。
處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那么該處理器可以獨占共享內存。
(2)使用緩存鎖保證原子性
第二個機制是通過緩存鎖定來保證原子性。在同一時刻,我們只需保證對某個內存地址的操作是原子性即可,但總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優(yōu)化。
頻繁使用的內存會緩存在處理器的L1、L2和L3高速緩存里,那么原子操作就可以直接在處理器內部緩存中進行,并不需要聲明總線鎖,在Pentium6和目前的處理器中可以使用“緩存鎖定”的方式來實現復雜的原子性。所謂“緩存鎖定”是指內存區(qū)域如果被緩存在處理器的緩存行中,并且在Lock操作期間被鎖定,那么當它執(zhí)行鎖操作回寫到內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,并允許它的緩存一致性機制來保證操作的原子性,因為緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區(qū)域數據,當其他處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效,在如圖2-3所示的例子中,當CPU1修改緩存行中的i時使用了緩存鎖定,那么CPU2就不能同時緩存i的緩存行。
但是有兩種情況下處理器不會使用緩存鎖定:
- 當操作的數據不能被緩存在處理器內部,或操作的數據跨多個緩存行(cache line)時,則處理器會調用總線鎖定。
- 有些處理器不支持緩存鎖定。對于Intel 486和Pentium 處理器,就算鎖定的內存區(qū)域在處理器的緩存行中也會調用總線鎖定。
- 針對以上兩個機制、我們通過Intel處理器提供了很多Lock前綴的指今來實現。
- 例如,位測試和修改指令:BTS、BTR、BTC;交換指令XADD、CMPXCHG,以及其他一些操作數和邏輯指令(如ADD、OR)等,被這些指令操作的內存區(qū)域就會加鎖,導致其他處理器不能同時訪問它。
Java 如何實現原子操作
在Java中可以通過鎖和循環(huán)CAS的方式來實現原子操作。
使用循環(huán)CAS實現原子操作
IVM 中的CAS操作正是利用了處理器提供的CMPXCHG指令實現的。
自旋CAS實現的基本思路就是循環(huán)進行CAS操作直到成功為止,以下代碼實現了一個基于CAS線程安全的計數器方法safeCount和一個非線程安全的計數器count。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private AtomicInteger atomicInteger = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<Thread>(600);
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();
}
// 等待所有線程執(zhí)行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(cas.i);
System.out.println(cas.atomicInteger.get());
System.out.println(System.currentTimeMillis() - start);
}
/**
* 使用CAS實現線程安全計數器
*/
private void safeCount() {
for (; ; ) {
int i = atomicInteger.get();
boolean suc = atomicInteger.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非線程安全計數器
*/
private void count() {
i++;
}
}打?。?/p>
767343
1000000
125
從 Java1.5 開始,JDK的并發(fā)包里提供了一些類來支持原子操作,如AtomicBoolean(用原子方式更新的 boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。
這些原子包裝類還提供了有用的工具方法,比如以原子的方式將當前值自增1和自減1。
CAS實現原子操作的三大問題
在Java并發(fā)包中有一些并發(fā)框架也使用了自旋CAS的方式來實現原子操作,比如LinkedTransferQueue 類的 Xfer 方法。CAS雖然很高效地解決了原子操作,但是CAS仍然存在三大問題:ABA問題、循環(huán)時間長開銷大、只能保證一個共享變量的原子操作。
- ABA問題。
- 因為CAS需要在操作值的時候,檢查值有沒有發(fā)生變化,如果沒有發(fā)生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那么使用CAS進行檢查時會發(fā)現它的值沒有發(fā)生變化,但是實際上卻變化了。
- ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加1,那么A→B→A就會變成1A→2B→3A。從Java1.5開始,JDK的Atomic包里提供了一個類AtomicStampedReference來解決 ABA問題。
- 這個類的compareAndSet方法的作用是首先檢查當前引用是否等于預期引用,并且檢查當前標志是否等于預期標志,如果全部相等,則以原子方式將該引用和該標志的值設置為給定的更新值。
/**
* Atomically sets the value of both the reference and stamp
* to the given update values if the
* current reference is {@code ==} to the expected reference
* and the current stamp is equal to the expected stamp.
*
* @param expectedReference the expected value of the reference
* @param newReference the new value for the reference
* @param expectedStamp the expected value of the stamp
* @param newStamp the new value for the stamp
* @return {@code true} if successful
*/
public boolean compareAndSet(V expectedReference, // 預期引用
V newReference, // 更新后的引用
int expectedStamp, // 預期標志
int newStamp) { // 更新后的標志
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}- 循環(huán)時間長開銷大。
- 自旋CAS如果長時間不成功,會給CPU帶來非常大的執(zhí)行開銷。如果JVM能支持處理器提供的pause指令,那么效率會有一定的提升。
- pause指令有兩個作用:第一,它可以延遲流水線執(zhí)行指令(de-pipeline),使 CPU不會消耗過多的執(zhí)行資源延遲的時間取決于具體實現的版本,在一些處理器上延遲時間是零;第二,它可以避免在退出循環(huán)的時候因內存順序沖突(MemoryOrder Violation)而引起CPU流水線被清空(CPU Pipeline Flush),從而提高CPU的執(zhí)行效率。
- 只能保證一個共享變量的原子操作。
- 當對一個共享變量執(zhí)行操作時,我們可以使用循環(huán)CAS的方式來保證原子操作,但是對多個共享變量操作時,循環(huán)CAS就無法保證操作的原子性,這個時候就可以用鎖。還有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如,有兩個共享變量i=2,j=a,合并一下ij=2a,然后用CAS來操作ij。從Java1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變量放在一個對象里來進行CAS操作。
使用鎖機制實現原子操作
鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區(qū)域。JVM內部實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。
有意思的是除了偏向鎖,JVM實現鎖的方式都用了循環(huán)CAS,即當一個線程想進人同步塊的時候使用循環(huán)CAS的方式來獲取鎖,當它退出同步塊的時候使用循環(huán)CAS釋放鎖。
總結
以上為個人經驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
Spring中DAO被循環(huán)調用的時候數據不實時更新的解決方法
這篇文章主要介紹了Spring中DAO被循環(huán)調用的時候數據不實時更新的解決方法,需要的朋友可以參考下2014-08-08
Mybatis-plus selectByMap條件查詢方式
這篇文章主要介紹了Mybatis-plus selectByMap條件查詢方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-06-06
關于kafka消費不到遠程bootstrap-server?數據的問題
很多朋友遇到kafka消費不到遠程bootstrap-server?數據的問題,怎么解決這個問題,很多朋友不知所措,下面小編給大家?guī)砹岁P于kafka消費不到遠程bootstrap-server?數據的問題及解決方法,感興趣的朋友跟隨小編一起看看吧2021-11-11
如何使用pipeline和jacoco獲取自動化測試代碼覆蓋率
這篇文章主要介紹了如何使用pipeline和jacoco獲取自動化測試代碼覆蓋率,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-11-11
Mabatis錯誤提示Parameter index out of range的處理方法
這篇文章主要介紹了Mabatis錯誤提示Parameter index out of range 的處理方法,本文給大家介紹的非常詳細,具有一定的參考借鑒價值,需要的朋友可以參考下2018-08-08

