深入學習synchronized

西涼馬戳戳發表於2020-11-23
synchronized

併發程式設計中的三個問題:

可見性(Visibility)

是指一個執行緒對共享變數進行修改,另一個先立即得到修改後的最新值。

程式碼演示:

public class Test01Visibility {
    public static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (flag) {

            }
        }).start();

        Thread.sleep(2000);

        new Thread(() -> {
            flag = false;
            System.out.println("修改了flag");
        }).start();
    }
}

小結:併發程式設計時,會出現可見性問題,當一個執行緒對共享變數進行了修改,另外的執行緒並沒有立即看到修改

後的最新值。

原子性(Atomicity)

在一次或多次操作中,要麼所有的操作都執行並且不會受其他因素干擾而中斷,要麼所有的操作都不執行

程式碼演示:

public class Test02Atomicity {
    public static int num = 0;
    public static void main(String[] args) throws InterruptedException {
        // 建立任務
        Runnable increment = () -> {
            for (int i = 0; i < 1000; i++) {
                num++;
            }
        };
        ArrayList<Thread> threads = new ArrayList<>();
        //建立執行緒
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(increment);
            t.start();
            threads.add(t);
        }
        for (Thread thread : threads) {
            thread.join();
        }
        System.out.println(num);
    }
}

通過 javap -p -v Test02Atomicity對class 檔案進行反彙編:發現++ 操作是由4條位元組碼指令組成,並不是原子操作

小結:併發程式設計時,會出現原子性問題,當一個執行緒對共享變數操作到一半時,另外的執行緒也有可能來操作共享變數,干擾了前一個執行緒的操作

有序性(Ordering)

是指程式中程式碼的執行順序,Java在編譯時和執行時會對程式碼進行優化,會導致程式最終的執行順序不一定就是我們編寫程式碼時的順序。

程式碼演示:

@JCStressTest
@Outcome(id={"1","4"},expect=Expect.ACCEPTABLE,desc="ok")
@Outcome(id="0",expect=Expect.ACCEPTABLE_INTERESTING,desc="danger")
@State
public class Test03Orderliness { 
    int num=0;
    boolean ready=false;
    //執行緒一執行的程式碼
    @Actor
    public void actor1(I_Resultr){
        if(ready){
            r.r1=num+num;
        }else{
            r.r1=1;
        }
    }
    //執行緒2執行的程式碼
    @Actor
    public void actor2(I_Resultr){
        num=2;
        ready=true;
    }
}

執行的結果有:0、1、4

小結:程式程式碼在執行過程中的先後順序,由於Java在編譯期以及執行期的優化,導致了程式碼的執行順序未必

就是開發者編寫程式碼時的順序。

Java記憶體模型(JMM)

計算機結構簡介

根據馮諾依曼體系結構,計算機由五大組成部分,輸入裝置,輸出裝置,儲存器,控制器,運算器。

CPU:

中央處理器,是計算機的控制和運算的核心,我們的程式最終都會變成指令讓CPU去執行,處理程式中的資料。

記憶體:

我們的程式都是在記憶體中執行的,記憶體會儲存程式執行時的資料,供CPU處理。

快取:

CPU的運算速度和記憶體的訪問速度相差比較大。這就導致CPU每次操作記憶體都要耗費很多等待時間。於是就有了在

CPU和主記憶體之間增加快取的設計。CPU Cache分成了三個級別: L1, L2, L3。級別越小越接近CPU,速度也更快,同時也代表著容量越小。

Java記憶體模型

Java記憶體模型是一套規範,描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取變數這樣的底層細節,具體如下。

主記憶體

主記憶體是所有執行緒都共享的,都能訪問的。所有的共享變數都儲存於主記憶體。

工作記憶體

每一個執行緒有自己的工作記憶體,工作記憶體只儲存該執行緒對共享變數的副本。執行緒對變數的所有的操作(讀,取)都必須在工作記憶體中完成,而不能直接讀寫主記憶體中的變數,不同執行緒之間也不能直接訪問對方工作記憶體中的變數。

小結

Java記憶體模型是一套規範,描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取變數這樣的底層細節,Java記憶體模型是對共享資料的可見性、有序性、和原子性的規則和保障。

主記憶體與工作記憶體之間的互動

注意:1. 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值

  1. 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中

synchronized保證三大特性

synchronized保證可見性

while(flag){
    //增加物件共享資料的列印,println是同步方法
    System.out.println("run="+run);
}

小結:

synchronized保證可見性的原理,執行synchronized時,lock原子操作會重新整理工作記憶體中共享變數的值。

synchronized保證原子性

for(int i = 0; i < 1000; i++){
    synchronized(Test01Atomicity.class){
        number++;
    }
}

小結:

synchronized保證原子性的原理,synchronized保證只有一個執行緒拿到鎖,能夠進入同步程式碼塊。

