高并發編程學習(2)——線程通信詳解

為獲得良好的閱讀體驗,請訪問原文: 傳送門
前序文章

一、經典的生產者消費者案例


上一篇文章我們提到一個應用可以創建多個線程去執行不同的任務,如果這些任務之間有著某種關系,那么線程之間必須能夠通信來協調完成工作。

生產者消費者問題(英語:Producer-consumer problem)就是典型的多線程同步案例,它也被稱為有限緩沖問題(英語:Bounded-buffer problem)。該問題描述了共享固定大小緩沖區的兩個線程——即所謂的“生產者”和“消費者”——在實際運行時會發生的問題。生產者的主要作用是生成一定量的數據放到緩沖區中,然后重復此過程。與此同時,消費者也在緩沖區消耗這些數據。該問題的關鍵就是要保證生產者不會在緩沖區滿時加入數據,消費者也不會在緩沖區中空時消耗數據。(摘自維基百科:生產者消費者問題)

  • 注意: 生產者-消費者模式中的內存緩存區的主要功能是數據在多線程間的共享,此外,通過該緩沖區,可以緩解生產者和消費者的性能差;

準備基礎代碼:無通信的生產者消費者

我們來自己編寫一個例子:一個生產者,一個消費者,并且讓他們讓他們使用同一個共享資源,并且我們期望的是生產者生產一條放到共享資源中,消費者就會對應地消費一條。

我們先來模擬一個簡單的共享資源對象:

public class ShareResource {

    private String name;
    private String gender;

    /**
     * 模擬生產者向共享資源對象中存儲數據
     *
     * @param name
     * @param gender
     */
    public void push(String name, String gender) {
        this.name = name;
        this.gender = gender;
    }

    /**
     * 模擬消費者從共享資源中取出數據
     */
    public void popup() {
        System.out.println(this.name + "-" + this.gender);
    }
}

然后來編寫我們的生產者,使用循環來交替地向共享資源中添加不同的數據:

public class Producer implements Runnable {

    private ShareResource shareResource;

    public Producer(ShareResource shareResource) {
        this.shareResource = shareResource;
    }

    @Override
    public void run() {
        for (int i = 0; i < 50; i++) {
            if (i % 2 == 0) {
                shareResource.push("鳳姐", "女");
            } else {
                shareResource.push("張三", "男");
            }
        }
    }
}

接著讓我們的消費者不停地消費生產者產生的數據:

public class Consumer implements Runnable {

    private ShareResource shareResource;

    public Consumer(ShareResource shareResource) {
        this.shareResource = shareResource;
    }

    @Override
    public void run() {
        for (int i = 0; i < 50; i++) {
            shareResource.popup();
        }
    }
}

然后我們寫一段測試代碼,來看看效果:

public static void main(String[] args) {
    // 創建生產者和消費者的共享資源對象
    ShareResource shareResource = new ShareResource();
    // 啟動生產者線程
    new Thread(new Producer(shareResource)).start();
    // 啟動消費者線程
    new Thread(new Consumer(shareResource)).start();
}

我們運行發現出現了詭異的現象,所有的生產者都似乎消費到了同一條數據:

張三-男
張三-男
....以下全是張三-男....

為什么會出現這樣的情況呢?照理說,我的生產者在交替地向共享資源中生產數據,消費者也應該交替消費才對呀..我們大膽猜測一下,會不會是因為消費者是直接循環了 30 次打印共享資源中的數據,而此時生產者還沒有來得及更新共享資源中的數據,消費者就已經連續打印了 30 次了,所以我們讓消費者消費的時候以及生產者生產的時候都小睡個 10 ms 來緩解消費太快 or 生產太快帶來的影響,也讓現象更明顯一些:

/**
 * 模擬生產者向共享資源對象中存儲數據
 *
 * @param name
 * @param gender
 */
public void push(String name, String gender) {
    try {
        Thread.sleep(10);
    } catch (InterruptedException ignored) {
    }
    this.name = name;
    this.gender = gender;
}

/**
 * 模擬消費者從共享資源中取出數據
 */
public void popup() {
    try {
        Thread.sleep(10);
    } catch (InterruptedException ignored) {
    }
    System.out.println(this.name + "-" + this.gender);
}

