在多線程環(huán)境中,Synchronized到底該不該用?
在多線程環(huán)境中,鎖的使用是避免不了的,使用鎖時(shí)候有多種鎖供我們選擇,比如 ReentrantLock、CountDownLatch等等,但是作為 Java 開發(fā)者來說,剛剛接觸多線程的時(shí)候,最早接觸和使用的恐怕非 synchronized莫屬了。那你真的了解synchronized嗎,今天我們就從以下幾個(gè)方面徹底搞懂 synchronized。
首先有一點(diǎn)要說明一下,各位可能或多或少都聽過這樣的說法:“synchronized 的性能不行,比顯式鎖差很多,開發(fā)中還是要慎用。”
大可不必有這樣的顧慮,要說在 JDK 1.6 之前,synchronized 的性能確實(shí)有點(diǎn)差,但是 JDK 1.6 之后,JDK 開發(fā)團(tuán)隊(duì)已經(jīng)持續(xù)對(duì) synchronized 做了性能優(yōu)化,其性能已經(jīng)與其他顯式鎖基本沒有差距了。所以,在考慮是不是使用 synchronized的時(shí)候,只需要根據(jù)場(chǎng)景是否合適來決定,性能問題不用作為衡量標(biāo)準(zhǔn)。
使用方法synchronized 是一個(gè)關(guān)鍵字,它的一個(gè)明顯特點(diǎn)就是使用簡(jiǎn)單,一個(gè)關(guān)鍵字搞定。它可以在一個(gè)方法上使用,也可以在一個(gè)方法中的某些代碼塊上使用,非常方便。
- public class SyncLock {
- private Object lock = new Object();
- /**
- * 直接在方法上加關(guān)鍵字
- */
- public synchronized void methodLock() {
- System.out.println(Thread.currentThread().getName());
- }
- /**
- * 在代碼塊上加關(guān)鍵字,鎖住當(dāng)前實(shí)例
- */
- public void codeBlockLock() {
- synchronized (this) {
- System.out.println(Thread.currentThread().getName());
- }
- }
- /**
- * 在代碼塊上加關(guān)鍵字,鎖住一個(gè)變量
- */
- public void codeBlockLock() {
- synchronized (lock) {
- System.out.println(Thread.currentThread().getName());
- }
- }
- }
依靠 JVM 中的 monitorenter 和 monitorexit 指令控制。通過 javap -v命令可以看到前面的實(shí)例代碼中對(duì) synchronized 關(guān)鍵字在字節(jié)碼層面的處理,對(duì)于在代碼塊上加 synchronized 關(guān)鍵字的情況,會(huì)通過 monitorenter和monitorexit指令來表示同步的開始和退出標(biāo)識(shí)。而在方法上加關(guān)鍵字的情況,會(huì)用 ACC_SYNCHRONIZED作為方法標(biāo)識(shí),這是一種隱式形式,底層原理都是一樣的。
- public synchronized void methodLock();
- descriptor: ()V
- flags: ACC_PUBLIC, ACC_SYNCHRONIZED
- Code:
- stack=2, locals=1, args_size=1
- 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
- 3: invokestatic #3 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
- 6: invokevirtual #4 // Method java/lang/Thread.getName:()Ljava/lang/String;
- 9: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 12: return
- LineNumberTable:
- line 12: 0
- line 13: 12
- public void codeBlockLock();
- descriptor: ()V
- flags: 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: invokestatic #3 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
- 10: invokevirtual #4 // Method java/lang/Thread.getName:()Ljava/lang/String;
- 13: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
- 16: aload_1
- 17: monitorexit
- 18: goto 26
- 21: astore_2
- 22: aload_1
- 23: monitorexit
- 24: aload_2
- 25: athrow
- 26: return
對(duì)象布局
為什么介紹 synchronized 要說到對(duì)象頭呢,這和它的鎖升級(jí)過程有關(guān)系,具體的鎖升級(jí)過程稍后會(huì)講到,作為鎖升級(jí)過程的數(shù)據(jù)支撐,必須要掌握對(duì)象頭的結(jié)構(gòu)才能了解鎖升級(jí)的完整過程。
在 Java 中,任何的對(duì)象實(shí)例的內(nèi)存布局都分為對(duì)象頭、對(duì)象實(shí)例數(shù)據(jù)和對(duì)齊填充數(shù)據(jù)三個(gè)部分,其中對(duì)象頭又包括 MarkWord 和 類型指針。
對(duì)象實(shí)例數(shù)據(jù): 這部分就是對(duì)象的實(shí)際數(shù)據(jù)。
對(duì)齊填充: 因?yàn)?HotSpot 虛擬機(jī)內(nèi)存管理要求對(duì)象的大小必須是8字節(jié)的整數(shù)倍,而對(duì)象頭正好是8個(gè)字節(jié)的整數(shù)倍,但是實(shí)例數(shù)據(jù)不一定,所以需要對(duì)齊填充補(bǔ)全。
對(duì)象頭:
Klass 指針: 對(duì)象頭中的 Klass 指針是用來指向?qū)ο笏鶎兕愋偷?,一個(gè)類實(shí)例究竟屬于哪個(gè)類,需要有地方記錄,就在這里記。
MarkWord: 還有一部分就是和 synchronized 緊密相關(guān)的 MarkWord 了,主要用來存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),如hashcode、gc 分代年齡等信息。MarkWord 的位長度為 JVM 的一個(gè) Word 大小,32位 JVM 的大小為32位,64位JVM的大小為64位。
下圖是 64 位虛擬機(jī)下的 MarkWord 結(jié)構(gòu)說明,根據(jù)對(duì)象鎖狀態(tài)不同,某些比特位代表的含義會(huì)動(dòng)態(tài)的變化,之所以要這么設(shè)計(jì),是因?yàn)椴幌胱寣?duì)象頭占用過大的空間,如果為每一個(gè)標(biāo)示都分配固定的空間,那對(duì)象頭占用的空間將會(huì)比較大。
數(shù)組長度: 要說明一下,如果是數(shù)組對(duì)象的話, 由于數(shù)組無法通過本身內(nèi)容求得自身長度,所以需要在對(duì)象頭中記錄數(shù)組的長度。
源碼中的定義
追根溯源,對(duì)象在 JVM 中是怎么定義的呢?打開 JVM 源碼,找到其中對(duì)象的定義文件,可以看到關(guān)于前面說的對(duì)象頭的定義。
- class oopDesc {
- friend class VMStructs;
- friend class JVMCIVMStructs;
- private:
- volatile markOop _mark;
- union _metadata {
- Klass* _klass;
- narrowKlass _compressed_klass;
- } _metadata;
- }
oop 是對(duì)象的基礎(chǔ)類定義,也就是或 Java 中的 Object 類的定義其實(shí)就是用的 oop,而任何類都由 Object 繼承而來。oopDesc 只是 oop 的一個(gè)別名而已。
可以看到里面有關(guān)于 Klass 的聲明,還有 markOop 的聲明,這個(gè) markOop 就是對(duì)應(yīng)上面說到的 MarkWord。
- class markOopDesc: public oopDesc {
- private:
- // Conversion
- uintptr_t value() const { return (uintptr_t) this; }
- public:
- // Constants
- enum { age_bits = 4, //分代年齡
- lock_bits = 2, //鎖標(biāo)志位
- biased_lock_bits = 1, //偏向鎖標(biāo)記
- max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
- hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,
- cms_bits = LP64_ONLY(1) NOT_LP64(0),
- epoch_bits = 2
- };
- }
以上代碼只是截取了其中一部分,可以看到其中有關(guān)于分代年齡、鎖標(biāo)志位、偏向鎖的定義。
雖然源碼咱也看不太懂,但是當(dāng)我看到它們的時(shí)候,恍惚之間,內(nèi)心會(huì)感嘆到,原來如此。有種宇宙之間,已盡在我掌控之中的感覺。過兩天才發(fā)現(xiàn),原來只是一種心理安慰。但是,已經(jīng)不重要了。
提示
如果你有興趣翻源碼看看,這部分的定義在 /src/hotspot/share/oops目錄下,能告訴你的就這么多了。
鎖升級(jí)JDK 1.6 之后,對(duì) synchronized 做了優(yōu)化,主要就是 CAS 自旋、鎖消除、鎖膨脹、輕量級(jí)鎖、偏向鎖等,這些技術(shù)都是為了在線程之間更高效地共享數(shù)據(jù)及解決競(jìng)爭(zhēng)問題,從而提高程序的執(zhí)行效率,進(jìn)而產(chǎn)生了一套鎖升級(jí)的規(guī)則。
synchronized 的鎖升級(jí)過程是通過動(dòng)態(tài)改變對(duì)象 MarkWord 各個(gè)標(biāo)志位來表示當(dāng)前的鎖狀態(tài)的,那修改的是哪個(gè)對(duì)象的 MarkWord 呢,看上面的代碼中,synchronized 關(guān)鍵字是加在 lock 變量上的,那就會(huì)控制 lock 的 MarkWord。如果是 synchronized(this)或者在方法上加關(guān)鍵字,那控制的就是當(dāng)前實(shí)例對(duì)象的 MarkWord。
synchronized 的核心準(zhǔn)則概括起來大概是這個(gè)樣子。
- 能不加鎖就不加鎖。
- 能偏向就盡量偏向。
- 能加輕量級(jí)鎖就不用重量級(jí)鎖。
無鎖轉(zhuǎn)向偏向鎖
偏向鎖的意思是說,這個(gè)鎖會(huì)偏向于第一個(gè)獲得它的線程,如果在接下來的執(zhí)行過程中,該鎖一直沒有被其他的線程獲取,則持有偏向鎖的線程將永遠(yuǎn)不需要再進(jìn)行同步。
當(dāng)線程嘗試獲取鎖對(duì)象的時(shí)候,先檢查 MarkWord 中的線程ID 是否為空。如果為空,則虛擬機(jī)會(huì)將 MarkWord 中的偏向標(biāo)記設(shè)置為 1,鎖標(biāo)記位為 01。同時(shí),使用 CAS 操作嘗試將線程ID記錄到 MarkWord 中,如果 CAS 操作成功,那之后這個(gè)持有偏向鎖的線程再次進(jìn)入相關(guān)同步塊的時(shí)候,將不需要再進(jìn)行任何的同步操作。
如果檢查線程ID不為空,并且不為當(dāng)前線程ID,或者進(jìn)行 CAS 操作設(shè)置線程ID失敗的情況下,都要撤銷偏向狀態(tài),這時(shí)候就要升級(jí)為偏向鎖了。
偏向鎖升級(jí)到輕量級(jí)鎖
當(dāng)多個(gè)線程競(jìng)爭(zhēng)鎖時(shí),偏向鎖會(huì)向輕量級(jí)鎖狀態(tài)升級(jí)。
首先,線程嘗試獲取鎖的時(shí)候,先檢查鎖標(biāo)志為是否為 01 狀態(tài),也就是未鎖定狀態(tài)。
如果是未鎖定狀態(tài),那就在當(dāng)前線程的棧幀中建立一個(gè)鎖記錄(Lock Record)區(qū)域,這個(gè)區(qū)域存儲(chǔ) MarkWord 的拷貝。
之后,嘗試用 CAS 操作將 MarkWord 更新為指向鎖記錄的指針(就是上一步在線程棧幀中的 MarkWord 拷貝),如果 CAS 更新成功了,那偏向鎖正式升級(jí)為輕量級(jí)鎖,鎖標(biāo)志為變?yōu)?00。
如果 CAS 更新失敗了,那檢查 MarkWord 是否已經(jīng)指向了當(dāng)前線程的鎖記錄,如果已經(jīng)指向自己,那表示已經(jīng)獲取了鎖,否則,輕量級(jí)鎖要膨脹為重量級(jí)鎖。
輕量級(jí)鎖升級(jí)到重量級(jí)鎖
上面的圖中已經(jīng)有了關(guān)于輕量級(jí)鎖膨脹為重量級(jí)鎖的邏輯。當(dāng)鎖已經(jīng)是輕量級(jí)鎖的狀態(tài),再有其他線程來競(jìng)爭(zhēng)鎖,此時(shí)輕量級(jí)鎖就會(huì)膨脹為重量級(jí)鎖。
重量級(jí)鎖的實(shí)現(xiàn)原理
為什么叫重量級(jí)鎖呢?在重量級(jí)鎖中沒有競(jìng)爭(zhēng)到鎖的對(duì)象會(huì) park 被掛起,退出同步塊時(shí) unpark 喚醒后續(xù)線程。喚醒操作涉及到操作系統(tǒng)調(diào)度會(huì)有額外的開銷,這就是它被稱為重量級(jí)鎖的原因。
當(dāng)鎖升級(jí)為重量級(jí)鎖的時(shí)候,MarkWord 會(huì)指向重量級(jí)鎖的指針 monitor,monitor 也稱為管程或監(jiān)視器鎖, 每個(gè)對(duì)象都存在著一個(gè) monitor 與之關(guān)聯(lián) ,對(duì)象與其 monitor 之間的關(guān)系有存在多種實(shí)現(xiàn)方式,如monitor可以與對(duì)象一起創(chuàng)建銷毀或當(dāng)線程試圖獲取對(duì)象鎖時(shí)自動(dòng)生成,但當(dāng)一個(gè) monitor 被某個(gè)線程持有后,它便處于鎖定狀態(tài)。
ObjectMonitor中有兩個(gè)隊(duì)列,_WaitSet 和 _EntryList,用來保存 ObjectWaiter 對(duì)象列表( 每個(gè)等待鎖的線程都會(huì)被封裝成 ObjectWaiter對(duì)象),_owner 指向持有 ObjectMonitor 對(duì)象的線程,當(dāng)多個(gè)線程同時(shí)訪問一段同步代碼時(shí),首先會(huì)進(jìn)入 _EntryList 集合,當(dāng)線程獲取到對(duì)象的monitor 后進(jìn)入 _Owner 區(qū)域并把 monitor 中的 owner 變量設(shè)置為當(dāng)前線程同時(shí) monitor 中的計(jì)數(shù)器 count 加1,若線程調(diào)用 wait() 方法,將釋放當(dāng)前持有的 monitor,owner 變量恢復(fù)為 null,count 自減1,同時(shí)該線程進(jìn)入 WaitSet 集合中等待被喚醒。若當(dāng)前線程執(zhí)行完畢也將釋放 monitor(鎖)并復(fù)位變量的值,以便其他線程進(jìn)入獲取 monitor(鎖)
monitor 對(duì)象存在于每個(gè) Java 對(duì)象的對(duì)象頭中(存儲(chǔ)的指針的指向),synchronized 鎖便是通過這種方式獲取鎖的,也是為什么 Java 中任意對(duì)象可以作為鎖的原因,同時(shí)也是notify/notifyAll/wait等方法存在于頂級(jí)對(duì)象Object中的原因。
適用場(chǎng)景
偏向鎖
優(yōu)點(diǎn): 加鎖和解鎖不需要額外的消耗,和執(zhí)行非同步方法比僅存在納秒級(jí)的差距。
缺點(diǎn): 如果線程間存在鎖競(jìng)爭(zhēng),會(huì)帶來額外的鎖撤銷的消耗。
適用場(chǎng)景: 適用于只有一個(gè)線程訪問同步塊場(chǎng)景。
有的同學(xué)可能會(huì)有疑惑,適用于只有一個(gè)線程的場(chǎng)景是什么鬼,一個(gè)線程還加什么鎖。
要知道,有些鎖不是你想不加就不加的。比方說你在使用一個(gè)第三方庫,調(diào)用它里面的一個(gè) API,你雖然知道是在單線程下使用,并不需要加鎖,但是第三方庫不知道啊,你調(diào)用的這個(gè) API 正好是用 synchronized 做了同步的。這種情況下,使用偏向鎖可以達(dá)到最高的性能。
輕量級(jí)鎖
優(yōu)點(diǎn): 競(jìng)爭(zhēng)的線程不會(huì)阻塞,提高了程序的響應(yīng)速度。
缺點(diǎn): 如果始終得不到鎖競(jìng)爭(zhēng)的線程使用自旋會(huì)消耗CPU。
適用場(chǎng)景: 追求響應(yīng)時(shí)間。同步塊執(zhí)行速度非常快。
重量級(jí)鎖
優(yōu)點(diǎn): 線程競(jìng)爭(zhēng)不使用自旋,不會(huì)消耗CPU。
缺點(diǎn): 線程阻塞,響應(yīng)時(shí)間緩慢。
適用場(chǎng)景: 追求吞吐量。同步塊執(zhí)行速度較長。
總結(jié)
1、synchronized 是可重入鎖,是一個(gè)非公平的可重入鎖,所以如果場(chǎng)景比較復(fù)雜的情況,還是要考慮其他的顯式鎖,比如 Reentrantlock、CountDownLatch等。
2、synchronized 有鎖升級(jí)的過程,當(dāng)有線程競(jìng)爭(zhēng)的情況下,除了互斥量的本身開銷外,還額外發(fā)生了CAS操作的開銷。因此在有競(jìng)爭(zhēng)的情況下,synchronized 會(huì)有一定的性能損耗。
本文轉(zhuǎn)載自微信公眾號(hào)「古時(shí)的風(fēng)箏」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系古時(shí)的風(fēng)箏公眾號(hào)。