synchronized保證有序性

synchronized(Test01Atomicity.class){
    num=2;
	ready=true;
}

小結

synchronized保證有序性的原理,我們加synchronized後,依然會發生重排序,只不過,我們有同步程式碼塊,可以保證只有一個執行緒執行同步程式碼中的程式碼,保證有序性。

synchronized的特性

可重入特性

public class Demo01 {
    public static void main(String[] args) {
        new MyThread().start();
        new MyThread().start();
    }
}
class MyThread extends Thread {
    @Override
    public void run() {
        synchronized (MyThread.class) {
            System.out.println(Thread.currentThread().getName() + "獲取了鎖1");
            synchronized (MyThread.class) {
                System.out.println(Thread.currentThread().getName() + "獲取了鎖2");
            }
        }
    }
}

可重入原理:

synchronized的鎖物件中有一個計數器(recursions變數)會記錄執行緒獲得幾次鎖。

可重入的好處:

  1. 可以避免死鎖

  2. 可以讓我們更好的來封裝程式碼

小結:

synchronized是可重入鎖,內部鎖物件中會有一個計數器記錄執行緒獲取幾次鎖啦,獲取一次鎖加+1,在執行完同步程式碼塊時,計數器的數量會-1,直到計數器的數量為0,就釋放這個鎖。

不可中斷特性

什麼是不可中斷?

一個執行緒獲得鎖後,另一個執行緒想要獲得鎖,必須處於阻塞或等待狀態,如果第一個執行緒不釋放鎖,第二個執行緒會一直阻塞或等待,不可被中斷。

