Java之併發三問題

Adrian_Dai發表於2018-05-03

 轉載自:https://javadoop.com/post/java-memory-model#toc10

1. 重排序

請先執行下面的程式碼

public class Test {

    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;
            CountDownLatch latch = new CountDownLatch(1);

            Thread one = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                b = 1;
                y = a;
            });
            one.start();other.start();
            latch.countDown();
            one.join();other.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

幾秒後,我們就可以得到 x == 0 && y == 0 這個結果,仔細看看程式碼就會知道,如果不發生重排序的話,這個結果是不可能出現的。

重排序由以下幾種機制引起:

  1. 編譯器優化:對於沒有資料依賴關係的操作,編譯器在編譯的過程中會進行一定程度的重排。

    大家仔細看看執行緒 1 中的程式碼,編譯器是可以將 a = 1 和 x = b 換一下順序的,因為它們之間沒有資料依賴關係,同理,執行緒 2 也一樣,那就不難得到 x == y == 0 這種結果了。

  2. 指令重排序:CPU 優化行為,也是會對不存在資料依賴關係的指令進行一定程度的重排。

    這個和編譯器優化差不多,就算編譯器不發生重排,CPU 也可以對指令進行重排,這個就不用多說了。

  3. 記憶體系統重排序:記憶體系統沒有重排序,但是由於有快取的存在,使得程式整體上會表現出亂序的行為。

    假設不發生編譯器重排和指令重排,執行緒 1 修改了 a 的值,但是修改以後,a 的值可能還沒有寫回到主存中,那麼執行緒 2 得到 a == 0 就是很自然的事了。同理,執行緒 2 對於 b 的賦值操作也可能沒有及時重新整理到主存中。

2. 記憶體可見性

前面在說重排序的時候,也說到了記憶體可見性的問題,這裡再囉嗦一下。

執行緒間的對於共享變數的可見性問題不是直接由多核引起的,而是由多快取引起的。如果每個核心共享同一個快取,那麼也就不存在記憶體可見性問題了。

現代多核 CPU 中每個核心擁有自己的一級快取或一級快取加上二級快取等,問題就發生在每個核心的獨佔快取上。每個核心都會將自己需要的資料讀到獨佔快取中,資料修改後也是寫入到快取中,然後等待刷入到主存中。所以會導致有些核心讀取的值是一個過期的值。

Java 作為高階語言,遮蔽了這些底層細節,用 JMM 定義了一套讀寫記憶體資料的規範,雖然我們不再需要關心一級快取和二級快取的問題,但是,JMM 抽象了主記憶體和本地記憶體的概念。

所有的共享變數存在於主記憶體中,每個執行緒有自己的本地記憶體,執行緒讀寫共享資料也是通過本地記憶體交換的,所以可見性問題依然是存在的。這裡說的本地記憶體並不是真的是一塊給每個執行緒分配的記憶體,而是 JMM 的一個抽象,是對於暫存器、一級快取、二級快取等的抽象。

3. 原子性

在本文中,原子性不是重點,它將作為併發程式設計中需要考慮的一部分進行介紹。

說到原子性的時候,大家應該都能想到 long 和 double,它們的值需要佔用 64 位的記憶體空間,Java 程式語言規範中提到,對於 64 位的值的寫入,可以分為兩個 32 位的操作進行寫入。本來一個整體的賦值操作,被拆分為低 32 位賦值和高 32 位賦值兩個操作,中間如果發生了其他執行緒對於這個值的讀操作,必然就會讀到一個奇怪的值。

這個時候我們要使用 volatile 關鍵字進行控制了,JMM 規定了對於 volatile long 和 volatile double,JVM 需要保證寫入操作的原子性。

另外,對於引用的讀寫操作始終是原子的,不管是 32 位的機器還是 64 位的機器。

Java 程式語言規範同樣提到,鼓勵 JVM 的開發者能保證 64 位值操作的原子性,也鼓勵使用者儘量使用 volatile 或使用正確的同步方式。關鍵詞是”鼓勵“。


相關文章