再次運行代碼,發現了出現了以下的幾種情況:

  • 重復消費:消費者連續地出現兩次相同的消費情況(張三-男/ 張三-男);
  • 性別紊亂:消費者消費到了臟數據(張三-女/ 鳳姐-男);

分析出現問題的原因

  • 重復消費:我們先來看看重復消費的問題,當生產者生產出一條數據的時候,消費者正確地消費了一條,但是當消費者再來共享資源中消費的時候,生產者還沒有準備好新的一條數據,所以消費者就又消費到老數據了,這其中的根本原因是生產者和消費者的速率不一致
  • 性別紊亂:再來分析第二種情況。不同于上面的情況,消費者在消費第二條數據時,生產者也正在生產新的數據,但是尷尬的是,生產者只生產了一半兒(也就是該執行完 this.name = name),也就是還沒有來得及給 gender 賦值就被消費者給取走消費了.. 造成這樣情況的根本原因是沒有保證生產者生產數據的原子性

解決出現的問題

加鎖解決性別紊亂

我們先來解決性別紊亂,也就是原子性的問題吧,上一篇文章里我們也提到了,對于這樣的原子性操作,解決方法也很簡單:加鎖。稍微改造一下就好了:

/**
 * 模擬生產者向共享資源對象中存儲數據
 *
 * @param name
 * @param gender
 */
synchronized public void push(String name, String gender) {
    this.name = name;
    try {
        Thread.sleep(10);
    } catch (InterruptedException ignored) {
    }
    this.gender = gender;
}

/**
 * 模擬消費者從共享資源中取出數據
 */
synchronized public void popup() {
    try {
        Thread.sleep(10);
    } catch (InterruptedException ignored) {
    }
    System.out.println(this.name + "-" + this.gender);
}
  • 我們在方法前面都加上了 synchronized 關鍵字,來保證每一次讀取和修改都只能是一個線程,這是因為當 synchronized 修飾在普通同步方法上時,它會自動鎖住當前實例對象,也就是說這樣改造之后讀/ 寫操作同時只能進行其一;
  • 我把 push 方法小睡的代碼改在了賦值 namegender 的中間,以強化驗證原子性操作是否成功,因為如果不是原子性的話,就很可能出現賦值 name 還沒賦值給 gender 就被取走的情況,小睡一會兒是為了加強這種情況的出現概率(可以試著把 synchronized 去掉看看效果);

運行代碼后發現,并沒有出現性別紊亂的現象了,但是重復消費仍然存在。

等待喚醒機制解決重復消費

我們期望的是 張三-男鳳姐-女 交替出現,而不是有重復消費的情況,所以我們的生產者和消費者之間需要一點溝通,最容易想到的解決方法是,我們新增加一個標志位,然后在消費者中使用 while 循環判斷,不滿足條件則不消費,條件滿足則退出 while 循環,從而完成消費者的工作。

while (value != desire) {
    Thread.sleep(10);
}
doSomething();

這樣做的目的就是為了防止「過快的無效嘗試」,這種方法看似能夠實現所需的功能,但是卻存在如下的問題:

  • 1)難以確保及時性。在睡眠時,基本不消耗處理器的資源,但是如果睡得過久,就不能及時發現條件已經變化,也就是及時性難以保證;
  • 2)難以降低開銷。如果降低睡眠的時間,比如休眠 1 毫秒,這樣消費者能夠更加迅速地發現條件變化,但是卻可能消耗更多的處理資源,造成了無端的浪費。

以上兩個問題嗎,看似矛盾難以調和,但是 Java 通過內置的等待/ 通知機制能夠很好地解決這個矛盾并實現所需的功能。

等待/ 通知機制,是指一個線程 A 調用了對象 O 的 wait() 方法進入等待狀態,而另一個線程 B 調用了對象 O 的 notifyAll() 方法,線程 A 收到通知后從對象 O 的 wait() 方法返回,進而執行后續操作。上述兩個線程都是通過對象 O 來完成交互的,而對象上的 waitnotify/ notifyAll 的關系就如同開關信號一樣,用來完成等待方和通知方之間的交互工作。

