99%的人答不對的併發題(從JVM底層理解線程安全,硬核萬字長文)

衆所周知,java 是一門可以輕鬆實現多線程的語言,再加之目前的社會環境和業務需求,對多線程的使用和高併發的場景也越來越多,與之帶來的就是併發安全的問題。如何在多線程的環境下寫出符合業務需求的代碼,是程序員的基本功。而理解 JVM 中的線程特性,則是我們紮實根底的第一步。

學習忌浮躁。

首先大家要知道一點,JVM 幫我們屏蔽了不同的操作系統的不同特性,來實現一次編寫到處運行的特點。所以有個很關鍵的點,在不同的平臺和情況下,運行的結果可能會因此不同。
因此在此聲明,下列代碼的運行環境是在 Windows 的 64位 JDK 上,默認不帶參數。

可見性

多個 demo 示例(檢查你能否全部答對並理解)

先給大家看一段剪短的代碼案例,如果你能瞭解正確結果,那你對可見性的理解應該是比較到位的。

demo 示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建線程,在true的情況下不斷print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子線程開始i++操作");
                while(demo.isRunning){
                    System.out.println(demo.i++);
                }
                // 主線程將變量設置爲false時退出循環
                System.out.println("我已退出,當前值爲:" + demo.i);
            }
        }).start();
        // 3秒後設置變量爲false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改變變量爲false,主線程結束...");
    }
}

demo1
也許答案和你想的一樣,但是也許你只是碰巧答對。

現在,我將依次修改代碼中的幾個小位置,看看你能否分別答對每一種情況。

demo 示例(將子線程中的 print 方法移除)

// System.out.println(demo.i++);
demo.i++;

完整示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建線程,在true的情況下不斷print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子線程開始i++操作");
                while(demo.isRunning){
                    demo.i++;
                }
                // 主線程將變量設置爲false時退出循環
                System.out.println("我已退出,當前值爲:" + demo.i);
            }
        }).start();
        // 3秒後設置變量爲false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改變變量爲false,主線程結束...");
    }
}

demo2
我們可以發現,這段程序即使在 isRunning 變量設置爲 false 之後仍然不斷運行。我還可以告訴你,即使再過很久很久,這個程序它仍然會正常執行。它已經陷入一個死循環。
原理我在後文解釋,稍安勿躁,再看下一個小程序。

demo 示例(synchronized 包裹 while 循環)

synchronized (this) {
    while (demo.isRunning) {
        demo.i++;
    }
}

完整示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建線程,在true的情況下不斷print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子線程開始i++操作");
                synchronized (this) {
                    while (demo.isRunning) {
                        demo.i++;
                    }
                }
                // 主線程將變量設置爲false時退出循環
                System.out.println("我已退出,當前值爲:" + demo.i);
            }
        }).start();
        // 3秒後設置變量爲false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改變變量爲false,主線程結束...");
    }
}

結果是不會停
demo6

demo 示例(synchronized 添加在 while 循環內)

while (demo.isRunning) {
    synchronized (this) {
        demo.i++;
    }
}

完整示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建線程,在true的情況下不斷print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子線程開始i++操作");
                while (demo.isRunning) {
                    synchronized (this) {
                        demo.i++;
                    }
                }
                // 主線程將變量設置爲false時退出循環
                System.out.println("我已退出,當前值爲:" + demo.i);
            }
        }).start();
        // 3秒後設置變量爲false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改變變量爲false,主線程結束...");
    }
}

發現程序正常結束
demo7

demo 示例(isRunning 添加 volatile 關鍵字)

// boolean isRunning = true;
volatile boolean isRunning = true;

完整示例

public class Demo1Visibility {
    int i = 0;
    volatile boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建線程,在true的情況下不斷print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子線程開始i++操作");
                while(demo.isRunning){
                    demo.i++;
                }
                // 主線程將變量設置爲false時退出循環
                System.out.println("我已退出,當前值爲:" + demo.i);
            }
        }).start();
        // 3秒後設置變量爲false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改變變量爲false,主線程結束...");
    }
}

demo3
發現程序已經正常退出。

demo 示例(虛擬機添加參數 -client)

demo4
此時關鍵代碼

// 沒有volatile
boolean isRunning = true;
// 沒有print
while(demo.isRunning){
    demo.i++;
}

