java高併發系列 - 第23天:JUC中原子類,一篇就夠了

路人甲Java發表於2019-08-07

這是java高併發系列第23篇文章,環境:jdk1.8。

本文主要內容

  1. JUC中的原子類介紹
  2. 介紹基本型別原子類
  3. 介紹陣列型別原子類
  4. 介紹引用型別原子類
  5. 介紹物件屬性修改相關原子類

預備知識

JUC中的原子類都是都是依靠volatileCASUnsafe類配合來實現的,需要了解的請移步:

volatile與Java記憶體模型

java中的CAS

JUC底層工具類Unsafe

JUC中原子類介紹

什麼是原子操作?

atomic 翻譯成中文是原子的意思。在化學上,我們知道原子是構成一般物質的最小單位,在化學反應中是不可分割的。在我們這裡 atomic 是指一個操作是不可中斷的。即使是在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒干擾,所以,所謂原子類說簡單點就是具有原子操作特徵的類,原子操作類提供了一些修改資料的方法,這些方法都是原子操作的,在多執行緒情況下可以確保被修改資料的正確性

JUC中對原子操作提供了強大的支援,這些類位於java.util.concurrent.atomic包中,如下圖:

java高併發系列 - 第23天:JUC中原子類,一篇就夠了

JUC中原子類思維導圖

java高併發系列 - 第23天:JUC中原子類,一篇就夠了

基本型別原子類

使用原子的方式更新基本型別

  • AtomicInteger:int型別原子類
  • AtomicLong:long型別原子類
  • AtomicBoolean :boolean型別原子類

上面三個類提供的方法幾乎相同,這裡以 AtomicInteger 為例子來介紹。

AtomicInteger 類常用方法

public final int get() //獲取當前的值
public final int getAndSet(int newValue)//獲取當前的值,並設定新的值
public final int getAndIncrement()//獲取當前的值,並自增
public final int getAndDecrement() //獲取當前的值,並自減
public final int getAndAdd(int delta) //獲取當前的值,並加上預期的值
boolean compareAndSet(int expect, int update) //如果輸入的數值等於預期值,則以原子方式將該值設定為輸入值(update)
public final void lazySet(int newValue)//最終設定為newValue,使用 lazySet 設定之後可能導致其他執行緒在之後的一小段時間內還是可以讀到舊的值。

部分原始碼

private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

2個關鍵欄位說明:
value:使用volatile修飾,可以確保value在多執行緒中的可見性。
valueOffset:value屬性在AtomicInteger中的偏移量,通過這個偏移量可以快速定位到value欄位,這個是實現AtomicInteger的關鍵。

getAndIncrement原始碼:

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

內部呼叫的是Unsafe類中的getAndAddInt方法,我們看一下getAndAddInt原始碼:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

說明:
this.getIntVolatile:可以確保從主記憶體中獲取變數最新的值。

compareAndSwapInt:CAS操作,CAS的原理是拿期望的值和原本的值作比較,如果相同則更新成新的值,可以確保在多執行緒情況下只有一個執行緒會操作成功,不成功的返回false。

上面有個do-while迴圈,compareAndSwapInt返回false之後,會再次從主記憶體中獲取變數的值,繼續做CAS操作,直到成功為止。

getAndAddInt操作相當於執行緒安全的count++操作,如同:
synchronize(lock){
count++;
}
count++操作實際上是被拆分為3步驟執行:

  1. 獲取count的值,記做A:A=count
  2. 將A的值+1,得到B:B = A+1
  3. 讓B賦值給count:count = B
    多執行緒情況下會出現執行緒安全的問題,導致資料不準確。

synchronize的方式會導致佔時無法獲取鎖的執行緒處於阻塞狀態,效能比較低。CAS的效能比synchronize要快很多。

示例

使用AtomicInteger實現網站訪問量計數器功能,模擬100人同時訪問網站,每個人訪問10次,程式碼如下:

package com.itsoku.chat23;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 跟著阿里p7學併發,微信公眾號:javacode2018
 */
public class Demo1 {
    //訪問次數
    static AtomicInteger count = new AtomicInteger();

    //模擬訪問一次
    public static void request() throws InterruptedException {
        //模擬耗時5毫秒
        TimeUnit.MILLISECONDS.sleep(5);
        //對count原子+1
        count.incrementAndGet();
    }

