Java基礎--synchronized原理詳解

1. 多線程特性

1.1 原子性(Atomicity)

原子性是指一個操作是不可中斷的。即使是在多個線程一起執行的時候,一個操作一單開始,就不會被其他線程干擾。

1.2 可見性(Visibility)

可見性是指當一個線程修改了某一個共享變量的值,其他線程是否能夠立即知道這個修改。

1.3 有序性(Ordering)

程序在執行時,編譯器可能會進行指令重排,重排後的指令原指令的順序未必一致。
指令重排可以保證串行語義一致,但是沒有義務保證多線程間的語義也一致。
爲什麼要進行指令重排?
CPU執行指令,需要進行這幾步(不同的指令集可能不同,一般認知中是這樣的)

  • 取指
  • 譯碼和取操作數
  • 執行或計算
  • 存儲器訪問
  • 寫回
    在這裏插入圖片描述
    CPU執行是一個一個的週期進行的,CPU組成中需要有晶振,晶振產生固定頻率的脈衝,每一次脈衝就是一次時鐘週期。CPU的一個時鐘週期內只能進行一個操作。
    那麼完成一次指令需要5個時鐘週期。
    仔細觀察這5個操作,分別都是CPU不同的區域。所以,在一個時鐘週期內,可以進行多個不同的操作。
    比如:
    在這裏插入圖片描述
    在上圖中執行了3條指令,如果是串行的,那麼需要15個時鐘週期才能執行完成。
    這就是CPU執行指令流水線執行,指令的執行效率高。
    CPU流水線執行指令,雖然效率高,但是依然存在問題。
    假設藍色的指令計算的數據,依賴綠色指令的計算結果,在第5個時鐘週期進行計算時,綠色的計算結果,還沒有寫到寄存器,此時就需要藍色指令等待綠色指令的計算結果寫入寄存器才能繼續進行。
    在這裏插入圖片描述
    發現因爲藍色需要等待綠色指令執行完畢,才能執行藍色指令。
    但是在代碼邏輯中,綠色後面就是藍色,而藍色後面是橙色。
    如果橙色和藍色沒有強烈的先後關係,那麼可以調整指令執行順序。
    在這裏插入圖片描述
    就可以避免CPU指令執行的中斷停頓。
    指令重排提高了CPU執行效率,但是也帶來了指令亂序的問題。
    相比之下,指令亂序的問題是可以接受的。

1.4 Happen-Before原則

Happen-Before原則是不進行指令重排的規則:

  • 程序順序原則:一個線程內保證語義的串行性
  • volatile規則:volatile變量的寫,先發生於讀,這保證了volatile變量的可見性
  • 鎖規則:解鎖(unlock)先發生於加鎖(lock)
  • 傳遞性:A先於B,B先於C,那麼A一定先於C
  • 線程的start方法先於線程的任務
  • 線程的任務先於線程的終止
  • 線程的中斷先於中斷前的代碼
  • 對象的構造函數先於對象finalize方法

2. 鎖定義

2.1 爲什麼需要鎖

因爲在CPU執行指令的時候,會進行指令重排,指令重排在串行上可以保證程序語義一致,但是在多線程情況下,就無法保證語義一致了。
舉個例子:
一個全局變量,每個線程對全局變量進行1w次自增操作。如果有10個線程,那麼最終的全局變量的值應該是10W。
串行:

public class Main {

    private static Long sum = 0L;

    public static void main(String[] args) {

        System.out.println("main start sum = " + sum);

        ExecutorService service = new ThreadPoolExecutor(
                10, 10, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

        for (int i = 0; i < 10; i++) {
            new Add().run();
        }
        service.shutdown();
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            System.out.println("main interrupt exception");
        }
        System.out.println("main end sum = " + sum);
    }

    static class Add implements Runnable {

        @Override
        public void run() {
            Thread thread = Thread.currentThread();
            System.out.println(thread.getName() + thread.getId() + " start add!");
            for (int i = 0; i < 10000; i++) {
                sum++;
            }
            System.out.println(thread.getName() + thread.getId() + " add over!");
        }
    }
}

執行結果:
在這裏插入圖片描述
併發:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
每次執行的結果都是不確定的。
所以,在併發情況下,對同一個變量的操作,會出現語義不一致的併發問題。
那麼,如何解決這個問題呢?
加鎖。
一般來說,Java中鎖的實現有兩種方式:synchronized和Lock.
我們先用synchronized修改
在這裏插入圖片描述
在這裏插入圖片描述
接下來使用Lock進行修改:

public class Main {

    private static volatile Long sum = 0L;

    private static volatile Lock lock = new ReentrantLock();

