Java併發編程之Volatile原理分析

引子

以前的文章,咱們講到volatile的一些做用數組

  • 可見性,保證此變量對全部線程是可見的。
  • 原子性,只對任意單個volatile變量的讀/寫具備原子性
  • 有序性,被volatile聲明過的變量會禁止指令重排序優化

今天咱們來分析一下volatile的具體用法和內存可見性/讀寫原子性的實現原理緩存

用法

咱們先來看看volatile的使用場景安全

可使用volatile的狀況包括:併發

  • 對變量的操做不依賴當前值
  • 該變量沒有包含在具備其它變量的不變式中

咱們來經過2個例子說明這些狀況性能

class VolatileFeatures {
    long vl = 0L;               // 64位的long型普通變量

    //對單個的普通 變量的寫用同一個鎖同步
    public synchronized void set(long l) {             
       vl = l;
    }

    public void getAndIncrement () { //普通方法調用
        long temp = get();           //調用已同步的讀方法
        temp += 1L;                  //普通寫操做
        set(temp);                   //調用已同步的寫方法
    }
    public synchronized long get() { 
        //對單個的普通變量的讀用同一個鎖同步
        return vl;
    }
}

上面的例子中對值的修改須要依賴當前值,可是當前值可能會同時修改,從而出錯測試

public class NumberRange {
    private volatile int lower = 0;
     private volatile int upper = 10;

    public int getLower() { return lower; }
    public int getUpper() { return upper; }

    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }

    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}

上述代碼中,上下界初始化分別爲0和10,假設線程A和B在某一時刻同時執行了setLower(8)和setUpper(5), 且都經過了不變式的檢查,設置了一個無效範圍(8, 5),優化

因此在這種場景下是沒法保證線程安全的,須要經過sychronize保證方法setLower和setUpper在每一時刻只有一個線程可以執行。this

簡單的來講寫入volatile 變量的這些有效值須要獨立於任何程序的狀態,包括變量的當前狀態.net

常見的使用場景線程

public class ServerHandler {
    private volatile isopen;
    public void run() {
        if (isopen) {
        } else {
        }
    }
    public void setIsopen(boolean isopen) {
        this.isopen = isopen
    }
}

在併發場景中經過volatile來控制isopen在控制線程的執行邏輯

當只有一個線程能夠修改字段的值,其它線程能夠隨時讀取,那麼把字段聲明爲volatile也是合理的。

須要注意的是聲明一個引用變量爲volatile,不能保證經過該引用變量訪問到的非volatile變量的可見性。同理,聲明一個數組變量爲volatile不能確保數組內元素的可見性。volatile的特性不能在數組內傳遞,由於數組裏的元素不能被聲明爲volatile

原理

下面咱們來分析一下volatile的實現原理,如何保證內存的可見性和讀寫的原子性

咱們經過觀察volatile變量和普通變量所生成的彙編代碼能夠發現,操做volatile變量會多出一個lock前綴指令:

Java代碼:
private volatile Singleton instance = new Singleton();

彙編代碼:
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: **lock** addl $0x0,(%esp);

在這裏這個lock前綴指令,提供瞭如下保證:

  1. 保證多個CPU同一時間不能操做相同的緩存
  2. 將當前CPU緩存行的數據寫回到主內存;
  3. 這個寫回內存的操做會致使在其它CPU裏緩存了該內存地址的數據無效。

CPU爲了提升處理性能,並不直接和內存進行通訊,而是將內存的數據讀取到內部緩存再進行操做,但操做完並不能肯定什麼時候寫回到內存,但對volatile變量進行寫操做,當CPU執行到Lock前綴指令時,會將這個變量所在緩存行的數據寫回到內存,但其它CPU緩存的仍是舊值,因此爲了保證各個CPU的緩存一致性,每一個CPU經過檢測在總線上傳播的數據來檢查本身緩存的數據有效性,當發現本身緩存行對應的內存地址的數據被修改,就會將該緩存行設置成無效狀態,當CPU讀取該變量時,發現所在的緩存行被設置爲無效,就會從新從內存中讀取數據到緩存中。

