java高併發系列 - 第21天:java中的CAS操做,java併發的基石

這是java高併發系列第21篇文章。java

本文主要內容

  1. 從網站計數器實現中一步步引出CAS操做
  2. 介紹java中的CAS及CAS可能存在的問題
  3. 悲觀鎖和樂觀鎖的一些介紹及數據庫樂觀鎖的一個常見示例
  4. 使用java中的原子操做實現網站計數器功能

咱們須要解決的問題

需求:咱們開發了一個網站,須要對訪問量進行統計,用戶每次發一次請求,訪問量+1,如何實現呢?web

下面咱們來模仿有100我的同時訪問,而且每一個人對我們的網站發起10次請求,最後總訪問次數應該是1000次。實現訪問以下。數據庫

方式1

代碼以下:tomcat

package com.itsoku.chat20;

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

/**
 * 跟着阿里p7學併發,微信公衆號:javacode2018
 */
public class Demo1 {
    //訪問次數
    static int count = 0;

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

    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,耗時:138,count=975

代碼中的count用來記錄總訪問次數,request()方法表示訪問一次,內部休眠5毫秒模擬內部耗時,request方法內部對count++操做。程序最終耗時1秒多,執行仍是挺快的,可是count和咱們指望的結果不一致,咱們指望的是1000,實際輸出的是973(每次運行結果可能都不同)。服務器

分析一下問題出在哪呢?微信

代碼中採用的是多線程的方式來操做count,count++會有線程安全問題,count++操做其實是由如下三步操做完成的:網絡

  1. 獲取count的值,記作A:A=count
  2. 將A的值+1,獲得B:B = A+1
  3. 讓B賦值給count:count = B

若是有A、B兩個線程同時執行count++,他們同時執行到上面步驟的第1步,獲得的count是同樣的,3步操做完成以後,count只會+1,致使count只加了一次,從而致使結果不許確。多線程

那麼咱們應該怎麼作的呢?併發

對count++操做的時候,咱們讓多個線程排隊處理,多個線程同時到達request()方法的時候,只能容許一個線程能夠進去操做,其餘的線程在外面候着,等裏面的處理完畢出來以後,外面等着的再進去一個,這樣操做count++就是排隊進行的,結果必定是正確的。

咱們前面學了synchronized、ReentrantLock能夠對資源加鎖,保證併發的正確性,多線程狀況下能夠保證被鎖的資源被串行訪問,那麼咱們用synchronized來實現一下。

使用synchronized實現

代碼以下:

package com.itsoku.chat20;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 跟着阿里p7學併發,微信公衆號:javacode2018
 */
public class Demo2 {
    //訪問次數
    static int count = 0;

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

    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,耗時:5563,count=1000

程序中request方法使用synchronized關鍵字,保證了併發狀況下,request方法同一時刻只容許一個線程訪問,request加鎖了至關於串行執行了,count的結果和咱們預期的結果一致,只是耗時比較長,5秒多。

方式3

咱們在看一下count++操做,count++操做其實是被拆分爲3步驟執行:

1. 獲取count的值,記作A:A=count
2. 將A的值+1,獲得B:B = A+1
3. 讓B賦值給count:count = B

方式2中咱們經過加鎖的方式讓上面3步驟同時只能被一個線程操做,從而保證結果的正確性。

咱們是否能夠只在第3步加鎖,減小加鎖的範圍,對第3步作如下處理:

獲取鎖
第三步獲取一下count最新的值,記作LV
判斷LV是否等於A,若是相等,則將B的值賦給count,並返回true,否者返回false
釋放鎖

若是咱們發現第3步返回的是false,咱們就再次去獲取count,將count賦值給A,對A+1賦值給B,而後再將A、B的值帶入到上面的過程當中執行,直到上面的結果返回true爲止。

咱們用代碼來實現,以下:

package com.itsoku.chat20;

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

/**
 * 跟着阿里p7學併發,微信公衆號:javacode2018
 */
public class Demo3 {
    //訪問次數
    volatile static int count = 0;

    //模擬訪問一次
    public static void request() throws InterruptedException {
        //模擬耗時5毫秒
        TimeUnit.MILLISECONDS.sleep(5);
        int expectCount;
        do {
            expectCount = getCount();
        } while (!compareAndSwap(expectCount, expectCount + 1));
    }

