1. 程式人生 > 其它 >Java併發程式設計(一)——程序和執行緒、Java物件記憶體佈局、synchronized、wait和notify、park和unpack

Java併發程式設計(一)——程序和執行緒、Java物件記憶體佈局、synchronized、wait和notify、park和unpack

一、程序和執行緒

程序和執行緒的區別?

程序:程序是程式的一次執行過程。是CPU資源分配的最小單位。每個程序都有自己獨立的一塊記憶體空間,一個程序可以有多個執行緒

執行緒:執行緒是CPU排程的最小單位,它可以和屬於同一個程序的其他執行緒共享這個程序的全部資源

程序和執行緒的區別

  • 根本區別:程序是作業系統資源分配的基本單位,而執行緒是處理器任務排程和執行的基本單位

  • 資源開銷:每個程序都有獨立的程式碼和資料空間(程式上下文),程式之間的切換會有較大的開銷;執行緒可以看做輕量級的程序,同一類執行緒共享程式碼和資料空間,每個執行緒都有自己獨立的執行棧和程式計數器(PC),執行緒之間切換的開銷小。

  • 包含關係:一般一個程序內有多個執行緒,執行過程不是一條線的,而是多條線(執行緒)共同完成的;執行緒是程序的一部分,所以執行緒也被稱為輕權程序或者輕量級程序。

  • 記憶體分配:同一程序的執行緒共享本程序的地址空間和資源,而程序之間的地址空間和資源是相互獨立的

  • 影響關係:一個程序崩潰後,在保護模式下不會對其他程序產生影響。但是一個執行緒崩潰可能導致整個程序都死掉。所以多程序要比多執行緒健壯。

從 JVM 角度說程序和執行緒之間的關係:

待補:

並行和併發有什麼區別?

  • 並行是指兩個或者多個事件在同一時刻發生
  • 併發是指兩個或多個事件在同一時間間隔發生

二、Java執行緒