這里有一個比較奇怪的點是,為什么看起來像是線程之間操作的 waitnotify/ notifyAll 方法會是 Object 類中的方法,而不是 Thread 類中的方法呢?

  • 簡單來說:因為 synchronized 中的這把鎖可以是任意對象,因為要滿足任意對象都能夠調用,所以屬于 Object 類;
  • 專業點說:因為這些方法在操作同步線程時,都必須要標識它們操作線程的鎖,只有同一個鎖上的被等待線程,可以被同一個鎖上的 notify 喚醒,不可以對不同鎖中的線程進行喚醒。也就是說,等待和喚醒必須是同一個鎖。而鎖可以是任意對象,所以可以被任意對象調用的方法是定義在 Object 類中。

好,簡單介紹完等待/ 通知機制,我們開始改造吧:

public class ShareResource {

    private String name;
    private String gender;
    // 新增加一個標志位,表示共享資源是否為空,默認為 true
    private boolean isEmpty = true;

    /**
     * 模擬生產者向共享資源對象中存儲數據
     *
     * @param name
     * @param gender
     */
    synchronized public void push(String name, String gender) {
        try {
            while (!isEmpty) {
                // 當前共享資源不為空的時,則等待消費者來消費
                // 使用同步鎖對象來調用,表示當前線程釋放同步鎖,進入等待池,只能被其他線程所喚醒
                this.wait();
            }
            // 開始生產
            this.name = name;
            Thread.sleep(10);
            this.gender = gender;
            // 生產結束
            isEmpty = false;
            // 生產結束喚醒一個消費者來消費
            this.notify();
        } catch (Exception ignored) {
        }
    }

    /**
     * 模擬消費者從共享資源中取出數據
     */
    synchronized public void popup() {
        try {
            while (isEmpty) {
                // 為空則等著生產者進行生產
                // 使用同步鎖對象來調用,表示當前線程釋放同步鎖,進入等待池,只能被其他線程所喚醒
                this.wait();
            }
            // 消費開始
            Thread.sleep(10);
            System.out.println(this.name + "-" + this.gender);
            // 消費結束
            isEmpty = true;
            // 消費結束喚醒一個生產者去生產
            this.notify();
        } catch (InterruptedException ignored) {
        }
    }
}
  • 我們期望生產者生產一條,然后就去通知消費者消費一條,那么在生產和消費之前,都需要考慮當前是否需要生產 or 消費,所以我們新增了一個標志位來判斷,如果不滿足則等待;
  • 被通知后仍然要檢查條件,條件滿足,則執行我們相應的生產 or 消費的邏輯,然后改變條件(這里是 isEmpty),并且通知所有等待在對象上的線程;
  • 注意:上面的代碼中通知使用的 notify() 方法,這是因為例子中寫死了只有一個消費者和生產者,在實際情況中建議還是使用 notifyAll() 方法,這樣多個消費和生產者邏輯也能夠保證(可以自己試一下);

小結

通過初始版本一步步地分析問題和解決問題,我們就差不多寫出了我們經典生產者消費者的經典代碼,但通常消費和生產的邏輯是寫在各自的消費者和生產者代碼里的,這里我為了方便閱讀,把他們都抽離到了共享資源上,我們可以簡單地再來回顧一下這個消費生產和等待通知的整個過程:

以上就是關于生產者生產一條數據,消費者消費一次的過程了,涉及的一些具體細節我們下面來說。

二、線程間的通信方式


等待喚醒機制的替代:Lock 和 Condition

我們從上面的中看到了 wait()notify() 方法,只能被同步監聽鎖對象來調用,否則就會報出 IllegalMonitorZStateException 的異常,那么現在問題來了,我們在上一篇提到的 Lock 機制根本就沒有同步鎖了,也就是沒有自動獲取鎖和自動釋放鎖的概念,因為沒有同步鎖,也就意味著 Lock 機制不能調用 waitnotify 方法,我們怎么辦呢?

好在 Java 5 中提供了 Lock 機制的同時也提供了用于 Lock 機制控制通信的 Condition 接口,如果大家理解了上面說到的 Object.wait()Object.notify() 方法的話,那么就能很容易地理解 Condition 對象了。

它和 wait()notify() 方法的作用是大致相同的,只不過后者是配合 synchronized 關鍵字使用的,而 Condition 是與重入鎖相關聯的。通過 Lock 接口(重入鎖就實現了這一接口)的 newCondition() 方法可以生成一個與當前重入鎖綁定的 Condition 實例。利用 Condition 對象,我們就可以讓線程在合適的時間等待,或者在某一個特定的時刻得到通知,繼續執行。