這裏能夠參考咱們以前的文章對cpu的原子性實現的分析

僞共享問題

在使用volatile的時候咱們還會遇到僞共享的問題

那麼什麼是僞共享問題

咱們首先要知道,cpu緩存加載的時候一次性最少會加載64個字節(64位處理器),這意味着若是一個隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每一個處理器都會緩存一樣的頭尾節點,當一個處理器試圖修改頭接點時會將整個緩存行鎖定,那麼在緩存一致性機制的做用下,會致使其餘處理器要從新加載緩存,而隊列的入隊和出隊操做是須要不停修改頭接點和尾節點,因此在多處理器的狀況下將會嚴重影響到隊列的入隊和出隊效率。咱們可使用追加到64字節的方式來填滿高速緩衝區的緩存行,避免頭接點和尾節點加載到同一個緩存行,使得頭尾節點在修改時不會互相鎖定,從而解決僞共享的問題。

下面咱們經過一個例子來講明這個問題

/**
 * 僞共享優化
 *
 */
public final class FalseSharing implements Runnable {
    public static int NUM_THREADS = 4; // change
	public final static long ITERATIONS = 500L * 1000L * 1000L;
	private final int arrayIndex;
	private static VolatileLong[] longs;

	public FalseSharing(final int arrayIndex) {
		this.arrayIndex = arrayIndex;
	}

	public static void main(final String[] args) throws Exception {
		Thread.sleep(1000);
		System.out.println("starting....");
		if (args.length == 1) {
			NUM_THREADS = Integer.parseInt(args[0]);
		}

		longs = new VolatileLong[NUM_THREADS];
		for (int i = 0; i < longs.length; i++) {
			longs[i] = new VolatileLong();
		}
		final long start = System.currentTimeMillis();
		runTest();
		System.out.println("duration = " + (System.currentTimeMillis() - start));
	}

	private static void runTest() throws InterruptedException {
		Thread[] threads = new Thread[NUM_THREADS];
		for (int i = 0; i < threads.length; i++) {
			threads[i] = new Thread(new FalseSharing(i));
		}
		for (Thread t : threads) {
			t.start();
		}
		for (Thread t : threads) {
			t.join();
		}
	}

	public void run() {
		long i = ITERATIONS + 1;
		while (0 != --i) {
			longs[arrayIndex].value = i;
		}
	}
   
    @Contended  //JDK8 默認支持自動填充加上這個註解 而且加上虛擬機參數-XX:-RestrictContended
	public final static class VolatileLong {
		public volatile long value = 0L;

		//64位系統默認對象頭12字節(開啓壓縮) 補充10個字節的無用對象讓緩存行共享失效
		public long p1, p2, p3, p4, p5, p6,p7,p8,p9,p10; // 這行代碼註釋掉速度就慢很多
	}
}

分別測試添加一些無用字節來填充緩存行,和不填充,發現速度上差了很多,這就是僞共享帶來的問題

那麼是否是在使用Volatile變量時都應該追加到64字節呢?在兩種場景下不該該使用這種方式。

第一:緩存行非64字節寬的處理器,如P6系列和奔騰處理器高速緩存行是32個字節寬。

第二:共享變量不會被頻繁的寫。由於使用追加字節的方式須要處理器讀取更多的字節到高速緩衝區,這自己就會帶來必定的性能消耗,共享變量若是不被頻繁寫的話,鎖的概率也很是小,就不必追加字節

總結

本文咱們探討了volatile的具體用法和 volatile經過CPU的Lock指令來保證內存可見性/讀寫原子性的實現原理 咱們還討論了緩存行引發的僞共享問題和解決方案,

其中省略了volatile引發的重排序內容等咱們將在後面的JVM內存模型文章中繼續探討

相關文章
相關標籤/搜索