「Java並發工具類」原子類

2020-12-25 計算機java編程

前言

為保證計數器中 count=+1 的原子性,我們在前面使用的都是 synchronized 互斥鎖方案,加鎖獨佔訪問的方式未免太過霸道,於是我們來介紹另一種解決原子性問題的無鎖方案:原子變量。在正式介紹原子變量之前,我們先來總結下鎖的不足,然後深入介紹原子變量。

鎖的劣勢

通過對共享變量加鎖,使得獲取到鎖的線程可以採用獨佔方式來訪問共享變量,並且對變量的修改對隨後獲取這個鎖的其他線程都是可見的(Happens-Before規則)。

當多個線程同時請求鎖時,對於沒有獲取到鎖的線程將有可能被掛起並且在稍後才恢復運行(有時會選擇自旋等待)。當線程恢復執行時,必須等待其他線程執行完它們的時間片後,才能被調度執行。我們需要知道,在掛起和恢復線程等過程中會存在著很大的開銷,並且通常存在著較長時間的中斷

加鎖方案在競爭激烈的情況下,其性能會由於上下文切換的開銷和調度延遲而降低。而如果鎖的持有時間非常短,那麼在不恰當的時間請求鎖時,線程休眠的代價將會不太划算。

加鎖還存在一些缺點:

當一個線程正在等待鎖時,它不能做任何其他事情。如果一個線程在持有鎖的情況下被延遲執行(例如發生了缺頁錯誤、調度延遲、或者其他情況),那麼所有需要這個鎖的線程都無法執行下去。如果被阻塞的線程優先級較高,而持有鎖的線程優先級較低,那麼將會導致優先級反轉問題。即使高優先級的線程可以搶先執行,但仍然需要等待鎖被釋放,從而導致它的優先級會降到低優先級線程的級別。如果持有鎖的線程被永久地阻塞(例如優於出現了無限循環,死鎖,活鎖或者其他活躍性問題),所有等待這個鎖的線程就永遠無法執行下去。與鎖相比,valatile變量是一種輕量級地同步機制,因為在使用這些變量時不會發生上下文切換或線程調度等操作。與鎖一樣雖然提供了可見性保證,但是volatile變量不能用於構建原子的複合操作。好消息是,下面我們將介紹的原子變量不僅提供了與volatile變量相同的內存語義,還支持原子的更新操作,比基於鎖的方案有著更高的可伸縮性

原子變量的實現原理: 硬體對並發的支持

硬體對並發的支持

獨佔鎖是一項悲觀技術,對於細粒度操作(例如計數器),還有另外一種更高效的方法,也是樂觀方法,通過這種方法也可以在不受其他線程幹擾的情況下完成更新操作。這種方法通過藉助衝突檢查機制來判斷在更新過程中是否存在來自其他線程的幹擾,如果存在,這個操作將失敗,並且可以重新嘗試(也可以不嘗試)。

這種方法也就是處理器中提供的一些特殊指令(這些特殊指令本身可以保證原子性)。這些指令用於管理對共享數據的並發訪問。在早期的處理器中支持原子的測試並設置(Test-and-Set),獲取並遞增(Fetch-and-Increment)以及交換(Swap)等指令,這些指令足以實現各種互斥體,而這些互斥體又可以實現一些更加複雜的並發對象。現在幾乎所有的現代處理器都包含了某種形式的原子讀-改-寫指令,例如比較並交換(CAS,Compare-and-Swap)或者關聯加載/條件存儲(Load-Linked/Store-Conditional)。

作業系統和JVM使用這些指令來實現鎖和並發的數據結構,在Java 5.0之前,Java類中還不能直接使用這些指令。

原子類由這些特殊的指令實現,所有其性能會比較高。

比較並交換

在大多數處理架構中,對上述方法的實現是實現一個比較並交換(CAS)之指令。(在其他處理器中,採用一對指令來實現相同的功能:關聯加載與條件存儲。)

CAS包含了3個操作數:需要讀寫的內存地址V,進行比較的值A和擬寫入的新值B

若且唯若V中的值等於A時,CAS才會通過原子方式用新值B來更新V中的值,否則不會執行任何操作。無論位置V的值是否等於A,都將返回V原有的值。