    public static void main(String[] args) {

        System.out.println("main start sum = " + sum);

        ExecutorService service = new ThreadPoolExecutor(
                10, 10, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

        for (int i = 0; i < 10; i++) {
            service.execute(new Add());
        }
        service.shutdown();
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            System.out.println("main interrupt exception");
        }
        System.out.println("main end sum = " + sum);
    }

    static class Add implements Runnable {

        @Override
        public void run() {
            Thread thread = Thread.currentThread();
            System.out.println(thread.getName() + thread.getId() + " start add!");
            try {
                while(!lock.tryLock()){
                    TimeUnit.MILLISECONDS.sleep(200);
                }
                for (int i = 0; i < 10000; i++) {
                    sum++;
                }
            }catch (InterruptedException e){
                System.out.println(e);
            } finally {
                lock.unlock();
            }
            System.out.println(thread.getName() + thread.getId() + " add over!");
        }
    }
}

在這裏插入圖片描述
我們增加了一個全局變量,這個全局變量就是sum的鎖,只有獲取到了鎖的線程,才能進行累加操作。如果沒有獲取鎖,那麼就線程sleep200毫秒。然後重新獲取鎖,直到獲取了鎖,否則就一直循環。

2.2 鎖存在的意義

在2.1 中,我們可以很明顯的看到,這是因爲併發情況下,多個線程對全局變量的讀寫,造成語義不一致。
說簡單點,就是第一個線程和第二個線程等多個線程讀取到了相同的sum初始值,然後對sum初始值進行遞增操作,導致多個線程遞增和一個線程的一次遞增的結果相同。(不考慮時間先後問題)(線程間指令重排問題)
還有就是第一個線程可能計算的快,已經計算到了 9++=10了,但是第二個線程還是比較慢,才計算到了1++=2。(線程執行,多個核心執行,每個核心的寄存器裏面都有sum的一個副本)(內存可見性問題)
鎖的存在就是爲了解決這些問題。

3. synchronized

3.1 synchronized的使用場景

分類 具體場景 被鎖的對象 僞代碼
方法 實例方法 類的實例對象 public synchronized void method(){}
方法 靜態方法 類對象 public static synchronized void method(){}
代碼塊 實例對象 類的實例對象 synchronized (this){}
代碼塊 class對象 類對象 synchronized(Main.class){}
代碼塊 任意實例對象Object 實例對象Object String x = “”; synchronized(x){}

3.2 synchronized原理

首先我們將2.1中的synchronized實現的代碼進行編譯javac Main.java,然後使用javap -v進行反編譯
在這裏插入圖片描述
這裏比較好找,先找遞增操作,ladd的指令,在ladd的指令前後有monitorenter指令。

3.2.1 Java對象在JVM中的結構

在這裏插入圖片描述
在這裏插入圖片描述

來源:https://blog.csdn.net/z_ssyy/article/details/103737553

通過上面兩張圖片,可以很直觀的知道,對象在jvm中分爲三塊區域:對象頭,對象實際數據,填充數據。

在這裏插入圖片描述在這裏插入圖片描述

來自:https://blog.csdn.net/javazejian/article/details/72828483

3.2.2 monitor指令

monitor指令分爲兩個:monitorenter和monitorexit。
分別代碼開始同步和結束同步。或者開始加鎖,結束加鎖。
在這裏插入圖片描述
在這裏插入圖片描述
可以理解爲:在遇到monitorenter指令的時候,進行加鎖,進入同步代碼後,每次進行操作前後,都需要獲取最新的數據,執行完畢,及時的寫回。(這是個人理解)
在執行過程中,遇到monitorenter指令,設置對象的鎖標誌以及線程id(重入鎖的核心實現)。
因爲第一個爭奪到鎖的線程已經將鎖標誌置1了,其他線程就無法獲取鎖了(無法在增加了)。
當執行完同步操作後,遇到monitorexit指令,設置對象的鎖標誌爲0,線程id清空(網上的資料沒有指明不過從重入鎖的定義來分析,應該是清空id的)
這樣其他線程就可以獲取鎖了。

3.2.3 monitor指令過程

在2.3.2.2小節中知道,每一個對象都有自己的對象頭,而在對象頭中有一個鎖標誌,只有線程修改鎖標誌成功,纔是獲取到了鎖,其他線程只能等待。
所以,如果有若干線程同時獲取一個對象的鎖,其中某一個線程得到鎖之後,執行線程的任務,而其他鎖則會進入同步隊列,線程也會進入BLOCKED的狀態。

在這裏插入圖片描述

圖片來自https://www.jianshu.com/p/d53bf830fa09