我可以告訴你,不添加參數,在默認情況下,等同於添加參數 -server。也就是 JVM 默認是作爲服務器條件去運行的。
然後給大家看結果。同樣也不會停。
demo5
大家可能發現,和之前的同樣代碼相比,並沒有什麼變化。
於是,在代碼和參數相同的情況下,我再給大家看一下運行在另一臺機器 JVM 上的結果。
demo3
然後你就會發現結果就不一樣了。程序正常退出這是在 32位的 JDK 上運行的。

所以你不要覺得自己好像什麼都懂的樣子,學習要保持一顆謙遜的心態,才能更多的汲取知識。(要是你全答出來了當我沒說,跪拜大佬)

對案例的分析

多線程的問題

多以這就可以體現出多線程所表現出的各種問題的特點:

  1. 所見非所得
  2. 無法用肉眼檢測程序的準確性
  3. 不同的運行平臺有不同的表現
  4. 錯誤很難重現

這就涉及到線程間的可見性問題,我們通過上面的代碼發現。我們的一個主線程修改了變量的值,但是另一個線程並沒有發現,所以導致了多線程中的線程安全問題。

Java 語言規範

要理解線程安全,我們就得首先去了解我們的 JVM。

我們的 java 源代碼被編譯,可以在任何 Java 虛擬機上運行,比如說最常用的 HotSpot,還有其他的很多虛擬機。但是我們的 java 程序,在這些虛擬機上運行,得到的結果是一樣的,這是怎麼做到的呢。
要做到這樣,就需要定義出一個規範。要實現一個虛擬機,都必須實現這個 Java 虛擬機規範。由於受到規範的約束,所以不會出現各種不同的結果。
同樣的,不僅僅只是 Java 語言可以在虛擬機上運行,還有很多其它語言,同樣的,這些語言都有自己的語言規範。

很多人可能分不清楚 Java 內存模型和 JVM 運行時數據區,把它們當做同一種東西。實際上不是的。我們要分清楚,就必須瞭解:
Java 內存模型是《Java 語言規範中的概念》,
而 JVM 運行時數據區是《Java 虛擬機規範》中的概念。
JVM
對於我們的 Java 多線程程序,是謹遵《Java 語言規範的》。它包括了 當多線程修改了共享內存中的值時,應該讀取到哪個值的規則。
所以我們的 Java 程序結果會是什麼樣子,在《Java 語言規範中》都已經定義好了。我們只要瞭解了它的規範,就會知曉程序運行的結果。

我們來看之前的程序,在沒有加其他任何關鍵字的時候,程序將無法退出。
按照道理,主內存修改了 isRunning 的值,會將其寫入主內存,然後子線程再將其讀取到。
但是無法退出,可以分析出其中一定是某個環節出了問題。要麼是寫入主內存沒有成功,要麼是讀取數據沒有成功。
demo

高速緩存

可能大家立刻會想到由於計算機高速緩存帶來的可見性問題。
首先可以明確的是,我們的 Java 程序是運行在內存中的。由於高速緩存的存在,我們的多線程環境很容易出現數據不一致的情況。因爲一個數據的寫要多一步從緩存同步到內存,而讀要多一步從內存同步到緩存。
可見性:一個數據的寫不能被另一個線程立即發現
huancun
但是下面我們繼續分析,我們今天看到的程序不能夠停止,是不是高速緩存導致的呢?
顯然不是。

因爲我們的 CPU 如果要有高速緩存,那就得遵循緩存一致性協議,不然這個 CPU,它就是不合格的。
緩存一致性協議有很多,最常見的 Intel 的 MESI 協議:
MESI協議,它規定每條緩存有個狀態位,同時定義了下面四個狀態:

  • 修改態(Modified)— 此 cache 行已被修改過(髒行),內容已不同於主存,爲此 cache 專-有;
  • 專有態(Exclusive)— 此 cache 行內容同於主存,但不出現於其它 cache 中;
  • 共享態(Shared)— 此 cache 行內容同於主存,但也出現於其它 cache 中;
  • 無效態(Invalid)— 此 cache 行內容無效(空行)。

多處理器時,單個 CPU 對緩存中數據進行了改動,需要通知給其他 CPU。
也就是意味着,CPU 處理要控制自己的讀寫操作,還要監聽其他 CPU 發出的通知,從而保證 最終一致性

所以如果是高速緩存,在緩存一致性的作用下,即使出現不一致,它的數據同步也會在很短的時間內完成,而不可能會出現無止無盡的循環而無法終止。
(所以高速緩存存在可見性問題,但這裏不是高速緩存的鍋)

