Java 線程和 volatile 解釋

Java 線程和 volatile 解釋

最近開始學習 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 線程和 volatile 解釋

當 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 在某個時刻被修改多次。


分享到:


相關文章: