Java 線程和 volatile 解釋

文章首發於 http://jaychen.cc
做者 JayChenjava

最近開始學習 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 函數有什麼區別。ide

最明顯的區別在於,直接調用 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 變量的時候使用 olatile修飾: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 可以實現內存可見性和避免指令重排,可是不能實現原子性。

相關文章
相關標籤/搜索