我們拿上面的生產者消費者來舉例,修改成 Lock 和 Condition 代碼如下:

public class ShareResource {

    private String name;
    private String gender;
    // 新增加一個標志位,表示共享資源是否為空,默認為 true
    private boolean isEmpty = true;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    /**
     * 模擬生產者向共享資源對象中存儲數據
     *
     * @param name
     * @param gender
     */
    public void push(String name, String gender) {
        lock.lock();
        try {
            while (!isEmpty) {
                // 當前共享資源不為空的時,則等待消費者來消費
                condition.await();
            }
            // 開始生產
            this.name = name;
            Thread.sleep(10);
            this.gender = gender;
            // 生產結束
            isEmpty = false;
            // 生產結束喚醒消費者來消費
            condition.signalAll();
        } catch (Exception ignored) {
        } finally {
            lock.unlock();
        }
    }

    /**
     * 模擬消費者從共享資源中取出數據
     */
    public void popup() {
        lock.lock();
        try {
            while (isEmpty) {
                // 為空則等著生產者進行生產
                condition.await();
            }
            // 消費開始
            Thread.sleep(10);
            System.out.println(this.name + "-" + this.gender);
            // 消費結束
            isEmpty = true;
            // 消費結束喚醒生產者去生產
            condition.signalAll();
        } catch (InterruptedException ignored) {
        } finally {
            lock.unlock();
        }
    }
}

在 JDK 內部,重入鎖和 Condition 對象被廣泛地使用,以 ArrayBlockingQueue 為例,它的 put() 方法實現如下:

/** Main lock guarding all access */
final ReentrantLock lock;
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;

// 構造函數,初始化鎖以及對應的 Condition 對象
public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            // 等待隊列有足夠的空間
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

private void enqueue(E x) {
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    // 通知需要 take() 的線程,隊列已有數據
    notEmpty.signal();
}

同理,對應的 take() 方法實現如下:

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            // 如果隊列為空,則消費者隊列要等待一個非空的信號
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

允許多個線程同時訪問:信號量(Semaphore)

以下內容摘錄 or 改編自 《實戰 Java 高并發程序設計》 3.1.3 節的內容

信號量為多線程協作提供了更為強大的控制方法。廣義上說,信號量是對鎖的擴展,無論是內部鎖 synchronized 還是重入鎖 ReentrantLock,一次都只允許一個線程訪問一個資源,而信號量卻可以指定多個線程,同時訪問某一個資源。信號量主要提供了以下構造函數:

public Semaphore(int permits)
public Semaphore(int permits, boolean fair)        // 第二個參數可以指定是否公平

在構造信號量對象時,必須要指定信號量的準入數,即同時能申請多少個許可。當每個線程每次只申請一個許可時,這就相當于指定了同時有多少個線程可以訪問某一個資源。信號量的主要邏輯如下:

public void acquire()
public void acquireUninterruptibly()
public boolean tryAcquire()
public boolean tryAcquire(long timeout, TimeUnit unit)
public void release()
  • acquire() 方法嘗試獲得一個準入的許可。若無法獲得,則線程會等待,直到有線程釋放一個許可或者當前線程被中斷。
  • acquireUninterruptibly() 方法和 acquire() 方法類似,但是不響應中斷。
  • tryAcquire() 嘗試獲得一個許可,如果成功則返回 true,失敗則返回 false,它不會進行等待,立即返回。
  • release() 用于在線程訪問資源結束后,釋放一個許可,以使其他等待許可的線程可以進行資源訪問。

在 JDK 的官方 Javadoc 中,就有一個有關信號量使用的簡單實例,有興趣的讀者可以自行去翻閱一下,這里給出一個更傻瓜化的例子:

public class SemapDemo implements Runnable {

    final Semaphore semaphore = new Semaphore(5);

