【細(xì)談Java并發(fā)】談?wù)勬i

我們知道在并發(fā)環(huán)境下為了保證共享變量的線程安全,除了可以使用某些原子類的操作,還可以通過為被保護(hù)的變量加鎖的方式實(shí)現(xiàn)該變量的線程安全。

而在java中我們有兩種方式來使用一個(gè)鎖,請(qǐng)注意,這里所說的鎖都是對(duì)象鎖。一種是JVM幫我們實(shí)現(xiàn)的,通過synchronized關(guān)鍵字來進(jìn)行加鎖,另外一種是J.U.C包中的Lock接口,該接口中的兩個(gè)常用的用來加鎖和解鎖的方法為:lock()、unlock(),除此以外還有其他的用來處理中斷的鎖等等。那么,對(duì)于我們使用者來說,該怎么選擇使用鎖就是一個(gè)需要解決的問題,而要解決這個(gè)問題,首先我們需要對(duì)這兩種鎖的實(shí)現(xiàn)原理進(jìn)行深入的了解。本文筆者將會(huì)深入了解java中的這兩種鎖的實(shí)現(xiàn)原理,以期望得出一種正確使用鎖的方法。

1、synchronized

synchronized是java中的一個(gè)關(guān)鍵字,通過該關(guān)鍵字,我們就可以很方便的對(duì)類方法、實(shí)例方法、代碼塊進(jìn)行加鎖,并且鎖的釋放由JVM來保證,不需要使用者執(zhí)行額外的操作。

Java中的每個(gè)對(duì)象都可以作為鎖。

  1. 普通同步方法,鎖是當(dāng)前實(shí)例對(duì)象。
  2. 靜態(tài)同步方法,鎖是當(dāng)前類的class對(duì)象。
  3. 同步代碼塊,鎖是括號(hào)中的對(duì)象。

我們來看一段代碼:

public class SynchronizedTest {

    private static Object object = new Object();

    public static void main(String[] args) {
        synchronized (object) {
        }
    }

    public synchronized static void m() {}
}

上述代碼中,使用了同步代碼塊和同步方法,我們通過javap -verbose com/github/lock/SynchronizedTest來查看class文件的信息,以此來分析synchronized關(guān)鍵字的實(shí)現(xiàn)細(xì)節(jié)。

  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                  // Field object:Ljava/lang/Object;
         3: dup
         4: astore_1
         5: monitorenter
         6: aload_1
         7: monitorexit
         8: goto          16
        11: astore_2
        12: aload_1
        13: monitorexit
        14: aload_2
        15: athrow
        16: return

  public static synchronized void m();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 17: 0

從生成的class信息中,可以清楚的看到

  1. 同步代碼塊使用了 monitorentermonitorexit 指令實(shí)現(xiàn)。
  2. 同步方法中依靠方法修飾符上的 ACC_SYNCHRONIZED 實(shí)現(xiàn)。

synchronized加鎖解鎖的過程可以描述為下面幾個(gè)步驟:

  1. 線程執(zhí)行monitorenter指令時(shí)嘗試獲取monitor的所有權(quán)。
  2. 如果monitor的進(jìn)入數(shù)為0,則該線程進(jìn)入monitor,然后將進(jìn)入數(shù)設(shè)置為1,該線程即為monitor的所有者。
  3. 如果線程已經(jīng)占有該monitor,只是重新進(jìn)入,則進(jìn)入monitor的進(jìn)入數(shù)加1。
  4. 如果其他線程已經(jīng)占用了monitor,則該線程進(jìn)入阻塞狀態(tài),直到monitor的進(jìn)入數(shù)為0,再重新嘗試獲取monitor的所有權(quán)。

但是synchronized并不是對(duì)所有的請(qǐng)求都直接通過使用monitor來進(jìn)行鎖的分配,因?yàn)閙onitor是比較重的鎖,獲取不到鎖的線程將進(jìn)入阻塞狀態(tài),這就涉及到線程狀態(tài)的切換,比較耗資源。synchronized除了實(shí)現(xiàn)了重量級(jí)鎖之外,還實(shí)現(xiàn)了偏向鎖,輕量級(jí)鎖,其中偏向鎖在1.6是默認(rèn)開啟的。