CAS是一項樂觀的技術,它希望能成功地執行更新操作,並且如果有另一個線程在最近一次檢查後更新了該變量,那麼CAS能檢測到這個錯誤。

以下的SimulatedCAS模擬代碼說明CAS的語義,用來理解CAS的工作原理。

public class SimulatedCAS {private int value;public synchronized int get() {return value;}public synchronized int compareAndSwap(int expectedValue, int newValue) {// 讀取目前的value值int oldValue = value;// 比較目前value值是否等於期望值if (oldValue == expectedValue)value = newValue; // 如果相等,則更新value的值return oldValue; // 返回寫入之前的值}public synchronized boolean compareAndSet(int expectedValue, int newValue) {return (expectedValue == compareAndSwap(expectedValue, newValue));}}

當多個線程嘗試使用CAS同時更新一個變量時,只有其中一個線程能夠更新變量的值,而其他線程都將失敗。但是,與獲取鎖實現的線程不一樣,這裡失敗的線程並不會被掛起,而是被告知在這次競爭中失敗,並可以再次嘗試(或者選擇不嘗試,做一些其他恢復操作等)。這種方式的靈活性大大減少與鎖相關的活躍性風險。

CAS的典型使用模式:首先從V中讀取值A,並根據A計算新值B,然後在通過CAS以原子方式將V中的值由A變為B,前提是這個期間沒有其他線程將V的值修改為其他值。由於CAS能檢測到來自其他線程的幹擾,因此即使不用鎖也能夠實現原子的讀-改-寫操作。

非阻塞的計數器

下面我們使用CAS來實現一個線程安全的計數器。遞增操作採用標準方式:讀取舊值,根據它計算新值,並使用CAS來設置這個新值。如果CAS失敗,那麼該操作將立即重試。反覆的重試也稱為自旋。通常,反覆地重試是一種合理的策略,但是在一些競爭激烈的情況下,最好的方式實在重試之前先等待一段時間或者回退,從而避免造成活鎖問題。

下面基於「CAS+自旋」實現計數器:

public class CasCounter {private SimulatedCAS value;public int getValue() {return value.get();}public int increment() {int v;do {v = value.get(); // 獲取舊值} while (v != value.compareAndSwap(v, v + 1)); // 自旋嘗試return v + 1;}}

CasCounter不會阻塞,但是如果其他線程同時更新計數器,那麼會多次執行重試操作。

這樣看來,似乎基於CAS的計數器比基於鎖的計數器性能要差一點,因為它需要執行更多的操作和更複雜的控制流。然而實際情況卻並非如此。雖然Java語言的鎖定語法簡潔,但是JVM和操作在管理鎖時需要完成的工作卻並不簡單,即使用鎖簡單,鎖背後要做的工作卻複雜。

在實現鎖定時需要遍歷JVM中一條非常複雜的代碼路徑,並可能導致作業系統級的鎖定、線程掛起以及上下文切換等操作。並且,在最好情況下(獲取無競爭的鎖),在鎖定時至少需要一次CAS,因此雖然在使用鎖時沒有用到CAS,但實際上也無法節約任何執行開銷,也就是說會比CAS執行更多的操作。

在程序內部執行CAS時是不需要執行JVM代碼、系統調用或者線程調度操作。在應用級別看起來越長的代碼路徑,如果加上JVM和作業系統中的代碼調用,那麼事實上卻變得更短。

而CAS的主要缺點在於:它將使調用者來處理競爭問題(通過重試、回退和放棄),而在鎖中能自動處理競爭問題(線程在獲取鎖之間將一直被阻塞)。

CAS的性能也會隨著處理器數量的不同、體系架構的不同甚至處理器版本的不同而產生變化。

ABA問題

ABA問題是指:如果V的值首先由A變成了B,再由B變成了A,雖然V中的值A好像沒有變,但是在某些算法中,A的屬性卻是變了。對於保護的變量是數值類型是不需要關心ABA問題,但是如果是對象,就需要注意。

一個相對簡單的解決辦法是:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本號。即使這個值由A變成了B,然後又變成了A,版本號也將是不同的。

AtomicStampedReferenceAtomicMarkableReference支持在兩個變量上執行原子的條件更新。

AtomicStampedReference將更新一個「對象-引用」二元組,通過在引用上加上「版本號」,從而避免了ABA問題。

同樣,AtomicMarkableReference將更新一個「對象引用-布爾值」二元組。

JVM對CAS的支持

在Java 5.0之前,如果不編寫明確的代碼,那麼就無法執行CAS。在Java 5.0中引入了底層的支持,在int、long和對象的引用等類型上都公開了CAS操作,並且JVM把它們編譯為底層硬體提供的最有效方法。在支持CAS的平臺上,運行時把它們編譯為相應的(多條)機器指令。在最壞情況下,如果不支持CAS指令,那麼JVM將使用自旋鎖。

在原子變量類(例如java.util.concurrent.atomic中的AtomicXxx)中使用了這些底層的JVM支持為數字類型和引用類型提供的一種高效的CAS操作,而在java.util.concurrent中的大多數類在實現時則直接或間接地使用了這些原子變量類。

原子類概覽

Java JDK並發包中提供的原子類很豐富,可以分為五個類別:標量類(基本數據類)、對象引用類、數組類、對象屬性更新器類和累加器類。

Java提供的原子類裡面CAS一般被實現為compareAndSet(),compareAndSet()的語義和CAS指令語義的差別僅僅是返回值的不同,compareAndSet()裡面如果更新成功,則會返回true,否則返回false。

do {// 獲取當前值oldV = xxxx;// 根據當前值計算新值newV = ...oldV...}while(!compareAndSet(oldV,newV);

原子標量類(原子基本數據類)

相關實現類有AtomicBoolean、AtomicInteger和AtomicLong,提供的方法主如下:

getAndIncrement() // 原子化 i++getAndDecrement() // 原子化的 i--incrementAndGet() // 原子化的 ++idecrementAndGet() // 原子化的 --i// 當前值 +=delta,返回 += 前的值getAndAdd(delta)// 當前值 +=delta,返回 += 後的值addAndGet(delta)//CAS 操作,返回是否成功compareAndSet(expect, update)// 以下四個方法// 新值可以通過傳入 func 函數來計算getAndUpdate(func)updateAndGet(func)getAndAccumulate(x,func)accumulateAndGet(x,func)

原子對象引用類

相關實現有AtomicReference、AtomicStampedReference和AtomicMarkableReference,利用它們可以實現對象引用的原子化更新。

AtomicReference提供的方法和原子化的基本數據類型差不多。不過,需要注意,對象引用的更新需要重點關注ABA問題,正如前面提過,AtomicStampedReference和AtomicMarkableReference這兩個原子類可以解決ABA問題。 

AtomicStampedReference實現的CAS方法增加的版本號參數,方法籤名如下:

boolean compareAndSet(V expectedReference,V newReference,int expectedStamp,int newStamp)

AtomicMarkableReference的實現機制更簡單,將版本號簡化成了一個Boolean值,方法籤名如下:boolean compareAndSet(V expectedReference,V newReference,boolean expectedMark,boolean newMark)

原子數組類

相關實現有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,利用這些原子類,我們可以原子化地更新數組中的每一個元素。

這些類提供的方法和原子化基本數據類型的區別僅僅是:每個方法多了一個數組的索引參數。

原子對象屬性更新器類

相關實現有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater。利用它們可以原子化地更新對象的屬性,這三個方法都是利用反射機制實現的,創建更新器的方法如下:

public static <U> AtomicXXXFieldUpdater<U> newUpdater(Class<U> tclass, String fieldName)

需要注意,對象屬性必須是volatile類型的,只有這樣才能保證可見性;如果對象屬性不是volatile類型的,newUpdater()方法會拋出IllegalArgumentException這個運行時異常。

newUpdater()方法的參數中只有類的信息沒有對象的引用,而更新對象的屬性,需要對象的引用,那麼這個參數是再哪裡傳入的呢?

是在原子類操作的方法參數中傳入的。例如,compareAndSet()這個原子操作,相比原子化的基本數據類型參數多了一個對象引用obj。

boolean compareAndSet(T obj, int expect, int update)

原子化對象屬性更新器相關方法,相比原子類的基本數據類型僅僅是多了對象引用參數。

原子累加器類

DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder,這四個類僅僅用來執行累加計數操作,相比原子化的基本數據類型,速度更快,但是不支持compareAndSet()方法。

在實際情況中,如果僅需一個計數器或者序列生成器,那麼可以直接使AtomicInteger或者AtomicLong,它們能提供原子的遞增方法以及其他算術方法。

小結

原子變量比鎖的粒度更細,量級更輕。原子變量類也相當於一種泛化的volatile變量。原子變量較於鎖來說,在性能和降低活躍性方面都表現很好,但是原子變量是將發生競爭的範圍縮小到單個變量上,當需要解決多個共享變量的原子性問題,還是建議使用鎖。

相關焦點

  • java並發編程-原子類
    而 java.util.concurrent.atomic 下的類,就是具有原子性的類,可以原子性地執行添加、遞增、遞減等操作。比如之前多線程下的線程不安全的 i++ 問題,到了原子類這裡,就可以用功能相同且線程安全的 getAndIncrement 方法來優雅地解決。
  • JAVA並發編程:線程並發工具類Callable、Future 和FutureTask的使用
    在Future類中一共有5個方法,如下圖所示。UML類圖如下所示,可清晰看到幾個類的關係。java.util.concurrent.ExecutionException;import java.util.concurrent.FutureTask;/** * 類說明:Future的使用,演示在計算過程中中斷任務 */public class UseFuture { /** * 實現Callable接口
  • Java多線程並發工具類-信號量Semaphore對象講解
    Java多線程並發工具類-Semaphore對象講解通過前面的學習,我們已經知道了Java多線程並發場景中使用比較多的兩個工具類:做加法的CycliBarrier對象以及做減法的CountDownLatch對象並對這兩個對象進行了比較。我們發現這兩個對象要麼是做加法,要麼是做減法的。那麼有沒有既做加法也做減法的呢?
  • 原子類Atomic家族,細看成員還不少
    而 java.util.concurrent.atomic 下的類,就是具有原子性的類,可以原子性地執行添加、遞增、遞減等操作。比如之前多線程下的線程不安全的 i++ 問題,到了原子類這裡,就可以用功能相同且線程安全的 getAndIncrement 方法來優雅地解決。原子類的作用和鎖有類似之處,是為了保證並發情況下線程安全。
  • Java中的並發工具類CountDownLatch
    Java中的並發工具類在多線程編程的時候,有時候需要控制並發流,Java本身提供了幾個控制並發的工具類,比如CountDownLatch,CyclicBarrier,SemaphoreSemaphore用來控制同時訪問資源的線程數量,比如用來限制流量,限制並發數等。acquire()方法是獲取一個通行證,releas()方法是歸還通行證。比如進小區的安檢,只能一個一個的來,代碼實現如下。
  • Java從零開始學 - 第23天:JUC中原子類,一篇就夠了
    點擊上方關注 「Java研究所」設為「星標」,和你一起掌握更多資料庫知識這是java高並發系列第23篇文章,環境:jdk1.8。本文主要內容JUC中的原子類介紹介紹基本類型原子類介紹數組類型原子類介紹引用類型原子類介紹對象屬性修改相關原子類預備知識JUC中的原子類都是都是依靠volatile、CAS、Unsafe類配合來實現的,需要了解的請移步
  • 「原創」Java並發編程系列25|交換器Exchanger(3)
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫正文很尷尬,發了並發編程的Exchanger是一個用於線程間協作的工具類,用於兩個線程間交換。本篇只介紹Exchanger的使用,不講解源碼。(因Exchanger類的源碼很是複雜,而且對平時開發設計借鑑意義也不是很大,所以本人至今沒有徹底研究明白,也就不在這裡誤導大家了。)
  • Java並發編程之支持並發的list集合你知道嗎
    Java並發編程之-list集合的並發.我們都知道Java集合類中的arrayList是線程不安全的。那麼怎麼證明是線程不安全的呢?怎麼解決在並發環境下使用安全的list集合類呢?本篇是《凱哥(凱哥Java:kagejava)並發編程學習》系列之《並發集合系列》教程的第一篇:本文主要內容:怎麼證明arrayList不是線程安全的?怎麼解決這個問題?以及遇到問題解決的四個步驟及從源碼來分析作者思路。一:怎麼證明arrayList在並發情況下是線程不安全的呢?
  • 「原創」Java並發編程系列28|Copy-On-Write容器
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫正文前面兩篇講了並發編程中線程安全HashMap:ConcurrentHashMap
  • 「零基礎學JAVA」基礎篇 第二章 JAVA編程初體驗
    JAVA【零基礎學編程】系列今天給大家帶來基礎篇 第二章 JAVA編程初體驗本節的部分編碼操作需要先安裝JDK開發工具「零基礎學JAVA」工具篇 JDK的安裝教程(WINDOWS版)和環境變量的配置「零基礎學JAVA」工具篇
  • 「原創」Java並發編程系列29|ConcurrentLinkedQueue
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫J.U.C 為常用的集合提供了並發安全的版本,前面講解了 map 的並發安全集合 ConcurrentHashMap,List 並發安全集合 CopyOnWriteArrayList,Set 並發安全集合
  • 【漫畫】面試經常問到的三個並發工具類
    如果你正準備找工作,那這篇文章就是為你量身準備的,不要想了,並發編程面試時一定會被問到的。
  • 「原創」Java並發編程系列09|基礎乾貨
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫本文為何適原創並發編程系列第9篇。現在,我們進入正題:介紹並發編程的基礎性概念。
  • 原創】Java並發編程系列01|開篇獲獎感言
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫為什麼要學並發編程我曾聽一個從事15年開發工作的技術人員說過,他剛工作時的並發編程第一原則就是不要寫並發程序。
  • Java 面試寶典!並發編程 71 道題及答案全送上!
    別急,我整理了71道並發相關的面試題,看這一文就夠了!1、在java中守護線程和本地線程區別?java中的線程分為兩種:守護線程(Daemon)和用戶線程(User)。任何線程都可以設置為守護線程和用戶線程,通過方法Thread.setDaemon(bool on);true則把該線程設置為守護線程,反之則為用戶線程。
  • 「原創」Java並發編程系列02|並發編程三大核心問題
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫寫在前面編寫並發程序是比較困難的,因為並發程序極易出現Bug,這些Bug有都是比較詭異的,很多都是沒辦法追蹤,而且難以復現。
  • 「原創」Java並發編程系列06|你不知道的final
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫final在Java中是一個保留的關鍵字,可以修飾變量、方法和類。那麼fianl在並發編程中有什麼作用呢?本文就在對final常見應用總結基礎上,講解final並發編程中的應用。
  • Java多線程工具類之循環柵欄計數器
    本篇是《凱哥(凱哥Java:kagejava)並發編程學習》系列之《並發工具類》教程的第二篇:《Java多線程下循環計數器》。一:CyclicBarrier是什麼cycBar是什麼呢?如果站在多線程並發場景下來分析的話:旅遊團中每個成員都是一個線程,入口集合點就是屏障(Barrier),每個成員都必須到達集合點(循環到達Cyclic)且人數和旅遊團人數相等的時候,才能觸發旅遊車發車去下一個景點的線程。還有一個例子更容易理解:集齊七龍珠,召喚神龍。相信看過《七龍珠》的都知道這個吧。想要召喚神龍的觸發點就是集齊七個龍珠。
  • 「從入門到放棄-Java」並發編程-鎖-synchronized
    簡介上篇【從入門到放棄-Java】並發編程-線程安全中,我們了解到,可以通過加鎖機制來保護共享對象,來實現線程安全。synchronized是java提供的一種內置的鎖機制。通過synchronized關鍵字同步代碼塊。
  • Java多線程並發編程中並發容器第二篇之List的並發類講解
    Java多線程並發編程中並發容器第二篇之List的並發類講解概述本文我們將詳細講解list對應的並發容器以及用代碼來測試ArrayList、vector以及CopyOnWriteArrayList在100個線程向list中添加1000個數據後的比較