併發程式設計基礎(二)—— ThreadLocal及CAS基本原理剖析

科小喵發表於2020-12-19

本篇博文接上一篇併發程式設計基礎(一)—— 關於java中的執行緒,沒看過的小夥伴請先移步上一篇。本博文相關程式碼已提交至GitHub:https://github.com/ZNKForSky/JavaBasis/tree/master/src/thread

等待通知機制

執行緒間的通訊依賴於等待通知機制,上篇博文只是對此機制一筆帶過,沒有細講,在這篇博文中作以補充。

機制原理

該機制是指一個執行緒A呼叫了物件O的wait()方法進入等待狀態,而另一個執行緒B呼叫了物件O的notify()或者notifyAll()方法,執行緒A收到通知後從物件O的wait()方法返回,也就是被喚醒了,進而執行後續操作。上述兩個執行緒通過物件O來完成互動,而物件上的wait()和notify/notifyAll()的關係就如同開關訊號一樣,用來完成等待方和通知方之間的互動工作。
PS:wait()、notify()、notifyAll()這些方法都是Object的,而非Thread。

API說明

  1. wait()
    呼叫該方法的執行緒進入 WAITING狀態,直到另一執行緒呼叫notify()/notifyAll()方法通知該執行緒,該執行緒才會被喚醒,之後該執行緒會重新獲取監視器,直到獲取到了監視器的所有權,它才會恢復執行。wait()會釋放物件的鎖,這一點在wait()方法的註釋上也有提到。
  2. wait(long)
    等待的最長時間(以毫秒為單位),也就是等待n毫秒後,如果沒有收到通知就自行喚醒。wait()其實也呼叫的是wait(long)方法,原始碼如下所示:
    /**
     * @throws IllegalMonitorStateException 如果當前執行緒不是物件監視器的所有者,也就是說
     *                                      沒有拿到鎖的情況下,呼叫wait()方法就會丟擲此異常
     * @throws InterruptedException         如果有任何執行緒在當前執行緒等待通知之前或期間中
     *                                      斷了當前執行緒。 引發此異常時,將清除當前執行緒的中斷狀態。
     */
    public final void wait() throws InterruptedException {
        wait(0);
    }

    在呼叫 wait()之前,執行緒必須要獲得該物件的物件級別鎖,即只能在同步方法或同步塊中呼叫 wait()方法,否則會報IllegalMonitorStateException異常:

  3. wait (long,int)
    乍一看,似乎是對於超時時間更細粒度的控制,可以達到納秒級別,細看其實只要指定了合法的大於0的納秒數,毫秒數就會加一。
    /**
     * @param timeout 等待的最長時間(以毫秒為單位)。
     * @param nanos   額外時間,以納秒為單位,範圍為0-999999。
     * @throws InterruptedException 當執行緒處於WAITING或者TIMED_WAITING時,
     *                              對執行緒物件呼叫interrupt()會使得該執行緒丟擲InterruptedException,需
     *                              要注意的是,丟擲異常後,中斷標誌位會被清空(執行緒的中斷標誌位會由true
     *                              重置為false,因為執行緒為了處理異常已經重新處於就緒狀態),而不是被設定。
     */
    public final void wait(long timeout, int nanos) throws InterruptedException {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }
    
        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                    "nanosecond timeout value out of range");
        }
    
        if (nanos > 0) {
            /*只要附加時間的納秒數合法且大於0,就把毫秒數加1*/
            timeout++;
        }
    
        wait(timeout);
    }

     

  4. notify()
    喚醒正在此物件的監視器上等待的任意一個執行緒,因為可能會有多個執行緒都在爭奪這個鎖,notify()只是喚醒這些執行緒中的其中一個,具有隨機性。拿到物件的鎖的執行緒會從wait()中返回,繼續執行其他業務邏輯,而沒拿到鎖的執行緒重新進入WAITING狀態。
  5. notifyAll()
    通知所有等待在該物件上的執行緒。

等待和通知的標準正規化

等待方遵循如下原則:


  • 獲取物件的鎖。
  • 如果條件不滿足,那麼呼叫物件的wait()方法,被通知後仍要檢查條件。
  • 條件滿足則執行對應的邏輯。
    synchronized (obj){
        while (&lt;condition does not hold&gt;)
        obj.wait();
        // Perform action appropriate to condition
    }

