1. 程式人生 > >java併發程式設計——四(synchronized\Lock\volatile) 鎖機制原理及關聯

java併發程式設計——四(synchronized\Lock\volatile) 鎖機制原理及關聯

前言

其實標題使用互斥機制更合適,併發中主要兩個問題是:執行緒如何同步以及執行緒如何通訊。

同步主要是通過互斥機制保證的,而互斥機制我們最熟悉的就是鎖,當然也有無鎖的CAS實現。

多執行緒共享資源,比如一個物件的記憶體,怎樣保證多個執行緒不會同時訪問(讀取或寫入)這個物件,這就是併發最大的難題,因此產生了 互斥機制(鎖)。

synchronized

When should you synchronize? Apply Brian’s Rule of Synchronization:

If you are writing a variable that might next be read by another


thread, or reading a variable that might have last been written by
another thread, you must use synchronization, and further, both the
reader and the writer must synchronize using the same monitor lock.

作用:

可見性
獲取鎖後,該執行緒本地儲存失效,臨界區(就是獲得鎖後釋放鎖之前 的程式碼區)從主存獲取資料,並在釋放鎖後刷入主存。
有序性(互斥)


保證臨界區程式碼執行緒間互斥。

synchronized實現同步的基礎:

synchronized通過物件的物件頭(markwork)來實現鎖機制。
java中每個物件都可以作為鎖(準確的說,每個物件都有的物件頭,那麼都為synchronized實現提供的基礎,每個物件都是一把物件鎖)

具體表現(程式碼例項):

public class myTest {
//靜態synchronized修飾:鎖myTest.class物件(當前類的class物件)
    public static synchronized void () throws IOException {
    ......
    }
//鎖當前物件(就是this指向的物件)
public synchronized void inc2() throws IOException { ...... } Object lock=new Object(); //顯示的指定鎖物件 lock public void lockObject() throws IOException { synchronized(lock){ ...... } }

synchronized鎖原理 位元組碼層面

我們先來看一下synchronized方法的位元組碼:

public class Synchronized {
    public static void main(String[] args) {
        synchronized (Synchronized.class) {

        }
        m();
    }

    public static synchronized void m() {
    }
}

javap -v Synchronized.class:

............
public static void main(java.lang.String[]);
 flags: ACC_PUBLIC, ACC_STATIC
 Code:
 stack=2, locals=1, args_size=1
 0: ldc #1 // class com/four/Synchronized
 2: dup
 3: monitorenter 
 4: monitorexit
 5: invokestatic #16 // Method m:()V
 8: return
 LineNumberTable:
 line 5: 0
 line 8: 5
 line 9: 8
 LocalVariableTable:
 Start Length Slot Name Signature
 0 9 0 args [Ljava/lang/String;

 public static synchronized void m();
 flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
 Code:
 stack=0, locals=0, args_size=0
 0: return
 LineNumberTable:
 line 12: 0
 LocalVariableTable:
 Start Length Slot Name Signature
 }
 ............

synchronized 塊是通過插入monitorenter,monitorexit完成同步的

通過javap命令生成的位元組碼中包含 monitorenter 和 monitorexit 指令,
這兩個指令依次在臨界區(就是需要同步的程式碼塊)前後。
持有Monitor物件,通過進入、退出這個Monitor物件來實現鎖機制,使用 monitorenter指令 與 moniterexit指令

那麼monitorenter,monitorexit又是什麼呢?從物件頭說起

Synchronized鎖儲存與物件頭:

上文說過,synchronized通過物件的物件頭(markwork)來實現鎖機制,java中每個物件都可以作為鎖(準確的說,每個物件都有的物件頭,那麼都為synchronized實現提供的基礎,每個物件都是一把物件鎖)

物件頭

物件在記憶體中的佈局分為三塊區域:物件頭、例項資料和對齊填充

這裡寫圖片描述

物件頭
物件頭包括兩部分:Mark Word 和 型別指標。

synchronized原始碼實現就用了Mark Word來標識物件加鎖狀態.

Mark Word

Mark Word用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、synchronized鎖資訊(鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳)等等,佔用記憶體大小與虛擬機器位長一致。

型別指標

型別指標指向物件的class元資料,虛擬機器通過這個指標確定該物件是哪個類的例項。

其中物件頭儲存了synchronized鎖實現的細節:

這裡寫圖片描述

monitorenter\monitorexit的背後:synchronizd鎖升級 C++程式碼實現

synchronized關鍵字基於上述兩個指令實現了鎖的獲取和釋放過程,直譯器執行monitorenter時會進入到InterpreterRuntime.cpp的InterpreterRuntime::monitorenter函式,具體實現如下:

這裡寫圖片描述

可重入
一個任務可以多次獲得鎖,比如在一個執行緒中呼叫一個物件的 synchronized標記的方法,在這個方法中呼叫第二個synchronized標記的方法,然後在第二個synchronized方法中呼叫第三個synchronized方法。一個執行緒每次進入一個synchronized方法中JVM都會跟蹤加鎖的次數,每次+1,當該這個方法執行完畢,JVM計數-1;當JVM計數為0時,鎖完全被釋放,其他執行緒可以訪問該變數。

顯示鎖

注意:return 語句放在try中,以避免過早的釋放鎖;JDOC推薦lock.lock後跟try{}finally{}
參考上篇文中的例子,我們用顯示鎖實現互斥機制:

