原子類和 volatile異同
首先,通過我們對原子類和的了解,原子類和volatile 都能保證多線程環境下的數據可見性。在多線程程序中,每個線程都有自己的工作內存,當多個線程訪問共享變量時,可能會出現一個線程修改了共享變量的值,而其他線程不能及時看到最新值的情況。原子類和volatile關鍵字都能在一定程度上解決這個問題。例如,當一個變量被volatile修飾后,對該變量的寫操作會立即刷新到主內存,讀操作會直接從主內存讀取,保證了其他線程能看到最新的值;原子類同樣可以保證對變量操作的結果能被其他線程及時看到。
下面我們通過一個代碼去看看它們的差異:
/*** 該類用于演示 volatile 關鍵字和 AtomicInteger 類在多線程環境下的不同表現。* 展示了使用 volatile 變量和 AtomicInteger 類進行自增操作的差異。*/
public class VolatileVsAtomic {// 用 volatile 修飾的變量,保證變量的可見性,但不保證操作的原子性private static volatile int volatileCount = 0;// 原子類,提供原子操作,保證操作的原子性private static AtomicInteger atomicCount = new AtomicInteger(0);/*** 主方法,程序的入口點。* 創建多個線程,分別對 volatile 變量和 AtomicInteger 類的實例進行自增操作,并輸出結果。** @param args 命令行參數* @throws InterruptedException 如果線程在等待時被中斷*/public static void main(String[] args) throws InterruptedException {// 定義線程數量int threadCount = 10;// 創建線程數組Thread[] threads = new Thread[threadCount];// 使用 volatile 變量進行自增操作for (int i = 0; i < threadCount; i++) {// 創建線程threads[i] = new Thread(() -> {// 每個線程執行 1000 次自增操作for (int j = 0; j < 1000; j++) {// 此操作不是原子性的,可能會出現數據競爭問題volatileCount++;}});// 啟動線程threads[i].start();}// 等待所有線程執行完畢for (Thread thread : threads) {thread.join();}// 輸出 volatile 變量的最終值System.out.println("Volatile count: " + volatileCount);// 重置計數器volatileCount = 0;atomicCount.set(0);// 使用原子類進行自增操作for (int i = 0; i < threadCount; i++) {// 創建線程threads[i] = new Thread(() -> {// 每個線程執行 1000 次自增操作for (int j = 0; j < 1000; j++) {// 原子性自增操作,保證操作的原子性atomicCount.incrementAndGet();}});// 啟動線程threads[i].start();}// 等待所有線程執行完畢for (Thread thread : threads) {thread.join();}// 輸出 AtomicInteger 類實例的最終值System.out.println("Atomic count: " + atomicCount.get());}
}
?輸出結果如下:
在上述代碼中,volatileCount是一個被volatile修飾的變量,多個線程對其進行自增操作時,由于自增操作不是原子性的,最終結果可能小于預期值;而atomicCount是一個AtomicInteger類型的原子類,多個線程對其進行自增操作時,能保證操作的原子性,最終結果是準確的。
原子類和 volatile 的使用場景
那下面我們就來說一下原子類和 volatile 各自的使用場景。
我們可以看出,volatile 和原子類的使用場景是不一樣的,如果我們有一個可見性問題,那么可以使用 volatile 關鍵字,但如果我們的問題是一個組合操作,需要用同步來解決原子性問題的話,那么可以使用原子變量,而不能使用 volatile 關鍵字。
通常情況下,volatile 可以用來修飾 boolean 類型的標記位,因為對于標記位來講,直接的賦值操作本身就是具備原子性的,再加上 volatile 保證了可見性,那么就是線程安全的了。
而對于會被多個線程同時操作的計數器 Counter 的場景,這種場景的一個典型特點就是,它不僅僅是一個簡單的賦值操作,而是需要先讀取當前的值,然后在此基礎上進行一定的修改,再把它給賦值回去。這樣一來,我們的 volatile 就不足以保證這種情況的線程安全了。我們需要使用原子類來保證線程安全。
原子類和 synchronized異同
原子類和 synchronized 關鍵字都可以用來保證線程安全,下面我們分別用原子類和 synchronized 關鍵字來解決一個經典的線程安全問題,給出具體的代碼對比,然后再分析它們背后的區別。
首先,原始的線程不安全的情況的代碼如下所示:
/*** BaseTest 類實現了 Runnable 接口,用于演示多線程并發修改共享變量的情況。* 該類包含一個靜態變量 value,多個線程會同時對其進行遞增操作。*/
public class BaseTest implements Runnable{// 靜態變量 value,用于存儲線程遞增的結果static int value = 0;/*** main 方法是程序的入口點,創建并啟動兩個線程來執行 BaseTest 實例的 run 方法。* 等待兩個線程執行完畢后,打印最終的 value 值。* * @param args 命令行參數* @throws InterruptedException 如果線程在等待過程中被中斷*/public static void main(String[] args) throws InterruptedException {// 創建 BaseTest 實例Runnable runnable = new BaseTest();// 創建第一個線程并傳入 BaseTest 實例Thread thread1 = new Thread(runnable);// 創建第二個線程并傳入 BaseTest 實例Thread thread2 = new Thread(runnable);// 啟動第一個線程thread1.start();// 啟動第二個線程thread2.start();// 等待第一個線程執行完畢thread1.join();// 等待第二個線程執行完畢thread2.join();// 打印最終的 value 值System.out.println(value);}/*** run 方法是 Runnable 接口的實現,包含一個循環,將 value 變量遞增 10000 次。*/@Overridepublic void run() {// 循環 10000 次,每次將 value 加 1for (int i = 0; i < 10000; i++) {value++;}}
}
在代碼中我們新建了一個 value 變量,并且在兩個線程中對它進行同時的自加操作,每個線程加 10000次,然后我們用 join 來確保它們都執行完畢,最后打印出最終的數值。
因為 value++ 不是一個原子操作,所以上面這段代碼是線程不安全的,所以代碼的運行結果會小于 20000,例如我執行的結果如下:
我們首先給出方法一,也就是用原子類來解決這個問題,代碼如下所示:
/*** AtomicTest 類實現了 Runnable 接口,用于演示使用 AtomicInteger 進行線程安全的計數操作。* 該類創建了兩個線程,每個線程都會對一個靜態的 AtomicInteger 實例進行 10000 次遞增操作。* 最后,主線程等待兩個子線程執行完畢,并輸出最終的計數值。*/
public class AtomicTest implements Runnable {// 靜態的 AtomicInteger 實例,用于線程安全的計數操作static AtomicInteger atomicInteger = new AtomicInteger();/*** 程序的入口點,創建并啟動兩個線程,等待它們執行完畢,然后輸出最終的計數值。** @param args 命令行參數,在本程序中未使用。* @throws InterruptedException 如果在等待線程執行完畢時被中斷。*/public static void main(String[] args) throws InterruptedException {// 創建一個 AtomicTest 實例,作為線程的任務Runnable runnable = new AtomicTest();// 創建第一個線程并傳入任務Thread thread1 = new Thread(runnable);// 創建第二個線程并傳入任務Thread thread2 = new Thread(runnable);// 啟動第一個線程thread1.start();// 啟動第二個線程thread2.start();// 等待第一個線程執行完畢thread1.join();// 等待第二個線程執行完畢thread2.join();// 輸出最終的計數值System.out.println(atomicInteger.get());}/*** 實現 Runnable 接口的 run 方法,該方法會對 atomicInteger 進行 10000 次遞增操作。*/@Overridepublic void run() {// 循環 10000 次,每次對 atomicInteger 進行遞增操作for (int i = 0; i < 10000; i++) {// 原子地遞增 atomicInteger 的值并返回更新后的值atomicInteger.incrementAndGet();}}
}
用原子類之后,我們的計數變量就不再是一個普通的?int?變量了,而是 AtomicInteger 類型的對象,并且自加操作也變成了 incrementAndGet 法。由于原子類可以確保每一次的自加操作都是具備原子性的,所以這段程序是線程安全的,所以以上程序的運行結果會始終等于 20000。
下面我們給出方法二,我們用 synchronized 來解決這個問題,代碼如下所示:
/*** SynTest 類用于演示多線程環境下的同步機制。* 該類實現了 Runnable 接口,多個線程可以共享同一個實例來執行任務。* 通過同步塊確保對靜態變量 value 的安全訪問。*/
public class SynTest implements Runnable {// 靜態變量,用于記錄所有線程累加的結果static int value = 0;/*** 程序的入口點,創建并啟動兩個線程來執行任務。** @param args 命令行參數* @throws InterruptedException 如果線程在等待時被中斷*/public static void main(String[] args) throws InterruptedException {// 創建 SynTest 類的實例Runnable runnable = new SynTest();// 創建第一個線程并傳入 Runnable 實例Thread thread1 = new Thread(runnable);// 創建第二個線程并傳入 Runnable 實例Thread thread2 = new Thread(runnable);// 啟動第一個線程thread1.start();// 啟動第二個線程thread2.start();// 等待第一個線程執行完畢thread1.join();// 等待第二個線程執行完畢thread2.join();// 輸出最終累加結果System.out.println(value);}/*** 實現 Runnable 接口的 run 方法,定義線程要執行的任務。* 在這個方法中,線程會對靜態變量 value 進行 10000 次累加操作。*/@Overridepublic void run() {// 循環 10000 次for (int i = 0; i < 10000; i++) {// 使用同步塊確保同一時間只有一個線程可以訪問和修改 value 變量synchronized (this) {// 對 value 變量進行累加操作value++;}}}
}
它與最開始的線程不安全的代碼的區別在于,在 run 方法中加了?synchronized 代碼塊,就可以非常輕松地解決這個問題,由于 synchronized 可以保證代碼塊內部的原子性,所以以上程序的運行結果也始終等于 20000,是線程安全的。
原子類和 synchronized 的使用對比
下面我們就對這兩種不同的方案進行分析。
第一點,我們來看一下它們背后原理的不同。
synchronized 保證線程安全的核心是?monitor 鎖,同步方法和同步代碼塊的背后原理會有少許差異,但總體思想是一致的:在執行同步代碼之前,需要首先獲取到 monitor 鎖,執行完畢后,再釋放鎖。而原子類保證線程安全的原理是利用了 CAS 操作。從這一點上看,雖然原子類和?synchronized 都能保證線程安全,但是其實現原理是大有不同的。
第二點不同是使用范圍的不同。
對于原子類而言,它的使用范圍是比較局限的。因為一個原子類僅僅是一個對象,不夠靈活。而synchronized 的使用范圍要廣泛得多。比如說 synchronized 既可以修飾一個方法,又可以修飾一段代碼,相當于可以根據我們的需要,非常靈活地去控制它的應用范圍。
所以僅有少量的場景,例如計數器等場景,我們可以使用原子類。而在其他更多的場景下,如果原子類不適用,那么我們就可以考慮用 synchronized 來解決這個問題。
第三個區別是粒度的區別。
原子變量的粒度是比較小的,它可以把競爭范圍縮小到變量級別。通常情況下,synchronized 鎖的粒度都要大于原子變量的粒度。如果我們只把一行代碼用 synchronized 給保護起來的話,有一點殺雞焉用牛刀的感覺。
第四點是它們性能的區別,同時也是悲觀鎖和樂觀鎖的區別。
因為 synchronized 是一種典型的悲觀鎖,而原子類恰恰相反,它利用的是樂觀鎖。所以,我們在比較synchronized 和 AtomicInteger 的時候,其實也就相當于比較了悲觀鎖和樂觀鎖的區別。
從性能上來考慮的話,悲觀鎖的操作相對來講是比較重量級的。因為 synchronized 在競爭激烈的情況下,會讓拿不到鎖的線程阻塞,而原子類是永遠不會讓線程阻塞的。不過,雖然 synchronized 會讓線程阻塞,但是這并不代表它的性能就比原子類差。
因為悲觀鎖的開銷是固定的,也是一勞永逸的。隨著時間的增加,這種開銷并不會線性增長。而樂觀鎖雖然在短期內的開銷不大,但是隨著時間的增加,它的開銷也是逐步上漲的。
所以從性能的角度考慮,它們沒有一個孰優孰劣的關系,而是要區分具體的使用場景。在競爭非常激烈的情況下,推薦使用 synchronized;而在競爭不激烈的情況下,使用原子類會得到更好的效果。
值得注意的是,synchronized 的性能隨著 JDK 的升級,也得到了不斷的優化。synchronized 會從無鎖升級到偏向鎖,再升級到輕量級鎖,最后才會升級到讓線程阻塞的重量級鎖。因此synchronized 在競爭不激烈的情況下,性能也是不錯的。