CAS、原子操作類的應用與淺析及Java8對其的優化

CoderBear發表於2019-03-02

前幾天刷朋友圈的時候,看到一段話:如果現在我是傻逼,那麼我現在不管怎麼努力,也還是傻逼,因為我現在的傻逼是由以前決定的,現在努力,是為了讓以後的自己不再傻逼。話糙理不糙,如果妄想現在努力一下,馬上就不再傻逼,那是不可能的,需要積累,需要沉澱,才能慢慢的不再傻逼。

好了,雞湯喝完。

今天我們的內容是CAS以及原子操作類應用與原始碼淺析,還會利用CAS來完成一個單例模式,還涉及到偽共享等。因為CAS是併發框架的基石,所以相當重要,這篇部落格是一個長文,請做好準備。

說到CAS,不得不提到兩個專業詞語:悲觀鎖,樂觀鎖。我們先來看看什麼是悲觀鎖,什麼是樂觀鎖。

悲觀鎖,樂觀鎖

第一次看到悲觀鎖,樂觀鎖的時候,應該是在應付面試,看面試題的時候。有這麼一個例子:如何避免多執行緒對資料庫中的同一條記錄進行修改。

悲觀鎖

如果是mysql資料庫,利用for update關鍵字+事務。這樣的效果就是當A執行緒走到for update的時候,會把指定的記錄上鎖,然後B執行緒過來,就只能等待,A執行緒修改完資料之後,提交事務,鎖就被釋放了,這個時候B執行緒終於可以繼續做他的事情了。悲觀鎖往往是互斥的:只有我一個人可以進來,其他人都給我等著。這麼做是相當影響效能的。

樂觀鎖

在資料表中加一個版本號的欄位:version,這個欄位不需要程式設計師手動維護,是資料庫主動維護的,每次修改資料,version都會發生更改。

當version現在是1:

  1. A執行緒進來,讀到version是1。
  2. B執行緒進來,讀到version是1。
  3. A執行緒執行了更新的操作:update stu set name='codebear' where id=1 and version=1。成功。資料庫主動把version改成了2。
  4. B執行緒執行了更新的操作:update stu set name='hello' where id=1 and version=1。失敗。因為這個時候version欄位已經不是1了。

樂觀鎖其實不能叫鎖,它沒有鎖的概念。

在Java中,也有悲觀鎖,樂觀鎖的概念,悲觀鎖的典型代表就是Synchronized,而樂觀鎖的典型代表就是今天要說的CAS。而說CAS之前,先要說下原子操作類,因為CAS是原子操作類的基石,我們先要看看原子操作類的強大之處,從而產生探究CAS的興趣。

原子操作類的應用

我們先來看看原子操作類的應用。在Java中提供了很多原子操作類,比如AtomicInteger,其中有一個自增方法。

