1. 程式人生 > 其它 >Java多執行緒概述、synchronized

Java多執行緒概述、synchronized

在 Java 早期版本中,synchronized 屬於 重量級鎖,效率低下。因為監視器鎖(monitor)是依賴於底層的作業系統的 Mutex Lock 來實現的,Java 的執行緒是對映到作業系統的原生執行緒之上的。如果要掛起或者喚醒一個執行緒,都需要作業系統幫忙完成,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高。在 Java 6 之後 Java 官方對從 JVM 層面對 synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK1.6 對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。

概述

Java 程式天生就是多執行緒程式,我們可以通過 JMX 來看一下一個普通的 Java 程式有哪些執行緒,程式碼如下。

public class MultiThread {
    public static void main(String[] args) {
        // 獲取Java執行緒管理MXBean
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        // 不需要獲取同步的 monitor 和 synchronizer 資訊,僅獲取執行緒和執行緒堆疊資訊
        ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
        for (ThreadInfo threadInfo:threadInfos) {
            System.out.println("[" + threadInfo.getThreadId() + "]" + threadInfo.getThreadName());
        }
    }
}

輸出:

[6]Monitor Ctrl-Break
[5] Attach Listener //新增事件
[4] Signal Dispatcher // 分發處理給 JVM 訊號的執行緒
[3] Finalizer //呼叫物件 finalize 方法的執行緒
[2] Reference Handler //清除 reference 執行緒
[1] main //main 執行緒,程式入口

一個 Java 程式的執行是 main 執行緒和多個其他執行緒同時執行

一個程序中可以有多個執行緒,多個執行緒共享程序的方法區 (JDK1.8 之後的元空間)資源,但是每個執行緒有自己的程式計數器虛擬機器棧本地方法棧

總結: 執行緒是程序劃分成的更小的執行單位。執行緒和程序最大的不同在於基本上各程序是獨立的,而各執行緒則不一定,因為同一程序中的執行緒極有可能會相互影響。執行緒執行開銷小,但不利於資源的管理和保護;而程序正相反。

一個 Native Method 就是一個 Java 呼叫非 Java 程式碼的介面。一個 Native Method 是這樣一個 Java 的方法:該方法的實現由非 Java 語言實現,比如 C。

虛擬機器棧為虛擬機器執行 Java 方法 (也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。 在 HotSpot 虛擬機器中和 Java 虛擬機器棧合二為一。

java.lang.Thread.State列舉類中定義了六種執行緒的狀態,可以呼叫執行緒Thread中的getState()方法獲取當前執行緒的狀態

原圖中 wait 到 runnable 狀態的轉換中,join實際上是Thread類的方法,但這裡寫成了Object

執行緒在執行過程中會有自己的執行條件和狀態(也稱上下文),比如程式計數器,棧資訊等。

死鎖例子:

public class MultiThread {
    private static Object resource1 = new Object();
    private static Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(()-> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread()+"get resource1");
                try {
                    Thread.sleep(1_000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread()+"waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread()+"get resource2");
                }
            }
        },"執行緒1").start();
        new Thread(()-> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread()+"get resource2");
                try {
                    Thread.sleep(1_000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread()+"waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread()+"get resource1");
                }
            }
        },"執行緒2").start();
    }
}

/*
輸出:
Thread[執行緒1,5,main]get resource1
Thread[執行緒2,5,main]get resource2
Thread[執行緒2,5,main]waiting get resource1
Thread[執行緒1,5,main]waiting get resource2
 */

死鎖的必備條件

資源鎖、獲取資源阻塞時不釋放、不被搶、迴圈等待資源。

sleep() 方法和 wait() 方法區別和共同點

  • 兩者最主要的區別在於:sleep() 方法沒有釋放鎖,而 wait() 方法釋放了鎖。
  • 兩者都可以暫停執行緒的執行。
  • wait() 通常被用於執行緒間互動/通訊,sleep() 通常被用於暫停執行。
  • wait() 方法被呼叫後,執行緒不會自動甦醒,需要別的執行緒呼叫同一個物件上的 notify() 或者 notifyAll() 方法。sleep() 方法執行完成後,執行緒會自動甦醒。或者可以使用 wait(long timeout) 超時後執行緒會自動甦醒。