4. synchronized 對類對象和實例對象的區別

4.1 static修飾和沒有static修飾的區別

首先理解一個關鍵字static,這個關鍵字是區分一個屬性變量是否是類變量,還是實例屬性變量。
同樣的,一個方法如果有static就是說,這個這個方法是類方法;如果以一個方法沒有static 就認爲這個方法是實例方法。
當然,最明顯的是:類方法和類變量,可以直接通過類名調用;而實例方法和實例變量,必須先創建類的實例,然後通過實例調用。
還有一點需要注意:非static方法可以調用static方法和非static方法,而static方法只能調用static方法。

從對象的角度來看:
類對象,類方法不需要使用new實例化對象,就可以調用類方法和類變量。
因爲類對象在內存中只會存儲一個。
還記得前面說的JVM中對象的結構嗎,在對象頭中,就會存儲類元數據:

在這裏插入圖片描述

來自:https://blog.csdn.net/z_ssyy/article/details/103737553
實例對象的對象頭中存儲的這個類元數據就是類對象的地址。
也就是說,在內存中,這個類的所有實例對象的對象頭都會存儲類元信息,也就是類對象。而且這些實例對象的類對象都是相同的。

用最直白的話說:類對象,內存中只有一份;實例對象,每new一次,就會有一個。

因爲內存中只有一個,所以不管是類變量還是類屬性,,都是同一個,怎麼調用都行。

而實例方法或者實例對象調用類方法或者類屬性:因爲實例對象和類對象是多對1的關係,所以實例方法調用類方法或者類屬性就是互斥的。在同一時刻,只能有一個實例對象可以調用成功(有鎖,或者有同步邏輯的)。如果是不需要同步的,那無所謂了。
比如:

public class Student {

    public static void say() {
        System.out.println("static method");
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + thread.getId());
        while (!thread.isInterrupted()) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                break;
            }
        }
        System.out.println("static end");
    }

    public void sing() {
        System.out.println("nomal method");
        Thread thread = Thread.currentThread();
        System.out.println(thread.getName() + thread.getId());
        while (!thread.isInterrupted()) {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                break;
            }
        }
        System.out.println("nomal method end");
    }
}
public class StudentMain {

    public static void main(String[] args) {
        Student student = new Student();
        Thread t1 = new Thread(() -> {
            Student.say();
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            student.sing();
        });
        t2.start();
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            System.out.println("main interrupt exception");
        }
        System.out.println("main method , static method and nomal method is BLOCKED");
        new Thread(() -> Student.say()).start();
        new Thread(() -> student.sing()).start();
        new Thread(() -> new Student().sing()).start();
    }

}

執行結果:
在這裏插入圖片描述
即使第一次調用的線程現在在方法內阻塞,但是,因爲方法不是同步方法,所以,後面創建的線程依然可以訪問,依然可以進入。
那麼,把方法修改成需要同步的呢?
在這裏插入圖片描述
在這裏插入圖片描述
這個時候,實例同步方法可以進入,但是類同步方法不可以進入。
從這裏也進一步說明,類對象,類方法在內存中是一份的。而實例方法是每new一次,就會產生一個的。
然後實例對象的類元信息就是類對象的地址。

4.2 synchronized 不同使用場景

這個時候,我們返回去看下2.3.1的使用場景在這裏插入圖片描述
其實就是可以分爲2類,一種是實例對象鎖,一種是類對象鎖。

4.3 不使用synchronized 同步

接下來,在看一個例子:
在多線程的情況下,多個線程對同一個屬性進行操作,會發生併發問題。
我們通過實例查看:

public class People {

    private Long sum = 0L;

    private static Long all = 0L;

    public People(){}

    public Long getSum(){
        return sum;
    }

    public void setSum(Long sum){
        this.sum = sum;
    }

    public Long getAll(){
        return all;
    }

    public void setAll(Long all){
        People.all = all;
    }
}
public class Main {

