JVM 原始碼分析(三):深入理解 CAS

張永恆發表於2021-01-14

前言

在上一篇文章中,我們完成了原始碼的編譯和除錯環境的搭建。

鑑於 CAS 的實現原理比較簡單, 然而很多人對它不夠了解,所以本篇將從 CAS 入手,首先介紹它的使用,然後分析它在 Hotsport 虛擬機器中的具體實現。

什麼是 CAS

CAS(Compare And Swap,比較並交換)通常指的是這樣一種原子操作:針對一個變數,首先比較它的記憶體值與某個期望值是否相同,如果相同,就給它賦一個新值。

CAS 的邏輯用虛擬碼描述如下:

if (value == expectedValue) {
    value = newValue;
}

以上虛擬碼描述了一個由比較和賦值兩階段組成的複合操作,CAS 可以看作是它們合併後的整體——一個不可分割的原子操作,並且其原子性是直接在硬體層面得到保障的,後面我會具體介紹。

Java 中的 CAS

在 Java 中,CAS 操作是由 Unsafe 類提供支援的,該類定義了三種針對不同型別變數的 CAS 操作,如圖。

它們都是 native 方法,由 Java 虛擬機器提供具體實現,這意味著不同的 Java 虛擬機器對它們的實現可能會略有不同。

下面我將通過程式碼演示一下它們的功能,以 compareAndSwapInt 為例。

首先需要得到 Unsafe 物件。由於 Unsafe 被設計為單例類,並且它的獲取例項的方法只允許被基礎類庫中的類呼叫,因此,我們自己的類要想獲取 Unsafe 物件,只能通過反射實現。

獲取 Unsafe 物件的程式碼如下:

private static Unsafe getUnsafe() {
    try {
        Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        theUnsafeField.setAccessible(true);
        return (Unsafe) theUnsafeField.get(Unsafe.class);
    } catch (NoSuchFieldException | IllegalAccessException e) {
        throw new Error(e);
    }
}

Unsafe 的 compareAndSwapInt 方法接收 4 個引數,分別是:物件例項、欄位偏移量、欄位期望值、欄位新值。該方法會針對指定物件例項中的相應偏移量的欄位執行 CAS 操作。

獲取欄位偏移量的程式碼如下:

private static long getFieldOffset(Unsafe unsafe, Class clazz, String fieldName) {
    try {
        return unsafe.objectFieldOffset(clazz.getDeclaredField(fieldName));
    } catch (NoSuchFieldException e) {
        throw new Error(e);
    }
}

演示程式碼如下:

public static void main(String[] args) {
    Unsafe unsafe = getUnsafe();

    long offset = getFieldOffset(unsafe, Entity.class, "x");

    boolean successful;

    successful = unsafe.compareAndSwapInt(entity, offset, 03);
    System.out.println(successful + "\t" + entity.x);

    successful = unsafe.compareAndSwapInt(entity, offset, 35);
    System.out.println(successful + "\t" + entity.x);

    successful = unsafe.compareAndSwapInt(entity, offset, 38);
    System.out.println(successful + "\t" + entity.x);
}

在我們的演示程式碼中,我們首先得到 Unsafe 物件,然後得到 Entity 中的 x 欄位的偏移量(Entity 是我們自定義的實體類)。接下來是針對 entity.x 的 3 次 CAS 操作,分別試圖將它從 0 改成 3、從 3 改成 5、從 3 改成 8。

執行結果如下:

可以看到,由於 entity.x 的原始值為 0,所以第一次 CAS 成功地將它更新為 3,第二次 CAS 也成功地將它更新為 5,但是在第三次 CAS 時,由於 entity.x 的當前值 5 與期望值 3 不相同,所以 CAS 失敗, entity.x 並沒有得到更新,它的值仍然是 5

以上就是 CAS 在 Java 中的直觀體現,它是所有併發原子型別的基礎。下面我們來看一下它的底層實現。

JVM 中的 CAS

關於上面演示的 compareAndSwapInt 方法,Hotspot 虛擬機器對它的實現如下:

為了更加直觀,我在這裡打上了斷點,並聯合上面的 Java 程式碼一起除錯。上圖顯示了當前執行緒停在了斷點處的對 Atomic::cmpxchg 方法的呼叫上。

Atomic::cmpxchg 方法非常關鍵,它是 Hotspot 虛擬機器對 CAS 操作的封裝。我們將斷點跟進方法內部,從 “Variables” 標籤頁中可以觀察到,當前 Java 虛擬機器正在處理上述 Java 程式的第一次 CAS 請求,準備將 entity.x 的值從 0 改成 3,如圖。

Atomic::cmpxchg 方法的定義如上圖所示,它首先通過 os::is_MP() 判斷當前執行環境是否為多處理器環境,然後嵌入一段彙編程式碼,這段彙編程式碼會執行一條 cmpxchgl 指令,同時把 exchange_value 等變數作為運算元,當它執行完成之後,方法將直接返回 exchange_value 的值。

從中可以看出, cmpxchgl 彙編指令是整個 Atomic::cmpxchg 方法的核心。

順便補充一下,彙編程式碼中的 LOCK_IF_MP 是一個巨集,這個巨集的作用是,在多處理器環境下,為 cmpxchgl 指令新增 lock 字首,以達到記憶體屏障的效果。記憶體屏障能夠在目標指令執行之前,保障多個處理器之間的快取一致性,由於單處理器環境下並不需要記憶體屏障,故做此判斷。

cmpxchgl 指令是包含在 x86 架構及 IA-64 架構中的一個原子條件指令,在我們的例子中,它會首先比較 dest 指標指向的記憶體值是否和 compare_value 的值相等,如果相等,則雙向交換 destexchange_value,否則就單方面地將 dest 指向的記憶體值交給 ``exchange_value。這條指令完成了整個 CAS 操作,因此它也被稱為 CAS 指令。

事實上,現代指令集架構基本上都會提供 CAS 指令,例如 x86 和 IA-64 架構中的 cmpxchgl 指令和 comxchgq 指令,sparc 架構中的 cas 指令和 casx 指令等等。

不管是 Hotspot 中的 Atomic::cmpxchg 方法,還是 Java 中的 compareAndSwapInt 方法,它們本質上都是對相應平臺的 CAS 指令的一層簡單封裝。CAS 指令作為一種硬體原語,有著天然的原子性,這也正是 CAS 的價值所在。

相關文章