    @Override
    public void run() {
        try {
            semaphore.acquire();
            // 模擬耗時操作
            Thread.sleep(2000);
            System.out.println(Thread.currentThread().getId() + ":done!");
            semaphore.release();
        } catch (InterruptedException ignore) {
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        final SemapDemo demo = new SemapDemo();
        for (int i = 0; i < 20; i++) {
            executorService.submit(demo);
        }
    }
}

執行程序,就會發現系統以 5 個線程為單位,依次輸出帶有線程 ID 的提示文本。

在實現上,Semaphore 借助了線程同步框架 AQS(AbstractQueuedSynchornizer),同樣借助了 AQS 來實現的是 Java 中可重入鎖的實現。AQS 的強大之處在于,你僅僅需要繼承它,然后使用它提供的 api 就可以實現任意復雜的線程同步方案,AQS 為我們做了大部分的同步工作,所以這里不細說,之后再來詳細探究一下...

我等著你:Thread.join()

如果一個線程 A 執行了 thread.join() 方法,其含義是:當前線程 A 等待 thread 線程終止之后才從 thread.join() 返回。線程 Thread 除了提供 join() 方法之外,還提供了 join(long millis)join(long millis, int nanos) 兩個具備超時特性的方法。這兩個超時方法表示,如果線程 Thread 在給定的超時時間里沒有終止,那么將會從該超時方法中返回。

在下面的代碼中,我們創建了 10 個線程,編號 0 ~ 9,每個線程調用前一個線程的 join() 方法,也就是線程 0 結束了,線程 1 才能從 join() 方法中返回,而線程 0 需要等待 main 線程結束。

public class Join {

    public static void main(String[] args) throws InterruptedException {
        Thread previous = Thread.currentThread();
        for (int i = 0; i < 10; i++) {
            // 每個線程擁有前一個線程的引用,需要等待前一個線程終止,才能從等待中返回
            Thread thread = new Thread(new Domino(previous), String.valueOf(i));
            thread.start();
            previous = thread;
        }
        TimeUnit.SECONDS.sleep(5);
        System.out.println(Thread.currentThread().getName() + " terminate. ");
    }

    static class Domino implements Runnable {

        private Thread thread;

        public Domino(Thread thread) {
            this.thread = thread;
        }

