1.什麼是原子操作?
我們在學習MYSQL時就瞭解過原子性,即整個事務是不可分割的最小單位,事務中任何一個語句執行失敗,所有已經執行成功的語句也要回滾,整個資料庫狀態要恢復到執行任務前的狀態。Java中的原子性其實就是和資料庫中說的相似,就是不可在分割,在我們的多執行緒裡面就是相當於一把鎖,在當前的執行緒沒有完成對應的操作之前,別的執行緒不允許切換過來,那麼Java中如何實現程式碼操作中的原子性?在說明這個問題之前,我們先來看一些術語,方便接下來的理解。
2.處理器如何實現操作的原子性?
處理器通常採用快取加鎖或者匯流排加鎖的方式來實現多處理器之間的原子操作。首先處理器會自動保證基本的記憶體操作的原子性。處理器保證從記憶體中讀取或者寫入一個位元組是原子的,意思是,當一個處理器讀取一個位元組時,其他處理器就不能訪問這個位元組的記憶體地址。Pentium6和最新的處理器可以保證單處理器對於同一個快取進行的16/32/64位的操作是原子性的,但是複雜的記憶體操作處理器是不能自動保證其原子性的,比如跨匯流排寬度,跨多個快取行和跨頁表的訪問。但是,處理器提供匯流排鎖定和快取行鎖定的兩個操作來保證複雜記憶體操作的原子性。
2.1使用匯流排鎖保證原子性:
如果多個處理器同時對共享變數進行改寫(例如i++),那麼共享變數就會被多個處理器同時進行操作,這樣讀寫操作就不是原子的,操作完之後共享變數的值就會和期望值不一致。
原因可能是多個處理器同時從各自的快取中讀取變數i,分別進行加1操作,然後分別寫入各自的記憶體中。那麼要想保證讀和寫是原子性的,就必須保證CPU1讀改寫共享變數的時候,CPU2不能操作快取了該共享變數記憶體地址的快取。處理器使用匯流排鎖就是來解決這個問題的。所謂匯流排索就是使用處理器提供一個LOCK#訊號,當一個處理器在匯流排上輸出此訊號時,其他處理器 的請求將被阻塞住,那麼該處理器可以獨佔共享資源。
這裡順便說一下,JVM也就是Java的記憶體模型:
上圖是傳統的計算機架構,組成包括以下幾個
(1)CPU
一般在大型伺服器上會配置多個CPU,每個CPU還會有多個核,這就意味著多個CPU或者多個核可以同時(併發)工作。如果使用Java起了一個多執行緒任務,很有可能每個CPU都會跑一個執行緒,那麼你的任務在某一時刻就是真正的併發執行了。
(2)CPU Register
CPU Register也就是CPU暫存器。CPU暫存器是CPU內部整合的,在暫存器上執行操作的效率要比在主存上高出幾個數量級。
(3)CPU Cache Memory
CPU Cache Memory就是CPU快取,相對於暫存器來說,通常也可以成為L2二級快取。相對於硬碟讀取速度來說記憶體讀取的效率非常高,但是與CPU還是相差數量級,所以在CPU和主存之間引入了多級快取,目的就是為了做一下緩衝。
(4)Main Memory
Main Memory就是主存。
2.2使用快取鎖保證原子性:
第二個機制就是使用快取鎖來保證原子性。在同一時刻,我們只需要對某個記憶體地址的操作是原子性即可,但匯流排鎖把CPU和記憶體之間的通訊鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體地址的資料,所以匯流排鎖的開銷較大,目前處理器在某些場合下適應快取鎖來代替匯流排鎖進行最佳化。
頻繁使用的記憶體會快取在L1,L2,L3快取記憶體中,那麼原子操作就可以直接在處理器內部快取中進行,並不需要宣告匯流排鎖,在Pentium6和目前的處理器中,可以使用”快取鎖定”的方式來實現複雜度原子性。所謂“快取鎖定”是指記憶體區域如果被快取在處理器的快取行中,並且在LOCK期間被鎖定,那麼當他執行所操作回寫奧記憶體時,處理器不再匯流排上宣告LOCK#訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改兩個以上處理器快取的記憶體資料區域資料,當其他處理器回寫已被修改快取行的資料時,會使得快取行無效。
但是有兩種情況處理器不會使用快取鎖定:
- 情況一:當操作的資料不能被快取在處理器內部,或操作的資料跨多個快取行時,處理器會呼叫匯流排鎖定。
- 情況二:有些處理器不支援快取鎖定
3.Java如何實現原子操作?
在Java中可以透過鎖和迴圈CAS的方式來實現原子操作。
3.1使用CAS實現原子操作
JVM中的CAS操作利用的是處理器提供的CMPXCHG指令實現。自旋CAS實現的基本思路就是迴圈進行CAS直到成功。舉例:
package com.cl.pattern.cas;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @desc author Chen lei
* @date 2024/9/22 18:29
*/
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();
}
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println(cas.i);
System.out.println(cas.atomicInteger.get());
System.out.println(System.currentTimeMillis() - start);
}
private void safeCount(){
for (;;){
int i = atomicInteger.get();
boolean suc = atomicInteger.compareAndSet(i,++i);
if (suc){
break;
}
}
}
private void count(){
i++;
}
}
3.2CAS實現原子性操作的三大問題
- ABA問題
- 迴圈時間長,開銷大
- 只能保證一個共享變數的原子操作
3.2.1ABA問題:
因為CAS需要在操作值的時候,檢查值有麼有發生變化,如果沒有發生變化則更新,但是如果一個值原來只是A,變成了B,又變成了A,那麼使用CAS進行檢查時就會發現它的值沒有發生變化,但實際上發生變化了。ABA問題的解決思路就是使用版本號,每次變數更新時把版本號+1,那麼A-B-A就會變成1A-2B-3A。從jdk1.5開始,JDK的Atomic包裡就提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和標誌位的值設定為給定的更新值。
3.2.2迴圈開銷時間長問題:
自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令,那麼效率就會有一定的提升。pause指令有兩個所用:第一,它可以延遲流水線執行指令,使得CPU不會消耗過多的執行資源,延遲時間取決於具體的實現版本,在一些處理器上延遲時間為0;第二,它可以避免在退出迴圈的時候因為記憶體順序衝突而引起CPU流水線被清空,從而提升CPU執行效率。
3.2.3只能保證一個共享變數的原子操作:
對一個共享變數進行CAS操作時,我們可以使用迴圈CAS的方式來保證操作的原子性,但是多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖,還有一個取巧的方法就是把多個共享變數合併成一個共享變數來進行操作。從 Java 1.5 開始, JDK 提供了 AtomicReference 類來保證引用物件之間的原子性,就可以把多個變數放在一個物件裡來進行 CAS 操作。
3.2使用鎖機制實現原子操作
鎖機制保證了只有獲得鎖的執行緒可以操作鎖定的記憶體區域。JVM內部實現了很多鎖機制,有偏向鎖,輕量級鎖和互斥鎖。有意思的是,除了偏向鎖,JVM實現鎖的方式都是用來迴圈CAS,即當一個執行緒進入同步塊時使用迴圈CAS的方式來獲取鎖,當他退出同步塊時使用迴圈CAS釋放鎖。