在進(jìn)一步深入之前,我們先認(rèn)識(shí)下兩個(gè)概念:對(duì)象頭和monitor。

1.1、對(duì)象頭

對(duì)象頭包括兩部分:Mark Word (存儲(chǔ)對(duì)象的hashCode或鎖信息等)和 類型指針(存儲(chǔ)到對(duì)象類型數(shù)據(jù)的指針)。如果對(duì)象時(shí)數(shù)組類型,對(duì)象頭還包括數(shù)組的長度,這三個(gè)部分每個(gè)都占32/64bit的長度。

這里我們主要說的是Mark Word。

Mark Word用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),如哈希碼(HashCode)、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時(shí)間戳等等,占用內(nèi)存大小與虛擬機(jī)位長一致。

HotSpot通過markOop類型實(shí)現(xiàn)Mark Word,32位虛擬機(jī)的markOop實(shí)現(xiàn)如下:

  • hash:保存對(duì)象的哈希碼
  • age:保存對(duì)象的分代年齡
  • biased_lock:偏向鎖標(biāo)識(shí)位
  • lock:鎖狀態(tài)標(biāo)識(shí)位
  • JavaThread:保存持有偏向鎖的線程ID
  • epoch:保存偏向時(shí)間戳

其中鎖狀態(tài)對(duì)應(yīng)的是biased_lock和lock,不同的鎖狀態(tài),存儲(chǔ)著不同的數(shù)據(jù):

image

1.2、monitor

monitor是線程私有的數(shù)據(jù)結(jié)構(gòu),每一個(gè)線程都有一個(gè)可用monitor列表,同時(shí)還有一個(gè)全局的可用列表。monitor內(nèi)部組成如下:

  • Owner:初始時(shí)為NULL表示當(dāng)前沒有任何線程擁有該monitor,當(dāng)線程成功擁有該鎖后保存線程唯一標(biāo)識(shí),當(dāng)鎖被釋放時(shí)又設(shè)置為NULL。
  • EntryQ:關(guān)聯(lián)一個(gè)系統(tǒng)互斥鎖(semaphore),阻塞所有試圖鎖住monitor失敗的線程。
  • RcThis:表示blocked或waiting在該monitor上的所有線程的個(gè)數(shù)。
  • Nest:用來實(shí)現(xiàn)重入鎖的計(jì)數(shù)。
  • HashCode:保存從對(duì)象頭拷貝過來的HashCode值(可能還包含GC age)。
  • Candidate:用來避免不必要的阻塞或等待線程喚醒,因?yàn)槊恳淮沃挥幸粋€(gè)線程能夠成功擁有鎖,如果每次前一個(gè)釋放鎖的線程喚醒所有正在阻塞或等待的線程,會(huì)引起不必要的上下文切換(從阻塞到就緒然后因?yàn)楦?jìng)爭(zhēng)鎖失敗又被阻塞)從而導(dǎo)致性能嚴(yán)重下降。Candidate只有兩種可能的值:0表示沒有需要喚醒的線程,1表示要喚醒一個(gè)繼任線程來競(jìng)爭(zhēng)鎖。

那么monitor的作用是什么呢?在 java 虛擬機(jī)中,線程一旦進(jìn)入到被synchronized修飾的方法或代碼塊時(shí),指定的鎖對(duì)象通過某些操作將對(duì)象頭中的Mark Word指向monitor 的起始地址與之關(guān)聯(lián),同時(shí)monitor 中的Owner存放擁有該鎖的線程的唯一標(biāo)識(shí),確保一次只能有一個(gè)線程執(zhí)行該部分的代碼,線程在獲取鎖之前不允許執(zhí)行該部分的代碼。

接下去,我們可以深入了解下在鎖各個(gè)狀態(tài)下,底層是如何處理多線程之間對(duì)鎖的競(jìng)爭(zhēng)。

