Java 多執行緒:並發編程的三大特性

Java 多執行緒:並發編程的三大特性

作者:Grey

原文地址:

部落格園:Java 多執行緒:並發編程的三大特性

CSDN:Java 多執行緒:並發編程的三大特性

可見性

所謂執行緒數據的可見性,指的就是記憶體中的某個數據,假如第一個 CPU 的一個核讀取到了,和其他的核讀取到這個數據之間的可見性。

每個執行緒會保存一份拷貝到執行緒本地快取,使用volatile,可以保持執行緒之間數據可見性。

如下示例

package git.snippets.juc;


import java.util.concurrent.TimeUnit;

/**
 * 並發編程三大特性之:可見性
 *
 * @author <a href="mailto:[email protected]">Grey</a>
 * @since 1.8
 */
public class ThreadVisible {

    static volatile boolean flag = true;

    public static void main(String[] args) throws Exception {
        Thread t = new Thread(() -> {
            System.out.println(Thread.currentThread() + " t start");
            while (flag) {
                // 如果這裡調用了System.out.println()
                // 會無論flag有沒有加volatile,數據都會同步
                // 因為System.out.println()背後調用的synchronized
                // System.out.println();
            }
            System.out.println(Thread.currentThread() + " t end");
        });
        t.start();
        TimeUnit.SECONDS.sleep(3);
        flag = false;


        // volatile修飾引用變數
        new Thread(a::m, "t2").start();
        TimeUnit.SECONDS.sleep(2);
        a.flag = false;

        // 阻塞主執行緒,防止主執行緒直接執行完畢,看不到效果
        System.in.read();
    }

    private static volatile A a = new A();

    static class A {
        volatile boolean flag = true;

        void m() {
            System.out.println("m start");
            while (flag) {
            }
            System.out.println("m end");
        }
    }
}

程式碼說明:

  • volatile修飾了flag變數,主執行緒改了flag的值,子執行緒可以感知到;

  • 如在上述程式碼的死循環中增加了System.out.println(), 則會強制同步flag的值,無論flag本身有沒有加volatile

  • 如果volatile修飾一個引用對象,如果對象的屬性(成員變數)發生了改變,volatile不能保證其他執行緒可以觀察到該變化。

關於三級快取

3_cache

如上圖,記憶體讀出的數據會在 L3,L2,L1 上都存一份。

在從記憶體中讀取數據的時候,根據的是程式局部性的原理,按塊來讀取,這樣可以提高效率,充分發揮匯流排 CPU 針腳等一次性讀取更多數據的能力。

所以這裡引入了一個快取行的概念,目前一個快取行多用64個位元組來表示。

如何來驗證 CPU 讀取快取行這件事,我們可以通過一個示例來說明:

package git.snippets.juc;

/**
 * 快取行對齊
 *
 * @author <a href="mailto:[email protected]">Grey</a>
 * @since 1.8
 */
public class CacheLinePadding {
    public static T[] arr = new T[2];

    static {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[0].x = i;
            }
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 1000_0000L; i++) {
                arr[1].x = i;
            }
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((System.nanoTime() - start) / 100_0000);
        System.out.println("arr[0]=" + arr[0].x + " arr[1]=" + arr[1].x);
    }

    private static class Padding {
        public volatile long p1, p2, p3, p4, p5, p6, p7;
    }

    // T這個類extends Padding與否,會影響整個流程的執行時間,如果繼承了,會減少執行時間,
    // 因為繼承Padding後,arr[0]和arr[1]一定不在同一個快取行裡面,所以不需要同步數據,速度就更快一些了。
    private static class T /*extends Padding*/ {
        public volatile long x = 0L;
    }
}

程式碼說明

以上程式碼,T這個類繼承Padding類與否,會影響整個流程的執行時間,如果繼承了,會減少執行時間,因為繼承Padding後,arr[0]arr[1]一定不在同一個快取行裡面,所以不需要同步數據,速度就更快一些了。

Java SE 1.8 增加了一個註解 @Contended,標註後就不會在同一快取行, 但是這個註解僅適用於 Java SE 1.8,而且還需要增加 JVM 參數-XX:-RestrictContended

CPU 為每個快取行標記四種狀態(使用兩位)

M: 被修改(Modified)

該快取行只被快取在該 CPU 的快取中,並且是被修改過的(dirty),即與主存中的數據不一致,該快取行中的記憶體需要在未來的某個時間點(允許其它 CPU 讀取請主存中相應記憶體之前)寫回(write back)主存。

當被寫回主存之後,該快取行的狀態會變成獨享(exclusive)狀態。

E: 獨享的(Exclusive)

該快取行只被快取在該 CPU 的快取中,它是未被修改過的(clean),與主存中數據一致。該狀態可以在任何時刻當有其它 CPU 讀取該記憶體時變成共享狀態(shared)。

同樣地,當 CPU 修改該快取行中內容時,該狀態可以變成Modified狀態。

S: 共享的(Shared)

