原子(atomic)本意是「不能被進一步分割的最小粒子」,而原子操作(atomic operation)意為「不可被中斷的一個或一系列操作」。在多處理器上實現原子操作就變得有點複雜。讓我們一起來聊一聊在Intel處理器和Java里是如何實現原子操作的。
在了解原子操作的實現原理前,先要了解一下相關的術語:
術語名稱英文解釋緩存行Cache line緩存的最小操作單位比較並交換Compare and SwapCAS操作需要輸入兩個數值,一個舊值(期望操作前的值)和一個新值,在操作期間先比較舊值有沒有發生變化,如果沒有發生變化,才交換成新值,發生了變化則不交換。CPU流水線CPU pipelineCPU流水線的工作方式就像工業生產上的裝配流水線,在CPU中由5~6個不同功能的電路單元組成一條指令處理流水線,然後將一條X86指令分成5~6步後再由這些電路單元分別執行,這樣就能實現在一個CPU時鐘周期完成一條指令,因此提高CPU的運算速度內存順序衝突Memory order violation內存順序衝突一般是由假共享引起的,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當出現這個內存順序衝突時,CPU必須清空流水線
32位IA-32處理器使用基於對緩存加鎖或總線加鎖的方式來實現多處理器之間的原子操作。首先處理器會自動保證基本的內存操作的原子性。處理器保證從系統內存中讀取或者寫入一個位元組是原子的,意思是當一個處理器讀取一個位元組時,其他處理器不能訪問這個位元組的內存地址。Pentium 6和最新的處理器能自動保證單處理器對同一個緩存行里進行16/32/64位的操作是原子的,但是複雜的內存操作處理器是不能自動保證其原子性的,比如跨總線寬度、跨多個緩存行和跨頁表的訪問。但是,處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜內存操作的原子性。
在Intel 2019年的文檔中,該部分闡述基本不變,具體可以查考文末Intel文檔的2957頁 8.1 LOCKED ATOMIC OPERATIONS
使用總線鎖保證原子性
第一個機制是通過總線鎖保證原子性。如果多個處理器同時對共享變量進行讀改寫操作(i++就是經典的讀改寫操作),那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一致。舉個例子,如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2,如圖2-3所示。
原因可能是多個處理器同時從各自的緩存中讀取變量i,分別進行加1操作,然後分別寫入系統內存中。那麼,想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量內存地址的緩存。處理器使用總線鎖就是來解決這個問題的。所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨占共享內存。
Intel文檔的2959頁 8.1.2 Bus Locking
使用緩存鎖保證原子性
第二個機制是通過緩存鎖定來保證原子性。在同一時刻,我們只需保證對某個內存地址的操作是原子性即可,但總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。
頻繁使用的內存會緩存在處理器的L1、L2和L3高速緩存里,那麼原子操作就可以直接在處理器內部緩存中進行,並不需要聲明總線鎖,在Pentium 6和目前的處理器中可以使用「緩存鎖定」的方式來實現複雜的原子性。所謂「緩存鎖定」是指內存區域如果被緩存在處理器的緩存行中,並且在Lock操作期間被鎖定,那麼當它執行鎖操作回寫到內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,並允許它的緩存一致性機制來保證操作的原子性,因為緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效,在如圖2-3所示的例子中,當CPU1修改緩存行中的i時使用了緩存鎖定,那麼CPU2就不能同時緩存i的緩存行。
但是有兩種情況下處理器不會使用緩存鎖定:
Intel文檔的2961頁 8.1.4 Effects of a LOCK Operation on Internal Processor Caches
在Java中可以通過鎖和循環CAS的方式來實現原子操作。
使用循環CAS實現原子操作
JVM中的CAS操作正是利用了處理器提供的CMPXCHG(Compare and Exchange)指令實現的。自旋CAS實現的基本思路就是循環進行CAS操作直到成功為止,以下代碼實現了一個基於CAS線程安全的計數器方法safeCount和一個非線程安全的計數器count。
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
Listts = new ArrayList (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();
}
// 等待所有線程執行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(cas.i);
System.out.println(cas.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}
/**
* 使用CAS實現線程安全計數器
*/
private void safeCount() {
for (; ; ) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非線程安全計數器
*/
private void count() {
i++;
}
從Java 1.5開始,JDK的並發包里提供了一些類來支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。這些原子包裝類還提供了有用的工具方法,比如以原子的方式將當前值自增1和自減1。
CAS實現原子操作的三大問題
在Java並發包中有一些並發框架也使用了自旋CAS的方式來實現原子操作,比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效地解決了原子操作,但是CAS仍然存在三大問題。ABA問題,循環時間長開銷大,以及只能保證一個共享變量的原子操作。
public boolean compareAndSet(
\t\tV expectedReference, \t// 預期引用
\t\tV newReference, \t\t// 更新後的引用
\t\tint expectedStamp, \t\t// 預期標誌
\t\tint newStamp \t\t\t// 更新後的標誌
)
使用鎖機制實現原子操作
鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區域。JVM內部實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實現鎖的方式都用了循環CAS,即當一個線程想進入同步塊的時候使用循環CAS的方式來獲取鎖,當它退出同步塊的時候使用循環CAS釋放鎖。
以上就是我的對此問題的整理和思考。如果你對此話題有自己的思考和理解,也歡迎留言一起探討