1.3、鎖的升級(jí)與對(duì)比

Java SE1.6為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級(jí)鎖”,在Java SE1.6中,鎖一共有4種狀態(tài),級(jí)別從低到高依次是:無鎖狀態(tài)、偏向鎖狀態(tài)、輕量級(jí)鎖狀態(tài)和重量級(jí)鎖狀態(tài),這幾個(gè)狀態(tài)會(huì)隨著競(jìng)爭(zhēng)情況逐漸升級(jí)。鎖可以升級(jí)但不能降級(jí),意味著偏向鎖升級(jí)成輕量級(jí)鎖后不能降級(jí)成偏向鎖。這種鎖升級(jí)不能降級(jí)的策略,目的是為了提高獲得鎖和釋放鎖的效率。

下面引用一張網(wǎng)上的圖說明白了synchronized的原理。

image

偏向鎖

HotSpot的作者經(jīng)過研究發(fā)現(xiàn),大多數(shù)情況下,鎖不僅不存在多線程競(jìng)爭(zhēng),而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價(jià)更低而引入了偏向鎖。當(dāng)一個(gè)線程訪問同步塊并獲取鎖時(shí),會(huì)在對(duì)象頭和棧幀中的鎖記錄里存儲(chǔ)鎖偏向的線程ID,以后該線程在進(jìn)入和退出同步塊時(shí)不需要進(jìn)行CAS操作來加鎖和解鎖,只需簡(jiǎn)單地測(cè)試一下對(duì)象頭的Mark Word里是否存儲(chǔ)著指向當(dāng)前線程的偏向鎖。如果測(cè)試成功,表示線程已經(jīng)獲得了鎖。如果測(cè)試失敗,則需要再測(cè)試一下Mark Word中偏向鎖的標(biāo)識(shí)是否設(shè)置成1(表示當(dāng)前是偏向鎖):如果沒有設(shè)置,則使用CAS競(jìng)爭(zhēng)鎖,如果設(shè)置了,則嘗試使用CAS將對(duì)象頭的偏向鎖指向當(dāng)前線程。

偏向鎖在Java6和Java7里是默認(rèn)啟用的,但是它在應(yīng)用程序啟動(dòng)幾秒鐘之后才激活,如果有必要可以使用JVM參數(shù)來關(guān)閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應(yīng)用程序里所有的鎖通常情況下處于競(jìng)爭(zhēng)狀態(tài),可以通過JVM參數(shù)關(guān)閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認(rèn)會(huì)進(jìn)入輕量級(jí)鎖狀態(tài)。

我們前面說到了synchronized首先會(huì)生成monitorenter指令,我們來看看Hotspot的入口:

image

UseBiasedLocking標(biāo)識(shí)虛擬機(jī)是否開啟偏向鎖功能,如果開啟則執(zhí)行fast_enter邏輯,否則執(zhí)行slow_enter;

可以看到偏向鎖的處理器邏輯在ObjectSynchronizer::fast_enter函數(shù)里。

[圖片上傳失敗...(image-32276b-1525442008508)]

偏向鎖的獲取

我們看看revoke_and_rebias方法(因?yàn)樘L,只放出部分重要代碼):

// 獲取對(duì)象的markOop數(shù)據(jù)mark,即對(duì)象頭的Mark Word
markOop mark = obj->mark();
if (mark->has_bias_pattern()) {
    Klass* k = obj->klass();
    // obj類的對(duì)象頭
    markOop prototype_header = k->prototype_header();

    if (!prototype_header->has_bias_pattern()) { // 對(duì)象頭里沒有偏向鎖
        markOop biased_value       = mark;
        // CAS替換失敗,說明有多線程競(jìng)爭(zhēng),直接返回BIAS_REVOKED
        markOop res_mark = (markOop) Atomic::cmpxchg_ptr(prototype_header, obj->mark_addr(), mark);
        assert(!(*(obj->mark_addr()))->has_bias_pattern(), "even if we raced, should still be revoked");
        return BIAS_REVOKED;
    } else if (prototype_header->bias_epoch() != mark->bias_epoch()) { // 偏向時(shí)間戳改變
        // 再偏向
        if (attempt_rebias) {
            assert(THREAD->is_Java_thread(), "");
            markOop biased_value       = mark;
            markOop rebiased_prototype = markOopDesc::encode((JavaThread*) THREAD, mark->age(), prototype_header->bias_epoch());
            // CAS替換成功,返回BIAS_REVOKED_AND_REBIASED,繼續(xù)偏向
            markOop res_mark = (markOop) Atomic::cmpxchg_ptr(rebiased_prototype, obj->mark_addr(), mark);
            if (res_mark == biased_value) {
                return BIAS_REVOKED_AND_REBIASED;
            }
        }
    }
}