通知方遵循如下原則:


  • 獲得物件的鎖。
  • 改變條件。
  • 通知所有等待在物件上的執行緒。
    synchronized (obj){
        // Change conditions
        obj.notifyAll();
    }

在從 wait()返回前,notifyAll()會喚醒所有處於WAITING和TIMED_WAITING的執行緒,此時所有被喚醒的執行緒會去競爭拿鎖,如果其中一個執行緒獲得了該物件鎖,它就會繼續往下執行,在它退出synchronized程式碼塊,釋放鎖後,其他的已經被喚醒的執行緒將會繼續競爭獲取該鎖,一直進行下去,直到所有被喚醒的執行緒都執行完畢。

PS:儘可能用notifyall(),謹慎使用notify()。

ThreadLocal

ThreadLocal其實很簡單,就是字面意思:執行緒本地變數。也就是說每個執行緒在使用ThreadLocal時都會在當前執行緒中建立一個屬於執行緒本身的本地變數副本,從而實現了執行緒的資料隔離。一句話可能把有些小夥伴搞蒙了,別急,且聽我細細道來。

ThreadLocal實現執行緒間資料隔離

我們先從ThreadLocal的set(T)方法入手,

/**
 * 將此執行緒區域性變數的當前執行緒副本設定為指定值。 大多數子類將不需要重寫此方法,
 * 而僅依靠{@link #initialValue}方法來設定執行緒區域性變數的值。
 *
 * @param value 要儲存在此本地執行緒的當前執行緒副本中的值。
 */
public void set(T value) {
    /*獲取呼叫 set方法的執行緒,即當前執行緒*/
    Thread t = Thread.currentThread();
    /*通過當前執行緒拿到一個叫做 ThreadLocalMap的東西*/
    ThreadLocal.ThreadLocalMap map = getMap(t);
    /*如果map不為空,則呼叫 ThreadLocalMap的 set(ThreadLocal<?> key, Object value)方法*/
    if (map != null)
        map.set(this, value);
        /*否則建立 ThreadLocalMap例項物件,並把當前執行緒和使用者設定的 value傳進 ThreadLocal的構造中*/
    else
        createMap(t, value);
}

上面程式碼中提到 ThreadLocalMap,所以接著我們分析 ThreadLocalMap:

如上圖所示,ThreadLocalMap是一個類似於Map的鍵值對資料結構,以ThreadLocal例項物件為鍵,任意物件為值,而它本身是作為 Thread的成員變數附加線上程上,所以 getMap(t)是直接返回Thread的成員。也就是說我們可以根據執行緒的的一個ThreadLocal物件獲取繫結在此執行緒上的一個值。下面通過一個例子讓大家更進一步瞭解它的用法:

package thread.threadlocal;

/**
 * @author Luffy
 * @Classname UseThreadLocal
 * @Description 演示使用 ThreadLocal實現執行緒間資料隔離。
 * @Date 2020/12/17 13:52
 */
public class UseThreadLocal {
    /**
     * 建立一個存放 Integer型別的 ThreadLocal例項並呼叫初始化方法設定執行緒本地變數的初始值為0,
     * ThreadLocal實現了執行緒間資料隔離,所以是執行緒安全的。
     */
    static ThreadLocal threadLocalInter = new ThreadLocal<Integer>() {
        /**
         * 返回該執行緒區域性變數的初始值,該方法是一個protected的方法,顯然是為了讓子類覆蓋而設計的。
         * 這個方法是一個延遲呼叫方法,線上程第1次呼叫get()或set(Object)時才執行,並且僅執行1次。
         * ThreadLocal中的預設實現直接返回一個null。
         * @return 初始值
         */
        @Override
        protected Integer initialValue() {
            return 1;
        }
    };