        @Override
        public void run() {
            try {
                thread.join();
            } catch (InterruptedException ignore) {
            }
            System.out.println(Thread.currentThread().getName() + " terminate. ");
        }
    }
}

運行程序,可以看到下列輸出:

main terminate. 
0 terminate. 
1 terminate. 
2 terminate. 
3 terminate. 
4 terminate. 
5 terminate. 
6 terminate. 
7 terminate. 
8 terminate. 
9 terminate. 

說明每個線程終止的前提都是前驅線程的終止,每個線程等待前驅線程結束后,才從 join() 方法中返回,這里涉及了等待/ 通知機制,在 JDK 的源碼中,我們可以看到 join() 的方法如下:

public final synchronized void join(long millis)
    throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;

    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (millis == 0) {
        // 條件不滿足則繼續等待
        while (isAlive()) {
            wait(0);
        }
        // 條件符合則返回
    } else {
        while (isAlive()) {
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}

當線程終止時,會調用線程自身的 notifyAll() 方法,會通知所有等待在該線程對象上的線程。可以看到 join() 方法的邏輯結構跟我們上面寫的生產者消費者類似,即加鎖、循環和處理邏輯三個步驟。

三、線程之間的數據交互


保證可見性:volatile 關鍵字

我們先從一個有趣的例子入手:

private static boolean isOver = false;

public static void main(String[] args) throws InterruptedException {
    Thread thread = new Thread(() -> {
        while (!isOver) {
        }
        System.out.println("線程已感知到 isOver 置為 true,線程正常返回!");
    });
    thread.start();
    Thread.sleep(500);
    isOver = true;
    System.out.println("isOver 已置為 true");
}

我們開啟了一個主線程和一個子線程,我們期望子線程能夠感知到 isOver 變量的變化以結束掉死循環正常返回,但是運行程序卻發現并不是像我們期望的那樣發生,子線程一直處在了死循環的狀態!

為什么會這樣呢?

Java 內存模型

關于這一點,我們有幾點需要說明,首先需要搞懂 Java 的內存模型:

Java 虛擬機規范中試圖定義一種 Java 內存模型(Java Memory Model, JMM)來屏蔽掉各層硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果。

Java 內存模型規定了所有的變量都存儲在主內存(Main Memory)中。每條線程還有自己的工作內存(Working Memory),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作(讀取、賦值等)都必須在主內存中進行,而不能直接讀寫主內存中的變量。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間的變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的關系如上圖。

那么不同的線程之間是如何通信的呢?

共享內存的并發模型里,線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信,典型的共享內存通信方式就是通過共享對象進行通信。

例如上圖線程 A 與 線程 B 之間如果要通信的話,那么就必須經歷下面兩個步驟:

  1. 首先,線程 A 把本地內存 A 更新過的共享變量刷新到主內存中去
  2. 然后,線程 B 到主內存中去讀取線程 A 之前更新過的共享變量

在消息傳遞的并發模型里,線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信,在 Java 中典型的消息傳遞方式就是 wait()notify()

說回剛才出現的問題,就很容易理解了:每個線程都有獨占的內存區域,如操作棧、本地變量表等。線程本地保存了引用變量在堆內存中的副本,線程對變量的所有操作都在本地內存區域中進行,執行結束后再同步到堆內存中去。也就是說,我們在主線程中修改的 isOver 的值并沒有被子線程讀取到(沒有被刷入主內存),也就造成了子線程對于 isOver 變量不可見。

解決方法也很簡單,只需要在 isOver 變量前加入 volatile 關鍵字就可以了,這是因為加入了 volatile 修飾的變量允許直接與主內存交互,進行讀寫操作,保證可見性。

指令重排/ happen-before 原則

再從另一個有趣的例子中入手,這是在高并發場景下會存在的問題:

class LazyInitDemo {
    private static TransationService service = null;
    
    public static TransationService getTransationService(){
        if (service == null) {
            synchronized (this) {
                if (service == null) {
                    service = new TransationService();
                }
            }
        }
    }
}

這是一個典型的雙重檢查鎖定思想,這段代碼也是一個典型的雙重檢查鎖定(Double-checked Locking)問題。在高并發的情況下,該對象引用在沒有同步的情況下進行讀寫操作,導致用戶可能會獲取未構造完成的對象

這是因為指令優化的結果。計算機不會根據代碼順序按部就班地執行相關指令,我們來舉一個借書的例子:假如你要去還書并且想要借一個《高并發編程學習》系列叢書,而你的室友恰好也要還書,并且還想讓你幫忙借一本《Java 從入門到放棄》。

這件事乍一看有兩件事:你的事和你室友的事。先辦完你的事,再開始處理你室友的事情是屬于單線程的死板行為,此時你會潛意識地進行「優化」,例如你可以把你要還的書和你室友需要還的書一起還了,再一起把想要借的書借出來,這其實就相當于合并數據進行存取的操作過程了。

我們知道一條指令的執行是可以分成很多步驟的,簡單地說,可以分為:

  • 取值 IF
  • 譯碼和去寄存器操作數 ID
  • 執行或者有效地址計算 EX
  • 存儲器訪問 MEM
  • 寫回 WB

由于每一個步驟可能使用不同的硬件完成,因此,聰明的工程師就發明了流水線技術來執行指令,如下圖所示:

可以看到,當第 2 條指令執行時,第 1 條執行其實并沒有執行完,確切地說第一條指令還沒有開始執行,只是剛剛完成了取值操作而已。這樣的好處非常明顯,假如這里每一個步驟都需要花費 1 毫秒,那么指令 2 等待指令 1 完全執行后再執行,則需要等待 5 毫秒,而使用流水線指令,指令 2 只需要等待 1 毫秒就可以執行了。如此大的性能提升,當然讓人眼紅。

回到最初的問題,我們分析一下:對于 Java 編譯器來說,初始化 TransactionService 實例和將對象地址寫到 service 字段并非原子操作,且這兩個階段的執行順序是未定義的。加入某個線程執行 new TransactionService() 時,構造方法還未被調用,編譯器僅僅為該對象分配了內存空間并設為默認值,此時若另一個線程調用 getTransactionService() 方法,由于 service != null,但是此時 service 對象還沒有被賦予真正的有效值,從而無法取到正確的 service 單例對象。

對于此問題,一種較為簡單的解決方案就是用 volatile 關鍵字修飾目標屬性(適用于 JDK5 及以上版本),這樣 service 就限制了編譯器對它的相關讀寫操作,對它的讀寫操作進行指令重排,確定對象實例化之后才返回引用。

另外指令重排也有自己的規則,并非所有的指令都可以隨意改變執行位置,下面列舉一下基本的原則:

  • 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生于書寫在后面的操作;
  • 鎖定規則:一個 unLock 操作先行發生于后面對同一個鎖的 lock 操作;
  • volatile 變量規則:對一個變量的寫操作先行發生于后面對這個變量的讀操作;
  • 傳遞規則:如果操作 A 先行發生于操作 B,而操作 B 又先行發生于操作 C,則可以得出操作 A 先行發生于操作 C;
  • 線程啟動規則:Thread 對象的 start() 方法先行發生于此線程的每個一個動作;
  • 線程中斷規則:對線程 interrupt() 方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生;
  • 線程終結規則:線程中所有的操作都先行發生于線程的終止檢測,我們可以通過 Thread.join() 方法結束、Thread.isAlive() 的返回值手段檢測到線程已經終止執行;
  • 對象終結規則:一個對象的初始化完成先行發生于他的 finalize() 方法的開始;

volatile 不保證原子性

volatile 解決的是多線程共享變量的可見性問題,類似于 synchronized,但不具備 synchronized 的互斥性。所以對 volatile 變量的操作并非都具有原子性,例如我們用下面的例子來說明:

public class VolatileNotAtomic {

    private static volatile long count = 0L;
    private static final int NUMBER = 10000;

    public static void main(String[] args) {
        Thread subtractThread = new SubstractThread();
        subtractThread.start();

        for (int i = 0; i < NUMBER; i++) {
            count++;
        }

        // 等待減法線程結束
        while (subtractThread.isAlive()) {
        }

        System.out.println("count 最后的值為: " + count);
    }

    private static class SubstractThread extends Thread {

        @Override
        public void run() {
            for (int i = 0; i < NUMBER; i++) {
                count--;
            }
        }
    }
}

多次執行后,發現結果基本都不為 0。只有在 count++count-- 兩處都進行加鎖時,才能正確的返回 0,了解 Java 的童鞋都應該知道這 count++count-- 都不是一個原子操作,這里就不作說明了。

volatile 的使用優化

在了解一點吧,注明的并發編程大師 Doug lea 在 JDK 7 的并發包里新增一個隊列集合類 LinkedTransferQueue,它在使用 volatile 變量時,用一種追加字節的方式來優化對列出隊和入隊的性能,具體的可以看一下下列的鏈接,這里就不具體說明了。

保證原子性:synchronized

Java 中任何一個對象都有一個唯一與之關聯的鎖,這樣的鎖作為該對象的一系列標志位存儲在對象信息的頭部。Java 對象頭里的 Mark Word 里默認的存放的對象的 Hashcode/ 分代年齡和鎖標記位。32 為JVM Mark Word 默認存儲結構如下:

Java SE 1.6中,鎖一共有 4 種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖后不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。

偏向鎖

HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。

  • 偏向鎖的獲取:當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程 ID,以后該線程在進入和退出同步塊時不需要進行 CAS 操作來加鎖和解鎖,只需簡單地測試一下對象頭的 Mark Word 里是否存儲著指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下 Mark Word 中偏向鎖的標識是否設置成 1(表示當前是偏向鎖),如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

  • 偏向鎖的撤銷:偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。

下圖線程 1 展示了偏向鎖獲取的過程,線程 2 展示了偏向鎖撤銷的過程。

輕量級鎖和自旋鎖

如果偏向鎖失敗,虛擬機并不會立即掛起線程。它還會使用一種稱為輕量級鎖的優化手段。

線程在執行同步塊之前,JVM 會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,并將對象頭中的 Mark Word 復制到鎖記錄中,官方稱為 Displaced Mark Word。然后線程嘗試使用 CAS 將對象頭中的 Mark Word 替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋(自己執行幾個空循環再進行嘗試)來獲取鎖。

輕量級解鎖時,會使用原子的 CAS 操作將 Displaced Mark Word 替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。

幾種鎖的比較

下圖就簡單概括了一下幾種鎖的比較:

每人一支筆:ThreadLocal

除了控制資源的訪問外,我們還可以通過增加資源來保證所有對象的線程安全。比如,讓 100 個人填寫個人信息表,如果只有一支筆,那么大家就得挨個寫,對于管理人員來說,必須保證大家不會去哄搶這僅存的一支筆,否則,誰也填不完。從另外一個角度出發,我們可以干脆就準備 100 支筆,那么所有人都可以各自為營,很快就能完成表格的填寫工作。

如果說鎖是使用第一種思路,那么 ThreadLocal 就是使用第二種思路了。

當使用 ThreadLocal 維護變量時,其為每個使用該變量的線程提供獨立的變量副本,所以每一個線程都可以獨立的改變自己的副本,而不會影響其他線程對應的副本。

ThreadLocal 內部實現機制

  1. 每個線程內部都會維護一個類似 HashMap 的對象,稱為 ThreadLocalMap,里邊會包含若干了 Entry(K-V 鍵值對),相應的線程被稱為這些 Entry 的屬主線程;

  2. Entry 的 Key 是一個 ThreadLocal 實例,Value 是一個線程特有對象。Entry 的作用即是:為其屬主線程建立起一個 ThreadLocal 實例與一個線程特有對象之間的對應關系;

  3. Entry 對 Key 的引用是弱引用;Entry 對 Value 的引用是強引用。

ThreadLodal 的副作用

為了讓線程安全地共享某個變量,JDK 開出了 ThreadLocal 這副藥方,但「是藥三分毒」,ThreadLocal 也有一定的副作用。主要問題是「產生臟數據」和「內存泄漏」。這兩個問題通常是在線程池中使用 ThreadLocal 引發的,因為線程池有 「線程復用」「內存常駐」 兩個特點。

臟數據

線程復用會產生臟數據。由于線程池會重用 Thread 對象,那么與 Thread 綁定的類的靜態屬性 ThreadLocal 變量也會被重用。如果在實現的線程 run() 方法中不顯式地 remove() 清理與線程相關的 ThreadLocal 信息,那么倘若下一個線程不調用 set() 設置初始值,就可能 get() 到重用的線程信息,包括 ThreadLocal 所關聯的線程對象的 value 值。

為了方便理解,用一段簡要代碼來模擬,如下所示:

public class DirtyDataInThreadLocal {

    public static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        // 使用固定大小為 1 的線程池,說明上一個的線程屬性會被下一個線程屬性復用
        ExecutorService pool = Executors.newFixedThreadPool(1);
        for (int i = 0; i < 2; i++) {
            Mythread mythread = new Mythread();
            pool.execute(mythread);
        }
    }

    private static class Mythread extends Thread {

        private static boolean flag = true;

        @Override
        public void run() {
            if (flag) {
                // 第 1 個線程 set 后,并沒有進行 remove
                // 而第二個線程由于某種原因沒有進行 set 操作
                threadLocal.set(this.getName() + ", session info.");
                flag = false;
            }
            System.out.println(this.getName() + " 線程是 " + threadLocal.get());
        }
    }
}

執行結果:

Thread-0 線程是 Thread-0, session info.
Thread-1 線程是 Thread-0, session info.

內存泄漏

在源碼注釋中提示使用 static 關鍵字來修飾 ThreadLocal。在此場景下,寄希望于 ThreadLocal 對象失去引用后,觸發弱引用機制來回收 Entry 的 Value 就變得不現實了。在上面的例子中,如果不進行 remove() 操作,那么這個線程執行完成后,通過 ThreadLocal 對象持有的 String 對象是不會被釋放的。

以上兩個問題的解決辦法很簡單,就是在每次使用完 ThreadLocal 時,必須要及時調用 remove() 方法清理。

參考資料


  1. 《Java 零基礎入門教程》 - http://study.163.com/course/courseMain.htm?courseId=1003108028
  2. 《Java 并發編程的藝術》
  3. 《碼出高效 Java 開發手冊》 - 楊冠寶(孤盡) 高海慧(鳴莎)著
  4. Java面試知識點解析(二)——高并發編程篇 - https://www.wmyskxz.com/2018/05/10/java-mian-shi-zhi-shi-dian-jie-xi-er-gao-bing-fa-bian-cheng-pian/
  5. 讓你徹底理解Synchronized - http://www.lxweimin.com/p/d53bf830fa09
  6. 《Offer來了 - Java面試核心知識點精講》 - 王磊 編著
  7. 《實戰Java高并發程序設計》 - 葛一鳴 郭超 編著

按照慣例黏一個尾巴:

歡迎轉載,轉載請注明出處!
獨立域名博客:wmyskxz.com
簡書 ID:@我沒有三顆心臟
github:wmyskxz
歡迎關注公眾微信號:wmyskxz
分享自己的學習 & 學習資料 & 生活
想要交流的朋友也可以加 qq 群:3382693

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容