  private Lock lock = new ReentrantLock();
  public int next() {
        lock.lock();
        try {
            ++currentEvenValue; // Danger point here!
            Thread.yield(); // 加快執行緒切換
            ++currentEvenValue;
            return currentEvenValue;
        } finally {
            lock.unlock();
        }
    }

使用 Lock lock =new ReentrantLock()的問題是程式碼不夠優雅,增加程式碼量;我們一般都是使用synchronized實現互斥機制。但是1.當代碼中丟擲異常時,顯示鎖的finally裡可以進行資源清理工作。2.ReentrantLock還給我們更細粒度的控制力

public class AttemptLocking {
    private Lock lock = new ReentrantLock();

    private void untimed() {
        // lock.lock();
        boolean captured = lock.tryLock();
        try {
            System.out.println("tryLock() " + captured);
        } finally {
            if (captured) {
                lock.unlock();
            }
        }

    }

    private void timed() {
        boolean captured = false;
        try {
            captured = lock.tryLock(2, TimeUnit.SECONDS);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        try {
            System.out.println("lock.tryLock(2, TimeUnit.SECONDS) " + captured);
        } finally {
            if (captured) {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        final AttemptLocking attemptLocking = new AttemptLocking();
        attemptLocking.untimed();
        attemptLocking.timed();

        new Thread() {
            {
                setDaemon(true);
            }

            public void run() {
                attemptLocking.lock.tryLock();
                System.out.println("acquired");
            }
        }.start();
        Thread.yield();
        attemptLocking.untimed();
        attemptLocking.timed();
    }

}
  • lock 互斥執行 原理是通過AQS實現的同步器實現.具體請看AQS詳解
  • lock可見性 也是AQS,具體是state變數的happen-before規則。 AQS(或JDK鎖)如何保證可見性

更深入的理解請閱讀:

Atomic&Volatie

什麼是原子性(Atomic):不會被執行緒排程機制中斷的操作,一旦操作開始,就會線上程上下文切換之前完成操作.
原子性應用於除了long\double之外其他的基本資料型別,因為long\double 是64bit ,JVM對於64bit會當作兩個32bit的操作來執行,那麼在這兩個執行直接可能會發生上下文切換。
當我們給 long\double 加上 volatile,可以保證原子性操作,僅限於讀、寫操作,比如long l=0;l++,++操作就是典型的非原子性操作,因為“++”操作其實是一個讀操作與一個寫操作的組合操作!

volatile

保證共享變數的“可見性”(一個執行緒修改這個共享變數時,另一個執行緒可以讀取到修改的值),某些情況下使用恰當的話,比synchronized效能更好,因為它不會競爭鎖,就不會引起上下文切換。
原理解析

用volatile修飾後的變數,轉化為組合語言後,會多出“lock add1…. ”的指令,該指令會引發兩件事情:
1.將當前處理器快取行的資料寫入系統主存
2.寫回主存操作使其他cpu中快取了該記憶體的資料失效(詳見下文,volatile記憶體語義)

為了提高處理速度,cpu先將系統記憶體的資料放到內部快取(L1,L2,L3…)中,然後再進行操作,下次會存在快取命中(cache hit).如果變數聲明瞭volatile,寫操作時,JVM會向cpu傳送一條lock字首命令,會將該資料直接寫入記憶體中,並使其他處理器快取該變數的記憶體地址失效,保證快取的一致性。

多個執行緒去訪問一個非volatile域,並且不用synchronized,其中一個任務修改了這個域,很可能這時只是把這個“修改”放入了處理器快取中,而非主記憶體。其他執行緒,可能並不會讀到這個域的修改值(讀操作發生在主記憶體!)。所以可以使用volatile去保證每次修改,都會把最新的值刷入主記憶體(或者你也可以使用synchronized去給每個訪問這個域的方法加鎖,synchronized也可以保證修改刷到主記憶體)!

當一個volatile域依賴於它之前的值(如++i 這種遞增),或者它依賴於其他變數,volatile就無法工作了。 建議使用 synchronized而非 volatile.請看下邊的例子:

慎重依賴基本型別的“原子性”

class CircularSet {
    private int[] array;
    private int len;
    private int index = 0;

    public CircularSet(int size) {
        array = new int[size];
        len = size;
        for (int i = 0; i < size; i++) {
            array[i] = -1;
        }
    }
    public synchronized void add(int i) {
        array[index] = i;
        index = ++index % len;
    }

    public synchronized boolean contains(int val) {
        for (int i = 0; i < len; i++) {
            if (array[i] == val) {
                return Boolean.TRUE;
            }
        }
        return Boolean.FALSE;
    }
}

public class SerialNumberChecker {
    private static final int SIZE = 10;
    private static CircularSet serials = new CircularSet(1000);
    private static ExecutorService exec = Executors.newCachedThreadPool();

    static class SerialChecker implements Runnable {
        @Override
        public void run() {
            while (Boolean.TRUE) {
                int serial = SerialNumberGenerator.nextSerialNumber();

                if (serials.contains(serial)) {
                    System.out.println("Duplicate:" + serial);
                    System.exit(0);
                }
                serials.add(serial);

            }
        }

    }

    public static void main(String[] args) {
        // 是個執行緒同時對SerialNumberGenerator的域serialNumber進行讀寫操作;
        // 如果serialNumber++是原子性的,程式不會中斷
        for (int i = 0; i < SIZE; i++) {
            exec.execute(new SerialChecker());
        }

    }
}
public class SerialNumberGenerator {
    // 使用volatile保證 serialNumber的可見性(值改變變後刷入主記憶體)
    private static volatile int serialNumber = 0;

    public static int nextSerialNumber() {
        // serialNumber++你認為是原子性嗎?
        return serialNumber++;
    }

}
//Outp:Duplicate:3954

以上例子證明了,volatile 基本變數 自增時,並無法保證原子性!所以,
1.一般情況下使用synchronized 而非 volatile. 2. ++操作是非原子性的,典型的讀寫組合操作

public class Atomicity {
int i;
void f1() { i++; }
void f2() { i += 3; }
} /* Output: (Sample)
...
//位元組碼:
void f1();
Code:
0: aload_0
1: dup
2: getfield #2; //Field i:I   首先,get
5: iconst_1
6: iadd
7: putfield #2; //Field i:I  經過幾個步驟,最後put
10: return
void f2();
Code:
0: aload_0
1: dup
2: getfield #2; //Field i:I  首先,get
5: iconst_3
6: iadd
7: putfield #2; //Field i:I  經過幾個步驟,最後put
10: return
*///:~

原子性

cpu角度的原子性實現:

  • 匯流排鎖定
    當一個執行緒在cpu1中執行i++操作時,會鎖定系統記憶體與各個cpu之間的通訊——匯流排,保證cup1執行i++操作時其他任務不會改變主存中i的值。但是在這個鎖定期間其他任何指令都不會執行.

  • 快取鎖定
    當一個執行緒在cpu1中執行i++操作時,不會鎖定系統記憶體與各個cpu之間的通訊,會鎖定這個資料快取資料的記憶體地址,只允許cpu1的i++操作寫入主存,阻止其他任務(cpu2 :i++)改變這個資料(快取一致性),同時使其他cpu快取失效

這裡寫圖片描述

java原子性實現

  • 使用CAS迴圈
    虛擬碼:
for (;;) {
            currentValue = getValue();// 獲取當前資料,位操作原子性
            boolean success = compareAndSet(currentValue, currentValue++);// 如果currentValue未改變(currentValue==getValue()),那麼用currentValue++(新值)替換currentValue

            if (success) {
                break;
            }
        }

Volatile記憶體語義

public class VolatileTest {
    volatile long i = 0;

    private long  get() {
        return i;
    }

    private void set(long i) {
        this.i = i;
    }

    private void addOne() {
        i++;
    }

}

等同於:


class VolatileTest2 {
    long i = 0;

    private synchronized long get() {//原子性
        return i;
    }

    private synchronized void set(long i) {//原子性
        this.i = i;
    }

    private void addOne() {// i++並沒有加方法鎖,該操作不是原子性的
        int tempI = get();
        tempI += 1L;
        set(tempI);
    }

}

總結:volatile
原子性:volatile變數,讀寫操作原子性,但對於++i這種複合操作並非原子性
可見性:volatile變數的讀操作總是可以看到最後一次寫操作的更新資料。

volatile寫操作記憶體語義:把該執行緒的本地儲存刷入主存(與釋放鎖的記憶體語義相同)
volatile讀操作記憶體語義:把該執行緒對應的本地儲存置為無效,從主存中讀取。(與獲取鎖的記憶體語義相同)

synchornized 與 volatile 的比較

  • synchornized與volatile共同點:
    保證資料的可見性(讀取主存);
  • synchornized缺點:
    1 synchornized 會引發鎖競爭,導致上下文切換,影響效能,volatile不會.
    2 synchronized 因為鎖競爭,有引發死鎖、餓死等多執行緒問題,volatile不會.
  • volatile缺點:
    1 volatile保證可見性但不保證原子性(如i++),synchronized保證可見性同時保證原子性
    2 僅限於在變數級別使用,而synchronized用法更廣泛

Lock(顯式鎖)與synchronized(隱式鎖) 的對比

  • ( tryLock()) 非阻塞的獲取鎖,也可設定等待時間
    synchronized悲觀鎖的併發策略,獲得獨佔鎖,所謂獨佔鎖就是一個執行緒進入synchronized後獲得鎖,其他執行緒如果也想獲得這個鎖只有進入(wait())阻塞狀態,阻塞狀態會引發上下文切換,當較多執行緒競爭,會產生頻繁上下文切換。
    Lock實現樂觀鎖的策略,使用CAS演算法, 不會產生執行緒的阻塞

  • (lock.lockInterruptibly())與synchronized不同,獲取到鎖的執行緒如果中斷,捕獲interrupted異常,同時釋放鎖

  • 當代碼中丟擲異常時,顯示鎖的finally裡可以進行資源清理工作。

  • Lock還給我們更細粒度的控制力

JDK原子類

原子操作:不可中斷的一個或一組操作

Atomiclnteger, AtomicLong, AtomicReference

還是建議使用 synchronized 或Lock,上述原子類使用,詳見JDK Document