2.1 建立執行緒的四種方式

  1. 建立繼承於Thread類的子類,並重寫Thread類的run()方法

  2. 建立一個實現了Runnable介面的類,並實現run()方法

  3. 通過Callable和FutureTask建立執行緒

    1. 建立一個實現Callable的實現類,並實現call方法
    2. 將Callable介面實現類的物件作為傳遞到FutureTask構造器中,建立FutureTask的物件
    3. 將FutureTask的物件作為引數傳遞到Thread類的構造器中,建立Thread物件,並呼叫start()
    4. 呼叫FutureTask物件的get()方法來獲得子執行緒執行結束後的返回值
    @Test
    public void test03() throws ExecutionException, InterruptedException {
        // 實現多執行緒的第三種方法可以返回資料
        FutureTask futureTask = new FutureTask<>(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                log.debug("多執行緒任務");
                Thread.sleep(100);
                return 100;
            }
        });
        // 主執行緒阻塞,同步等待 task 執行完畢的結果
        new Thread(futureTask,"分執行緒").start();
        log.debug("主執行緒");
        log.debug("{}",futureTask.get()); //獲得分執行緒的返回值,get方法為阻塞方法
        
    }
    
  4. 使用執行緒池

    class NumberThread implements Runnable{
        @Override
        public void run() {
            for(int i = 0;i<10;i++){
                System.out.println(Thread.currentThread().getName()+":"+i);
            }
        }
    }
    
    class Number2Thread implements Callable {
        @Override
        public Object call() throws Exception {
            int sum = 0;
            for(int i = 1;i<=10;i++){
                System.out.println(Thread.currentThread().getName()+":"+i);
                sum+=i;
            }
            return sum;
        }
    }
    
    public class ThreadPool {
        public static void main(String[] args) {
            //1. 提供指定執行緒數量的執行緒池
            ExecutorService service = Executors.newFixedThreadPool(10);//建立一個可重用固定執行緒數為10的執行緒池
    
            //檢視該物件是哪個類造的
            System.out.println(service.getClass());//class java.util.concurrent.ThreadPoolExecutor
            //設定執行緒池的屬性
    //        service1.setCorePoolSize(15);
    //        service1.setKeepAliveTime();
    
            //2.執行指定的執行緒的操作。需要提供實現Runnable介面或Callable介面實現類的物件
            service.execute(new NumberThread());//適合使用於Runnable
            Future future = service.submit(new Number2Thread());//適合使用於Callable
            try {
                System.out.println(future.get());//輸出返回值
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
            //3.關閉連線池
            service.shutdown();
        }
    }
    

runnable 和 callable 有什麼區別?

  • 相同點

    • 都是介面
    • 都可以編寫多執行緒程式
    • 都採用Thread.start()啟動執行緒
  • 主要區別

    • Runnable 介面 run 方法無返回值;Callable 介面 call 方法有返回值,支援泛型,和Future、FutureTask配合可以用來獲取非同步執行的結果
    • Runnable 介面 run 方法只能丟擲執行時異常,且無法捕獲處理;Callable 介面 call 方法允許丟擲異常,可以獲取異常資訊
      注:Callalbe介面支援返回執行結果,需要呼叫FutureTask.get()得到,此方法會阻塞主程序的繼續往下執行,如果不呼叫不會阻塞。

執行緒的 run()和 start()有什麼區別?

  • start() 方法用於啟動執行緒,run() 方法用於執行執行緒的執行時程式碼。run() 可以重複呼叫,而 start() 只能呼叫一次。 多次呼叫會丟擲 java.lang.IllegalThreadStateException 異常

  • new 一個 Thread,執行緒進入了新建狀態。呼叫 start() 方法,會啟動一個執行緒並使執行緒進入了就緒狀態,當分配到時間片後就可以開始運行了。 start() 會執行執行緒的相應準備工作,然後自動執行 run() 方法的內容,這是真正的多執行緒工作。

  • 而直接執行 run() 方法,會把 run 方法當成一個 main 執行緒下的普通方法去執行,並不會在某個執行緒中執行它,所以這並不是多執行緒工作。

2.2 執行緒的生命週期

  1. 新建(new):新建立了一個執行緒物件。

  2. 就緒(runnable):執行緒物件建立後,當呼叫執行緒物件的 start()方法,該執行緒處於就緒狀態,等待被執行緒排程選中,獲取cpu的使用權。

  3. 執行(running):可執行狀態(runnable)的執行緒獲得了cpu時間片(timeslice),執行程式程式碼。注:就緒狀態是進入到執行狀態的唯一入口,也就是說,執行緒要想進入執行狀態執行,首先必須處於就緒狀態中;

  4. 阻塞(block):處於執行狀態中的執行緒由於某種原因,暫時放棄對 CPU的使用權,停止執行,此時進入阻塞狀態,直到其進入到就緒狀態,才有機會再次被 CPU 呼叫以進入到執行狀態。

    阻塞的情況分三種:

    1. 等待阻塞:執行狀態中的執行緒執行 wait()方法,JVM會把該執行緒放入等待佇列(waitting queue)中,使本執行緒進入到等待阻塞狀態;
    2. 同步阻塞:執行緒在獲取 synchronized 同步鎖失敗(因為鎖被其它執行緒所佔用),,則JVM會把該執行緒放入鎖池(lock pool)中,執行緒會進入同步阻塞狀態;
    3. 其他阻塞: 通過呼叫執行緒的 sleep()或 join()或發出了 I/O 請求時,執行緒會進入到阻塞狀態。當 sleep()狀態超時、join()等待執行緒終止或者超時、或者 I/O 處理完畢時,執行緒重新轉入就緒狀態。
  5. 死亡(dead):執行緒run()、main()方法執行結束,或者因異常退出了run()方法,則該執行緒結束生命週期。死亡的執行緒不可再次復生。

執行緒的六種狀態:

  • 這是從 Java API 層面來描述的。根據Thread.State 列舉,分為六種狀態
  • NEW (新建狀態) 執行緒剛被建立,但是還沒有呼叫 start() 方法

  • RUNNABLE (執行狀態) 當呼叫了 start() 方法之後,注意,Java API 層面的RUNNABLE 狀態涵蓋了作業系統層面的 【就緒狀態】、【執行中狀態】和【阻塞狀態】(由於 BIO 導致的執行緒阻塞,在 Java 裡無法區分,仍然認為 是可執行)

  • BLOCKED (阻塞狀態)WAITING (等待狀態)TIMED_WAITING(定時等待狀態) 都是 Java API 層面對【阻塞狀態】的細分,如sleep就位TIMED_WAITINGjoinWAITING狀態。

  • TERMINATED (結束狀態) 當執行緒程式碼執行結束

2.3 執行緒的狀態轉換(API層次)

假設有執行緒 Thread t

  • 情況1:NEW –> RUNNABLE

    • 當呼叫t.start()方法時, NEW --> RUNNABLE
  • 情況2:RUNNABLE <–> WAITING

    • t執行緒用synchronized(obj)獲取了物件鎖後
      • 呼叫 obj.wait()方法時,t 執行緒進入waitSet中, 從RUNNABLE --> WAITING
      • 呼叫obj.notify()obj.notifyAll()t.interrupt()時, 喚醒的執行緒都到entrySet阻塞佇列成為BLOCKED狀態, 在阻塞佇列,和其他執行緒再進行競爭鎖
        • 競爭鎖成功,t 執行緒從 WAITING --> RUNNABLE
        • 競爭鎖失敗,t 執行緒從 WAITING --> BLOCKED
  • 情況3:RUNNABLE <–> WAITING

    • 當前執行緒呼叫 t.join() 方法時,當前執行緒RUNNABLE --> WAITING
      • 注意是當前執行緒在t執行緒物件在waitSet上等待
    • t 執行緒執行結束,或呼叫了當前執行緒的 interrupt() 時當前執行緒WAITING --> RUNNABLE
  • 情況4:RUNNABLE <–> WAITING

    • 當前執行緒呼叫 LockSupport.park() 方法會讓當前執行緒RUNNABLE --> WAITING
    • 呼叫 LockSupport.unpark(目標執行緒) 或呼叫了執行緒 的 interrupt() ,會讓目標執行緒從 WAITING --> RUNNABLE
  • 情況5:RUNNABLE <–> TIMED_WAITING (帶超時時間的wait)

    • t 執行緒用synchronized(obj)獲取了物件鎖後
      • 呼叫 obj.wait(long n) 方法時,t 執行緒從 RUNNABLE --> TIMED_WAITING
      • t 執行緒等待時間超過了 n 毫秒,或呼叫 obj.notify() , obj.notifyAll() , t.interrupt() 時; 喚醒的執行緒都到entrySet阻塞佇列成為BLOCKED狀態, 在阻塞佇列,和其他執行緒再進行競爭鎖
        • 競爭鎖成功,t 執行緒從 TIMED_WAITING --> RUNNABLE
        • 競爭鎖失敗,t 執行緒從 TIMED_WAITING --> BLOCKED
  • 情況6:RUNNABLE <–> TIMED_WAITING

    • 當前執行緒呼叫 t.join(long n) 方法時,當前執行緒從 RUNNABLE --> TIMED_WAITING 注意是當前執行緒在t 執行緒物件的waitSet等待
    • 當前執行緒等待時間超過了 n 毫秒,或t 執行緒執行結束,或呼叫了當前執行緒的 interrupt() 時,當前執行緒從 TIMED_WAITING --> RUNNABLE
  • 情況7:RUNNABLE <–> TIMED_WAITING

    • 當前執行緒呼叫 Thread.sleep(long n) ,當前執行緒從 RUNNABLE --> TIMED_WAITING
    • 當前執行緒等待時間超過了 n 毫秒或呼叫了執行緒的 interrupt() ,當前執行緒從 TIMED_WAITING --> RUNNABLE
  • 情況8:RUNNABLE <–> TIMED_WAITING

    • 當前執行緒呼叫 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 時,當前執行緒從 RUNNABLE --> TIMED_WAITING
    • 呼叫LockSupport.unpark(目標執行緒) 或呼叫了執行緒的interrupt() ,或是等待超時,會讓目標執行緒從 TIMED_WAITING--> RUNNABLE
  • 情況9:RUNNABLE <–> BLOCKED

    • t 執行緒用 synchronized(obj) 獲取了物件鎖時如果競爭失敗,從 RUNNABLE –> BLOCKED
    • 持 obj 鎖執行緒的同步程式碼塊執行完畢,會喚醒該物件上所有 BLOCKED 的執行緒重新競爭,如果其中 t 執行緒競爭 成功,從 BLOCKED –> RUNNABLE ,其它失敗的執行緒仍然 BLOCKED
  • 情況10:RUNNABLE –> TERMINATED

    • 當前執行緒所有程式碼執行完畢,進入 TERMINATED

2.4 執行緒執行原理

虛擬機器棧與棧幀

  • 虛擬機器棧描述的是Java方法執行的記憶體模型每個方法被執行的時候都會同時建立一個棧幀(stack frame)用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊,是屬於執行緒的私有的。當Java中使用多執行緒時,每個執行緒都會維護它自己的棧幀!每個執行緒只能有一個活動棧幀(在棧頂),對應著當前正在執行的那個方法

執行緒上下文切換(Thread Context Switch)

因為以下一些原因導致 cpu 不再執行當前的執行緒,轉而執行另一個執行緒的程式碼

  • 執行緒的 cpu 時間片用完(每個執行緒輪流執行,看前面並行的概念)
  • 垃圾回收
  • 有更高優先順序的執行緒需要執行
  • 執行緒自己呼叫了 sleepyieldwaitjoinparksynchronizedlock 等方法

Thread Context Switch發生時,需要由作業系統儲存當前執行緒的狀態,並恢復另一個執行緒的狀態,Java 中對應的概念就是程式計數器(Program Counter Register),它的作用是記住下一條 jvm 指令的執行地址,是執行緒私有的

  • 執行緒的狀態包括程式計數器、虛擬機器棧中每個棧幀的資訊,如區域性變數、運算元棧、返回地址等
  • Context Switch 頻繁發生會影響效能

2.5 守護執行緒

  • 守護執行緒,是指在程式執行的時候在後臺提供一種通用服務的執行緒
  • Java程序中有多個執行緒在執行時,只有當所有非守護執行緒都執行完畢後,Java程序才會結束。但當非守護執行緒全部執行完畢後,守護執行緒無論是否執行完畢,也會一同結束。普通執行緒t1可以呼叫t1.setDeamon(true); 方法變成守護執行緒

注意:

  • 垃圾回收器執行緒就是一種守護執行緒
  • Tomcat 中的 Acceptor 和 Poller 執行緒都是守護執行緒,所以 Tomcat 接收到 shutdown 命令後,不會等

三、Java物件記憶體佈局和物件頭

在 JVM 中,Java物件儲存在堆中時,由以下三部分組成

  • 物件頭(object header):包括了關於堆物件的佈局、型別、GC狀態、同步狀態和標識雜湊碼的基本資訊。Java物件和vm內部物件都有一個共同的物件頭格式。
  • 例項資料(Instance Data):主要是存放類的資料資訊,父類的資訊,物件欄位屬性資訊。
  • 對齊填充(Padding):為了位元組對齊,填充的資料,不是必須的。預設情況下,Java虛擬機器堆中物件的起始地址需要對齊至8的倍數。如果一個物件用不到8N個位元組則需要對其填充

即:物件示例 = 物件頭 + 例項資料 + 對齊填充


物件頭分為兩類資訊:一類是Mark Word用(於儲存物件自身的執行時資料),一類是Klass Point(型別指標)。

  • 第一部分是Mark Word用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。 這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32個位元和64個位元。

  • 第二部分是Klass Point(型別指標),即物件指向它的型別元資料的指標,Java虛擬機器通過這個指標來確定該物件是哪個類的例項

  • 此外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料。

即:物件頭 = 物件標記markword + 型別指標

即:物件在堆記憶體中的整體結構佈局

Mark Word:

Mark Word在不同的鎖狀態下儲存的內容不同。

在32位JVM中是這麼存的(瞭解)

在64位JVM中的儲存結構:

雖然它們在不同位數的JVM中長度不一樣,但是基本組成內容是一致的。

  • 鎖標誌位(lock):區分鎖狀態,11時表示物件待GC回收狀態, 只有最後2位鎖標識(11)有效。
  • biased_lock:是否偏向鎖,由於無鎖和偏向鎖的鎖標識都是 01,沒辦法區分,這裡引入一位的偏向鎖標識位。
  • 分代年齡(age):表示物件被GC的次數,當該次數到達閾值的時候,物件就會轉移到老年代。
  • 物件的hashcode(hash):執行期間呼叫System.identityHashCode()來計算,延遲計算,並把結果賦值到這裡。當物件加鎖後,計算的結果31位不夠表示,在偏向鎖,輕量鎖,重量鎖,hashcode會被轉移到Monitor中。
  • 偏向鎖的執行緒ID(JavaThread):偏向模式的時候,當某個執行緒持有物件的時候,物件這裡就會被置為該執行緒的ID。 在後面的操作中,就無需再進行嘗試獲取鎖的動作。
  • epoch:偏向鎖在CAS鎖操作過程中,偏向性標識,表示物件更偏向哪個鎖。
  • ptr_to_lock_record:輕量級鎖狀態下,指向棧中鎖記錄的指標。當鎖獲取是無競爭的時,JVM使用原子操作而不是OS互斥。這種技術稱為輕量級鎖定。在輕量級鎖定的情況下,JVM通過CAS操作在物件的標題字中設定指向鎖記錄的指標。
  • ptr_to_heavyweight_monitor:重量級鎖狀態下,指向物件監視器Monitor的指標。如果兩個不同的執行緒同時在同一個物件上競爭,則必須將輕量級鎖定升級到Monitor以管理等待的執行緒。在重量級鎖定的情況下,JVM在物件的ptr_to_heavyweight_monitor設定指向Monitor的指標。

程式碼示例證明物件頭:(藉助JOL工具)

  1. 檢視new一個Object物件的物件頭

    欄位 說明
    OFFSET 偏移量,也就是到這個欄位位置所佔用的byte數
    SIZE 後面型別的位元組大小
    TYPE 是Class中定義的型別
    DESCRIPTION DESCRIPTION是型別的描述
    VALUE VALUE是TYPE在記憶體中的值

    可以看到這裡mark word佔8byte(64bit),klass pointe 佔4byte,另外剩餘4byte是填充對齊的

    這是由於預設開啟了指標壓縮 ,klass pointe 佔4byte(預設其實是佔用8byte)

  2. 關閉指標壓縮後,檢視new一個Object物件的物件頭。

    jdk8版本是預設開啟指標壓縮的,可以通過配置jvm引數開啟關閉指標壓縮,-XX:-UseCompressedOops

    如果關閉指標壓縮重新列印物件的記憶體佈局,可以發現總SIZE變大了,從下圖中可以看到,物件頭所佔用的記憶體大小變為16byte(128bit),其中 mark word佔8byte,klass pointe 佔8byte,無對齊填充。

一般而言64位JDK8按照預設情況下,new一個物件佔多少記憶體空間?

以下面的物件為例:其中int佔4個位元組,char佔1個位元組。

class MyObject{
    int i = 5;
    char a = 'a';
}

所以是 8(物件頭)+ 8(型別指標,關閉指標壓縮的情況) + 5 + 3(型別填充) = 24位元組(虛擬機器要求物件起始地址必須是8位元組的整數倍。)

好的部落格:Java物件的記憶體佈局

四、synchronized與鎖升級

4.1 synchronized關鍵字

方法上的 synchronized

  • 普通synchronized方法相當於給當前類物件加鎖

    class Test{
        public synchronized void test() {
    
        }
    }
    等價於
    class Test{
        public void test() {
            synchronized(this) { // 普通synchronized方法相當於給當前類物件加鎖
    
            }
        }
    }
    
  • 靜態synchronized方法,相當於給當前類的class物件加鎖

    class Test{
        public synchronized static void test() {
        }
    }
    等價於
    class Test{
        public static void test() {
            synchronized(Test.class) { // 靜態synchronized方法,相當於給當前類的class物件加鎖
    
            }
        }
    }
    

**private 或 final的重要性: **提高執行緒的安全性

  • 分析下面的程式:

    class ThreadSafe {
        public final void method1(int loopNumber) {
            ArrayList<String> list = new ArrayList<>();
            for (int i = 0; i < loopNumber; i++) {
                method2(list);
                method3(list);
            }
        }
        private void method2(ArrayList<String> list) {
            list.add("1");
        }
        public void method3(ArrayList<String> list) {
            list.remove(0);
        }
    }
    class ThreadSafeSubClass extends ThreadSafe{
        @Override
        public void method3(ArrayList<String> list) {
            new Thread(() -> {
                list.remove(0);
            }).start();
        }
    }
    

    本來ThreadSafe類為執行緒安全類,但由於子類ThreadSafeSubClass重寫了method3()方法,導致ThreadSafe類不線上程安全。

    由於method3()方法為public, 此時子類可以重寫父類的方法, 在子類中開執行緒來操作list物件, 此時就會出現執行緒安全問題: 子類和父類共享了list物件

總結:

  • 如果改為private, 子類就不能重寫父類的私有方法, 也就不會出現執行緒安全問題; 所以所private修飾符是可以避免執行緒安全問題.
  • 所以如果不想子類, 重寫父類的方法的時候, 我們可以將父類中的方法設定為private, final修飾的方法, 此時子類就無法影響父類中的方法了

4.2 synchronized的鎖升級

4.2.1 偏向鎖

為什麼要引入偏向鎖?

  • 因為經過HotSpot的作者大量的研究發現,大多數時候是不存在鎖競爭的,常常是一個執行緒多次獲得同一個鎖,因此如果每次都要競爭鎖會增大很多沒有必要付出的代價,為了降低獲取鎖的代價,才引入的偏向鎖。

偏向鎖的升級:

  • 當執行緒1訪問程式碼塊並獲取鎖物件時,會在java物件頭和棧幀中記錄偏向的鎖的threadID,因為偏向鎖不會主動釋放鎖,因此以後執行緒1再次獲取鎖的時候,需要比較當前執行緒的threadID和Java物件頭中的threadID是否一致。

    • 如果一致(還是執行緒1獲取鎖物件),表示偏向鎖是偏向於當前執行緒的,則無需使用CAS來加鎖、解鎖了,直接進入同步;
    • 如果不一致,那麼需要檢視Java物件頭中記錄的執行緒1是否處於同步塊中
      • 如果已經退出同步塊,則將物件頭設定成無鎖狀態並撤銷偏向鎖,重新偏向。
      • 如果處於同步塊中,它還沒有執行完,其它執行緒來搶奪,該偏向鎖會被取消掉並出現鎖升級。此時輕量級鎖由原持有偏向鎖的執行緒持有,繼續執行其同步程式碼,而正在競爭的執行緒會進入自旋等待獲得該輕量級鎖。
  • 鎖升級過程中Mark Word的改變。從無鎖升級到偏向鎖,Mark Word的後三位會從001變為101。並且Mark Word將執行持有偏向鎖的執行緒id。

偏向鎖相關jvm引數:

  • 偏向鎖是預設開啟的,但是預設偏向鎖開始時間比應用程式啟動有四秒的延遲

    • 可以使用XX:BiasedLockingStartupDelay=0來禁用延遲
    • 可以使用-XX:-UseBiasedLocking來禁止偏向鎖

    jvm預設和偏向鎖有關的引數

程式碼測試:

  • 禁用延遲之後可以看到,執行緒獲取鎖物件時,Mark Word的標誌位變成了101

相關了解

  • 偏向鎖的撤銷情況。當呼叫物件的hashcode方法的時候就會撤銷這個物件的偏向鎖因為使用偏向鎖時沒有位置存hashcode的值了

  • 批量重偏向

    • 如果物件被多個執行緒訪問,但是沒有競爭 , 這時偏向T1的物件仍有機會重新偏向T2。重偏向會重置Thread ID
    • 當撤銷偏向鎖閾值超過 20 次後,jvm 會這樣覺得,我是不是偏向錯了呢,於是會在給這些物件加鎖時重新偏向至加鎖執行緒
  • 批量撤銷偏向鎖。當撤銷偏向鎖閾值超過 40 次後,jvm 會這樣覺得,自己確實偏向錯了,根本就不該偏向。於是整個類的所有物件都會變為不可偏向的,新建的物件也是不可偏向的

4.2.2 輕量級鎖

輕量級鎖的本質就是自旋鎖

為什麼要引入輕量級鎖?

  • 輕量級鎖考慮的是競爭鎖物件的執行緒不多,而且執行緒持有鎖的時間也不長的情景。因為阻塞執行緒需要CPU從使用者態轉到核心態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失了,因此這個時候就乾脆不阻塞這個執行緒,讓它自旋這等待鎖釋放。

輕量鎖的升級時機 : 當關閉偏向鎖功能多執行緒競爭偏向鎖會導致偏向鎖升級為輕量級鎖


輕量級鎖什麼時候升級為重量級鎖?

  • 執行緒1獲取輕量級鎖時會先把鎖物件的物件頭MarkWord複製一份到執行緒1的棧幀中建立的用於儲存鎖記錄的空間(Lock Record),然後使用CAS把物件頭中的內容替換為執行緒1儲存的鎖記錄的地址

    • 如果線上程1複製物件頭的同時(線上程1CAS之前),執行緒2也準備獲取鎖,複製了物件頭到執行緒2的鎖記錄空間(Lock Record)中,但是線上程2CAS的時候,發現執行緒1已經把物件頭換了,執行緒2的CAS失敗,那麼執行緒2就嘗試使用自旋鎖來等待執行緒1釋放鎖

    • 但是如果自旋的時間太長也不行,因為自旋是要消耗CPU的,因此自旋的次數是有限制的,如果自旋次數到了執行緒1還沒有釋放鎖,或者執行緒1還在執行,執行緒2還在自旋等待,這時又有一個執行緒3過來競爭這個鎖物件,那麼這個時候輕量級鎖就會膨脹為重量級鎖重量級鎖把除了擁有鎖的執行緒都阻塞,防止CPU空轉。


自適應自旋鎖

  • JDK 1.6引入了更加聰明的自旋鎖,即自適應自旋鎖。所謂自適應就意味著自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。

  • 執行緒如果自旋成功了,那麼下次自旋的次數會更加多,因為虛擬機器認為既然上次成功了,那麼此次自旋也很有可能會再次成功,那麼它就會允許自旋等待持續的次數更多。反之,如果對於某個鎖,很少有自旋能夠成功,那麼在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。

4.2.2.1 輕量級鎖流程解釋

輕量級鎖加鎖流程:

  1. 在獲取輕量鎖是會建立鎖記錄(Lock Record)物件,每個執行緒的棧幀都會包含一個鎖記錄的結構,內部可以儲存鎖定物件的Mark Word。

  2. 讓鎖記錄中的Object reference指向鎖物件地址,並且嘗試用CAS將棧幀中的鎖記錄的(lock record 地址 00)替換Object物件的Mark Word,將Mark Word 的值(01)存入鎖記錄(lock record地址)------相互替換

    • 01 表示 無鎖 (看Mark Word結構, 數字的含義)
    • 00 表示 輕量級鎖
  3. 如果cas替換成功, 獲得了輕量級鎖,那麼物件的物件頭儲存的就是鎖記錄的地址和狀態00執行緒中鎖記錄, 記錄了鎖物件的鎖狀態標誌; 鎖物件的物件頭中儲存了鎖記錄的地址和狀態, 標誌哪個執行緒獲得了鎖

  4. 如果cas替換失敗,有兩種情況 : ① 鎖膨脹 ② 執行了鎖重入

    • 如果是其它執行緒已經持有了該Object的輕量級鎖,那麼表示有競爭,自旋一定的時間後,將進入鎖膨脹階段(膨脹我重量級鎖)

    • 如果是自己的執行緒已經執行了synchronized進行加鎖,那麼再新增一條 Lock Record 作為重入鎖的計數 – 執行緒多次加鎖, 鎖重入。


輕量級鎖解鎖流程:

  • 執行緒退出synchronized程式碼塊的時候,如果獲取的是取值為 null 的鎖記錄 ,表示有鎖重入,這時重置鎖記錄,表示重入計數減一
  • 當執行緒退出synchronized程式碼塊的時候,如果獲取的鎖記錄取值不為 null,那麼使用cas將Mark Word的值恢復給物件, 將直接替換的內容還原。
    • 成功則解鎖成功 (輕量級鎖解鎖成功)
    • 失敗,表示有競爭, 則說明輕量級鎖進行了鎖膨脹或已經升級為重量級鎖進入重量級鎖解鎖流程 (Monitor流程)

輕量級鎖膨脹流程:

  • 如果在嘗試加輕量級鎖的過程中,CAS 操作無法成功,這時一種情況就是有其它執行緒為此物件加上了輕量級鎖(有競爭),這時需要進行鎖膨脹,將輕量級鎖變為重量級鎖

  • 當 Thread-1 進行輕量級加鎖時,Thread-0 已經對該物件加了輕量級鎖, 此時發生鎖膨脹

  • 這時Thread-1加輕量級鎖失敗,進入鎖膨脹流程

    • 因為Thread-1執行緒加輕量級鎖失敗, 輕量級鎖沒有阻塞佇列的概念, 所以此時就要為物件申請Monitor鎖(重量級鎖),讓Object指向重量級鎖地址 。
    • 然後自己進入Monitor 的EntryList 變成BLOCKED狀態
  • 當 Thread-0 退出同步塊解鎖時,使用 cas 將 Mark Word 的值恢復給物件頭,失敗。這時會進入重量級解鎖流程,即按照 Monitor 地址找到 Monitor 物件,設定 Owner 為 null,喚醒 EntryList 中 BLOCKED 執行緒

4.2.3 Monitor 原理 (重量級鎖原理)

Monitor也稱為監視器或者管程

每個Java物件都可以關聯一個(作業系統的)Monitor,如果使用synchronized給物件上鎖(重量級),該物件頭的MarkWord中就被設定為指向Monitor物件的指標

下圖原理解釋:

  • 當Thread2訪問到synchronized(obj)中的共享資源的時候

    • 首先會將synchronized中的鎖物件物件頭MarkWord去嘗試指向作業系統Monitor物件. 讓鎖物件中的MarkWord和Monitor物件相關聯. 如果關聯成功, 將obj物件頭中的MarkWord為指向重量級鎖的指標,並且標誌位變為10。

    • 因為Monitor沒有和其他的obj的MarkWord相關聯, 所以Thread2就成為了該Monitor的Owner(所有者)。

    • 又來了個Thread1執行synchronized(obj)程式碼, 它首先會看看能不能執行該臨界區的程式碼; 它會檢查obj是否關聯了Montior, 此時已經有關聯了, 它就會去看看該Montior有沒有所有者(Owner), 發現有所有者了(Thread2); Thread1也會和該Monitor關聯, 該執行緒就會進入到它的EntryList(阻塞佇列);

    • Thread2執行完臨界區程式碼後, Monitor的Owner(所有者)就空出來了. 此時就會通知Monitor中的EntryList阻塞佇列中的執行緒, 這些執行緒通過競爭, 成為新的所有者

總結:

  • 剛開始時Monitor中的Owner為null
  • 當Thread-2 執行synchronized(obj){}程式碼時,首先會關聯obj物件的Monitor,然後會將Monitor的所有者Owner 設定為 Thread-2,上鎖成功,Monitor中同一時刻只能有一個Owner
  • 當Thread-2 佔據鎖時,如果執行緒Thread-3,Thread-4也來執行synchronized(obj){}程式碼,就會進入EntryList中變成BLOCKED狀態
  • Thread-2 執行完同步程式碼塊的內容,然後喚醒 EntryList 中等待的執行緒來競爭鎖,競爭時是非公平的 (仍然是搶佔式)
  • 圖中 WaitSet 中的Thread-0,Thread-1 是之前獲得過鎖,但條件不滿足進入 WAITING 狀態的執行緒,後面講wait-notify 時會分析

注意:它加鎖就是依賴底層作業系統的 mutex相關指令實現, 所以會造成使用者態和核心態之間的切換, 非常耗效能 !

  • 在JDK6的時候, 對synchronized進行了優化, 引入了輕量級鎖, 偏向鎖, 它們是在JVM的層面上進行加鎖邏輯, 就沒有了切換的消耗

分析synchronized的位元組碼

Synchronized程式碼塊同步在需要同步的程式碼塊開始的位置插入monitorenter指令,在同步結束的位置或者異常出現的位置插入monitorexit指令;JVM要保證monitorentermonitorexit都是成對出現的,任何物件都有一個monitor與之對應,當這個物件的monitor被持有以後,它將處於鎖定狀態。

static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
    synchronized (lock) {
        counter++;
    }
}