該狀態意味著該快取行可能被多個 CPU 快取,並且各個快取中的數據與主存數據一致(clean),當有一個 CPU 修改該快取行中,其它 CPU 中該快取行可以被作廢(變成無效狀態(Invalid))。

I: 無效的(Invalid)

該快取是無效的(可能有其它 CPU 修改了該快取行)。

參考:【並發編程】MESI–CPU快取一致性協議

有序性

電腦在執行程式時,為了提高性能,編譯器和處理器常常會對指令做重排。

為什麼指令重排序可以提高性能?

簡單地說,每一個指令都會包含多個步驟,每個步驟可能使用不同的硬體。因此,流水線技術產生了,它的原理是:指令1還沒有執行完,就可以開始執行指令2,而不用等到指令1執行結束之後再執行指令2,這樣就大大提高了效率。

但是,流水線技術最害怕中斷,恢復中斷的代價是比較大的,所以我們要想盡辦法不讓流水線中斷。指令重排就是減少中斷的一種技術。

我們分析一下下面這個程式碼的執行情況:

a = b + c;
d = e - f ;

先載入b、c(注意,既有可能先載入b,也有可能先載入c),但是在執行b + c的時候,需要等待 b、c 裝載結束才能繼續執行,也就是增加了停頓,那麼後面的指令也會依次有停頓,這降低了電腦的執行效率。

為了減少這個停頓,我們可以先載入 e 和 f ,然後再去載入b + c,這樣做對程式(串列)結果是沒有影響的,但卻減少了停頓:既然b + c需要停頓,那還不如去做一些有意義的事情。

綜上所述,指令重排對於提高 CPU 處理性能十分必要。雖然由此帶來了亂序的問題,但是這點犧牲是值得的。

指令重排一般分為以下三種:

第一種:編譯器優化重排

編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

第二種:指令並行重排

現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。

第三種:記憶體系統重排

由於處理器使用快取和讀寫快取沖區,這使得載入( load )和存儲( store )操作看上去可能是在亂序執行,因為三級快取的存在,導致記憶體與快取的數據同步存在時間差。

指令重排可以保證串列語義一致,但是沒有義務保證多執行緒間的語義也一致。所以在多執行緒下,指令重排序可能會導致一些問題。

亂序存在的條件是:不影響單執行緒的最終一致性( as – if – serial )

驗證亂序執行的程式示例

package git.snippets.juc;

/**
 * 並發編程的三大特性之:有序性
 *
 * @author <a href="mailto:[email protected]">Grey</a>
 * @since 1.8
 */
public class DisOrder {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    // 以下程式可能會執行比較長的時間
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(() -> {
                // 由於執行緒one先啟動,下面這句話讓它等一等執行緒two. 讀著可根據自己電腦的實際性能適當調整等待時間.
                shortWait(100000);
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                b = 1;
                y = a;
            });
            one.start();
            other.start();
            one.join();
            other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                // 出現這個分支,說明指令出現了重排
                // 否則不可能 x和y同時都為0
                System.err.println(result);
                break;
            } else {
                // System.out.println(result);
            }
        }
    }

    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}

程式碼說明:

如上示例,如果指令不出現亂序,那麼 x 和 y 不可能同時為 0,通過執行這個程式可以驗證出來,在我本機測試的結果是:

image

執行到第 385634 次 出現了 x 和 y 同時為 0 的情況,說明出現了亂序。

原子性

程式的原子性是指整個程式中的所有操作,要麼全部完成,要麼全部失敗,不可能滯留在中間某個環節;在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所打斷。

一個示例:

class T {   
    m =9;
}

對象 T 在創建過程中,背後其實是包含了多條執行語句的,由於有 CPU 亂序執行的情況,所以極有可能會在初始化過程中生成以一個半初始化對象 t,這個 t 的 m 等於 0(還沒有來得及做賦值操作)

所以,不要在某個類的構造方法中啟動一個執行緒,這樣會導致 this 對象逸出:因為這個類的對象可能還來不及執行初始化操作,就啟動了一個執行緒,導致了異常情況。

volatile一方面可以保證執行緒數據之間的可見性,另外一方面,也可以防止類似這樣的指令重排,所以,單例模式中,DCL方式的單例一定要加volatile修飾:

public class Singleton6 {
    private volatile static Singleton6 INSTANCE;
 
    private Singleton6() {
    }
 
    public static Singleton6 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton6.class) {
                if (INSTANCE == null) {
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    INSTANCE = new Singleton6();
                }
            }
        }
        return INSTANCE;
    }
}

具體可以參考設計模式學習筆記 中單例模式的說明。

說明

本文涉及到的所有程式碼和圖例

圖例

程式碼

更多內容見:Java 多執行緒

參考資料

實戰Java高並發程式設計(第2版)

深入淺出Java多執行緒

多執行緒與高並發-馬士兵

Java並發編程實戰

【並發編程】MESI–CPU快取一致性協議

【並發編程】細說並發編程的三大特性

設計模式學習筆記