    public static void main(String[] args) throws InterruptedException {
        long starTime = System.currentTimeMillis();
        int threadSize = 100;
        CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        for (int i = 0; i < threadSize; i++) {
            Thread thread = new Thread(() -> {
                try {
                    for (int j = 0; j < 10; j++) {
                        request();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    countDownLatch.countDown();
                }
            });
            thread.start();
        }

        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + ",耗時:" + (endTime - starTime) + ",count=" + count);
    }
}

輸出:

main,耗時:158,count=1000

通過輸出中可以看出incrementAndGet在多執行緒情況下能確保資料的正確性。

陣列型別原子類介紹

使用原子的方式更新陣列裡的某個元素,可以確保修改陣列中資料的執行緒安全性。

  • AtomicIntegerArray:整形陣列原子操作類
  • AtomicLongArray:長整形陣列原子操作類
  • AtomicReferenceArray :引用型別陣列原子操作類

上面三個類提供的方法幾乎相同,所以我們這裡以 AtomicIntegerArray 為例子來介紹。

AtomicIntegerArray 類常用方法

public final int get(int i) //獲取 index=i 位置元素的值
public final int getAndSet(int i, int newValue)//返回 index=i 位置的當前的值,並將其設定為新值:newValue
public final int getAndIncrement(int i)//獲取 index=i 位置元素的值,並讓該位置的元素自增
public final int getAndDecrement(int i) //獲取 index=i 位置元素的值,並讓該位置的元素自減
public final int getAndAdd(int delta) //獲取 index=i 位置元素的值,並加上預期的值
boolean compareAndSet(int expect, int update) //如果輸入的數值等於預期值,則以原子方式將 index=i 位置的元素值設定為輸入值(update)
public final void lazySet(int i, int newValue)//最終 將index=i 位置的元素設定為newValue,使用 lazySet 設定之後可能導致其他執行緒在之後的一小段時間內還是可以讀到舊的值。

示例

統計網站頁面訪問量,假設網站有10個頁面,現在模擬100個人並行訪問每個頁面10次,然後將每個頁面訪問量輸出,應該每個頁面都是1000次,程式碼如下:

package com.itsoku.chat23;

import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerArray;

/**
 * 跟著阿里p7學併發,微信公眾號:javacode2018
 */
public class Demo2 {

    static AtomicIntegerArray pageRequest = new AtomicIntegerArray(new int[10]);

    /**
     * 模擬訪問一次
     *
     * @param page 訪問第幾個頁面
     * @throws InterruptedException
     */
    public static void request(int page) throws InterruptedException {
        //模擬耗時5毫秒
        TimeUnit.MILLISECONDS.sleep(5);
        //pageCountIndex為pageCount陣列的下標,表示頁面對應陣列中的位置
        int pageCountIndex = page - 1;
        pageRequest.incrementAndGet(pageCountIndex);
    }

    public static void main(String[] args) throws InterruptedException {
        long starTime = System.currentTimeMillis();
        int threadSize = 100;
        CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        for (int i = 0; i < threadSize; i++) {
            Thread thread = new Thread(() -> {
                try {

                    for (int page = 1; page <= 10; page++) {
                        for (int j = 0; j < 10; j++) {
                            request(page);
                        }
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    countDownLatch.countDown();
                }
            });
            thread.start();
        }

        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + ",耗時:" + (endTime - starTime));

        for (int pageIndex = 0; pageIndex < 10; pageIndex++) {
            System.out.println("第" + (pageIndex + 1) + "個頁面訪問次數為" + pageRequest.get(pageIndex));
        }
    }
}

輸出:

main,耗時:635
第1個頁面訪問次數為1000
第2個頁面訪問次數為1000
第3個頁面訪問次數為1000
第4個頁面訪問次數為1000
第5個頁面訪問次數為1000
第6個頁面訪問次數為1000
第7個頁面訪問次數為1000
第8個頁面訪問次數為1000
第9個頁面訪問次數為1000
第10個頁面訪問次數為1000

說明:

程式碼中將10個面的訪問量放在了一個int型別的陣列中,陣列大小為10,然後通過AtomicIntegerArray來運算元組中的每個元素,可以確保運算元據的原子性,每次訪問會呼叫incrementAndGet,此方法需要傳入陣列的下標,然後對指定的元素做原子+1操作。輸出結果都是1000,可以看出對於陣列中元素的併發修改是執行緒安全的。如果執行緒不安全,則部分資料可能會小於1000。