public class Main {
    public static void main(String[] args) {
        Thread[] threads = new Thread[20];
        AtomicInteger atomicInteger = new AtomicInteger();
        for (int i = 0; i < 20; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    atomicInteger.incrementAndGet();
                }
            });
            threads[i].start();
        }
        join(threads);
        System.out.println("x=" + atomicInteger.get());
    }

    private static void join(Thread[] threads) {
        for (int i = 0; i < 20; i++) {
            try {
                threads[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

執行結果:

image.png

這就是原子操作類的神奇之處了,在高併發的情況下,這種方法會比Synchronized更有優勢,畢竟Synchronized關鍵字會讓程式碼序列化,失去了多執行緒優勢。

我們再來看個案例:

如果有一個需求,一個欄位的初始值為0,開三個執行緒:

  1. 一個執行緒執行:當x=0,x修改為100
  2. 一個執行緒執行:當x=100,x修改為50
  3. 一個執行緒執行:當x=50,x修改為60
    public static void main(String[] args) {
        AtomicInteger atomicInteger=new AtomicInteger();
        new Thread(() -> {
            if(!atomicInteger.compareAndSet(0,100)){
                System.out.println("0-100:失敗");
            }
        }).start();

        new Thread(() -> {
            try {
                Thread.sleep(500);////注意這裡睡了一會兒,目的是讓第三個執行緒先執行判斷的操作,從而讓第三個執行緒修改失敗
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if(!atomicInteger.compareAndSet(100,50)){
                System.out.println("100-50:失敗");
            }
        }).start();

        new Thread(() -> {
            if(!atomicInteger.compareAndSet(50,60)){
                System.out.println("50-60:失敗");
            }
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
複製程式碼

執行結果也是一樣的:

image.png

這個例子好像沒有什麼意思啊,甚至有點無聊,為什麼要舉這個例子呢,因為在這裡,我所呼叫的方法compareAndSet,首字母就是CAS,而且傳遞了兩個引數,這兩個引數是在原生CAS操作中必須要傳遞的,離原生的CAS操作更近一些。

既然原子操作類那麼牛逼,我們很有必要探究下原子操作類的基石:CAS。

CAS

CAS的全稱是Compare And Swap,即比較交換,當然還有一種說法:Compare And Set,呼叫原生CAS操作需要確定三個值:

  • 要更新的欄位
  • 預期值
  • 新值

其中,要更新的欄位(變數)有時候會被拆分成兩個引數:1.例項 2.偏移地址。

也許你看到這裡,會覺得雲裡霧裡,不知道我在說什麼,沒關係,繼續硬著頭皮看下去。

我們先來看看compareAndSet的原始碼。

compareAndSet原始碼淺析

首先,呼叫這個方法需要傳遞兩個引數,一個是預期值,一個是新值,這個預期值就相當於資料庫樂觀鎖版本號的概念,新值就是我們希望修改的值(是值,不是欄位)。我們來看看這個方法的內部實現:

 public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
複製程式碼

呼叫了unsafe下的compareAndSwapInt方法,除了傳遞了我們傳到此方法的兩個引數之外,又傳遞了兩個引數,這兩個引數就是我上面說的例項和偏移地址,this代表是當前類的例項,即AtomicInteger類的例項,這個偏移地址又是什麼鬼呢,說的簡單點,就是確定我們需要修改的欄位在例項的哪個位置。知道了例項,知道了我們的需要修改的欄位是在例項的哪個位置,就可以確定這個欄位了。不過,這個確定的過程不是在Java中做的,而是在更底層做的。

偏移地址是在本類的靜態程式碼塊中獲得的:

    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
複製程式碼

unsafe.objectFieldOffset接收的是Field型別的引數,得到的就是對應欄位的偏移地址了,這裡就是獲得value欄位在本類,即AtomicInteger中的偏移地址。

我們在來看看value欄位的定義:

 private volatile int value;
複製程式碼

volatile是為了保證記憶體的可見性。

大家肯定想一探究竟compareAndSwapInt和objectFieldOffset這兩個方法中做了什麼事情,很遺憾,個人水平有限,目前還沒有能力去探究,只知道這種寫法是JNI,會呼叫到C或者C++,最終會把對應的指令傳送給CPU,這是可以保證原子性的。

我們可以看下這兩個方法的定義:

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public native long objectFieldOffset(Field var1);
複製程式碼

這兩個方法被native標記了。

我們來為compareAndSwapInt方法做一個比較形象的解釋:

當我們執行compareAndSwapInt方法,傳入10和100,Java會和更底層進行通訊:老鐵,我給你了欄位的所屬例項和偏移地址,你幫我看下這個欄位的值是不是10,如果是10的話,你就改成100,並且返回true,如果不是的話,不用修改,返回false把。

其中比較的過程就是compare,修改的值的過程就是swap,因為是把舊值替換成新值,所以我們把這樣的操作稱為CAS。

我們再來看看incrementAndGet的原始碼。

incrementAndGet原始碼淺析

    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
複製程式碼
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
複製程式碼

incrementAndGet方法會調到用getAndAddInt方法,這裡有三個引數:

  • var1:例項。
  • var2:偏移地址。
  • var4:需要自增的值,這裡是1。

getAndAddInt方法內部有一個while迴圈,迴圈體內部根據例項和偏移地址獲得對應的值,這裡先稱為A,再來看看while裡面的判斷內容,JDK和更底層進行通訊:嘿,我把例項和偏移地址給你,你幫我看下這個值是不是A,如果是的話,幫我修改成A+1,返回true,如果不是的話,返回false吧。

這裡要思考一個問題:為什麼需要while迴圈?

比如同時有兩個執行緒執行到了getIntVolatile方法,拿到的值都是10,其中執行緒A執行native方法,修改成功,但是執行緒B就修改失敗了啊,因為CAS操作是可以保證原子性的,所以執行緒B只能苦逼的再一次迴圈,這一次拿到的值是11,又去執行native方法,修改成功。

像這樣的while迴圈,有一個高大上的稱呼:CAS自旋

讓我們試想一下,如果現在併發真的很高很高,會出現什麼事情?大量的執行緒在進行CAS自旋,這太浪費CPU了吧。所以在Java8之後,對原子操作類進行了一定的優化,這個我們後面再說。

可能大家對於原子操作類的底層實現,還是比較迷茫,還是不知道unsafe下面的方法到底是什麼意思,畢竟剛才只是簡單的讀了下程式碼,俗話說“紙上得來終覺淺,絕知此事要躬行”,所以我們需要自己呼叫下unsafe下面的方法,來加深理解。

Unsafe

Unsafe:不安全的,既然有這樣的命名,說明這個類是比較危險的,Java官方也不推薦我們直接操作Unsafe類,但是畢竟現在是學習階段,寫寫demo而已,只要不是釋出到生產環境,又有什麼關係呢?

Unsafe下面的方法還是比較多的,我們選擇幾個方法來看下,最終我們會利用這幾個方法來完成一個demo。

objectFieldOffset:接收一個Field型別的資料,返回偏移地址。 compareAndSwapInt:比較交換,接收四個引數:例項,偏移地址,預期值,新值。 getIntVolatile:獲得值,支援Volatile,接收兩個引數:例項,偏移地址。

這三個方法在上面的原始碼淺析中,已經出現過了,也進行了一定的解釋,這裡再解釋一下,就是為了加深印象,我在學習CAS的時候,也是反覆的看部落格,看原始碼,突然恍然大悟。我們需要用這三個方法來完成一個demo:寫一個原子操作自增的方法,自增的值可以自定義,沒錯,這個方法上面我已經分析過了。下面直接放出程式碼:

public class MyAtomicInteger {

    private volatile int value;

    private static long offset;//偏移地址

    private static Unsafe unsafe;

    static {
        try {
            Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafeField.setAccessible(true);
            unsafe = (Unsafe) theUnsafeField.get(null);
            Field field = MyAtomicInteger.class.getDeclaredField("value");
            offset = unsafe.objectFieldOffset(field);//獲得偏移地址
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void increment(int num) {
        int tempValue;
        do {
            tempValue = unsafe.getIntVolatile(this, offset);//拿到值
        } while (!unsafe.compareAndSwapInt(this, offset, tempValue, value + num));//CAS自旋
    }

    public int get() {
        return value;
    }
}
複製程式碼
public class Main {
    public static void main(String[] args) {
        Thread[] threads = new Thread[20];
        MyAtomicInteger atomicInteger = new MyAtomicInteger();
        for (int i = 0; i < 20; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    atomicInteger.increment(1);
                }
            });
            threads[i].start();
        }
        for (int i = 0; i < threads.length; i++) {
            try {
                threads[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("x=" + atomicInteger.get());
    }
}
複製程式碼

執行結果:

image.png

你可能會有疑問,為什麼需要用反射來獲取theUnsafe,其實這是JDK為了保護我們,讓我們無法方便的獲得unsafe,如果我們和JDK一樣來獲得unsafe會報錯:

    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class var0 = Reflection.getCallerClass();
        if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
            throw new SecurityException("Unsafe");//如果我們也以getUnsafe來獲得theUnsafe,會丟擲異常
        } else {
            return theUnsafe;
        }
    }
複製程式碼

CAS與單例模式

對的,你沒看錯,我也沒寫錯,用CAS也可以完成單例模式,雖然在正常開發中,不會有人用CAS來完成單例模式,但是是檢驗是否學會CAS的一個很好的題目。

public class Singleton {
    private Singleton() {
    }

    private static AtomicReference<Singleton> singletonAtomicReference = new AtomicReference<>();

    public static Singleton getInstance() {
        while (true) {
            Singleton singleton = singletonAtomicReference.get();// 獲得singleton
            if (singleton != null) {// 如果singleton不為空,就返回singleton
                return singleton;
            }
            // 如果singleton為空,建立一個singleton
            singleton = new Singleton();
            // CAS操作,預期值是NULL,新值是singleton
            // 如果成功,返回singleton
            // 如果失敗,進入第二次迴圈,singletonAtomicReference.get()就不會為空了
            if (singletonAtomicReference.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }
}
複製程式碼

註釋寫的已經比較清楚了,可以對著註釋,再好好理解一下。

ABA

compareAndSet方法,上面已經寫過一個demo,大家可以也試著分析下原始碼,我就不再分析了,我之所以要再次提到compareAndSet方法,是為了引出一個問題。

假設有三個步驟:

  1. 修改150為50
  2. 修改50為150
  3. 修改150為90

請仔細看,這三個步驟做的事情,一個變數剛開始是150,修改成了50,後來又被修改成了150!(又改回去了),最後如果這個變數是150,再改成90。這就是CAS中ABA的問題。

第三步,判斷這個值是否是150,有兩種不同的需求:

  • 沒錯啊,雖然這個值被修改了,但是現在被改回去了啊,所以第三步的判斷是成立的。
  • 不對,這個值雖然是150,但是這個值曾經被修改過,所以第三步的判斷是不成立的。

針對於第二個需求,我們可以用AtomicStampedReference來解決這個問題,AtomicStampedReference支援泛型,其中有一個stamp的概念。下面直接貼出程式碼:

    public static void main(String[] args) {
        try {
            AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<Integer>(150, 0);
            Thread thread1 = new Thread(() -> {
                Integer oldValue = atomicStampedReference.getReference();
                int stamp = atomicStampedReference.getStamp();
                if (atomicStampedReference.compareAndSet(oldValue, 50, 0, stamp + 1)) {
                    System.out.println("150->50 成功:" + (stamp + 1));
                }
            });
            thread1.start();

            Thread thread2 = new Thread(() -> {
                try {
                    Thread.sleep(1000);//睡一會兒,是為了保證執行緒1 執行完畢
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Integer oldValue = atomicStampedReference.getReference();
                int stamp = atomicStampedReference.getStamp();
                if (atomicStampedReference.compareAndSet(oldValue, 150, stamp, stamp + 1)) {
                    System.out.println("50->150 成功:" + (stamp + 1));
                }
            });
            thread2.start();

            Thread thread3 = new Thread(() -> {
                try {
                    Thread.sleep(2000);//睡一會兒,是為了保證執行緒1,執行緒2 執行完畢
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Integer oldValue = atomicStampedReference.getReference();
                int stamp = atomicStampedReference.getStamp();
                if (atomicStampedReference.compareAndSet(oldValue, 90, 0, stamp + 1)) {
                    System.out.println("150->90 成功:" + (stamp + 1));
                }
            });
            thread3.start();

            thread1.join();
            thread2.join();
            thread3.join();
            System.out.println("現在的值是" + atomicStampedReference.getReference() + ";stamp是" + atomicStampedReference.getStamp());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
複製程式碼

Java8對於原子操作類的優化

在進行incrementAndGet原始碼解析的時候,說到一個問題:在高併發之下,N多執行緒進行自旋競爭同一個欄位,這無疑會給CPU造成一定的壓力,所以在Java8中,提供了更完善的原子操作類:LongAdder。

我們簡單的說下它做了下什麼優化,它內部維護了一個陣列Cell[]和base,Cell裡面維護了value,在出現競爭的時候,JDK會根據演算法,選擇一個Cell,對其中的value進行操作,如果還是出現競爭,會換一個Cell再次嘗試,最終把Cell[]裡面的value和base相加,得到最終的結果。

因為其中的程式碼比較複雜,我就選擇幾個比較重要的問題,帶著問題去看原始碼:

  1. Cell[]是何時被初始化的。
  2. 如果沒有競爭,只會對base進行操作,這是從哪裡看出來的。
  3. 初始化Cell[]的規則是什麼。
  4. Cell[]擴容的時機是什麼。
  5. 初始化Cell[]和擴容Cell[]是如何保證執行緒安全性的。

這是LongAdder類的UML圖:

image.png

add方法:

 public void add(long x) {
        Cell[] cs; long b, v; int m; Cell c;
        if ((cs = cells) != null || !casBase(b = base, b + x)) {//第一行
            boolean uncontended = true;
            if (cs == null || (m = cs.length - 1) < 0 ||//第二行
                (c = cs[getProbe() & m]) == null ||//第三行
                !(uncontended = c.cas(v = c.value, v + x)))//第四行
                longAccumulate(x, null, uncontended);//第五行
        }
    }
複製程式碼

第一行: ||判斷,前者是判斷cs=cells是否【不為空】,後者是判斷CAS是否【不成功】 。 casBase做什麼了?

final boolean casBase(long cmp, long val) {
        return BASE.compareAndSet(this, cmp, val);
}
複製程式碼

這個比較簡單,就是呼叫compareAndSet方法,判斷是否成功:

  • 如果當前沒有競爭,返回true。
  • 如果當前有競爭,有執行緒會返回false。

再回到第一行,整體解釋下這個判斷:如果cell[]已經被初始化了,或者有競爭,才會進入到第二行程式碼。如果沒有競爭,也沒有初始化,就不會進入到第二行程式碼。

這就回答了第二個問題:如果沒有競爭,只會對base進行操作,是從這裡看出來的。

第二行程式碼: ||判斷,前者判斷cs是否【為NULL】,後者判斷(cs的長度-1)是否【大於0】。這兩個判斷,應該都是判斷Cell[]是否初始化的。如果沒有初始化,會進入第五行程式碼。

第三行程式碼: 如果cell進行了初始化,通過【getProbe() & m】演算法得到一個數字,判斷cs[數字]是否【為NULL】,並且把cs[數字]賦值給了c,如果【為NULL】,會進入第五行程式碼。 我們需要簡單的看下getProbe() 中做了什麼:

    static final int getProbe() {
        return (int) THREAD_PROBE.get(Thread.currentThread());
    }

    private static final VarHandle THREAD_PROBE;
複製程式碼

我們只要知道這個演算法是根據THREAD_PROBE算出來的即可。

第四行程式碼: 對c進行了CAS操作,看是否成功,並且把返回值賦值給uncontended,如果當前沒有競爭,就會成功,如果當前有競爭,就會失敗,在外面有一個!(),所以CAS失敗了,會進入第五行程式碼。需要注意的是,這裡已經是對Cell元素進行操作了。

第五行程式碼: 這方法內部非常複雜,我們先看下方法的整體:

image.png

有三個if: 1.判斷cells是否被初始化了,如果被初始化了,進入這個if。

這裡面又包含了6個if,真可怕,但是在這裡,我們不用全部關注,因為我們的目標是解決上面提出來的問題。

我們還是先整體看下:

image.png

第一個判斷:根據演算法,拿出cs[]中的一個元素,並且賦值給c,然後判斷是否【為NULL】,如果【為NULL】,進入這個if。

                    if (cellsBusy == 0) {       // 如果cellsBusy==0,代表現在“不忙”,進入這個if
                        Cell r = new Cell(x);   //建立一個Cell
                        if (cellsBusy == 0 && casCellsBusy()) {//再次判斷cellsBusy ==0,加鎖,這樣只有一個執行緒可以進入這個if
                            //把建立出來Cell元素加入到Cell[]
                            try {       
                                Cell[] rs; int m, j;
                                if ((rs = cells) != null &&
                                    (m = rs.length) > 0 &&
                                    rs[j = (m - 1) & h] == null) {
                                    rs[j] = r;
                                    break done;
                                }
                            } finally {
                                cellsBusy = 0;//代表現在“不忙”
                            }
                            continue;           // Slot is now non-empty
                        }
                    }
                    collide = false;
複製程式碼

這就對第一個問題進行了補充,初始化Cell[]的時候,其中一個元素是NULL,這裡對這個為NULL的元素進行了初始化,也就是隻有用到了這個元素,才去初始化。

第六個判斷:判斷cellsBusy是否為0,並且加鎖,如果成功,進入這個if,對Cell[]進行擴容。

                     
                    try {
                        if (cells == cs)        // Expand table unless stale
                            cells = Arrays.copyOf(cs, n << 1);
                    } finally {
                        cellsBusy = 0;
                    }
                    collide = false;
                    continue;  
複製程式碼

這就回答了第五個問題的一半:擴容Cell[]的時候,利用CAS加了鎖,所以保證執行緒的安全性。

那麼第四個問題呢?首先你要注意,最外面是一個for (;;)死迴圈,只有break了,才終止迴圈。

一開始collide為false,在第三個if中,對cell進行CAS操作,如果成功,就break了,所以我們需要假設它是失敗的,進入第四個if,第四個if中會判斷Cell[]的長度是否大於CPU核心數, 如果小於核心數,會進入第五個判斷,這個時候collide為false,會進入這個if,把collide改為true,代表有衝突,然後跑到advanceProbe方法,生成一個新的THREAD_PROBE,再次迴圈。如果在第三個if中,CAS還是失敗,再次判斷Cell[]的長度是否大於核心數,如果小於核心數,會進入第五個判斷,這個時候collide為true,所以不會進入第五個if中去了,這樣就進入了第六個判斷,進行擴容。是不是很複雜。

簡單的來說,Cell[]擴容的時機是:當Cell[]的長度小於CPU核心數,並且已經兩次Cell CAS失敗了。

2.前面兩個判斷很好理解,主要看第三個判斷:

    final boolean casCellsBusy() {
        return CELLSBUSY.compareAndSet(this, 0, 1);
    }
複製程式碼

cas設定CELLSBUSY為1,可以理解為加了個鎖,因為馬上就要進行初始化了。

                try {                           // Initialize table
                    if (cells == cs) {
                        Cell[] rs = new Cell[2];
                        rs[h & 1] = new Cell(x);
                        cells = rs;
                        break done;
                    }
                } finally {
                    cellsBusy = 0;
                }
複製程式碼

初始化Cell[],可以看到長度為2,根據演算法,對其中的一個元素進行初始化,也就是此時Cell[]的長度為2,但是裡面有一個元素還是NULL,現在只是對其中一個元素進行了初始化,最終把cellsBusy修改成了0,代表現在“不忙了”。

這就回答了 第一個問題:當出現競爭,且Cell[]還沒有被初始化的時候,會初始化Cell[]。 第四個問題:初始化的規則是建立長度為2的陣列,但是隻會初始化其中一個元素,另外一個元素為NULL。 第五個問題的一半:在對Cell[]進行初始化的時候,是利用CAS加了鎖,所以可以保證執行緒安全。

3.如果上面的都失敗了,對base進行CAS操作。

如果大家跟著我一起在看原始碼,會發現一個可能以前從來也沒有見過的註解:

image.png

這個註解是幹什麼的?Contended是用來解決偽共享的

好了,又引出來一個知識盲區,偽共享為何物。

偽共享

我們知道CPU和記憶體之間的關係:當CPU需要一個資料,會先去快取中找,如果快取中沒有,會去記憶體找,找到了,就把資料複製到快取中,下次直接去快取中取出即可。

但是這種說法,並不完善,在快取中的資料,是以快取行的形式儲存的,什麼意思呢?就是一個快取行可能不止一個資料。假如一個快取行的大小是64位元組,CPU去記憶體中取資料,會把臨近的64位元組的資料都取出來,然後複製到快取。

這對於單執行緒,是一種優化。試想一下,如果CPU需要A資料,把臨近的BCDE資料都從記憶體中取出來,並且放入快取了,CPU如果再需要BCDE資料,就可以直接去快取中取了。

但在多執行緒下就有劣勢了,因為同一快取行的資料,同時只能被一個執行緒讀取,這就叫偽共享了。

有沒有辦法可以解決這問題呢?聰明的開發者想到了一個辦法:如果快取行的大小是64位元組,我可以加上一些冗餘欄位來填充到64位元組。

比如我只需要一個long型別的欄位,現在我再加上6個long型別的欄位作為填充,一個long佔8位元組,現在是7個long型別的欄位,也就是56位元組,另外物件頭也佔8個位元組,正好64位元組,正好夠一個快取行。

但是這種辦法不夠優雅,所以在Java8中推出了@jdk.internal.vm.annotation.Contended註解,來解決偽共享的問題。但是如果開發者想用這個註解, 需要新增 JVM 引數,具體引數我在這裡就不說了,因為我沒有親測過。

這一章的篇幅相當長,幾乎涵蓋了CAS中大部分常見的問題。

併發框架,是非常難學的,因為在開發中,很少會真正用到併發方面的知識,但是併發對於提高程式的效能,吞吐量是非常有效的手段,所以併發是值得花時間去學習,去研究的。

相關文章