Java 執行緒和 volatile 解釋

JayChen發表於2019-03-03

最近開始學習 Java,所以記錄一些 Java 的知識點。這篇是一些關於 Java 執行緒的文章。

Java 支援多執行緒,Java 中建立執行緒的方式有兩種:

  • 繼承 Thread 類,重寫 run 方法。
  • 實現 Runnable 介面,實現 run 方法。

// 繼承 Thread 類
class ThreadDemo extends Thread {

    @Override
    public void run() {
        System.out.println("一個簡單的例子就需要這麼多程式碼...");
    }
}



// 實現 Runnable 介面
class RunnableDemo implements Runnable {
    public void run() {
        System.out.println("一個簡單的例子就需要這麼多程式碼...");
    }
}


public class Main {
    public static void main(String[] strings) {

        // 繼承 Thread 類
        Thread thread = new ThreadDemo();
        thread.start();

        // 實現 Runnable 介面
        Thread again = new Thread(new RunnableDemo());
        again.start();
    }
}複製程式碼

通過呼叫 start 函式可以啟動有一個新的執行緒,並且執行 run 方法中的邏輯。這裡可以引出一個很容易被問道的面試題:

Thread 類中 start 函式和 run 函式有什麼區別。

最明顯的區別在於,直接呼叫 run 方法並不會啟動一個新的執行緒來執行,而是呼叫 run 方法的執行緒直接執行。只有呼叫 start 方法才會啟動一個新的執行緒來執行。

引入執行緒的目的是為了使得多個執行緒可以在多個 CPU 上同時執行,提高多核 CPU 的利用率。

多執行緒程式設計很常見的情況下是希望多個執行緒共享資源,通過多個執行緒同時消費資源來提高效率,但是新手一不小心很容易陷入一個編碼誤區。

class ThreadDemo extends Thread {
    private int i = 3;
    @Override
    public void run() {
        i--;
        System.out.println(i);
    }
}

public class Main {
    public static void main(String[] strings) {
        Thread thread = new ThreadDemo();
        thread.start();
        Thread thread1 = new ThreadDemo();
        thread1.start();
        Thread thread2 = new ThreadDemo();
        thread2.start();
    }
}複製程式碼

上面的例項程式碼,希望通過 3 個執行緒同時執行 i--; 操作,使得最終 i 的值為 0,但是結果不如人意,3 次輸出的結果都為 2。這是因為在 main 方法中建立的三個執行緒都獨自持有一個 i ,我們的目的一應該是 3 個執行緒共享一個 i。

public class Main {
    public static void main(String[] strings) {
        DemoRunnable demoRunnable = new DemoRunnable();
        new Thread(demoRunnable).start();
        new Thread(demoRunnable).start();
        new Thread(demoRunnable).start();
    }
}

class DemoRunnable implements Runnable {

    private int i= 3;

    @Override
    public void run() {
        i--;
        System.out.println(i);
    }
}複製程式碼

使用上面的程式碼才有可能使得 i 最終的結果為0。所以,在進行多執行緒程式設計的時候一定要留心多個執行緒是否共享資源。

Volatile

如果你運氣好,執行上面的程式碼發現,有時候三次 i--; 的結果也不一定是 0。這種怪異的現象需要從 JVM 的記憶體模型說起。

當 Java 啟動了多個執行緒分佈在不同的 CPU 上執行邏輯,JVM 為了提高效能,會把在記憶體中的資料拷貝一份到 CPU 的暫存器中,使得 CPU 讀取資料更快。很明顯,這種提高效能的做法會使得 Thread1 中對 i 的修改不能馬上反應到 Thread2 中。

下面例子可以明顯的體現出這個問題。

public class Main {
    static int NEXT_IN_LINE = 0;

    public static void main(String[] args) throws Exception {
        new ThreadA().start();
        new ThreadB().start();
    }

    static class ThreadA extends Thread {
        @Override
        public void run() {
            while (true) {
                if (NEXT_IN_LINE >= 4) {
                    break;
                }
            }
            System.out.println("in CustomerInLine...." + NEXT_IN_LINE);
        }
    }