對應的位元組碼為:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC 
    Code:
        stack=2, locals=3, args_size=1
            0: getstatic #2 	// <- lock引用 (synchronized開始)
            3: dup
            4: astore_1 		// lock引用 -> slot 1
            5: monitorenter 	// 將 lock物件 MarkWord 置為 Monitor 指標
            6: getstatic #3 	// <- i ,6-14行即為i++操作
            9: iconst_1 		// 準備常數 1
            10: iadd 			// +1
            11: putstatic #3 	// -> i
            14: aload_1 		// <- lock引用
            15: monitorexit 	// 將 lock物件 MarkWord 重置, 喚醒 EntryList
            16: goto 24
            19: astore_2 		// e -> slot 2
            20: aload_1 		// <- lock引用
            21: monitorexit 	// 將 lock物件 MarkWord 重置, 喚醒 EntryList
            22: aload_2 		// <- slot 2 (e)
            23: athrow 			// throw e
            24: return
        Exception table:
        from to target type
        6 16 19 any     //如果6-16行出現異常則轉到19行,如果出現異常也可以釋放鎖
        19 22 19 any

當執行 monitorenter 指令時,執行緒試圖獲取鎖也就是獲取 物件監視器 monitor 的持有權。

在執行monitorenter時,會嘗試獲取物件的鎖,如果鎖的計數器為 0 則表示鎖可以被獲取,獲取後將鎖計數器設為 1 也就是加 1。

