線程安全之可見性(Volatile)和原子性(Atomic)

線程之可見性

可見性原理分析

可見性問題:讓一個線程對共享變量的修改,能夠及時的被其他線程看到。
JAVA 內存模型規定:
對volatile變量v的寫入,與所有其他線程後續對v的讀同步
要滿足這些條件,所以volatile 關鍵字就有以下的功能:

  1. 禁止緩存;
    volatile 變量訪問控制符會加個ACC_VOLATILE
    https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.2
  2. 對volatile 變量相關的指令不做重排序。

共享變量的定義
可以在線程之間共享的內存稱爲共享內存或堆內存。
所有實例字段,靜態字段和數組元素都存在在堆內存中,這些字段和數組都是共享變量
衝突發生:如果至少有一個訪問是寫操作,那麼對同一個變量的兩次訪問是衝突的。
這些能被多個線程訪問的共享變量時內存模型規範的對象。

線程間操作的定義

1.線程間的操作是指:一個程序執行的操作可被其他線程感知或被其他線程直接影響。
2. Java 內存模型只描述線程間操作,不描述線程內操作,線程內操作按照線程內語義執行。線程間的操作有:

read 一般讀操作,非volatile讀
write 一般寫操作,非volatile寫
volatile read
volatile write
Lock.(鎖monitor)、Unlock
線程的第一個和最後一個操作
外部操作
所有線程間的操作都存在可見性問題,JMM需要對其進行規範

同步的規則定義

對volatile變量v的寫入,與所有其他線程後續對v的讀同步。
對於監視器m的解鎖與所有後續操作對於m的加鎖同步。

Happens-before 先行發生原則

happens before 用於描述兩個有衝突的動作之間的順序,如果有一個action happends before 另一個action,則第一個操作被第二個操作可見,JVM 需要實現如下happens-before 規則:

某個線程中的每個動作都happens-before 該線程後面的動作。
某個管程上的unlock 動作和happens-before 同一個管程上後續的lock動作。
對某個volatile字段的寫操作happens-before 每個後續對該volatile字段的讀操作。
在某個線程對象上調用start() happens-before 被啓動線程中的任意動作。
如果在線程t1 中成功執行了t2.join(),則t2 中的所有操作對t1可見。
如果某個動作a happens-before動作b,且b happens-before 動作c,則有 a happens-before c。

當程序包含兩個沒有被happens-before關係排序的衝突訪問時,就稱存在數據競爭,遵守了這個原則,也就意味着有些代碼不能進行重排序,有些數據不能緩存!

Final 修飾符

final 在該對象的構造函數中設置對象的字段,當線程看到該對象時,將始終看到該對象的final 字段的正確構造版本。僞代碼示例: f = new FinalDemo(); 讀取到的f.x 一定是最新的值,x 爲final 修飾的字段。
如果在構造函數中設置字段後發生讀取,則會看到該final 字段分配的值,否則它將看到默認值:僞代碼示例:pulbic finalDemo(){x=1;y=x} y會等於1;

Word Tearing 字節處理
有些處理器(尤其是早起的Alphas處理器)沒有提供些單個字節的功能,在這樣的處理器上更新byte數組,若只是簡單的讀取整個內容,更新對象的字節,然後將這個內容再寫回內存,將是不合法的。
這個問題有時被分爲“字分裂(word tearing)”更新單個字節有難度的處理器,就需要尋求其它方式來解決問題。因此在開發找那個儘量不要對byte[] 中的元素進行重新賦值,更不要在多線程程序這樣做。

線程可見性總結

保證變量可見性的方式:

  1. final變量

  2. synchronized 關鍵字:
    synchronized 語義規範:
    1) 進入同步代碼塊前,先清空工作內存中的所有共享變量,從主內存中重新加載。
    2) 解鎖前必須把修改的共享變量同步回主內存。
    注意:如果線程競爭兩把不同的鎖則不能保證
    如何保證線程安全:
    1) 鎖機制保護共享資源,只有獲得鎖的線程纔可操作共享資源。
    2) Synchronized 語義規範保證了修改共享資源後,會同步主內存,就做到了線程安全。

  3. 用volatile修飾
    1) 使用volatile變量時,必須重新從主內存加載,並且read,load是連續的。
    2) 修改volatile變量時,必須立馬同步回主內存,並且store,write是連續的。

線程安全之原子性

原子操作

原子操作可以是一個步驟,也可以是多個操作步驟,但是其順序不可以被打亂,也不可以被切割而只執行其中的一部分(不可中斷性)。
將整個操作視爲一個整體,資源在該次操作中保持一致,這是原子性的核心特性。

存在的問題及分析原因

示例代碼:

/**
* 實現累加的功能
*/
public class Counter {

    volatile int i = 0;

    public synchronized void add() {
        i++;
    }
}
/**
*  對累加器的測試類
*/
public class CounterTest {

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

        Counter counter = new Counter();

