圖解 Java 線程安全

什麼是線程

按操做系統中的描述,線程是 CPU 調度的最小單元,直觀來講線程就是代碼按順序執行下來,執行完畢就結束的一條線。java

舉個 🌰,富土康的一個組裝車間至關於 CPU ,而線程就是當前車間裏的一條條做業流水線。爲了提升產能和效率,車間裏通常都會有多條流水線同時做業。一樣在咱們 Android 開發中多線程能夠說是隨處可見了,如執行耗時操做,網絡請求、文件讀寫、數據庫讀寫等等都會開單獨的子線程來執行。數據庫

那麼你的線程是安全的嗎?線程安全的原理又是什麼呢?(本文內容是我的學習總結淺見,若有錯誤的地方,望大佬們輕拍指正)緩存

線程安全

瞭解線程安全的以前先來了解一下 Java 的內存模型,先搞清楚線程是怎麼工做的。安全

Java 內存模型 - JMM

什麼是 JMM

JMM(Java Memory Model),是一種基於計算機內存模型(定義了共享內存系統中多線程程序讀寫操做行爲的規範),屏蔽了各類硬件和操做系統的訪問差別的,保證了Java程序在各類平臺下對內存的訪問都能保證效果一致的機制及規範。保證共享內存的原子性可見性有序性性能優化

能用圖的地方儘可能不廢話,先來看一張圖: 網絡

image

上圖描述了一個多線程執行場景。 線程 A 和線程 B 分別對主內存的變量進行讀寫操做。其中主內存中的變量共享變量,也就是說此變量只此一份,多個線程間共享。可是線程不能直接讀寫主內存的共享變量,每一個線程都有本身的工做內存,線程須要讀寫主內存的共享變量時須要先將該變量拷貝一份副本到本身的工做內存,而後在本身的工做內存中對該變量進行全部操做,線程工做內存對變量副本完成操做以後須要將結果同步至主內存。多線程

線程的工做內存是線程私有內存,線程間沒法互相訪問對方的工做內存。併發

爲了便於理解,用圖來描述一下線程對變量賦值的流程。 ide

image

那麼問題來了,線程工做內存怎麼知道何時又是怎樣將數據同步到主內存呢? 這裏就輪到 JMM 出場了。 JMM 規定了什麼時候以及如何作線程工做內存與主內存之間的數據同步。源碼分析

對 JMM 有了初步的瞭解,簡單總結一下原子性可見性有序性

原子性

對共享內存的操做必須是要麼所有執行直到執行結束,且中間過程不能被任何外部因素打斷,要麼就不執行。

可見性

多線程操做共享內存時,執行結果可以及時的同步到共享內存,確保其餘線程對此結果及時可見。

有序性

程序的執行順序按照代碼順序執行,在單線程環境下,程序的執行都是有序的,可是在多線程環境下,JMM 爲了性能優化,編譯器和處理器會對指令進行重排,程序的執行會變成無序。

到這裏,咱們能夠引出本文的主題了 --【線程安全】

線程安全的本質

其實第一張圖的例子是有問題的,主內存中的變量是共享的,全部線程均可以訪問讀寫,而線程工做內存又是線程私有的,線程間不可互相訪問。那在多線程場景下,圖上的線程 A 和線程 B 同時來操作共享內存裏的同一個變量,那麼主內存內的此變量數據就會被破壞。也就是說主內存內的此變量不是線程安全的。 咱們來看個代碼小例子幫助理解。

public class ThreadDemo {
    private int x = 0;

    private void count() {
        x++;
    }

    public void runTest() {
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("final x from 1: " + x);
            }
        }.start();
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("final x from 2: " + x);
            }
        }.start();
    }

    public static void main(String[] args) {
        new ThreadDemo().runTest();
    }
}
複製代碼

示例代碼中 runTest 方法2個線程分別執行 1_000_000count() 方法, count() 方法中只執行簡單的 x++ 操做,理論上每次執行 runTest 方法應該有一個線程輸出的 x 結果應該是2_000_000。但實際的運行結果並不是咱們所想:

final x from 1: 989840
final x from 2: 1872479
複製代碼

我運行了10次,其中一個線程輸出 x 的值爲 2_000_000 只出現了2次。

final x from 1: 1000000
final x from 2: 2000000
複製代碼

出現這樣的結果的緣由也就是咱們上面所說的,在多線程環境下,咱們主內存的 x 變量的數據被破壞了。 咱們都知道完成一次 i++ 至關於執行了:

int tmp = x + 1;
x = tmp;
複製代碼

在多線程環境下就會出如今執行完 int tmp = x + 1; 這行代碼時就發生了線程切換,當線程再次切回來的時候,x 就會被重複賦值,致使出現上面的運行結果,2個線程都沒法輸出 2_000_000

下圖描述了示例代碼的執行時序:

image