實(shí)現(xiàn)邏輯如下:

  1. 通過markOop mark = obj->mark()獲取對(duì)象的markOop數(shù)據(jù)mark,即對(duì)象頭的Mark Word。
  2. 判斷mark是否為可偏向狀態(tài),即mark的偏向鎖標(biāo)志位為 1,鎖標(biāo)志位為 01
  3. 判斷mark中JavaThread的狀態(tài):如果為空,則進(jìn)入步驟(4);如果指向當(dāng)前線程,則執(zhí)行同步代碼塊;如果指向其它線程,進(jìn)入步驟(5)。
  4. 通過CAS原子指令設(shè)置mark中JavaThread為當(dāng)前線程ID,如果執(zhí)行CAS成功,則執(zhí)行同步代碼塊,否則進(jìn)入步驟(5)。
  5. 如果執(zhí)行CAS失敗,表示當(dāng)前存在多個(gè)線程競(jìng)爭(zhēng)鎖,當(dāng)達(dá)到全局安全點(diǎn)(safepoint),獲得偏向鎖的線程被掛起,撤銷偏向鎖,并升級(jí)為輕量級(jí),升級(jí)完成后被阻塞在安全點(diǎn)的線程繼續(xù)執(zhí)行同步代碼塊。

偏向鎖的撤銷

只有當(dāng)其它線程嘗試競(jìng)爭(zhēng)偏向鎖時(shí),持有偏向鎖的線程才會(huì)釋放鎖,撤銷的邏輯如下:

  1. 偏向鎖的撤銷動(dòng)作必須等待全局安全點(diǎn)。
  2. 暫停擁有偏向鎖的線程,判斷鎖對(duì)象是否處于被鎖定狀態(tài)。
  3. 撤銷偏向鎖,恢復(fù)到無鎖(標(biāo)志位為 01)或輕量級(jí)鎖(標(biāo)志位為 00)的狀態(tài)。

輕量級(jí)鎖

JVM通過CAS修改對(duì)象頭來獲取鎖,如果CAS修改成功則獲取鎖,如果獲取失敗,則說明有競(jìng)爭(zhēng),則通過CAS自旋一段時(shí)間來修改對(duì)象頭,如果還是獲取失敗,則升級(jí)為重量級(jí)鎖。如果沒有競(jìng)爭(zhēng),輕量級(jí)鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競(jìng)爭(zhēng),除了互斥量的開銷外,還額外發(fā)生了CAS操作,因此在有競(jìng)爭(zhēng)的情況下,輕量級(jí)鎖會(huì)比傳統(tǒng)的重量級(jí)鎖更慢。

重量級(jí)鎖

重量級(jí)鎖通過對(duì)象內(nèi)部的監(jiān)視器(monitor)實(shí)現(xiàn),其中monitor的本質(zhì)是依賴于底層操作系統(tǒng)的Mutex Lock實(shí)現(xiàn),操作系統(tǒng)實(shí)現(xiàn)線程之間的切換需要從用戶態(tài)到內(nèi)核態(tài)的切換,切換成本非常高。

優(yōu)缺點(diǎn)對(duì)比