指令重排序

那我們得繼續思考。相信大家都記得,在學習計算機的知識的時候都明白 CPU 指令重排的概念。
我在《Java 併發基礎總結》提到過

指令重排的場景:當 CPU 寫緩存時 發現緩存區塊正被其他 CPU 佔用,爲了提高 CPU 處理性能,可能將後面的 讀緩存命令優先執行
但也並非隨便重排,需要遵循 as-if-serial 語義
as-if-serial 語義的意思是指:不管怎麼重排序(編譯器和處理器爲了提高並行速度),(單線程)程序的執行結果不能被改變。編譯器,runtime 處理器都必須遵守 as-if-serial 語義。
也就是說:編譯器和處理器 不會對存在數據依賴關係的操作做重排序

如圖:
指令重排
雖然保證了單線程的一致,但並不保證多線程的運行情況一致,多線程的各種運行狀態是被允許的,這需要程序員自己的代碼去實現多線程的統一。
指令重排
但是不僅僅只有 CPU 會指令重排,Java 編譯器 也會 進行指令重排。
但是注意,我這裏指的不是 javac 編譯器,javac 編譯器在將 .java 文件編譯爲 class 字節碼的時候,是不會進行任何優化的。

JIT 編譯器

Java 基礎比較好的同學應該都知道有一個 JIT 編譯器(Just In Time Compiler)。
要解釋它,首先我們得明白,不管是編譯後執行,還是解釋執行的語言,它們最終都是成爲機器碼在計算機上運行。
解釋執行就是將語言一條一條翻譯成機器碼去執行。
而編譯執行是將代碼一次性編譯成機器碼給機器執行。

而 java 是一門很特殊的語言。(它既包含編譯,也包含解釋)
首先,我們的 java 代碼會被 javac 編譯器編譯成 class 字節碼(也就是我們常說的 ca fe ba be),然後在虛擬機上解釋執行。但是,由於有些代碼重複次數很多,每一次解釋執行的效率不高,因此會觸發 JIT 即時編譯器對重複代碼進行編譯,這樣之後就不用再費力解釋了。
JIT
不過要注意的是,我們的 JIT 在編譯時會做很多的 性能優化
比如這裏,在 JIT 編譯時,發現,由於每次這裏傳入的參數都是 true,爲了提高性能,在編譯時將代碼的讀取部分刪減,直接進入死循環,來提高性能。
所以 JIT 編譯存在 過於激進 的情況
JIT
我們在之前的案例中還有一些因爲 JDK 32位、64位,server、client 出現的不同情況。這是由於它們的優化處理不一樣。

可見性

線程間操作

線程間操作的定義:
一個程序執行的操作可以被其他線程 感知 或被其他線程 直接影響
Java 內存模型只描述 線程間操作,不描述線程內操作,線程內操作按照線程內語義執行。

線程間操作有:

  • read(一般讀,即非 volatile 讀)
  • write(一般寫,即非 volatile 寫)
  • volatile read
  • volatile write
  • lock(鎖 monitor)、unlock
  • 線程的第一個和最後一個操作
  • 外部操作

volatile

我們再繼續回顧之前的案例。發現有幾種情況可以讓程序正常結束,一個最簡單的方式就是在共享變量前加上 volatile 關鍵字。
這是爲什麼呢?
可能很多人都會回答是因爲:volatile 保證可見性(順便還補充對非原子操作仍不安全禁止指令重排序)。
不過爲什麼能做到可見性?、、可能就有很多人回答不上來。
volatile
要滿足規定,就要有實現:

  1. 禁止緩存
  2. 對 volatile 相關指令不做重排序。

處理器提供了兩個內存屏障指令(Memory Barrier)來實現。

  • 寫內存屏障(Store Memory Barrier):
    在指令後面插入 Store Barrier,能讓寫入緩存中的最新數據更新寫入主內存,讓其他線程可見。
    強制寫入主內存,這種顯示調用,CPU 就不會因爲性能考慮二區對指令重排。
  • 讀內存屏障(Load Memory Barrier):
    在指令前插入 Load Barrier,可以讓高速緩存中的數據失效,強制重新從主內存加載數據。
    強制讀取主內存內容,讓 CPU 緩存與主內存保持一致,避免了緩存導致的一致性問題。

synchronized