在執行 monitorexit 指令後,將鎖計數器設為 0,表明鎖被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到鎖被另外一個執行緒釋放為止。

4.2.4 鎖消除和鎖粗化

鎖消除

  • Java虛擬機器在JIT(Just In Time Compiler,一般翻譯為即時編譯器)編譯時(可以簡單理解為當某段程式碼即將第一次被執行時進行編譯),通過對執行上下文的掃描,經過逃逸分析,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間

  • 關閉鎖消除的開關:java -XX:-EliminateLocks -jar benchmarks.jar

鎖粗化

  • 按理來說,同步塊的作用範圍應該儘可能小,僅在共享資料的實際作用域中才進行同步,這樣做的目的是為了使需要同步的運算元量儘可能縮小,縮短阻塞時間,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快拿到鎖。 但是加鎖解鎖也需要消耗資源,如果存在一系列的連續加鎖解鎖操作,可能會導致不必要的效能損耗。
  • 鎖粗化就是將多個連續的加鎖、解鎖操作連線在一起,擴充套件成一個範圍更大的鎖,避免頻繁的加鎖解鎖操作。

4.2.5 總結

這幾種鎖的優缺點:

優點 缺點 適用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距。 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗。 適用於基本沒有執行緒競爭鎖的同步場景。
輕量級鎖 競爭的執行緒不會阻塞,提高了程式的響應速度。 如果始終得不到鎖競爭的執行緒使用自旋會消耗CPU。 適用於少量執行緒競爭鎖物件,且執行緒持有鎖的時間不長,追求響應速度的場景。
重量級鎖 執行緒競爭不使用自旋,不會消耗CPU。 執行緒阻塞,響應時間緩慢。 很多執行緒競爭鎖,同步塊執行時間較長。追求吞吐量的場景。