優(yōu)點(diǎn) 缺點(diǎn) 適用場(chǎng)景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執(zhí)行非同步方法相比僅存在納秒級(jí)的差距 如果線程間存在鎖競(jìng)爭(zhēng),會(huì)帶來額外的鎖撤銷的消耗 適用于只有一個(gè)線程訪問同步塊的場(chǎng)景
輕量級(jí)鎖 競(jìng)爭(zhēng)的線程不會(huì)阻塞,提高了程序的響應(yīng)速度 如果始終得不到鎖競(jìng)爭(zhēng)的線程,使用自旋會(huì)消耗CPU 追求響應(yīng)時(shí)間同步塊執(zhí)行速度非常快
重量級(jí)鎖 線程競(jìng)爭(zhēng)不使用自旋,不會(huì)消耗CPU 線程阻塞,響應(yīng)時(shí)間緩慢 追求吞吐量,同步塊執(zhí)行速度較長

1.4、其他概念

鎖消除

鎖消除是指虛擬機(jī)在即時(shí)編譯器在運(yùn)行時(shí),對(duì)于一些在代碼上要求同步,但是被檢測(cè)到不可能存在數(shù)據(jù)競(jìng)爭(zhēng)的鎖進(jìn)行消除。比如,一個(gè)鎖不可能被多個(gè)線程訪問到,那么在這個(gè)鎖上的同步塊JVM將把鎖消除掉。

鎖粗化

程序中一系列的連續(xù)操作都對(duì)同一個(gè)對(duì)象反復(fù)加鎖和解鎖,甚至加鎖操作是出現(xiàn)在循環(huán)體中的,那即使沒有線程競(jìng)爭(zhēng),頻繁地進(jìn)行互斥同步操作也會(huì)導(dǎo)致不必要的性能損耗。

for(int i=0; i<1000; i++){
    synchronized(this){
        ...
    }
}

上面代碼JVM將會(huì)優(yōu)化成如下:

synchronized(this){
    for(int i=0; i<1000; i++){
        ...
    }
}

2、Lock

Lock是java并發(fā)包J.U.C中的一個(gè)用來實(shí)現(xiàn)鎖的接口,該接口的主要實(shí)現(xiàn)類有ReadLock,WriteLock,ReentrantLock,實(shí)際使用過程中比較常用的就是ReentrantLock。而ReentrantLock主要是基于AQS來完成同步操作的。

Lock有以下幾個(gè)特點(diǎn):

  1. 可重入:同一個(gè)線程可以多次獲取鎖,不會(huì)阻塞。
  2. 可中斷:可以使用lock.lockInterruptibly()來響應(yīng)中斷,當(dāng)有中斷出現(xiàn)時(shí),即放棄鎖的請(qǐng)求。
  3. 公平、非公平:公平鎖講究先來先到,線程在獲取鎖時(shí),如果這個(gè)鎖的等待隊(duì)列中已經(jīng)有線程在等待,那么當(dāng)前線程就會(huì)進(jìn)入等待隊(duì)列中 。非公平鎖不管是否有等待隊(duì)列,如果可以獲取鎖,則立刻占有鎖對(duì)象。

其他的詳細(xì)原理的分析請(qǐng)看我的幾篇文章:【細(xì)談Java并發(fā)】談?wù)凙QS【細(xì)談Java并發(fā)】談?wù)凴eentrantLock

3、鎖的內(nèi)存語義

內(nèi)存可見性

synchronized關(guān)鍵字強(qiáng)制實(shí)施一個(gè)互斥鎖,使得被保護(hù)的代碼塊在同一時(shí)間只能有一個(gè)線程進(jìn)入并執(zhí)行。當(dāng)然synchronized還有另外一個(gè) 方面的作用:在線程進(jìn)入synchronized塊之前,會(huì)把工作存內(nèi)存中的所有內(nèi)容映射到主內(nèi)存上,然后把工作內(nèi)存清空再從主存儲(chǔ)器上拷貝最新的值。而 在線程退出synchronized塊時(shí),同樣會(huì)把工作內(nèi)存中的值映射到主內(nèi)存,但此時(shí)并不會(huì)清空工作內(nèi)存。這樣一來就可以強(qiáng)制其按照上面的順序運(yùn)行,以 保證線程在執(zhí)行完代碼塊后,工作內(nèi)存中的值和主內(nèi)存中的值是一致的,保證了數(shù)據(jù)的一致性!
所以由synchronized修飾的set與get方法都是相當(dāng)于直接對(duì)主內(nèi)存進(jìn)行操作,不會(huì)出現(xiàn)數(shù)據(jù)一致性方面的問題。