    public static void main(String[] args) throws InterruptedException {
        /*開啟三個執行緒*/
        for (int i = 0; i < 3; i++) {
            new Thread(new WorkThread(i)).start();
            /*加休眠錯開執行緒開啟時間*/
            Thread.sleep(10);
        }
        /**
         * 將當前執行緒區域性變數的值刪除,目的是為了減少記憶體的佔用,該方法是JDK 5.0新增的方法。需要指出的是,
         * 當執行緒結束後,對應該執行緒的區域性變數將自動被垃圾回收,所以顯式呼叫該方法清除執行緒的區域性變數並不是
         * 必須的操作,但它可以加快記憶體回收的速度。
         */
        threadLocalInter.remove();
    }

    /**
     * 測試執行緒:執行緒的工作是將ThreadLocal變數的值變化,並寫回,看看執行緒之間是否會互相影響
     */
    static class WorkThread implements Runnable {
        int id;

        public WorkThread(int id) {
            this.id = id;
        }

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + ":start");
            /*該方法返回當前執行緒所對應的執行緒區域性變數。*/
            int oldValue = (int) threadLocalInter.get();
            /*設定當前執行緒的執行緒區域性變數的值。*/
            threadLocalInter.set(id + oldValue);
            System.out.println("執行緒" + Thread.currentThread().getName() + "中本地變數新值是: " + threadLocalInter.get());
        }
    }
}

CAS

CAS基本原理

CAS是Compare And Swap的縮寫,意為比較並且交換,是一個原子操作。下面是百度百科關於“原子操作”的定義:

如果這個操作所處的層(layer)的更高層不能發現其內部實現與結構,那麼這個操作是一個原子(atomic)操作。

原子操作可以是一個步驟,也可以是多個操作步驟,但是其順序不可以被打亂,也不可以被切割而只執行其中的一部分。

將整個操作視作一個整體是原子性的核心特徵。

通俗的講,就是一個操作無論它內部多麼複雜,但對於外界看來只是一個操作,它要麼執行,要麼不執行。比如我打了小明一巴掌,這個操作就可以視為原子操作,我要麼打了要麼沒打,不能存在我舉起手來然後又放下去的情況,要麼出手就打小明一巴掌,要麼就別出手。

CAS的基本思路就是,如果這個地址上的值和期望的值相等,則給其賦予新值,否則重新獲取地址上的值,再和期望的值作比較,如此往復,直至成功。如下圖所示:

CAS原理圖
藉助現代CPU都支援CAS指令,迴圈這個指令,直到成功為止。

CAS實現原子操作的三大問題

ABA問題

因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。

舉個例子:現有一個用單向連結串列實現的堆疊,棧頂為A,這時執行緒T1已經知道A.next為B,然後希望用CAS將棧頂替換為B: head.compareAndSet(A,B)。

在T1執行上面這條指令之前,執行緒T2介入,將A、B出棧,再pushD、C、A,此時堆疊結構如下圖,而物件B此時處於遊離狀態:

此時輪到執行緒T1執行CAS操作,檢測發現棧頂仍為A,所以CAS成功,棧頂變為B,但實際上B.next為null,所以此時的情況變為:

其中堆疊中只有B一個元素,C和D組成的連結串列不再存在於堆疊中,平白無故就把C、D丟掉了。

上述例子來源:CAS原理分析

ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A。舉個通俗點的例子,你從飲水機接了杯開水,水溫太高需要涼一涼,於是你去幹別的事了,然後你鄰桌的同事小趙剛出差回來渴的不行,就把你涼的水喝掉了,其他同事提醒小趙那個水是你涼的,於是小趙趕緊重新倒了一杯水,你回來看水還在,拿起來就喝,如果你不管水中間被人喝過,只關心水還在,這就是ABA問題。

如果你是一個有潔癖的小夥子,不但關心水在不在,還要在乎你離開的時候水被人動過沒有,於是你買了一個可以在開啟蓋子之後計數的水杯,只要在你離開後別人開啟過水杯,就說明水杯被別人動過。

JDK1.5之後,JUC.atomic包下提供了AtomicMarkableReferenceAtomicStampedReference解決ABA問題,它倆的唯一區別就是AtomicMarkableReference只關心引用變數是否被更改過,AtomicStampedReference除此之外還可以知道引用變數被更改過幾次。