public class Uninterruptible {
    private static Object obj = new Object();
    public static void main(String[] args) throws InterruptedException {
        Runnable run = () -> {
            synchronized (obj) {
                String name = Thread.currentThread().getName();
                System.out.println(name + "執行同步程式碼塊");
                try {
                    Thread.sleep(888888);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        Thread t1 = new Thread(run);
        t1.start();
        Thread.sleep(1000);
        Thread t2 = new Thread(run);
        t2.start();

        Thread.sleep(1000);
        System.out.println("停止執行緒2前");
        System.out.println(t2.getState());
        t2.interrupt();
        System.out.println("停止執行緒2後");
        System.out.println(t1.getState());
        System.out.println(t2.getState());
    }
}

synchronized是不可中斷,處於阻塞狀態的執行緒會一直等待鎖。

ReentrantLock可中斷演示

public class Interruptible {
    private static Lock lock = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException {
        test01();
    }

    private static void test01() throws InterruptedException {
        Runnable run = () -> {
            boolean flag = false;
            String  name = Thread.currentThread().getName();
            try {
                flag = lock.tryLock(3, TimeUnit.SECONDS);
                if (flag) {
                    System.out.println(name + "獲得鎖,進入鎖執行");
                    Thread.sleep(888888);
                } else {
                    System.out.println(name + "沒有獲得鎖");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (flag) {
                    lock.unlock();
                    System.out.println(name + "釋放鎖");
                }
            }
        };

        Thread t1 = new Thread(run);
        t1.start();
        Thread.sleep(1000);

        Thread t2 = new Thread(run);
        t2.start();
    }
}

小結:

synchronized屬於不可被中斷

Lock的lock方法是不可中斷的

Lock的tryLock方法是可中斷的

synchronized 的原理

monitorenter:

每一個物件都會和一個監視器monitor關聯。監視器被佔用時會被鎖住,其他執行緒無法來獲取該monitor。當JVM執行某個執行緒的某個方法內部的monitorenter時,它會嘗試去獲取當前物件對應的monitor的所有權。其過程如下:

  1. 若monior的進入數為0,執行緒可以進入monitor,並將monitor的進入數置為1。當前執行緒成為monitor的owner(所有者)

  2. 若執行緒已擁有monitor的所有權,允許它重入monitor,則進入monitor的進入數加1

  3. 若其他執行緒已經佔有monitor的所有權,那麼當前嘗試獲取monitor的所有權的執行緒會被阻塞,直到monitor的進入數變為0,才能重新嘗試獲取monitor的所有權。

monitorenter小結:

synchronized的鎖物件會關聯一個monitor, 這個monitor不是我們主動建立的, 是JVM的執行緒執行到這個同步程式碼塊,發現鎖物件

有monitor就會建立monitor, monitor內部有兩個重要的成員變數owner擁有這把鎖的執行緒,recursions會記錄執行緒擁有鎖的次數,

當一個執行緒擁有monitor後其他執行緒只能等待。

monitorexit:

  1. 能執行monitorexit 指令的執行緒一定是擁有當前物件的monitor的所有權的執行緒。

  2. 執行monitorexit 時會將monitor的進入數減1。當monitor的進入數減為0時,當前執行緒退出monitor,不再擁有monitor的所有權,此時其他被這個monitor阻塞的執行緒可以嘗試去獲取這個monitor的所有權

monitorexit釋放鎖。

monitorexit插入在方法結束處和異常處,JVM保證每個monitorenter必須有對應的monitorexit。

面試題synchroznied出現異常會釋放鎖嗎?

:會釋放鎖。

同步方法

同步方法在反彙編後,會增加ACC_SYNCHRONIZED修飾。會隱式呼叫monitorenter 和monitorexit。在執行同步方法前會呼叫

monitorenter,在執行完同步方法後會呼叫monitorexit 。

小結:

通過javap反彙編可以看到synchronized 使用了monitorentor和monitorexit兩個指令。每個鎖物件都會關聯一個monitor(監視

器,它才是真正的鎖物件),它內部有兩個重要的成員變數owner會儲存獲得鎖的執行緒,recursions會儲存執行緒獲得鎖的次數, 當執行到

monitorexit時, recursions會-1, 當計數器減到0時這個執行緒就會釋放鎖。

面試題:synchronized與Lock的區別

1、synchronized 是關鍵字,lock 是一個介面

2、synchronized 會自動釋放鎖,lock 需要手動釋放鎖。

3、synchronized 是不可中斷的,lock 可以中斷也可以不中斷。

4、通過lock 可以知道執行緒有沒有拿到鎖,而synchronized 不能。

5、synchronized 能鎖住方法和程式碼塊,而lock 只能鎖住程式碼塊。

6、lock 可以使用讀鎖提高多執行緒讀效率。

7、synchronized 是非公平鎖,ReentrantLock 可以控制是否是公平鎖。

CAS

cas的概述和作用:

compare and swap,可以將比較和交換轉為原子操作,這個原子操作直接由cpu保證,cas可以保證共享變數賦值時的原子操作,cas依賴3個值:記憶體中的值v,舊的預估值x,要修改的新值b。根據atomicInteger的地址加上偏移量offset的值可以得到記憶體中的值,將記憶體中的值和舊的預估值進行比較,如果相同,就將新值儲存到記憶體中。不相同就進行重試。

Java物件的佈局

在JVM中,物件在記憶體中的佈局分為三塊區域:物件頭、例項資料和對齊填充。如下圖所示:

HotSpot採用instanceOopDesc和arrayOopDesc來描述物件頭,arrayOopDesc物件用來描述陣列型別。

從instanceOopDesc程式碼中可以看到 instanceOopDesc繼承自oopDesc。

_mark表示物件標記、屬於markOop型別,也就是Mark World,它記錄了物件和鎖有關的資訊

_metadata表示類元資訊,類元資訊儲存的是物件指向它的類後設資料(Klass)的首地址,其中Klass表示普通指標、compressed_klass表示壓縮類指標。

Mark Word

鎖狀態 儲存內容 鎖標誌位
無鎖 物件的hashcode、物件分代年齡、是否是偏向鎖(0) 01
偏向鎖 偏向執行緒id、偏向時間戳、物件分代年齡、是否是偏向鎖(1) 01
輕量級鎖 指向棧中鎖記錄的指標 00
重量級鎖 指向互斥量(重量級鎖)的指標 10

klass pointer

用於儲存物件的型別指標,該指標指向它的類後設資料,JVM通過這個指標確定物件是哪個類的例項。通過-XX:+UseCompressedOops開啟指標壓縮,

在64位系統中,Mark Word = 8 bytes,型別指標 = 8bytes,物件頭 = 16 bytes = 128bits;

例項資料

就是類中定義的成員變數。

對齊填充

由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,物件的大小必須是8位元組的整數倍。因此,當物件例項資料部分沒有對齊時,就需要通過對齊填來補全。

檢視Java物件佈局

<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.9</version>
</dependency>

小結

Java物件由3部分組成,物件頭,例項資料,對齊資料,物件頭分成兩部分:Mark World + Klass pointer

偏向鎖

什麼是偏向鎖?

鎖會偏向於第一個獲得它的執行緒,會在物件頭儲存鎖偏向的執行緒ID,以後該執行緒進入和退出同步塊時只需要檢查是否為偏向鎖、鎖標誌位以及ThreadID即可。

不過一旦出現多個執行緒競爭時必須撤銷偏向鎖,所以撤銷偏向鎖消耗的效能必須小於之前節省下來的CAS原子操作的效能消耗,不然就得不償失了。

偏向鎖原理

當執行緒第一次訪問同步塊並獲取鎖時,偏向鎖處理流程如下:

  1. 虛擬機器將會把物件頭中的標誌位設為“01”,即偏向模式。
  2. 同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的Mark Word之中,如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作,偏向鎖的效率高。

偏向鎖的撤銷

  1. 偏向鎖的撤銷動作必須等待全域性安全點

  2. 暫停擁有偏向鎖的執行緒,判斷鎖物件是否處於被鎖定狀態

  3. 撤銷偏向鎖,恢復到無鎖(標誌位為01)或輕量級鎖(標誌位為00)的狀態

偏向鎖是自適應的

小結:

偏向鎖的原理是什麼?

當鎖物件第一次被執行緒獲取的時候,虛擬機器將會把物件頭中的標誌位設為“01”,即偏向模式。同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的MarkWord之中,如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作,偏向鎖的效率高。

偏向鎖的好處是什麼?

偏向鎖是在只有一個執行緒執行同步塊時進一步提高效能,適用於一個執行緒反覆獲得同一鎖的情況。偏向鎖可以提高帶有同步但無競爭的程式效能。

輕量級鎖

什麼是輕量級鎖?

輕量級鎖是JDK 6之中加入的新型鎖機制,輕量級鎖並不是用來代替重量級鎖的。

引入輕量級鎖的目的:在多執行緒交替執行同步塊的情況下,儘量避免重量級鎖引起的效能消耗,但是如果多個執行緒在同一時刻進入臨界區,會導致輕量級鎖膨脹升級為重量級鎖,所以輕量級鎖的出現並非是要代替重量級鎖。

輕量級鎖原理

當關閉偏向鎖或多個執行緒競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖,其步驟如下:

  1. 判斷當前物件是否處於無鎖狀態,如果是,則JVM 首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word 的拷貝,將物件的Mark Word 複製到棧幀中的Lock Record 中,將Lock Record中的owner指向當前物件。
  2. JVM 利用CAS 操作嘗試將物件的Mark Word 更新為指向Lock Record 的指標,如果成功表示競爭到鎖,則將鎖標誌位變成00,執行同步操作。
  3. 如果失敗則判斷當前物件的Mark Word 是否指向當前執行緒的棧幀,如果是則表示當前執行緒已經持有當前物件的鎖,則直接執行同步程式碼塊;否則只能說明該鎖物件已經被其他執行緒搶佔了,這時輕量級鎖需要膨脹為重量級鎖,鎖標誌位變成10,後面等待的執行緒將會進入阻塞狀態。

輕量級鎖的釋放:

輕量級鎖的釋放也是通過CAS操作來進行的,主要步驟如下:

  1. 取出在獲取輕量級鎖時儲存在Mark Word 中的資料;
  2. 用CAS 操作將取出的資料替換當前物件的Mark Word 中,如果成功,則說明釋放鎖成功。
  3. 如果CAS 操作替換失敗,說明有其他執行緒獲取該鎖,則需要將輕量級鎖膨脹升級為重量級鎖。

對於輕量級鎖,其效能提升的依據是“對於絕大部分的鎖,在整個生命週期內都是不會存在競爭的”,如果打破這個依據則除了互斥的開銷外,還有額外的CAS 操作,因此在有多執行緒競爭的情況下,輕量級鎖比重量級鎖更慢。

輕量級鎖好處:

在多執行緒交替執行同步塊的情況下,可以避免重量級鎖引起的效能消耗。

自旋鎖

monitor 實現鎖的時候, monitor 會阻塞和喚醒執行緒,執行緒的阻塞和喚醒需要CPU 從使用者態轉為核心態,頻繁的阻塞和喚醒對CPU 來說是一件負擔很重的工作,這些操作給系統的併發效能帶來了很大的壓力。同時,共享資料的鎖定狀態可能只會持續很短的一段時間,為了這段時間阻塞和喚醒執行緒並不值得。如果有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,就可以讓後面請求鎖的那個執行緒“稍微等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否釋放了鎖。為了讓執行緒等待,我們只需讓執行緒執行一個迴圈(即自旋),這就是自旋鎖。

自旋鎖在JDK 1.4.2中就已經引入,只不過預設是關閉的,可以使用-XX:+UseSpinning引數來開啟,在JDK 6中就已經改為預設開啟了。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被佔用的時間很長。那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來效能上的浪費。因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了。自旋次數的預設值是10次,使用者可以使用引數-XX : PreBlockSpin來更改。

適應性自旋鎖

在JDK 6 中引入了自適應的自旋鎖。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。

平時寫程式碼如何對synchronized優化

減少synchronized的範圍:

同步程式碼塊中儘量短,減少同步程式碼塊中程式碼的執行時間,減少鎖的競爭。

synchronized(Demo01.class){
    System.out.println("aaa");
}

降低synchronized鎖的粒度:

將一個鎖拆分為多個鎖提高併發度,如HashTable:鎖定整個雜湊表,一個操作正在進行時,其他操作也同時鎖定,效率低下。ConcurrentHashMap:區域性鎖定,只鎖定桶。

讀寫分離:

讀取時不加鎖,寫入和刪除時加鎖

ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet

相關文章