其他的一些方法可以自行操作一下,都非常簡單。

引用型別原子類介紹

基本型別原子類只能更新一個變數,如果需要原子更新多個變數,需要使用 引用型別原子類。

  • AtomicReference:引用型別原子類
  • AtomicStampedRerence:原子更新引用型別裡的欄位原子類
  • AtomicMarkableReference :原子更新帶有標記位的引用型別

AtomicReferenceAtomicInteger 非常類似,不同之處在於 AtomicInteger是對整數的封裝,而AtomicReference則是對應普通的物件引用,它可以確保你在修改物件引用時的執行緒安全性。在介紹AtomicReference的同時,我們先來了解一個有關原子操作邏輯上的不足。

ABA問題

之前我們說過,執行緒判斷被修改物件是否可以正確寫入的條件是物件的當前值和期望值是否一致。這個邏輯從一般意義上來說是正確的,但是可能出現一個小小的例外,就是當你獲得當前資料後,在準備修改為新值錢,物件的值被其他執行緒連續修改了兩次,而經過這2次修改後,物件的值又恢復為舊值,這樣,當前執行緒就無法正確判斷這個物件究竟是否被修改過,這就是所謂的ABA問題,可能會引發一些問題。

舉個例子

有一家蛋糕店,為了挽留客戶,決定為貴賓卡客戶一次性贈送20元,刺激客戶充值和消費,但條件是,每一位客戶只能被贈送一次,現在我們用AtomicReference來實現這個功能,程式碼如下:

package com.itsoku.chat22;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/**
 * 跟著阿里p7學併發,微信公眾號:javacode2018
 */
public class Demo3 {
    //賬戶原始餘額
    static int accountMoney = 19;
    //用於對賬戶餘額做原子操作
    static AtomicReference<Integer> money = new AtomicReference<>(accountMoney);