    public static void main(String[] args) {
        People people = new People();
        Runnable runnable = () -> {
            Thread thread = Thread.currentThread();
            System.out.println(thread.getName() + thread.getId() + " start ");
            for (int i = 0; i < 10000; i++) {
                people.setAll(people.getAll() + 1);
                people.setSum(people.getSum() + 1);
            }
            System.out.println(thread.getName() + thread.getId() + " end ");
        };
        System.out.println("main thread sum = " + people.getSum() + " , all = " + people.getAll());
        ExecutorService service = new ThreadPoolExecutor(10, 10, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
        for (int i = 0; i < 10; i++) {
            service.execute(runnable);
        }
        service.shutdown();
        try {
            TimeUnit.SECONDS.sleep(4);
        } catch (InterruptedException e) {
            System.out.println("main interrupt exception");
        }
        System.out.println("main end sum = " + people.getSum() + " , all = " + people.getAll());
    }

}

我們創建了一個類,類裏面有兩個屬性,一個是static的,另一個是非static的。
也就是說,all是類變量,sum是實例變量。

在主線程中,我們一個線程將People的屬性值增加1W,那麼,10個線程就是10W。
我們預期的目標是all和sum都是10W。
在這裏插入圖片描述

4.4 synchronized 同步代碼塊–類對象

爲了解決這個問題,有兩種解決方式:加鎖(Lock)或者同步(synchronized)
在這裏只考慮同步的實現方式。
你可能注意到了,我們的People中的兩個屬性,一個是類屬性,一個是實例屬性。
首先,我們使用代碼塊同步類的方式,進行同步:
在這裏插入圖片描述
運行結果:
預期分析:因爲使用的是類同步,對於每一個實例對象來說,對應的都是同一個類對象。所以當這10個線程的其中某一個線程獲取了類同步的鎖,其他線程就無法獲取類同步的鎖了,其他線程就會被阻塞了。
這樣就保證了同一時間只會有一個線程操作類變量和實例變量。就不存在併發問題了。
在這裏插入圖片描述

4.5 synchronized 同步代碼塊–實例對象

接下來,我們使用對象同步呢?
在這裏插入圖片描述
預期分析:經過上面的例子,這個可以很輕鬆的分析出來,這個例子也能達到我們的目的。
因爲這10個線程使用的是同一個實例對象,所以使用實例對象,也就是10個線程在競爭一個實例對象的同步鎖。
也能夠保證同一時間內,只有一個線程操作類變量和實例變量。

4.6 synchronized 同步代碼塊–任意實例對象

上面兩個小例子是synchronized同步對象的例子,在同步代碼塊的場景中,還有一種,同步任意實例對象。
其實同步任意實例對象和同步某一個實例對象的原理是一樣的:
在這裏插入圖片描述
在這種寫法下,10個線程競爭同一個實例對象的同步鎖,當然可以保證同一時間內只有一個線程進行操作。
在這裏插入圖片描述
可是,如果每一個線程使用的都是自己線程內創建的實例對象呢?
在這裏插入圖片描述
預期分析:因爲我們將實例對象放到了線程內,那麼首先這個10個線程對應的是10個實例對象,每一個線程同步的都是自己線程內創建的對象,這當然每一個線程都能夠獲取到實例對象鎖了,也就是每一個線程在任意時間都可以操作類變量和實例變量。
也就無法達到預期目標了。
換個角度想,當我們將實例對象的創建移到線程內的時候,對於每一個單個的線程來說,其同步的都是自己線程內的局部變量。
在這裏插入圖片描述

4.7 synchronized 同步方法–類方法

我們看完了synchronized同步代碼塊,接下來看看synchronized同步方法:
在這裏插入圖片描述
在這裏插入圖片描述
預期分析:
因爲方法是類方法,在整個內存中只有一個,所以,可以保證同一時間只有一個線程能夠獲取鎖。
在這裏插入圖片描述
這個可能不太好對比:
在這裏插入圖片描述
我們新增了兩個方法,一個是類方法,一個是實例方法。
因爲我們在類方法上進行同步,所以類變量符合預期結果,而實例方法因爲沒有進行同步,所以,實例變量不符合預期結果:
在這裏插入圖片描述

4.8 synchronized 同步方法–實例方法

接下來我們根據上面的例子,同步實例方法,然後不同步類方法,以作對比:
在這裏插入圖片描述
在這裏插入圖片描述
預期分析:因爲類方法沒有進行同步,所以類方法應該不符合預期結果。
而實例方法進行同步,那麼同步方法應該是符合預期的。
在這裏插入圖片描述
即使這樣調用,類方法也不同步的:
在這裏插入圖片描述
在這裏插入圖片描述

5. synchronized 的缺陷

  1. synchronized效率低
    如果在同步方法或者同步代碼所需時間較長,那麼其他線程就必須阻塞等待, 而且可能會發生無限等待的情況,synchronized非常影響程序執行效率(Lock有多種鎖,可以根據需要同步的操作選擇不同的鎖)
  2. synchronized資源利用率低
    一般來說,寫操作和寫操作有衝突,寫操作和讀操作有衝突。
    但是讀操作和讀操作是沒有衝突的。
    在程序中,我們較多的操作是讀取計算,寫入只佔一部分。
    使用synchronized的時候,如果是讀操作,同步了,其他線程也無法進行讀操作。
    也就造成資源的浪費。
  3. synchronized無法知道是否成功獲取到鎖
    使用synchronized我們可以實現同步,但是線程是否成功獲取鎖,這是一個不確定事件。

6. synchronized的鎖處理

在jdk5之後,jvm對synchronized做了優化。

