詭異的併發之原子性

上一節我和你們一塊兒打到了併發中的惡霸可見性,這一節咱們繼續討伐三惡之一的原子性。html

序、原子性的闡述

一個或者多個操做在 CPU 執行的過程當中不被中斷的特性稱爲原子性。java

我理解是一個操做不可再分,即爲原子性。而在併發編程的環境中,原子性的含義就是隻要該線程開始執行這一系列操做,要麼所有執行,要麼所有未執行,不容許存在執行一半的狀況。數據庫

咱們試着從數據庫事務和併發編程兩個方面來進行對比:編程

一、在數據庫中

原子性概念是這樣子的:事務被當作一個不可分割的總體,包含在其中的操做要麼所有執行,要麼所有不執行。且事務在執行過程當中若是發生錯誤,會被回滾到事務開始前的狀態,就像這個事務沒有執行同樣。(也就是說:事務要麼被執行,要麼一個都沒被執行)安全

二、在併發編程中

原子性概念是這樣子的:多線程

  • 第一種理解:一個線程或進程在執行過程當中,沒有發生上下文切換。
    • 上下文切換:指CPU從一個進程/線程切換到另一個進程/線程(切換的前提就是獲取CPU的使用權)。
  • 第二種理解:咱們把一個線程中的一個或多個操做(不可分割的總體),在CPU執行過程當中不被中斷的特性,稱爲原子性。(執行過程當中,一旦發生中斷,就會發生上下文切換)

從上文中對原子性的描述能夠看出,併發編程和數據庫二者之間的原子性概念有些類似: 都是強調,一個原子操做不能被打斷!併發

而非原子操做用圖片表示就是這樣子的:ide

線程A在執行一下子(尚未執行完成),就出讓CPU讓線程B執行。這樣的操做在操做系統中有不少,犧牲切換線程的極短耗時,來提升CPU的利用率,從而在總體上提升系統性能;操做系統的這種操做就被稱爲「時間片」切換。post

1、出現原子性問題的緣由

經過序中描述的原子性的概念,咱們總結出了:致使共享變量在線程之間出現原子性問題的緣由是上下文切換。性能

那麼接下來,咱們經過一個例子來重現原子性問題。

首先定義一個銀行帳戶實體類:

@Data
    @AllArgsConstructor
    public static class BankAccount {
        private long balance;

        public long deposit(long amount){
            balance = balance + amount;
            return balance;
        }
    }複製代碼

而後開啓多個線程對這個共享的銀行帳戶進行存款操做,每次存款1元:

import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.ArrayList;

/**
 * @author :mmzsblog
 * @description:併發中的原子性問題
 * @date :2020/2/25 14:05
 */
public class AtomicDemo {

    public static final int THREAD_COUNT = 100;
    static BankAccount depositAccount = new BankAccount(0);