    static class ThreadB extends Thread {
        @Override
        public void run() {
            while (NEXT_IN_LINE < 10) {
                System.out.println("in Queue ..." + NEXT_IN_LINE++);
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}複製程式碼

上面的程式碼中,ThreadA 執行緒進入死迴圈一直到 NEXT_IN_LINE 的值為 4 才退出,ThreadB 執行緒不停的對 NEXT_IN_LINE++ 操作。然而執行程式碼發現 ThreadA 沒有輸出 in CustomerInLine...." + NEXT_IN_LINE,而是一直處於死迴圈狀態。這個例子可以很明顯的驗證:"JVM 會把執行緒共享的變數拷貝到暫存器中以提高效率" 的說法。

那麼,怎麼才能避免這種優化給程式設計帶來的困擾?這裡要引出一個記憶體可見性 的概念。

記憶體可見性指的是一個執行緒對共享變數值的修改,能夠及時地被其他執行緒看到。

為了實現記憶體可見性,Java 引入了 volatile 的關鍵字。這個關鍵字的作用在於,當使用 volatile 修改了某個變數,那麼 JVM 就不會對該變數進行優化,即意味著,不會把該變數拷貝到 CPU 暫存器中,每個變數對該變數的修改,都會實時的反應在記憶體中。

針對上面的例子,把 static int NEXT_IN_LINE = 0; 改成 static volatile int NEXT_IN_LINE = 0; 那麼執行的結果就如我們所預料的,在 ThraedB 自增到 NEXT_IN_LINE = 4 的時候 ThreadA 會跳出死迴圈。

指令重排

volatile 還有一個很好玩的特性:防止指令重排。

首先要明白什麼是指令重排?

假設在 ThreadA 中有

context = loadContext();
inited = true;複製程式碼

ThreadB 中

while(!inited) {
    sleep(100);
}
doSomething(context);複製程式碼

那麼,ThreadB 中會在 inited 置位 true 之後執行 doSomething 方法,inited 變數的作用就是用來標誌 context 是否被初始化了。但是實際上在執行 ThreadA 程式碼的時候 JVM 會根據上下行程式碼是否互相關聯而決定是否對程式碼執行順序進行重排。這就意味著 CPU 認為 ThreadA 中的兩行程式碼沒有順序關聯,於是先執行 inited=true 再執行 context=loadContext()。如此一來,就會導致 ThreadB 中引用了一個值為 null 的 context 物件。

使用 volatile 可以避免指令重排。在定義 inited 變數的時候使用 volatile修飾:volatile boolean inited = false;。 使用 volatile 修飾 inited 之後,JVM 就不會對 inited 相關的變數進行指令重排。

原子性

回到最初的例子。在 volatile 部分我們說過最終的結果不是輸出 i = 0 的原因是 JVM 拷貝記憶體變數到 CPU 暫存器中導致執行緒之間沒辦法實時更新 i 變數的值導致的,只要使用 volatile 修飾 i 就可以實現記憶體可見性,可以使得結果輸出 i = 0。但是實際上,即使使用了 volatile 之後,還是有可能的導致 i != 0 的結果。

輸出 i != 0 的結果是由於 i++; 操作並非為原子性操作。

什麼是原子性操作?簡單來說就是一個操作不能再分解。i++ 操作實際上分為 3 步:

  • 讀取 i 變數的值。
  • 增加 i 變數的值。
  • 把新的值寫到記憶體中。

那麼,假設 ThraedA 在執行第 2 步之後,ThreadB 讀取了 i 變數的值,這時候還未被 ThreadA 更新,讀取的仍是舊的值,之後 ThreadA 寫入了新的值。這種情況下就會導致 i 在某個時刻被修改多次。

解決這種問題需要用到 synchronized。但是這裡不打算對 synchronized 進行討論。這裡指出一個很容易被誤解的概念:volatile 能夠實現記憶體可見性和避免指令重排,但是不能實現原子性。

相關文章