/**
 * 使用給定的初始值建立一個新的{@code AtomicMarkableReference}。
 *
 * @param initialRef  初始引用
 * @param initialMark 初始標記,用來標記引用變數是否被更改過
 */
public AtomicMarkableReference(V initialRef, boolean initialMark) {
    pair = AtomicMarkableReference.Pair.of(initialRef, initialMark);
}
/**
 * 使用給定的初始值建立一個新的{@code AtomicStampedReference}
 *
 * @param initialRef 初始引用e
 * @param initialStamp 初始戳
 */
public AtomicStampedReference(V initialRef, int initialStamp) {
    pair = AtomicStampedReference.Pair.of(initialRef, initialStamp);
}

 通過它倆的構造方法,我們可以看到AtomicMarkableReference使用boolean變數mark標記引用變數是否被更改過,而AtomicStampedReference使用int變數stamp標記引用變數被更改過幾次。

解決ABA問題的示例:

package thread.cas;

import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * @author Luffy
 * @Classname UseAtomicStampedReference
 * @Description 使用JDK提供的 AtomicStampedReference解決CAS的ABA問題。
 * @Date 2020/12/19 23:44
 */
public class UseAtomicStampedReference {
    static AtomicStampedReference<String> asr
            = new AtomicStampedReference("Luffy", 0);

    public static void main(String[] args) throws InterruptedException {
        /*拿到當前的版本號(舊)*/
        final int oldStamp = asr.getStamp();
        /*拿到當前的引用變數(舊)*/
        final String oldReference = asr.getReference();
        System.out.println(Thread.currentThread().getName() + "當前變數值: " + oldReference + ",當前版本戳: " + oldStamp);

        Thread rightStampThread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + ":當前變數值:"
                        + oldReference + ",當前版本戳:" + oldStamp + ","
                        + asr.compareAndSet(oldReference,
                        oldReference + " Java", oldStamp,
                        oldStamp + 1));
            }
        });

        Thread errorStampThread = new Thread(new Runnable() {
            @Override
            public void run() {
                String reference = asr.getReference();
                System.out.println(Thread.currentThread().getName()
                        + ":當前變數值:"
                        + reference + ",當前版本戳:" + asr.getStamp() + ","
                        + asr.compareAndSet(reference,
                        reference + " C", oldStamp,
                        oldStamp + 1));
            }
        });
        rightStampThread.start();
        rightStampThread.join();
        errorStampThread.start();
        errorStampThread.join();

        System.out.println(asr.getReference() + "============" + asr.getStamp());
    }
}

迴圈時間長開銷大

自旋CAS指令如果長時間不成功,會給CPU帶來非常大的執行開銷。

只能保證一個共享變數的原子操作

當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖。

還有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如,有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java 1.5開始,JDK提供了AtomicReference類來保證引用物件之間的原子性,就可以把多個變數放在一個物件裡來進行CAS操作。示例程式碼如下:

package thread.cas;

import java.util.concurrent.atomic.AtomicReference;

/**
 * @author Luffy
 * @Classname UseAtomicReference
 * @Description 使用JDK提供的 AtomicReference解決“CAS只能保證一個共享變數的原子操作”的問題
 * @Date 2020/12/19 23:37
 */
public class UseAtomicReference {
    static AtomicReference<UserInfo> atomicUserRef;

    public static void main(String[] args) {
        /*要修改的實體的例項*/
        UserInfo user = new UserInfo("Luffy", 17);
        atomicUserRef = new AtomicReference(user);
        UserInfo updateUser = new UserInfo("Lady Gaga", 25);
        atomicUserRef.compareAndSet(user, updateUser);

        System.out.println(atomicUserRef.get());
        System.out.println(user);
    }

    /**
     * 定義一個使用者實體類
     */
    static class UserInfo {
        private volatile String name;
        private int age;

        public UserInfo(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() {
            return name;
        }

        public int getAge() {
            return age;
        }

        @Override
        public String toString() {
            return "UserInfo{" +
                    "name='" + name + '\'' +
                    ", age=" + age +
                    '}';
        }
    }
}

這一講先講這麼多,欲知後事如何,且聽下回分說~歡迎大家評論加點贊,大夥的支援對我很重要哦(≧◉◡◉≦)

相關文章