    public static void main(String[] args) throws Exception {

        ArrayList<Thread> threads = new ArrayList<>();
        for (int i = 0; i < THREAD_COUNT; i++) {
            Thread thread = new DepositThread();
            thread.start();
            threads.add(thread);
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Now the balance is " + depositAccount.getBalance() + "元");
    }

    static class DepositThread extends Thread {
        @Override
        public void run() {
            for (int j = 0; j < 1000; j++) {
                depositAccount.deposit(1);   // 每次存款1元
            }
        }
    }
}複製代碼

屢次運行上面的程序,每次的結果幾乎都不同,偶爾能獲得咱們指望的結果100*1*1000=100000元,以下是我列舉的幾回運行結果:

出現上面狀況的緣由就是由於

balance = balance + amount;複製代碼

這段代碼並非原子操做,其中的balance是一個共享變量。在多線程環境下可能會被打斷。就這樣原子性問題就赤裸裸的出現了。如圖所示:

固然,若是balance是一個局部變量的話,即便在多線程的狀況也不會出現問題(可是這個共享銀行帳戶不適用局部變量啊,不然就不是共享變量了,哈哈,至關於廢話),由於局部變量是當前線程私有的。就像圖中for循環裏的j變量。

歡迎關注公衆號"Java學習之道",查看更多幹貨!

可是呢,即便是共享變量,小編我也毫不容許這樣的問題出現,因此咱們須要解決它,而後更加深入的理解併發編程中的原子性問題。

2、解決上下文切換帶來的原子性問題

2.一、使用局部變量

局部變量的做用域是方法內部,也就是說當方法執行完,局部變量就被拋棄了,局部變量和方法同生共死。而調用棧的棧幀也是和方法同生共死的,因此局部變量放到調用棧裏那兒是至關的合理。而事實上,局部變量的確是放到了調用棧裏。

正是由於每一個線程都有本身的調用棧,局部變量保存在線程各自的調用棧裏面,不會共享,因此天然也就沒有併發問題。總結起來就是:沒有共享,就不會出錯。

但此處若是用局部變量的話,100個線程各自存1000元,最後都是從0開始存,不會累計,也就失去了本來想要展示的結果。故此方法不可行。

正如此處使用單線程也能保證原子性同樣,由於不適合當前場景,所以並不能解決問題。

2.二、自帶原子性保證

在Java中,對基本數據類型的變量的讀取和賦值操做是原子性操做。

好比下面這幾行代碼:

// 原子性
a = true;  

// 原子性
a = 5;     

// 非原子性,分兩步完成:
//          第1步讀取b的值
//          第2步將b賦值給a
a = b;     

// 非原子性,分三步完成:
//          第1步讀取b的值
//          第2步將b值加2
//          第3步將結果賦值給a
a = b + 2; 

// 非原子性,分三步完成:
//          第1步讀取a的值
//          第2步將a值加1
//          第3步將結果賦值給a
a ++;      複製代碼

2.三、synchronized

把全部java代碼都弄成原子性那確定是不可能的,計算機一個時間內能處理的東西永遠是有限的。因此當無法達到原子性時,咱們就必須使用一種策略去讓這個過程看上去是符合原子性的。所以就有了synchronized。

synchronized既能夠保證操做的可見性,也能夠保證操做結果的原子性。

某個對象實例內,synchronized aMethod(){}能夠防止多個線程同時訪問這個對象的synchronized方法。

若是一個對象有多個synchronized方法,只要一個線程訪問了其中的一個synchronized方法,其它線程不能同時訪問這個對象中任何一個synchronized方法。

因此,此處咱們只須要將存款的方法設置成synchronized的就能保證原子性了。

private volatile long balance;

 public synchronized long deposit(long amount){
     balance = balance + amount; //1
     return balance;
 }複製代碼

加了synchronized後,當一個線程沒執行完加了synchronized的deposit這個方法前,其餘線程是不能執行這段被synchronized修飾的代碼的。所以,即便在執行代碼行1的時候被中斷了,其它線程也不能訪問變量balance;因此從宏觀上來看的話,最終的結果是保證了正確性。但中間的操做是否被中斷,咱們並不知道。如需瞭解詳情,能夠看看CAS操做。

PS:對於上面的變量balance你們可能會有點疑惑:變量balance爲何還要加上volatile關鍵字?其實這邊加上volatile關鍵字的目的是爲了保證balance變量的可見性,保證進入synchronized代碼塊每次都會去從主內存中讀取最新值。

故此,此處的

private volatile long balance;複製代碼

也能夠換成synchronized修飾

private synchronized long balance;複製代碼

由於而這都能保證可見性,咱們在第一篇文章詭異的併發之可見性中已經介紹過了。

2.四、Lock鎖

public long deposit(long amount) {
    readWriteLock.writeLock().lock();
    try {
        balance = balance + amount;
        return balance;
    } finally {
        readWriteLock.writeLock().unlock();
    }
}複製代碼

Lock鎖保證原子性的原理和synchronized相似,這邊不進行贅述了。

可能有的讀者會好奇,Lock鎖這裏有釋放鎖的操做,而synchronized好像沒有。其實,Java 編譯器會在 synchronized 修飾的方法或代碼塊先後自動加上加鎖 lock() 和解鎖 unlock(),這樣作的好處就是加鎖 lock() 和解鎖 unlock() 必定是成對出現的,畢竟忘記解鎖 unlock() 但是個致命的 Bug(意味着其餘線程只能死等下去了)。

2.五、原子操做類型

若是要用原子類定義屬性來保證結果的正確性,則須要對實體類做以下修改:

@Data
    @AllArgsConstructor
    public static class BankAccount {
        private AtomicLong balance;