對於同步規則的定義,除了 volatile,還有:
對於監視器 m 的解鎖與所有後續操作對於 m 的加鎖同步
所以,在我們的 demo 中,在添加了 synchronized 時,( print 方法也有 sync 關鍵字)程序也可以正常退出。

final

除了 volatile 和 synchronized 之外,final 也可以保證可見性
所有對 final 字段的讀取,都將讀取到正確的版本。
比如這個 demo,對 final 字段的讀取不可能讀取到默認值。

public class DemoFinal {
    final int x;
    int y;
    
    static DemoFinal f;

    public DemoFinal(){
        x = 3;
        y = 4;
    }
    static void writer(){
        f = new DemoFinal();
    }
    static void reader(){
        if (f!=null){
            int i = f.x;        //一定讀到正確構造版本
            int j = f.y;        //可能會讀到 默認值0
            System.out.println("i=" + i + ", j=" +j);
        }
    }
}

稍微修改一下代碼,讓 x 和 y 產生關聯。
這時你去讀取 y 的值,一定也是正確的版本,不會讀取到賦值前的默認初始值0。

public class DemoFinal {
    final int x;
    int y;
    
    static DemoFinal f;

    public DemoFinal(){
        x = 3;
        y = x;
    }
    static void writer(){
        f = new DemoFinal();
    }
    static void reader(){
        if (f!=null){
            int i = f.x;        //一定讀到正確構造版本
            int j = f.y;        //可能會讀到 默認值0
            System.out.println("i=" + i + ", j=" +j);
        }
    }
}

原子性

案例

創建出 100 個線程,每個線程都對 i 進行 1000 次的累加,這樣我們就需要 100000 的結果。

public class CounterTest {
    // 多線程對volatile變量執行++
    static volatile int i;
    // main
    public static void main(String[] args) throws InterruptedException {
        // 創建100個線程,每個線程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    i++;
            }).start();
        }
        Thread.sleep(6000); // 等待各個線程執行完畢
        System.out.println(i);     // 打印 i 的值
    }
}

實際結果
1
2
在這裏插入圖片描述
很明顯這個值並不是我們期待的,這就涉及到我們的線程安全的原子問題。
雖然共享變量加上了 volatile 關鍵字,保證了可見性和禁止指令重排序,但是這由於是一個非原子操作,所以不能保證線程對共享變量的操作安全。

指令的執行過程

我們可以通過反編譯看到這段指令的具體執行過程。
i++

  1. 首先從內存中將 i 的值裝入操作數棧
  2. 將 1 裝入操作數棧
  3. 將操作數棧的 i 和 1 相加
  4. 將結果返回內存中的 i 字段
    i++
    這時我們看多線程的環境。
    在一個線程讀取後,還沒有往回寫,另一個線程就讀取了。此時,兩個線程讀取的是同樣的值,因此,寫回的也是同一個值。所以,兩個線程執行了兩次 i++ 操作,卻僅僅相當於執行了一次。
    i++
    要解決這種不安全的操作,一種方式是加鎖。synchronized 關鍵字保證內部的方法具有原子性。
    或者採用原子類 AutomaticInteger,用 CAS 機制反覆嘗試。

內存交互操作

Java 內存模型中定義了以下 8 種操作來完成內存交互操作,虛擬機實現時必須保證下面提及的每一種操作都是原子的、不可再分的。

  • read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的 load 動作使用。
  • load(載入):作用於工作內存的變量,它把 read 操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的 write 操作使用。
  • write(寫入):作用於主內存的變量,它把 store 操作從工作內存中得到的變量的值放入主內存的變量中。
  • lock(鎖定):作用於主內存的變量,它把一個變量標識爲一條線程獨佔的狀態。
  • unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。

我們可以發現,對於共享內存的讀寫,由於很多情況下都是非原子操作,其中有很多小的原子操作組成。所以在對共享變量讀寫時,容易產生線程安全問題。

synchronized

修改案例

public class CounterTest {
    // 多線程對volatile變量執行++
    static volatile int i;
    // 同步方法
    public static synchronized void add() {
        i++;
    }
    // main
    public static void main(String[] args) throws InterruptedException {
        // 創建100個線程,每個線程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    add();
            }).start();
        }
        Thread.sleep(6000); // 等待各個線程執行完畢
        System.out.println(i);     // 打印 i 的值
    }
}

觀察結果我們發現,可以在多線程下得到正確的值。可見 synchronized 也確實保證了原子性。
sync

synchronized 原理

