Java 鎖的那些事兒

雲棲號資訊:【點擊查看更多行業資訊
在這裏您可以找到不同行業的第一手的上雲資訊,還在等什麼,快來!

Java 多線程開發中,如果涉及到共享資源操作場景,那就必不可少要和 Java 鎖打交道。

Java 中的鎖機制主要分爲 Lock和 Synchronized,本文主要分析 Java 鎖機制的使用和實現原理,按照 Java 鎖使用、JDK 中鎖實現、系統層鎖實現的順序來進行分析,話不多說,let’s go~

一、Java 鎖使用

在 Lock 接口出現之前,Java 程序是靠 synchronized 關鍵字實現鎖功能的,而 JavaSE 5 之後,併發包中新增了 Lock 接口(以及相關實現類)用來實現鎖功能,它提供了與 synchronized 關鍵字類似的同步功能,只是在使用時需要顯式地獲取和釋放鎖。雖然它缺少了(通過 synchronized 塊或者方法)隱式獲取釋放鎖的便捷性,但是卻擁有了鎖獲取與釋放的可操作性、可中斷的獲取鎖以及超時獲取鎖等多種 synchronized 關鍵字所不具備的同步特性。

Java 鎖使用示例:

Lock lock = new ReentrantLock();
lock.lock();
try {
    // ..
} finally {
    lock.unlock();
}

注意:在 finally 塊中釋放鎖,目的是保證在獲取到鎖之後,最終能夠被釋放。不要將獲取鎖的過程寫在 try 塊中,因爲如果在獲取鎖(自定義鎖的實現)時發生了異常,異常拋出的同時,會提前進行 unlock 導致 IllegalMonitorStateException異常。

Lock 相較於 Synchronized 優勢如下:

  • 可中斷獲取鎖 :使用 synchronized 關鍵字獲取鎖的時候,如果線程沒有獲取到被阻塞了,那麼這個時候該線程是不響應中斷 (interrupt) 的,而使用 Lock.lockInterruptibly() 獲取鎖時被中斷,線程將拋出中斷異常。
  • 可非阻塞獲取鎖 :使用 synchronized 關鍵字獲取鎖時,如果沒有成功獲取,只有被阻塞,而使用 Lock.tryLock() 獲取鎖時,如果沒有獲取成功也不會阻塞而是直接返回 false。
  • 可限定獲取鎖的超時時間 :使用 Lock.tryLock(long time, TimeUnit unit)。
  • 同一個所對象上可以有多個等待隊列(Conditin,類似於 Object.wait(),支持公平鎖模式)。

Lock 除了更多的功能之外,有一個很大的優勢:synchronized 的同步是 jvm 底層實現的,對一般程序員來說程序遇到出乎意料的行爲的時候,除了查官方文檔幾乎沒有別的辦法;而顯示鎖除了個別操作用了底層的 Unsafe 類 (LockSupport 封裝了 Unsafe 類) 之外,幾乎都是用 java 語言實現的,我們可以通過學習顯示鎖的源碼,來更加得心應手的使用顯示鎖。

當然,Lock 也不是完美的,否則 java 就不會保留着 synchronized 關鍵字了,顯示鎖的缺點主要有兩個:

  • 使用比較複雜,這點之前提到了,需要手動加鎖,解鎖,而且還必須保證在異常狀態下也要能夠解鎖。而 synchronized 的使用就簡單多了。
  • 效率較低,synchronized 關鍵字畢竟是 jvm 底層實現的,因此用了很多優化措施來優化速度 (偏向鎖、輕量鎖等),而顯示鎖的效率相對低一些。

1.1 Synchronized

Synchronized 在 JVM 裏的實現是基於進入和退出 Monitor 對象來實現方法同步和代碼塊同步的。monitorenter 指令是在編譯後插入到同步代碼塊的開始位置,而 monitorexit 是插入到方法結束處和異常處,JVM 要保證每個 monitorenter 必須有對應的 monitorexit 與之配對。任何對象都有一個 monitor 與之關聯,當且一個 monitor 被持有後,它將處於鎖定狀態。線程執行到 monitorenter 指令時,將會嘗試獲取對象所對應的 monitor 的所有權,即嘗試獲得對象的鎖。

synchronized 用的鎖是存在 Java 對象頭裏的。如果對象是數組類型,則虛擬機用 3 個字寬(Word)存儲對象頭,如果對象是非數組類型,則用 2 字寬存儲對象頭。

image

關於對 Java 象頭,可以使用 JOL 工具(jol-core)類直接打印對象頭,如下所示:

image

1.2 鎖升級

Java 1.6 爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,在 Java SE 1.6 中,鎖一共有 4 種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。

鎖可以升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提高獲得鎖和釋放鎖的效率。

image

二、JDK 中鎖實現

JDK 中 Lock 是一個接口,其定義了鎖獲取和釋放的基本操作:

image

Lock 底層是基於 AQS 同步器(AbstractQueuedSynchronizer)的,AQS 是用來構建鎖或者其他同步組件的基礎框架,它使用了一個 int 成員變量表示同步狀態,通過內置的 FIFO 隊列來完成資源獲取線程的排隊工作,併發包的作者(Doug Lea)期望它能夠成爲實現大部分同步需求的基礎,事實上目前的 JDK 併發包都是基於 AQS 來完成同步需求的。