        public long deposit(long amount) {
            return balance.addAndGet(amount);
        }
    }複製代碼

JDK提供了不少原子操做類來保證操做的原子性。好比最多見的基本類型:

AtomicBoolean
AtomicLong
AtomicDouble
AtomicInteger複製代碼

這些原子操做類的底層是使用CAS機制的,這個機制保證了整個賦值操做是原子的不能被打斷的,從而保證了最終結果的正確性。

和synchronized相比,原子操做類型至關因而從微觀上保證原子性,而synchronized是從宏觀上保證原子性。

上面的2.5解決方案中,每一個小操做都是原子性的,好比AtomicLong這些原子類的修改操做,它們自己的crud操做是原子的。

那麼,僅僅是將每一個小操做都符合原子性是否是表明了這整個構成是符合原子性了呢?

顯然不是。

它仍然會產生線程安全問題,好比一個方法的整個過程是讀取A-讀取B-修改A-修改B-寫入A-寫入B;那麼,若是在修改A完成之後,失去操做原子性,此時線程B卻開始執行讀取B操做,此時就會出現原子性問題。

總之不要覺得使用了線程安全類,你的全部代碼就都是線程安全的!這總歸都要從審查你代碼的總體原子性出發。就好比下面的例子:

@NotThreadSafe
    public class UnsafeFactorizer implements Servlet {

        private final AtomicReference<BigInteger> lastNum = new AtomicReference<BigInteger>();
        private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

        @Override
        public void service(ServletRequest request, ServletResponse response) {
            BigInteger tmp = extractFromRequest(request);
            if (tmp.equals(lastNum.get())) {
                System.out.println(lastFactors.get());
            } else {
                BigInteger[] factors = factor(tmp);
                lastNum.set(tmp);
                lastFactors.set(factors);
                System.out.println(factors);
            }
        }
    }複製代碼

雖然它所有用了原子類來進行操做,可是各個操做之間不是原子性的。也就是說:好比線程A在執行else語句裏的lastNumber.set(tmp)完後,也許其餘線程執行了if語句裏的lastFactorys.get()方法,隨後線程A才繼續執行lastFactors.set(factors)方法更新factors

從這個邏輯過程當中,線程安全問題就已經發生了。

它破壞了方法的讀取A-讀取B-修改A-修改B-寫入A-寫入B這一總體過程,在寫入A完成之後其餘線程去執行了讀取B,就致使了讀取到的B值不是寫入後的B值。就這樣原子性就出現了。

好了,以上內容就是我對並法中的原子性的一點理解與總結了,經過這兩篇文章咱們也就大體掌握了併發中常見的可見性、原子性問題以及它們常見的解決方案。

最後

貼一段常常看到的原子性實例問題。

:常聽人說,在32位的機器上對long型變量進行加減操做存在併發隱患,究竟是不是這樣呢?

:在32位的機器上對long型變量進行加減操做存在併發隱患的說法是正確的。

緣由就是:線程切換帶來的原子性問題。

非volatile類型的long和double型變量是8字節64位的,32位機器讀或寫這個變量時得把人家咔嚓分紅兩個32位操做,可能一個線程讀了某個值的高32位,低32位已經被另外一個線程改了。因此官方推薦最好把longdouble 變量聲明爲volatile或是同步加鎖synchronize以免併發問題。

參考文章:

  • 一、極客時間的Java併發編程實戰
  • 二、https://juejin.im/post/5d52abd1e51d4561e6237124
  • 三、https://www.cnblogs.com/54chensongxia/p/12073428.html
  • 四、https://www.dazhuanlan.com/2019/10/04/5d972ff1e314a/


歡迎關注公衆號:Java學習之道

我的博客網站:www.mmzsblog.cn

相關文章
相關標籤/搜索