既然 synchronized 通過互斥,能實現原子性,那麼它是如何實現的呢?
我們要探究這麼幾個問題:

  1. 加鎖的狀態如何記錄?
  2. 狀態會被記錄到 synchronized 指定的對象中嗎?
  3. 釋放鎖時如何喚醒阻塞的線程?

對象的存儲方式

我們知道,除了基本類型,所有的對象都是由一個引用來進行操作的。我們可以通過一個引用,找到堆中的對象。這個對象由各種基本類型的值,也會有其它引用指向其他的對象。其中對象有一個對象頭,有引用指向類信息。
object
這個大家肯定都不陌生,我們繼續深入。
對象頭有三個部分,一個指向類信息,一個是數組長度,還有一個就是這次的重點:Mark Word。
對象頭
在《HotspotOverview》官方文檔中關於 Mark Word 的詳細圖解:Mark Word
你別看它有很多行,實際上每行表示一種狀態。

  • 01:未鎖定、偏向鎖
  • 00:輕量級鎖
  • 10:重量級鎖
  • 11:GC 標記,此處不作探討

輕量級鎖狀態

首先,我們的對象開始是沒有線程訪問的,是無鎖狀態。
隨後,有兩個線程來訪問,存在了線程競爭,此時鎖成爲輕量級鎖。於是,兩個線程開始搶鎖:

  1. 首先,線程會把 Mark Word 中的 BitFields 那部分內存拷貝到線程內部。
  2. 線程用 CAS 操作,將 Mark Word 中的那部分內存替換爲,指向自己拷貝的那部分內存的地址
    CAS
    設置成功後,線程內部也會有一個地址指向 Mark Word,來記錄搶到了誰的鎖。
    CAS
    不過我們都知道,只有一個線程會操作成功,另一個線程一定會失敗。
  3. 沒有成功的線程會重複 10 次(次數會動態調整)的自旋,重複 CAS 搶鎖。
  4. 一直搶不到,或者期間有其他線程來搶鎖會導致鎖升級。
    3

重量級鎖

升級爲重量級鎖那就無法靠之前的拷貝內存加 CAS 操作實現了,必須涉及到線程的阻塞與喚醒。這是就需要對象監視器(Object Monitor)出場了。
點開 Hotspot 虛擬機源碼,我們可以看到:
objectmonitor
可見對象監視器並不是信口胡謅。
在膨脹爲重量級鎖後,對象的 Mark Word 就不能夠再指向之前線程拷貝的內存地址,因爲已經要開始涉及管理更多的線程。所以這時對象頭中的 Mark Word 爲指向 Object Monitor 的內存地址,標誌位改爲 10,獲取鎖的線程存儲到 Object Monitor 的 owner 中,其他來搶鎖的線程阻塞,進入 EntryList。
阻塞
此時,若是 t1 釋放鎖了,如果是原來輕量級鎖,只需要將原來的對象頭的內存重新拷貝回去即可,但現在,由於鎖已經膨脹,Mark Word 的值已經改變,爲了讓線程安全的釋放鎖,就需要 CAS 去替換之前的內存:
如果 CAS 替換成功,表明鎖未膨脹;
如果 CAS 失敗,則表示鎖已經膨脹,此時則需要去喚醒其他阻塞的線程。

但是 t1 釋放鎖之後,t2 一定能跟着獲取到鎖碼?
答案是不一定的,synchronized 並不保證鎖的公平。此時,若是來了一個 t3,他會和 t2 一起去爭搶這把鎖,若是 t2 搶奪失敗,會回到隊尾重新排隊,等待下一次搶鎖。
t3

偏向鎖

不過實際上,在輕量級鎖之前還有一個偏向鎖。
在只有一個線程的情況下,一個線程去加鎖之後,在代碼結束後並不會立即釋放鎖,也就是持有偏向鎖。以後這個線程重複來,就可以免去加鎖解鎖的開銷。這樣,就使得 synchronized 在單線程操作時也完全和沒有 synchronized 有同樣的性能(也不用加鎖解鎖)。

之前描述的都是沒有開啓偏向鎖的場景,不過目前的 JDK 版本都是默認開啓偏向鎖的。
標誌位 01 時本表示無鎖,此時在內存中還有一個小標誌位,如果爲 1,則表示開啓偏向鎖,0 表示未開啓。
此時線程來獲取鎖的偏向待遇時,只需要將自己的 Thread ID 存入 Mark Word,代表以後被偏向對待。
不過若有其他線程來,偏向鎖就不復存在,就會升級爲輕量級鎖,以後的加鎖解鎖都是 CAS 操作。
同理,升級爲重量級鎖後,也無法降級,從此以後的加鎖解鎖都只能通過阻塞線程。
偏向鎖