    /**
     * 模擬2個執行緒同時更新後臺資料庫,為使用者充值
     */
    static void recharge() {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                for (int j = 0; j < 5; j++) {
                    Integer m = money.get();
                    if (m == accountMoney) {
                        if (money.compareAndSet(m, m + 20)) {
                            System.out.println("當前餘額:" + m + ",小於20,充值20元成功,餘額:" + money.get() + "元");
                        }
                    }
                    //休眠100ms
                    try {
                        TimeUnit.MILLISECONDS.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }

    /**
     * 模擬使用者消費
     */
    static void consume() throws InterruptedException {
        for (int i = 0; i < 5; i++) {
            Integer m = money.get();
            if (m > 20) {
                if (money.compareAndSet(m, m - 20)) {
                    System.out.println("當前餘額:" + m + ",大於10,成功消費10元,餘額:" + money.get() + "元");
                }
            }
            //休眠50ms
            TimeUnit.MILLISECONDS.sleep(50);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        recharge();
        consume();
    }

}

輸出:

當前餘額:19,小於20,充值20元成功,餘額:39元
當前餘額:39,大於10,成功消費10元,餘額:19元
當前餘額:19,小於20,充值20元成功,餘額:39元
當前餘額:39,大於10,成功消費10元,餘額:19元
當前餘額:19,小於20,充值20元成功,餘額:39元
當前餘額:39,大於10,成功消費10元,餘額:19元
當前餘額:19,小於20,充值20元成功,餘額:39元

從輸出中可以看到,這個賬戶被先後反覆多次充值。其原因是賬戶餘額被反覆修改,修改後的值和原有的數值19一樣,使得CAS操作無法正確判斷當前資料是否被修改過(是否被加過20)。雖然這種情況出現的概率不大,但是依然是有可能出現的,因此,當業務上確實可能出現這種情況時,我們必須多加防範。JDK也為我們考慮到了這種情況,使用AtomicStampedReference可以很好地解決這個問題。

使用AtomicStampedRerence解決ABA的問題

AtomicReference無法解決上述問題的根本原因是,物件在被修改過程中丟失了狀態資訊,比如充值20元的時候,需要同時標記一個狀態,用來標註使用者被充值過。因此我們只要能夠記錄物件在修改過程中的狀態值,就可以很好地解決物件被反覆修改導致執行緒無法正確判斷物件狀態的問題。

AtomicStampedRerence正是這麼做的,他內部不僅維護了物件的值,還維護了一個時間戳(我們這裡把他稱為時間戳,實際上它可以使用任何一個整形來表示狀態值),當AtomicStampedRerence對應的數值被修改時,除了更新資料本身外,還必須要更新時間戳。當AtomicStampedRerence設定物件值時,物件值及時間戳都必須滿足期望值,寫入才會成功。因此,即使物件值被反覆讀寫,寫回原值,只要時間戳發生變數,就能防止不恰當的寫入。

AtomicStampedRerence的幾個Api在AtomicReference的基礎上新增了有關時間戳的資訊。

//比較設定,引數依次為:期望值、寫入新值、期望時間戳、新時間戳
public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp);
//獲得當前物件引用
public V getReference();
//獲得當前時間戳
public int getStamp();
//設定當前物件引用和時間戳
public void set(V newReference, int newStamp);

現在我們使用AtomicStampedRerence來修改一下上面充值的問題,程式碼如下:

package com.itsoku.chat22;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * 跟著阿里p7學併發,微信公眾號:javacode2018
 */
public class Demo4 {
    //賬戶原始餘額
    static int accountMoney = 19;
    //用於對賬戶餘額做原子操作
    static AtomicStampedReference<Integer> money = new AtomicStampedReference<>(accountMoney, 0);

    /**
     * 模擬2個執行緒同時更新後臺資料庫,為使用者充值
     */
    static void recharge() {
        for (int i = 0; i < 2; i++) {
            int stamp = money.getStamp();
            new Thread(() -> {
                for (int j = 0; j < 50; j++) {
                    Integer m = money.getReference();
                    if (m == accountMoney) {
                        if (money.compareAndSet(m, m + 20, stamp, stamp + 1)) {
                            System.out.println("當前時間戳:" + money.getStamp() + ",當前餘額:" + m + ",小於20,充值20元成功,餘額:" + money.getReference() + "元");
                        }
                    }
                    //休眠100ms
                    try {
                        TimeUnit.MILLISECONDS.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }

    /**
     * 模擬使用者消費
     */
    static void consume() throws InterruptedException {
        for (int i = 0; i < 50; i++) {
            Integer m = money.getReference();
            int stamp = money.getStamp();
            if (m > 20) {
                if (money.compareAndSet(m, m - 20, stamp, stamp + 1)) {
                    System.out.println("當前時間戳:" + money.getStamp() + ",當前餘額:" + m + ",大於10,成功消費10元,餘額:" + money.getReference() + "元");
                }
            }
            //休眠50ms
            TimeUnit.MILLISECONDS.sleep(50);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        recharge();
        consume();
    }

}

輸出:

當前時間戳:1,當前餘額:19,小於20,充值20元成功,餘額:39元
當前時間戳:2,當前餘額:39,大於10,成功消費10元,餘額:19元

結果正常了。

關於這個時間戳的,在資料庫修改資料中也有類似的用法,比如2個編輯同時編輯一篇文章,同時提交,只允許一個使用者提交成功,提示另外一個使用者:部落格已被其他人修改,如何實現呢?

部落格表:t_blog(id,content,stamp),stamp預設值為0,每次更新+1

A、B 二個編輯同時對一篇文章進行編輯,stamp都為0,當點選提交的時候,將stamp和id作為條件更新部落格內容,執行的sql如下:

update t_blog set content = 更新的內容,stamp = stamp+1 where id = 部落格id and stamp = 0;

這條update會返回影響的行數,只有一個會返回1,表示更新成功,另外一個提交者返回0,表示需要修改的資料已經不滿足條件了,被其他使用者給修改了。這種修改資料的方式也叫樂觀鎖。

物件的屬性修改原子類介紹

如果需要原子更新某個類裡的某個欄位時,需要用到物件的屬性修改原子類。

  • AtomicIntegerFieldUpdater:原子更新整形欄位的值
  • AtomicLongFieldUpdater:原子更新長整形欄位的值
  • AtomicReferenceFieldUpdater :原子更新應用型別欄位的值

要想原子地更新物件的屬性需要兩步:

  1. 第一步,因為物件的屬性修改型別原子類都是抽象類,所以每次使用都必須使用靜態方法 newUpdater()建立一個更新器,並且需要設定想要更新的類和屬性。

  2. 第二步,更新的物件屬性必須使用 public volatile 修飾符。

上面三個類提供的方法幾乎相同,所以我們這裡以AtomicReferenceFieldUpdater為例子來介紹。

呼叫AtomicReferenceFieldUpdater靜態方法newUpdater建立AtomicReferenceFieldUpdater物件

public static <U,W> AtomicReferenceFieldUpdater<U,W> newUpdater(Class<U> tclass,
                                                                    Class<W> vclass,
                                                                    String fieldName) 

說明:

三個引數

tclass:需要操作的欄位所在的類
vclass:操作欄位的型別
fieldName:欄位名稱

示例

多執行緒併發呼叫一個類的初始化方法,如果未被初始化過,將執行初始化工作,要求只能初始化一次

程式碼如下:

package com.itsoku.chat22;

import com.sun.org.apache.xpath.internal.operations.Bool;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;

/**
 * 跟著阿里p7學併發,微信公眾號:javacode2018
 */
public class Demo5 {

    static Demo5 demo5 = new Demo5();
    //isInit用來標註是否被初始化過
    volatile Boolean isInit = Boolean.FALSE;
    AtomicReferenceFieldUpdater<Demo5, Boolean> updater = AtomicReferenceFieldUpdater.newUpdater(Demo5.class, Boolean.class, "isInit");

    /**
     * 模擬初始化工作
     *
     * @throws InterruptedException
     */
    public void init() throws InterruptedException {
        //isInit為false的時候,才進行初始化,並將isInit採用原子操作置為true
        if (updater.compareAndSet(demo5, Boolean.FALSE, Boolean.TRUE)) {
            System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + ",開始初始化!");
            //模擬休眠3秒
            TimeUnit.SECONDS.sleep(3);
            System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + ",初始化完畢!");
        } else {
            System.out.println(System.currentTimeMillis() + "," + Thread.currentThread().getName() + ",有其他執行緒已經執行了初始化!");
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    demo5.init();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

輸出:

1565159962098,Thread-0,開始初始化!
1565159962098,Thread-3,有其他執行緒已經執行了初始化!
1565159962098,Thread-4,有其他執行緒已經執行了初始化!
1565159962098,Thread-2,有其他執行緒已經執行了初始化!
1565159962098,Thread-1,有其他執行緒已經執行了初始化!
1565159965100,Thread-0,初始化完畢!

說明:

  1. isInit屬性必須要volatille修飾,可以確保變數的可見性
  2. 可以看出多執行緒同時執行init()方法,只有一個執行緒執行了初始化的操作,其他執行緒跳過了。多個執行緒同時到達updater.compareAndSet,只有一個會成功。

java高併發系列目錄

  1. 第1天:必須知道的幾個概念
  2. 第2天:併發級別
  3. 第3天:有關並行的兩個重要定律
  4. 第4天:JMM相關的一些概念
  5. 第5天:深入理解程式和執行緒
  6. 第6天:執行緒的基本操作
  7. 第7天:volatile與Java記憶體模型
  8. 第8天:執行緒組
  9. 第9天:使用者執行緒和守護執行緒
  10. 第10天:執行緒安全和synchronized關鍵字
  11. 第11天:執行緒中斷的幾種方式
  12. 第12天JUC:ReentrantLock重入鎖
  13. 第13天:JUC中的Condition物件
  14. 第14天:JUC中的LockSupport工具類,必備技能
  15. 第15天:JUC中的Semaphore(訊號量)
  16. 第16天:JUC中等待多執行緒完成的工具類CountDownLatch,必備技能
  17. 第17天:JUC中的迴圈柵欄CyclicBarrier的6種使用場景
  18. 第18天:JAVA執行緒池,這一篇就夠了
  19. 第19天:JUC中的Executor框架詳解1
  20. 第20天:JUC中的Executor框架詳解2
  21. 第21天:java中的CAS,你需要知道的東西
  22. 第22天:JUC底層工具類Unsafe,高手必須要了解

java高併發系列連載中,總計估計會有四五十篇文章。

阿里p7一起學併發,公眾號:路人甲java,每天獲取最新文章!

java高併發系列 - 第23天:JUC中原子類,一篇就夠了

相關文章