    /**
     * 獲取count當前的值
     *
     * @return
     */
    public static int getCount() {
        return count;
    }

    /**
     * @param expectCount 指望count的值
     * @param newCount    須要給count賦的新值
     * @return
     */
    public static synchronized boolean compareAndSwap(int expectCount, int newCount) {
        //判斷count當前值是否和指望的expectCount同樣,若是同樣將newCount賦值給count
        if (getCount() == expectCount) {
            count = newCount;
            return true;
        }
        return false;
    }

    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,耗時:116,count=1000

代碼中用了volatile關鍵字修飾了count,能夠保證count在多線程狀況下的可見性。關於volatile關鍵字的使用,也是很是很是重要的,前面有講過,不太瞭解的朋友能夠去看一下:volatile與Java內存模型

我們再看一下代碼,compareAndSwap方法,咱們給起個簡稱吧叫CAS,這個方法有什麼做用呢?這個方法使用synchronized修飾了,能保證此方法是線程安全的,多線程狀況下此方法是串行執行的。方法由兩個參數,expectCount:表示指望的值,newCount:表示要給count設置的新值。方法內部經過getCount()獲取count當前的值,而後與指望的值expectCount比較,若是指望的值和count當前的值一致,則將新值newCount賦值給count。

再看一下request()方法,方法中有個do-while循環,循環內部獲取count當前值賦值給了expectCount,循環結束的條件是compareAndSwap返回true,也就是說若是compareAndSwap若是不成功,循環再次獲取count的最新值,而後+1,再次調用compareAndSwap方法,直到compareAndSwap返回成功爲止。

代碼中至關於將count++拆分開了,只對最後一步加鎖了,減小了鎖的範圍,此代碼的性能是否是比方式2快很多,還能保證結果的正確性。你們是否是感受這個compareAndSwap方法挺好的,這東西確實很好,java中已經給咱們提供了CAS的操做,功能很是強大,咱們繼續向下看。

CAS

CAS,compare and swap的縮寫,中文翻譯成比較並交換。

CAS 操做包含三個操做數 —— 內存位置(V)、預期原值(A)和新值(B)。 若是內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值 。不然,處理器不作任何操做。不管哪一種狀況,它都會在 CAS 指令以前返回該 位置的值。(在 CAS 的一些特殊狀況下將僅返回 CAS 是否成功,而不提取當前 值。)CAS 有效地說明了「我認爲位置 V 應該包含值 A;若是包含該值,則將 B 放到這個位置;不然,不要更改該位置,只告訴我這個位置如今的值便可。」

一般將 CAS 用於同步的方式是從地址 V 讀取值 A,執行多步計算來得到新 值 B,而後使用 CAS 將 V 的值從 A 改成 B。若是 V 處的值還沒有同時更改,則 CAS 操做成功。

系統底層進行CAS操做的時候,會判斷當前系統是否爲多核系統,若是是就給總線加鎖,只有一個線程會對總線加鎖成功,加鎖成功以後會執行cas操做,也就是說CAS的原子性其實是CPU實現的, 其實在這一點上仍是有排他鎖的.,只是比起用synchronized, 這裏的排他時間要短的多, 因此在多線程狀況下性能會比較好。

java中提供了對CAS操做的支持,具體在sun.misc.Unsafe類中,聲明以下:

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

上面三個方法都是相似的,主要對4個參數作一下說明。

var1:表示要操做的對象

var2:表示要操做對象中屬性地址的偏移量

var4:表示須要修改數據的指望的值

var5:表示須要修改成的新值

JUC包中大部分功能都是依靠CAS操做完成的,因此這塊也是很是重要的,有關Unsafe類,下篇文章會具體講解。

synchronizedReentrantLock這種獨佔鎖屬於悲觀鎖,它是在假設須要操做的代碼必定會發生衝突的,執行代碼的時候先對代碼加鎖,讓其餘線程在外面等候排隊獲取鎖。悲觀鎖若是鎖的時間比較長,會致使其餘線程一直處於等待狀態,像咱們部署的web應用,通常部署在tomcat中,內部經過線程池來處理用戶的請求,若是不少請求都處於等待獲取鎖的狀態,可能會耗盡tomcat線程池,從而致使系統沒法處理後面的請求,致使服務器處於不可用狀態。

除此以外,還有樂觀鎖,樂觀鎖的含義就是假設系統沒有發生併發衝突,先按無鎖方式執行業務,到最後了檢查執行業務期間是否有併發致使數據被修改了,若是有併發致使數據被修改了 ,就快速返回失敗,這樣的操做使系統併發性能更高一些。cas中就使用了這樣的操做。

關於樂觀鎖這塊,想必你們在數據庫中也有用到過,給你們舉個例子,可能之後會用到。

若是大家的網站中有調用支付寶充值接口的,支付寶那邊充值成功了會回調商戶系統,商戶系統接收到請求以後怎麼處理呢?假設用戶經過支付寶在商戶系統中充值100,支付寶那邊會從用戶帳戶中扣除100,商戶系統接收到支付寶請求以後應該在商戶系統中給用戶帳戶增長100,而且把訂單狀態置爲成功。

處理過程以下:

開啓事務
獲取訂單信息
if(訂單狀態==待處理){
    給用戶帳戶增長100
    將訂單狀態更新爲成功
}
返回訂單處理成功
提交事務

因爲網絡等各類問題,可能支付寶回調商戶系統的時候,回調超時了,支付寶又發起了一筆回調請求,恰好這2筆請求同時到達上面代碼,最終結果是給用戶帳戶增長了200,這樣事情就搞大了,公司蒙受損失,嚴重點可能讓公司就此倒閉了。

那咱們能夠用樂觀鎖來實現,給訂單表加個版本號version,要求每次更新訂單數據,將版本號+1,那麼上面的過程能夠改成:

獲取訂單信息,將version的值賦值給V_A
if(訂單狀態==待處理){
    開啓事務
    給用戶帳戶增長100
    update影響行數 = update 訂單表 set version = version + 1 where id = 訂單號 and version = V_A;
    if(update影響行數==1){
        提交事務
    }else{
        回滾事務
    }
}
返回訂單處理成功

上面的update語句至關於咱們說的CAS操做,執行這個update語句的時候,多線程狀況下,數據庫會對當前訂單記錄加鎖,保證只有一條執行成功,執行成功的,影響行數爲1,執行失敗的影響行數爲0,根據影響行數來決定提交仍是回滾事務。上面操做還有一點是將事務範圍縮小了,也提高了系統併發處理的性能。這個知識點但願大家能get到。

CAS 的問題

cas這麼好用,那麼有沒有什麼問題呢?還真有

ABA問題

CAS須要在操做值的時候檢查下值有沒有發生變化,若是沒有發生變化則更新,可是若是一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,可是實際上卻變化了。這就是CAS的ABA問題。 常見的解決思路是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。 目前在JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法做用是首先檢查當前引用是否等於預期引用,而且當前標誌是否等於預期標誌,若是所有相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

循環時間長開銷大

上面咱們說過若是CAS不成功,則會原地循環(自旋操做),若是長時間自旋會給CPU帶來很是大的執行開銷。併發量比較大的狀況下,CAS成功機率可能比較低,可能會重試不少次纔會成功。

使用JUC中的類實現計數器

juc框架中提供了一些原子操做,底層是經過Unsafe類中的cas操做實現的。經過原子操做能夠保證數據在併發狀況下的正確性。

此處咱們使用java.util.concurrent.atomic.AtomicInteger類來實現計數器功能,AtomicInteger內部是採用cas操做來保證對int類型數據增減操做在多線程狀況下的正確性。

計數器代碼以下:

package com.itsoku.chat20;

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

/**
 * 跟着阿里p7學併發,微信公衆號:javacode2018
 */
public class Demo4 {
    //訪問次數
    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,耗時:119,count=1000

耗時很短,而且結果和指望的一致。

關於原子類操做,都位於java.util.concurrent.atomic包中,下篇文章咱們主要來介紹一下這些經常使用的類及各自的使用場景。

java高併發系列

阿里p7一塊兒學併發,公衆號:路人甲java,天天獲取最新文章!

clipboard.png

相關文章
相關標籤/搜索