CAS

CAS 的特點

我們知道,用 synchronized 同步關鍵字底層是 JVM 實現的互斥鎖,對於多線程的互斥操作,會導致線程阻塞。而線程的阻塞和喚醒是有很多開銷的,而我們執行的方法僅僅只有 i++ 這樣的操作,開銷很小,所以程序執行的很多開銷會花在線程的阻塞與喚醒上,這樣程序的執行效率就不會高。
不過好在有 CAS。

CAS 不會阻塞線程,僅僅只是一個輕巧的指令,只會消耗一部分執行代碼的 CPU。像這樣僅僅執行 i++ 這樣的簡單代碼,用 CAS 循環嘗試幾次很快就可以成功修改到變量,這樣僅僅只是耗費了自旋的幾個 CPU 的消耗,是遠遠小於阻塞帶來的性能損耗的。
(不過要注意,要是線程很多,代碼邏輯很複雜,CAS 的自旋就會持續很久很久,這個時候消耗的 CPU 就很厲害了,所以得阻塞起來。)

CAS:compare and swap,又言 compare and set。
類似於

public boolean compareAndSwap(int oldValue, int newValue) {
    if(i != oldValue)
        return false;
    i = newValue;
    return true;
}

用 Java 代碼這麼寫肯定無法保證原子性,但是 CAS 的原理就相當於這樣的代碼。
只不過 CAS 屬於硬件同步原語,處理器提供了基本內存操作的原子性。

這時候再看之前的代碼,假設用 CAS,我們可以發現,同一時刻多線程對一個共享變量的修改,只有一個線程可以操作成功。
cas
這時候我們只要在 CAS 操作加上一個循環,重複操作,直到成功爲止。

while(!compareAndSwap(i, i + 1)) {
    // 不成功就循環 成功就退出循環
}

CAS 修改案例

這時可以用 CAS 修改之前的案例
(用到了 Unsafe 這個類,由於這個類是不允許直接使用的,所以用反射獲取)

public class CounterUnsafe {
    volatile int i = 0;

    private static Unsafe unsafe = null;

    //i字段的偏移量
    private static long valueOffset;

    static {
        //unsafe = Unsafe.getUnsafe();
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);

            Field fieldi = CounterUnsafe.class.getDeclaredField("i");
            valueOffset = unsafe.objectFieldOffset(fieldi);

        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
    // i++ 的原子操作
    public void add() {
        //i++;
        for (;;){
            int current = unsafe.getIntVolatile(this, valueOffset);
            if (unsafe.compareAndSwapInt(this, valueOffset, current, current+1))
                break;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        CounterUnsafe counterUnsafe = new CounterUnsafe();
        // 創建100個線程,每個線程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    counterUnsafe.add();
            }).start();
        }
        Thread.sleep(6000); // 等待各個線程執行完畢
        System.out.println(counterUnsafe.i);     // 打印 i 的值
    }
}

可以發現結果確實很正確
在這裏插入圖片描述
不過上面的代碼用到了 Unsafe,由於這個類 JDK 不推薦使用,我們應該用一個封裝了 Unsafe 的原子類:AtomicInteger。

public class CountAtomic {
    // 原子類,有CAS,自增等操作
    static AtomicInteger i = new AtomicInteger();
    // main
    public static void main(String[] args) throws InterruptedException {
        // 創建100個線程,每個線程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    i.getAndIncrement(); // 相當於i++
            }).start();
        }
        Thread.sleep(6000); // 等待各個線程執行完畢
        System.out.println(i.get());     // 打印 i 的值
    }
}

atomic

CAS 原子類

juc
juc

有序性

有序性沒有什麼很複雜的內容可以說,這裏我直接奉上《深入理解 Java 虛擬機》作者說的話。

Java內存模型的有序性在前面講解 volatile 時也詳細地討論過 了,Java程序中天然的有序性可以總結爲一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指 “線程內表現爲串行的語義”(Within-Thread As-If-Serial Semantics),後半句是指“指令重排序” 現象和 “工作內存與主內存同步延遲” 現象。
Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile 關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由 “一個變量在同一個時刻只允許一條線程對其進行 lock 操作” 這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能串行地進入。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章