synchronized

synchronized 關鍵字解決的是多個執行緒之間訪問資源的同步性,synchronized關鍵字可以保證被它修飾的方法或者程式碼塊在任意時刻只能有一個執行緒執行。

另外,在 Java 早期版本中,synchronized 屬於 重量級鎖,效率低下。

因為監視器鎖(monitor)是依賴於底層的作業系統的 Mutex Lock 來實現的,Java 的執行緒是對映到作業系統的原生執行緒之上的。如果要掛起或者喚醒一個執行緒,都需要作業系統幫忙完成,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高。

慶幸的是在 Java 6 之後 Java 官方對從 JVM 層面對 synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK1.6 對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。

所以,你會發現目前的話,不論是各種開源框架還是 JDK 原始碼都大量使用了 synchronized 關鍵字。

三種使用方式

// 1 修飾例項方法,獲取當前物件例項的鎖
synchronized void method() {
    // code
}

// 2 修飾靜態方法, 獲得當前class的鎖,所以1和2兩個方法不會衝突,不是一個鎖
synchronized static void method() {
    // code
}

// 3 修飾程式碼塊,獲取指定物件的鎖
synchronized(this) {
    // code
}
// 儘量不要使用 synchronized(String a) 因為 JVM 中,字串常量池具有快取功能!

volatile

可見性,是指執行緒之間的可見性,一個執行緒修改的狀態對另一個執行緒是可見的。也就是一個執行緒修改的結果。另一個執行緒馬上就能看到。比如:用volatile修飾的變數,就會具有可見性。volatile修飾的變數不允許執行緒內部快取和重排序,即直接修改記憶體。所以對其他執行緒是可見的。