關於鎖和 AQS,可以這樣理解二者之間的關係:鎖是面向使用者的,它定義了使用者與鎖交互的接口(比如可以允許兩個線程並行訪問),隱藏了實現細節;同步器面向的是鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步狀態管理、線程的排隊、等待與喚醒等底層操作。鎖和同步器很好地隔離了使用者和實現者所需關注的領域。

這裏稍微分析下 AQS,其是由一個同步狀態 +FIFO 的同步隊列組成,提供了同步隊列、獨佔式同步狀態獲取與釋放、共享式同步狀態獲取與釋放以及超時獲取同步狀態等同步器的核心數據結構與模板方法。簡單來說就是,當線程需要阻塞時就將其放到同步隊列中,等到該喚醒時就將其移除隊列並喚醒,使其繼續工作。關於 AQS 具體的實現原理,可以參考阿里大神寫的《Java 併發編程的藝術》。

AQS 當需要阻塞或喚醒一個線程的時候,都會使用 LockSupport 工具類來完成相應工作。LockSupport 定義了一組的公共靜態方法,這些方法提供了最基本的線程阻塞和喚醒功能,而 LockSupport 也成爲構建同步組件的基礎工具。LockSupport 定義了一組以 park 開頭的方法用來阻塞當前線程,以及 unpark(Thread thread) 方法來喚醒一個被阻塞的線程。

LockSupport 提供的阻塞和喚醒方法:

image

LockSupport 常用方法源碼如下:

// LockSupport
public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    // blocker 在什麼對象上進行的阻塞操作
    setBlocker(t, blocker);
    UNSAFE.park(false, 0L);
    setBlocker(t, null);
}
 
public static void parkNanos(Object blocker, long nanos) {
    if (nanos > 0) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        // 超時阻塞
        UNSAFE.park(false, nanos);
        setBlocker(t, null);
    }
}
 
public static void unpark(Thread thread) {
    if (thread != null)
        UNSAFE.unpark(thread);
}

三、系統層鎖實現

UNSAFE 使用 park 和 unpark 進行線程的阻塞和喚醒操作,park 和 unpark 底層是藉助系統層(Linux 下)方法 pthread_mutex和 pthread_cond來實現的,通過 pthread_cond_wait函數可以對一個線程進行阻塞操作,在這之前,必須先獲取 pthread_mutex,通過 pthread_cond_signal函數對一個線程進行喚醒操作。

pthread_mutex和 pthread_cond使用示例如下:

void *r1(void *arg)
{
    pthread_mutex_t* mutex = (pthread_mutex_t *)arg;
    static int cnt = 10;
 
    while(cnt--)
    {
        printf("r1: I am wait.\n");
        pthread_mutex_lock(mutex);
        pthread_cond_wait(&cond, mutex); /* mutex 參數用來保護條件變量的互斥鎖,調用 pthread_cond_wait 前 mutex 必須加鎖 */
        pthread_mutex_unlock(mutex);
    }
    return "r1 over";
}
 
void *r2(void *arg)
{
    pthread_mutex_t* mutex = (pthread_mutex_t *)arg;
    static int cnt = 10;
 
    while(cnt--)
    {
        pthread_mutex_lock(mutex);
        printf("r2: I am send the cond signal.\n");
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(mutex);
        sleep(1);
    }
    return "r2 over";
}

注意 ,Linux 下使用 pthread_cond_signal的時候,會產生“驚羣”問題的,但是 Java 中是不會存在這個“驚羣”問題的,那麼 Java 是如何處理的呢?

實際上,Java 只會對一個線程調用 pthread_cond_signal操作,這樣肯定只會喚醒一個線程,也就不存在所謂的驚羣問題。Java 在語言層面實現了自己的線程管理機制(阻塞、喚醒、排隊等),每個 Thread 實例都有一個獨立的 pthread_mutex和 pthread_cond(系統層面的 /C 語言層面),在 Java 語言層面上對單個線程進行獨立喚醒操作。(怎麼感覺 Java 中線程有點小可憐呢,只能在 Java 線程庫的指揮下作戰,竟然無法直接獲取同一個 pthread_mutex或者 pthread_cond。但是 Java 這種實現線程機制的實現實在太巧妙了,雖然底層都是使用 pthread_mutex和 pthread_cond這些方法,但是貌似 C/C++ 還沒這麼強大易用的線程庫)

具體 LockSuuport.park 和 LockSuuport.unpark 的底層實現可以參考對應 JDK 源碼,下面看一下 gdb 打印處於 LockSuuport.park 時的線程狀態信息:

image

由上圖可知底層確實是基於 pthread_cond 函數來實現的。

小結

瞭解 Java 鎖機制之後,在後續的業務開發過程中,當需要進行同步時,優先考慮使用 synchronized 關鍵字,只有 synchronized 關鍵字不能滿足需求時,才考慮使用顯示鎖(Lock)。

【雲棲號在線課堂】每天都有產品技術專家分享!
課程地址:https://yqh.aliyun.com/live

立即加入社羣,與專家面對面,及時瞭解課程最新動態!
【雲棲號在線課堂 社羣】https://c.tb.cn/F3.Z8gvnK

原文發佈時間:2020-06-22
本文作者:駱向南
本文來自:“infoq”,瞭解相關信息可以關注“infoq

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