  • 默認開啓偏向鎖
  • 會進行鎖升級
    在jdk5之前synchronized是重量級鎖。
    在jdk5之前,使用synchronized是比較耗費資源的。
    因爲在jdk5之前,synchronized是需要調用OperatorSystem的一些操作,實現鎖的。
    這就涉及到線程需要從用戶態切換到內核態。
    這個切換過程非常耗費時間。所以,在jdk5之前,synchronized是重量級鎖,耗費性能。

在jdk5之後,jvm對synchronized進行了優化。
在jdk5之後,線程使用synchronized進行同步,首先會使用偏向鎖,如果有第二個線程競爭鎖,此時鎖會升級爲輕量級鎖,多個線程競爭輕量級鎖,未競爭到鎖的線程進行自旋等待。如果自旋超過10次還未獲取到鎖,那麼鎖就會升級爲重量級鎖。
在這裏插入圖片描述

偏向鎖的機制也比較簡單,在對象的對象頭中寫入了一個線程的id,那麼此時,如果這個線程再次獲取鎖,jvm將對象的對象頭中的線程id與競爭鎖的線程id進行對比,如果是一樣的,那麼這個線程就直接獲取鎖。
如果有多於1個線程進行競爭鎖,此時偏向鎖只能記錄一個線程id,就不合適了,此時會升級爲輕量級鎖。

偏向鎖的設計思想是:在大多數程序中,我們還是串行處理佔多數;併發處理的時間或者操作佔比比較低。
輕量級鎖的設計思想是:在大多數併發中,我們需要同步加鎖的操作是比較簡單,快速的操作,佔整個線程處理時間的佔比很小。所以,每一個線程獲取到鎖之後,大多數是在很短的時間內就會釋放。
重量級鎖的設計思想是:即使併發衝突的概率比較小,但是併發衝突的造成的後果非常的嚴重。當併發衝突無法避免的時候,我們就需要保證併發的安全。

7. synchronized 處理過程

在這裏插入圖片描述
當有多個線程一起訪問某個對象的monitor對象的時候,對象監視器會將這些線程存儲在不同的容器中:

  1. Contention List:競爭隊列,所有請求鎖的線程首先被放在這個競爭隊列中;
  2. Entry List:Contention List中那些有資格成爲候選資源的線程被移動到Entry List中;
  3. Wait Set:哪些調用wait方法被阻塞的線程被放置在這裏;
  4. OnDeck:任意時刻,最多隻有一個線程正在競爭鎖資源,該線程被成爲OnDeck;
  5. Owner:當前已經獲取到所資源的線程被稱爲Owner;
  6. !Owner:當前釋放鎖的線程。


JVM每次從隊列的尾部取出一個數據用於鎖競爭候選者(OnDeck),但是併發情況下,ContentionList會被大量的併發線程進行CAS訪問,爲了降低對尾部元素的競爭,JVM會將一部分線程移動到EntryList中作爲候選競爭線程。Owner線程會在unlock時,將ContentionList中的部分線程遷移到EntryList中,並指定EntryList中的某個線程爲OnDeck線程(一般是最先進去的那個線程)。Owner線程並不直接把鎖傳遞給OnDeck線程,而是把鎖競爭的權利交給OnDeck,OnDeck需要重新競爭鎖。這樣雖然犧牲了一些公平性,但是能極大的提升系統的吞吐量,在JVM中,也把這種選擇行爲稱之爲“競爭切換”。

OnDeck線程獲取到鎖資源後會變爲Owner線程,而沒有得到鎖資源的仍然停留在EntryList中。如果Owner線程被wait方法阻塞,則轉移到WaitSet隊列中,直到某個時刻通過notify或者notifyAll喚醒,會重新進去EntryList中。

處於ContentionList、EntryList、WaitSet中的線程都處於阻塞狀態,該阻塞是由操作系統來完成的(Linux內核下采用pthread_mutex_lock內核函數實現的)。

Synchronized是非公平鎖。 Synchronized在線程進入ContentionList時,等待的線程會先嚐試自旋獲取鎖,如果獲取不到就進入ContentionList,這明顯對於已經進入隊列的線程是不公平的,還有一個不公平的事情就是自旋獲取鎖的線程還可能直接搶佔OnDeck線程的鎖資源。

來自:https://blog.csdn.net/zqz_zqz/article/details/70233767

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