        for (int i = 0; i < 8; i++) {

            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    counter.add();
                }
                System.out.println("done...");
            }).start();

        }

        Thread.sleep(6000L);

		//期望的結果是:i*j 爲循環次數 = 80000 
        System.out.println(counter.i);

    }
}

輸出的結果:

done...
done...
done...
done...
done...
done...
done...
done...
27661

存在的問題

輸出結果最終是: 27661 並不是我們所期望的80000。再多運行幾次呢,輸出的結果也是每次都不同,但都不是我們想要的值。那麼我們想到可能線程併發的問題,我們將累加器加上鎖或者加上synchronized 關鍵字試試看:

/**
* 加synchronized 的 Counter
*/
public class Counter {

    volatile int i = 0;

   	// 已加上同步關鍵字
    public synchronized void add() {
        i++;
    }
}
/**
* 加鎖的Counter 
*/
public class Counter {

    volatile int i = 0;
    Lock lock = new ReentrantLock();

   //已加上鎖
    public void add() {
        lock.lock();

        i++;

        lock.unlock();
    }
}

再使用上面的CounterTest 分別調用不同寫法的Counter 類,發現運行結果都是我們期望的80000:

done...
done...
done...
done...
done...
done...
done...
done...
80000

這就說明以上我們的猜測是正確的,但是爲什麼會出現不同的結果呢?接下來以下分析:
首先我們還是將Counter 類還原到最初的狀態(不加鎖也不加synchronized):
然後進入到Counter.class 所在的目錄打開終端執行(反編譯查看字節碼):

javap -v -p Counter.class

字節碼顯示:

  Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return

將以上的指令碼對應內存分析,假如現在有t1 線程,按照從上到下的指令碼執行,無論加多少次,結果是我們所期望的。
在這裏插入圖片描述
以上是隻有一個線程的情況,現在又加入了一個線程t2, 按照同樣的指令碼從上到下執行,t1,t2 執行完一次後 i 的結果是1而不是2,前面講到原子操作,是一個不可中斷的操作。 按照下圖可以知道,對於t1線程來說,從上到下執行的指令碼應爲一個原子操作,不可讓其它線程同時執行。由於前面提到可以使用鎖機制來解決這一系列問題,但是現有另一種實現反方式!
在這裏插入圖片描述

解決辦法

藉助sun.misc.Unsafe 類:

public class CounterUnsafe {

    volatile int i = 0;

    private static Unsafe unsafe = null;

    private static long valueOffset;

    static {

        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);

            Field declaredField_i = CounterUnsafe.class.getDeclaredField("i");
            valueOffset = unsafe.objectFieldOffset(declaredField_i);

        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    /**
     * 自旋
     */
    public void add() {

        for (; ; ) {
            int current = unsafe.getIntVolatile(this, valueOffset);
            if (unsafe.compareAndSwapInt(this, valueOffset, current, current + 1)) {
                break;//如果修改不成功就自旋,修改成功就退出循環
            }
        }
    }

使用CounterTest 類來創建CounterUnsafe 對象作爲counter 來調用add():


public class Demo1_CounterTest {

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

	//創建CounterUnsafe  對象:
        CounterUnsafe counter = new CounterUnsafe();


        for (int i = 0; i < 8; i++) {

            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    counter.add();
                }
                System.out.println("done...");
            }).start();

        }

        Thread.sleep(6000L);
        
		//最終輸出的結果是我們期望的結果:80000
        System.out.println(counter.i);

    }
}
 if (unsafe.compareAndSwapInt(this, valueOffset, current, current + 1)) {...}

如果您覺得以上條件判斷眼熟,那麼您一定了解過:java.util.concurrent.atomic (併發原子類)
在這裏插入圖片描述

CAS(Compare and swap)

Compare and swap 比較和交換。屬於硬件同步原語,處理器提供了基本內存操作的原子性保證。
CAS 操作需要輸入兩個值,一個舊值A(期望操作前的值)和一個新值B, 在操作期間先對舊值進行比較,若沒有發生變化,才交換成新值,發生了變化則不交換。
JAVA 中的sun.misc.Unsafe 類,提供了compareAndSwapInt() 和compareAndSwapLong() 等幾個方法實現CAS。
CAS 原理
在這裏插入圖片描述
同時有多個操作去修改同一個值時,先比較內存中期望的值是否爲CAS中的第一個參數,如果是則用新值替換舊值,如果不是則不修改(自旋)。

CAS 操作的缺點

  1. 循環+cas ,自旋的實現讓所有線程都處於高頻運行,爭搶CPU執行時間的狀態。如果操作長時間不成功,會帶來很大的CPU資源消耗。
  2. 僅針對單個變量的操作,不能用於多個變量來實現原子操作。
  3. ABA 問題。

ABA 問題

線程1,線程2同時執行CAS(0,1)操作,將i 的值由0 改爲1
假設線程1操作成功,線程2操作失敗
緊接着線程1執行了CAS(1,0),將i 的值改回0

ABA 問題的解決辦法
在每次CAS操作中帶上版本號,作爲每次的操作的區分。AtomicStampedReference 中的源碼,initialStamp爲初始化的版本號:
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章