鎖升級的流程圖:

synchronized 鎖升級原理:

  • 在鎖物件的物件頭裡面有一個 threadid 欄位,在第一次訪問的時候 threadid 為空,jvm 讓其持有偏向鎖,並將 threadid 設定為其執行緒 id
  • 再次進入的時候會先判斷 threadid 是否與其執行緒 id 一致,如果一致則可以直接使用此物件,如果不一致,則判斷上一個執行緒是否退出同步程式碼塊。
    • 如果已經退出同步塊,則將物件頭設定成無鎖狀態並撤銷偏向鎖,重新偏向。
    • 如果處於同步塊中,它還沒有執行完,其它執行緒來搶奪,該偏向鎖會被取消掉並出現鎖升級。此時輕量級鎖由原持有偏向鎖的執行緒持有,繼續執行其同步程式碼,而正在競爭的執行緒會進入自旋等待獲得該輕量級鎖。
    • 執行一定次數之後,如果還沒有正常獲取到要使用的物件,此時就會把鎖從輕量級升級為重量級鎖

好的部落格:

深入分析Synchronized原理(阿里面試題

Java併發——Synchronized關鍵字和鎖升級,詳細分析偏向鎖和輕量級鎖的升級

Java併發程式設計(三) : synchronized底層原理、優化Monitor重量級鎖、輕量級鎖、自旋鎖(優化重量級鎖競爭)、偏向鎖

五、wait和notify

wait、notify原理

  • 執行緒0獲得到了鎖, 成為Monitor的Owner, 但是此時它發現自己想要執行synchroized程式碼塊的條件不滿足; 此時它就呼叫obj.wait方法, 進入到Monitor中的WaitSet集合, 此時執行緒0的狀態就變為WAITING

  • 處於BLOCKED和WAITING狀態的執行緒都為阻塞狀態,CPU都不會分給他們時間片。但是有所區別:

    • BLOCKED狀態的執行緒是在競爭鎖物件時,發現Monitor的Owner已經是別的執行緒了,此時就會進入EntryList中,並處於BLOCKED狀態
    • WAITING狀態的執行緒是獲得了物件的鎖,但是自身的原因無法執行synchroized的臨界區資源需要進入阻塞狀態時,鎖物件呼叫了wait方法而進入了WaitSet中,處於WAITING狀態
  • 處於BLOCKED狀態的執行緒會在鎖被釋放的時候被喚醒

  • 處於WAITING狀態的執行緒只有被鎖物件呼叫了notify方法(obj.notify/obj.notifyAll),才會被喚醒。然後它會進入到EntryList, 重新競爭鎖


API介紹:

下面的四個方法都是Object中的方法; 通過鎖物件來呼叫

  • wait(): 方法會釋放物件的鎖,進入 WaitSet 等待區,從而讓其他執行緒就機會獲取物件的鎖。無限制等待,直到notify 為止

  • wait(long n) : 當該等待執行緒沒有被notify, 等待時間到了之後, 也會自動喚醒

  • notify(): 讓獲得物件鎖的執行緒, 使用鎖物件呼叫notifywaitSet的等待執行緒中挑一個喚醒

  • notifyAll() : 讓獲得物件鎖的執行緒, 使用鎖物件呼叫notifyAll喚醒waitSet中所有的等待執行緒

注意:它們都是執行緒之間進行協作的手段, 都屬於Object物件的方法, 必須獲得此物件的鎖, 才能呼叫這些方法

public class Test1 {	final static Object LOCK = new Object();	public static void main(String[] args) throws InterruptedException {        //只有在物件被鎖住後才能呼叫wait方法		synchronized (LOCK) {			LOCK.wait();		}	}}

wait()使用注意:防止出現虛假喚醒機制

  • 當對共享變數進行判斷的時候,為了防止出現虛假喚醒機制,不能使用if來進行判斷,而應該使用while。因為當執行緒被喚醒時候必須再進行一次判斷。

    synchronized(lock) {    while(條件不成立) {        lock.wait();    }    // 幹活}//另一個執行緒synchronized(lock) {    lock.notifyAll();}
    

sleep() 和 wait() 有什麼區別?

相同點:

  • 一旦執行方法,都可以使得當前的執行緒進入阻塞狀態。

不同點:

  • 兩個方法宣告的位置不同:sleep() 是 Thread執行緒類的靜態方法wait() 是 Object類的方法
  • 是否釋放鎖:如果兩個方法都使用在同步程式碼塊或同步方法中,sleep() 不釋放鎖;wait() 釋放鎖
  • 用途不同:Wait 通常被用於執行緒間互動/通訊,sleep 通常被用於暫停執行。
  • 呼叫的要求不同:sleep()可以在任何需要的場景下呼叫。 wait()必須使用在同步程式碼塊或同步方法中

5.1 消費者、生產者模式

  • 我們下面寫的例子是執行緒間通訊訊息佇列,要注意區別,像RabbitMQ等訊息框架是程序間通訊的。
@Slf4j(topic = "c.Test21")public class TestConsume {    public static void main(String[] args) {        MessageQueue queue = new MessageQueue(2);        for (int i = 0; i < 3; i++) {            int id = i;            new Thread(() -> {                queue.put(new Message(id , "值"+id));            }, "生產者" + i).start();        }        new Thread(() -> {            while(true) {                try {                    Thread.sleep(1000);                } catch (InterruptedException e) {                    e.printStackTrace();                }                Message message = queue.take();            }        }, "消費者").start();    }}// 訊息佇列類 , java 執行緒之間通訊@Slf4j(topic = "c.MessageQueue")class MessageQueue {    // 訊息的佇列集合    private LinkedList<Message> list = new LinkedList<>();    // 佇列容量    private int capcity;    public MessageQueue(int capcity) {        this.capcity = capcity;    }    // 獲取訊息    public Message take() {        // 檢查佇列是否為空        synchronized (list) {            while(list.isEmpty()) {                try {                    log.debug("佇列為空, 消費者執行緒等待");                    list.wait();                } catch (InterruptedException e) {                    e.printStackTrace();                }            }            // 從佇列頭部獲取訊息並返回            Message message = list.removeFirst();            log.debug("已消費訊息 {}", message);            list.notifyAll();            return message;        }    }    // 存入訊息    public void put(Message message) {        synchronized (list) {            // 檢查物件是否已滿            while(list.size() == capcity) {                try {                    log.debug("佇列已滿, 生產者執行緒等待");                    list.wait();                } catch (InterruptedException e) {                    e.printStackTrace();                }            }            // 將訊息加入佇列尾部            list.addLast(message);            log.debug("已生產訊息 {}", message);            list.notifyAll();        }    }}final class Message {    private int id;    private Object value;    public Message(int id, Object value) {        this.id = id;        this.value = value;    }    public int getId() {        return id;    }    public Object getValue() {        return value;    }    @Override    public String toString() {        return "Message{" +                "id=" + id +                ", value=" + value +                '}';    }}

執行結果:

六、LockSupport之park、unpack

  • park/unpark都是LockSupport類中的的方法
  • park用於暫停某個執行緒,unpark用於恢復某個執行緒的執行。

注意:先呼叫unpark後,再呼叫park, 此時park不會暫停執行緒

@slf4jpublic class Test {    public static void main(String[] args) {        Thread t1 = new Thread(() -> {            log.debug("start...");            sleep(2);            log.debug("park...");            LockSupport.park();            log.debug("resume...");        }, "t1");        t1.start();        sleep(1);        log.debug("unpark...");        LockSupport.unpark(t1);    }}

輸出:

18:43:50.765 c.TestParkUnpark [t1] - start... 18:43:51.764 c.TestParkUnpark [main] - unpark... 18:43:52.769 c.TestParkUnpark [t1] - park... 18:43:52.769 c.TestParkUnpark [t1] - resume...

與Object的wait&notify區別

  • wait,notify 和 notifyAll 必須配合 Object Monitor 一起使用,而 park,unpark 不需要
  • park & unpark 是以執行緒為單位來【阻塞】和【喚醒】執行緒,而 notify 只能隨機喚醒一個等待執行緒,notifyAll是喚醒所有等待執行緒,就不那麼【精確】
  • park&unpark可以先unpark,而wait&notify不能先notify

park、unpark 原理:

  • 先呼叫park再呼叫upark的過程

    • 先呼叫park的情況

      • 當前執行緒呼叫 Unsafe.park() 方法
      • 檢查 _counter, 本情況為0, 這時, 獲得 _mutex 互斥鎖_
      • 執行緒進入 _cond 條件變數阻塞
      • 設定 _counter = 0
    • 呼叫unpark

    • 呼叫Unsafe.unpark(Thread_0)方法,設定_counter 為 1

    • 喚醒 _cond 條件變數中的 Thread_0

    • Thread_0 恢復執行

    • 設定 _counter 為 0

  • 先呼叫upark再呼叫park的過程

    • 呼叫 Unsafe.unpark(Thread_0)方法,設定 _counter 為 1
    • 當前執行緒呼叫 Unsafe.park() 方法
    • 檢查 _counter,本情況為 1,這時執行緒 無需阻塞,繼續執行
    • 設定 _counter 為 0
    • 注意: _counter的值最大為1,所以unpark給執行緒最多1個"許可"
  • 總結:

    • park和unpark會呼叫Unsafe類中的native方法
    • 每個執行緒都會和一個park物件關聯起來,由三部分組成 _counter , _cond 和 _mutex。核心部分是counter,我們可以理解為一個標記位。
    • 當呼叫park時會看counter是否為0,為0則進入阻塞佇列。為1則繼續執行並將counter置為0。
    • 當呼叫unpark時,會將counter置為1,若之前的counter值為0,還喚醒阻塞的執行緒。