雙重校驗鎖實現單例模式

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {}

    public static Singleton getUniqueInstance() {
        // 基本判斷不加鎖,提升效率
        // 不會出現就算是有例項了也要加鎖情況
        if (uniqueInstance == null) {
            // 加鎖是因為此時可能有執行緒初始化好了例項
            synchronized (Singleton.class) {
                if(uniqueInstance == null) {
                    // volatile 防止指令重排序
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

構造方法本身就屬於執行緒安全的,不存在同步的構造方法一說。

Synchronized原理

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 程式碼塊");
        }
    }
}

通過 JDK 自帶的 javap 命令檢視 SynchronizedDemo 類的相關位元組碼資訊:

$ javap -c -s -v -l SynchronizedDemo.class
Classfile .../SynchronizedDemo.class
  Last modified 2021年9月11日; size 546 bytes
  MD5 checksum a4e4607df26d89d05a469490ff762e94
  Compiled from "SynchronizedDemo.java"
    ...
  public void method();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter  # 指向同步程式碼塊的開始位置
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String synchronized code block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit   # 指向同步程式碼塊的結束位置
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 5: 0
        line 6: 4
        line 7: 12
        line 8: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/lhq/SynchronizedDemo, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "SynchronizedDemo.java"

當執行 monitorenter 指令時,執行緒試圖獲取鎖也就是獲取 物件監視器 monitor 的持有權。wait/notify等方法也依賴於monitor物件,這就是為什麼只有在同步的塊或者方法中才能呼叫wait/notify等方法,否則會丟擲java.lang.IllegalMonitorStateException的異常的原因。

synchronized 修飾的方法並沒有 monitorenter 指令和 monitorexit 指令,取得代之的確實是 ACC_SYNCHRONIZED 標識,該標識指明瞭該方法是一個同步方法。JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。

兩者的本質都是對物件監視器 monitor 的獲取。

ReentrantLock

可重入鎖

重入鎖實現可重入性原理或機制是:每一個鎖關聯一個執行緒持有者和計數器,當計數器為 0 時表示該鎖沒有被任何執行緒持有,那麼任何執行緒都可能獲得該鎖而呼叫相應的方法;當某一執行緒請求成功後,JVM 會記下鎖的持有執行緒,並且將計數器置為 1;此時其它執行緒請求該鎖,則必須等待;而該持有鎖的執行緒如果再次請求這個鎖,就可以再次拿到這個鎖,同時計數器會遞增;當執行緒退出同步程式碼塊時,計數器會遞減,如果計數器為 0,則釋放該鎖。

ReentrantLocksynchronized的比較

相似點

它們都是加鎖方式同步,而且都是阻塞式的同步,也就是說當如果一個執行緒獲得了物件鎖,進入了同步塊,其他訪問該同步塊的執行緒都必須阻塞在同步塊外面等待,等到釋放掉鎖或者喚醒後才能繼續獲得鎖。

都是可重入鎖

區別

對於Synchronized來說,它是java語言的關鍵字,是原生語法層面的互斥,需要jvm實現。而ReentrantLock它是JDK 1.5之後提供的API層面的互斥鎖,需要lock()和unlock()方法配合try/finally語句塊來完成

便利性:Synchronized的使用比較方便簡潔,並且由編譯器去保證鎖的加鎖和釋放,而ReenTrantLock需要手工宣告來加鎖和釋放鎖,為了避免忘記手工釋放鎖造成死鎖,所以最好在finally中宣告釋放鎖。

虛擬機器團隊在 JDK1.6 為 synchronized 關鍵字進行了很多優化,但是這些優化都是在虛擬機器層面實現的,並沒有直接暴露給我們。

相比synchronizedReentrantLock增加了一些高階功能。主要來說主要有三點:

  • 等待可中斷 : ReentrantLock提供了一種能夠中斷等待鎖的執行緒的機制,通過 lock.lockInterruptibly() 來實現這個機制。也就是說正在等待的執行緒可以選擇放棄等待,改為處理其他事情。

lock 優先考慮獲取鎖,待獲取鎖成功後,才響應中斷。
lockInterruptibly 優先考慮響應中斷,而不是響應鎖的普通獲取或重入獲取。

ReentrantLock.lockInterruptibly允許在等待時,由其它執行緒呼叫等待執行緒的Thread.interrupt方法來中斷等待執行緒的等待而直接返回,這時不用獲取鎖,而會丟擲一個InterruptedException ReentrantLock.lock方法不允許Thread.interrupt中斷,即使檢測到Thread.isInterrupted,一樣會繼續嘗試獲取鎖,失敗則繼續休眠。只是在最後獲取鎖成功後再把當前執行緒置為interrupted狀態,然後再中斷執行緒。

public class SynchronizedDemo {
    public static void main(String[] args) throws InterruptedException {
        final Lock lock = new ReentrantLock();
        lock.lock();
        Thread.sleep(1000);
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //lock.lock();  // 即使執行了interrupt()方法也沒有反應
                try {
                    lock.lockInterruptibly();  
                    // 輸出 Thread-0 interrupted.丟擲異常
                    // 見程式碼最後
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+" interrupted.");
            }
        });
        t1.start();
        Thread.sleep(1000);
        t1.interrupt();
        Thread.sleep(1000);
    }
}
/*
java.lang.InterruptedException
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchronizer.java:898)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1222)
	at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
	at com.lhq.SynchronizedDemo$1.run(SynchronizedDemo.java:18)
	at java.lang.Thread.run(Thread.java:748)
 */
  • 可實現公平鎖 : ReentrantLock可以指定是公平鎖還是非公平鎖。而synchronized只能是非公平鎖。所謂的公平鎖就是先等待的執行緒先獲得鎖。ReentrantLock預設情況是非公平的,可以通過 ReentrantLock類的ReentrantLock(boolean fair)構造方法來制定是否是公平的。
  • 可實現選擇性通知(鎖可以繫結多個條件): synchronized關鍵字與wait()notify()/notifyAll()方法相結合可以實現等待/通知機制。ReentrantLock類當然也可以實現,但是需要藉助於Condition介面與newCondition()方法。

公平鎖在公平性得以保障,但因為公平的獲取鎖沒有考慮到作業系統對執行緒的排程因素以及其他因素,會影響效能。非公平鎖:飢餓問題。

參考

併發基礎常見面試題總結

什麼是Native方法

Java:執行緒的六種狀態及轉化

Java中Volatile關鍵字詳解

RMI和JMX

ReentrantLock詳解

lock()與lockInterruptibly()的區別