那麼 Java 是如何來解決上述問題來保證線程安全,保證共享內存的原子性可見性有序性的呢?

線程同步

Java 提供了一系列的關鍵字和類來保證線程安全

Synchronized 關鍵字

Synchronized 做用

1. 保證方法或代碼塊操做的原子性

Synchronized 保證⽅法內部或代碼塊內部資源(數據)的互斥訪問。即同⼀時間、由同⼀個 Monitor(監視鎖) 監視的代碼,最多隻能有⼀個線程在訪問。

關於 Monitor 和 Synchronized 實現原理了解能夠看下這2篇文章:Synchronized 的實現原理Moniter 的實現原理

話很少說來張動圖描述一下 Monitor 工做機制:

image

被 Synchronized 關鍵字描述的方法或代碼塊在多線程環境下同一時間只能由一個線程進行訪問,在持有當前 Monitor 的線程執行完成以前,其餘線程想要調用相關方法就必須進行排隊,知道持有持有當前 Monitor 的線程執行結束,釋放 Monitor ,下一個線程纔可獲取 Monitor 執行。

若是存在多個 Monitor 的狀況時,多個 Monitor 之間是不互斥的。

多個 Monitor 的狀況出如今自定義多個鎖分別來描述不一樣的方法或代碼塊,Synchronized 在描述代碼塊時能夠指定自定義 Monitor ,默認爲 this 即當前類。

image

2.保證監視資源的可見性

保證多線程環境下對監視資源的數據同步。即任何線程在獲取到 Monitor 後的第⼀時 間,會先將共享內存中的數據複製到⾃⼰的緩存中;任何線程在釋放 Monitor 的第⼀ 時間,會先將緩存中的數據複製到共享內存中。

3.保證線程間操做的有序性

Synchronized 的原子性保證了由其描述的方法或代碼操做具備有序性,同一時間只能由最多隻能有一個線程訪問,不會觸發 JMM 指令重排機制。

Volatile 關鍵字

Volatile 做用

保證被 Volatile 關鍵字描述變量的操做具備可見性有序性(禁止指令重排)

注意:
1.Volatile 只對基本類型 (byte、char、short、int、long、float、double、boolean) 的賦值 操做和對象的引⽤賦值操做有效。
2 對於 i++ 此類複合操做, Volatile 沒法保證其有序性和原子性。
3.相對 Synchronized 來講 Volatile 更加輕量一些。

3. java.util.concurrent.atomic

java.util.concurrent.atomic 包提供了一系列的 AtomicBooleanAtomicIntegerAtomicLong 等類。使用這些類來聲明變量能夠保證對其操做具備原子性來保證線程安全。

實現原理上與 Synchronized 使用 Monitor(監視鎖)保證資源在多線程環境下阻塞互斥訪問不一樣,java.util.concurrent.atomic 包下的各原子類基於 CAS(CompareAndSwap) 操做原理實現。

CAS 又稱無鎖操做,一種樂觀鎖策略,原理就是多線程環境下各線程訪問共享變量不會加鎖阻塞排隊,線程不會被掛起。通俗來說就是一直循環對比,若是有訪問衝突則重試,直到沒有衝突爲止。

4. Lock

Lock 也是 java.util.concurrent 包下的一個接口,定義了一系列的鎖操做方法。Lock 接口主要有 ReentrantLock,ReentrantReadWriteLock.ReadLock,ReentrantReadWriteLock.WriteLock 實現類。與 Synchronized 不一樣是 Lock 提供了獲取鎖和釋放鎖等相關接口,使得使用上更加靈活,同時也能夠作更加複雜的操做,如:

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
private int x = 0;
private void count() {
    writeLock.lock();
    try {
        x++;
    } finally {
        writeLock.unlock();
    }
}
private void print(int time) {
    readLock.lock();
    try {
        for (int i = 0; i < time; i++) {
            System.out.print(x + " ");
        }
        System.out.println();
    } finally {
        readLock.unlock();
    }
}
複製代碼

關於 Lock 實現原理和更詳細的使用推薦如下2篇文章:
Lock鎖的使用
Lock鎖源碼分析

總結

  1. 出現線程安全問題的緣由:
    在多個線程併發環境下,多個線程共同訪問同一共享內存資源時,其中一個線程對資源進行寫操做的中途(寫⼊入已經開始,但還沒 結束),其餘線程對這個寫了一半的資源進⾏了讀操做,或者對這個寫了一半的資源進⾏了寫操做,致使此資源出現數據錯誤。

  2. 如何避免線程安全問題?

  • 保證共享資源在同一時間只能由一個線程進行操做(原子性,有序性)。
  • 將線程操做的結果及時刷新,保證其餘線程能夠當即獲取到修改後的最新數據(可見性)。

參考文獻:

HenCoder Plus

再有人問你Java內存模型是什麼,就把這篇文章發給他

相關文章
相關標籤/搜索