指令重排序

指令重排序是JVM為了優(yōu)化指令,提高程序運(yùn)行效率,在不影響單線程程序執(zhí)行結(jié)果的前提下,盡可能地提高并行度。
synchronized塊對(duì)應(yīng)java程序來說是原子操作,所以說內(nèi)部不管怎么重排序都不會(huì)影響其它線程執(zhí)行導(dǎo)致數(shù)據(jù)錯(cuò)誤,執(zhí)行的指令也不會(huì)溢出方法。

這里放出一張之前分析volatile的圖:

image

可以看出MonitorEnter(鎖獲取)和volatile讀的語義一致,而MonitorExit(鎖釋放)和volatile寫的語義一致。

下面對(duì)鎖釋放和鎖獲取的內(nèi)存語義做個(gè)總結(jié):

  • 線程A釋放一個(gè)鎖,實(shí)質(zhì)上是線程A向接下來將要獲取這個(gè)鎖的某個(gè)線程發(fā)出了(線程A對(duì)共享變量所做修改的)消息。
  • 線程B獲取一個(gè)鎖,實(shí)質(zhì)上是線程B接收了之前某個(gè)線程發(fā)出的(在釋放這個(gè)鎖之前對(duì)共享變量所做修改的)消息。
  • 線程A釋放鎖,隨后線程B獲取這個(gè)鎖,這個(gè)過程實(shí)質(zhì)上是線程A通過主內(nèi)存向線程B發(fā)送消息。

4、synchronized和lock的區(qū)別

synchronized和lock的區(qū)別如下:

compare synchronized lock
哪層面 虛擬機(jī)層面 代碼層面
鎖類型 可重入、不可中斷、非公平 可重入、可中斷、可公平
鎖獲取 A線程獲取鎖,B線程等待 可以嘗試獲取鎖,不需要一直等待
鎖釋放 由JVM 釋放鎖 在finally中手動(dòng)釋放。如不釋放,會(huì)造成死鎖
鎖狀態(tài) 無法判斷 可以判斷


lock比synchronized有如下優(yōu)點(diǎn):

  1. 支持公平鎖,某些場(chǎng)景下需要獲得鎖的時(shí)間與申請(qǐng)鎖的時(shí)間相一致,但是synchronized做不到 。
  2. 支持中斷處理,就是說那些持有鎖的線程一直不釋放,正在等待的線程可以放棄等待。如果不支持中斷處理,那么線程可能一直無限制的等待下去,就算那些正在占用資源的線程死鎖了,正在等待的那些資源還是會(huì)繼續(xù)等待,但是ReentrantLock可以選擇放棄等待 。
  3. condition和lock配合使用,以獲得最大的性能。

5、建議

  1. 如果沒有特殊的需求,建議使用synchronized,因?yàn)椴僮骱?jiǎn)單,便捷,不需要額外進(jìn)行鎖的釋放。鑒于JDK1.8中的ConcurrentHashMap也使用了CAS+synchronized的方式替換了老版本中使用分段鎖(ReentrantLock)的方式,可以得知,JVM中對(duì)synchronized的性能做了比較好的優(yōu)化。
  2. 如果代碼中有特殊的需求,建議使用Lock。例如并發(fā)量比較高,且有些操作比較耗時(shí),則可以使用支持中斷的所獲取方式;如果對(duì)于鎖的獲取,講究先來后到的順序則可以使用公平鎖;另外對(duì)于多個(gè)變量的鎖保護(hù)可以通過lock中提供的condition對(duì)象來和lock配合使用,獲取最大的性能。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容