1、概述
在并發編程中,經常遇到多個線程訪問同一個共享資源 ,這時候作為開發者必須考慮如何維護數據一致性,在java中synchronized關鍵字被常用于維護數據一致性。synchronized機制是給共享資源上鎖,只有拿到鎖的線程才可以訪問共享資源,這樣就可以強制使得對共享資源的訪問都是順序的。一般在java中所說的鎖就是指的內置鎖,每個java對象都可以作為一個實現同步的鎖,雖然說在java中一切皆對象, 但是鎖必須是引用類型的,基本數據類型則不可以 。每一個引用類型的對象都可以隱式的扮演一個用于同步的鎖的角色,執行線程進入synchronized塊之前會自動獲得鎖,無論是通過正常語句退出還是執行過程中拋出了異常,線程都會在放棄對synchronized塊的控制時自動釋放鎖。 獲得鎖的唯一途徑就是進入這個內部鎖保護的同步塊或方法 。當多個線程對共享資源訪問的時候,只能有一個線程可以獲得該共享資源的鎖,當線程A嘗試獲取目前在線程B手上的鎖的時候,線程A必須等待或者阻塞,直到線程B釋放該鎖為止,否則線程A將一直等待下去,因此java內置鎖也稱作互斥鎖,也即是說鎖實際上是一種互斥機制。根據使用方式的不同一般我們會將鎖分為對象鎖和類鎖,兩個鎖是有很大差別的,對象鎖是作用在實例方法或者一個對象實例上面的,而類鎖是作用在靜態方法或者Class對象上面的。一個類可以有多個實例對象,因此一個類的對象鎖可能會有多個,但是每個類只有一個Class對象,所以類鎖只有一個。
2、鎖相關概念
從不同角度可以將鎖進行分類,一種具體的鎖操作,在不同分類方式下一般會有對應的所屬。
2.1 樂觀鎖和悲觀鎖
樂觀鎖是正如其名字,很樂觀,默認覺得不會有人在其進行操作的時候進行修改操作,遇到并發寫的可能性低,即認為讀多寫少。所以不會上鎖,但是在更新的時候會判斷一下在此期間數據有么有被其他人更新,方法可以是在寫時先讀出當前版本號,然后加鎖操作,再比較跟上一次的版本號,如果沒變則更新。如果版本號變了,說明在此期間有人修改了數據,則要重復讀-比較-寫的操作。
悲觀鎖是就是就不一樣了,它總認為有人要和它爭搶操作的數據,所以每次在讀寫數據的時候都會上鎖,這樣別人想讀寫這個數據就會被阻塞直到拿到鎖。
2.2 公平鎖和非公平鎖
公平鎖是指多個線程按照申請鎖的順序來獲取鎖,先到先得。
非公平鎖是指多個線程獲取鎖的順序并不是按照申請鎖的順序,有可能后申請的線程比先申請的線程優先獲取鎖。這就可能,有的申請者老是被插隊而造成優先級反轉或者饑餓現象。
2.3 可重入鎖和不可重入鎖
可重入鎖又名遞歸鎖,是指當一個線程已經擁有某一對象鎖時候,就可以遞歸的調用該對象的帶鎖方法。比如說線程調用對象A的帶鎖方法a,而方法a中要調用這個對象A的帶鎖方法b,這時如果這時可重入鎖,線程由于已經在調用a方法時獲得了對象A的鎖,所以調用b方法時也自然可以進入到b方法中,而不需要這個線程釋放調用a方法時候的鎖,再重新在調用b方法時候獲取鎖。
不可重入鎖恰好相反。所以在上述情況下,就會出現問題,即要調用b方法就要釋放a方法時候獲取的鎖再重新獲取對象A的鎖,而此時a方法還沒有執行完畢,故a方法的鎖不能釋放。這就出現了死鎖的情況。
所以一般來說Java里面的鎖設計都是可重入鎖。一個線程一旦獲得了對象的鎖,就可以調用這個對象的所有方法而不需要重新獲取這個對象的鎖。
2.4 獨享鎖和共享鎖
獨享鎖是指該鎖一次只能被一個線程所持有。共享鎖是指該鎖可被多個線程所持有。一般來說讀鎖是共享鎖,而寫鎖是獨享鎖。因為讀的時候不會改變內容,所以多個讀線程可以同時讀取一個對象。但是一般來說這時候就不能讓寫線程進來進行操作了。而寫線程如果好幾個線程同時寫一個對象,那就會造成混亂,所以寫操作時候一般都是獨享對象的。同時也可以理解,共享鎖的執行效率是要高于獨享鎖的。
2.5 分段鎖
分段鎖是一種鎖的設計思想,它的設計目的是細化鎖的粒度,當操作不需要更新整個數組的時候,就僅僅針對數組中的一項進行加鎖操作。比如本來要鎖整個數據庫的,改成鎖其中一張表,這樣的好處就是對于同一個數據庫但不是同一張表的操作可以同時進行,而不用一個等另一個操作完。ConcurrentHashMap內部也是通過分段鎖的形式來實現高效的并發操作。ConcurrentHashMap中的分段鎖稱為Segment,它即類似于HashMap的結構,即內部擁有一個Entry數組,數組中的每個元素又是一個鏈表。當需要put元素的時候,并不是對整個hashmap進行加鎖,而是先通過hashcode來知道他要放在那一個分段中,然后對這個分段進行加鎖,所以當多線程put的時候,只要不是放在一個分段中,就實現了并行的插入。這么做的缺點是,在統計size的時候,需要獲取hashmap全局信息,這時就需要獲取所有的分段鎖才能統計。
2.6 自旋鎖
自旋鎖是指Java一種等待獲取鎖的策略。自旋鎖原理是如果持有鎖的線程能在很短時間內釋放鎖資源,那么那些等待競爭鎖的線程就不需要做內核態和用戶態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的線程釋放鎖后即可立即獲取鎖,這樣就避免用戶線程和內核的切換的消耗,可以簡單理解為不停的while循環去獲取鎖,沒有獲取到就一直循環,獲取到了就調出循環進行下一步操作。所以說線程自旋是需要消耗cup的,說白了就是讓cup在做無用功。所以一般來說自旋鎖操作出現在估計能馬上獲取到鎖的情況下進行。自旋了一段時間如果還沒有拿到鎖,一般來說還是要取消自選進入阻塞掛起狀態。這個零界點就是切換用戶內核線程的消耗和自旋對CPU的消耗那個損失更大。
3、synchronized
前面介紹了鎖在不同角度下的分類,現在討論一下線程同步中很重要的synchronized。先將它實現的鎖進行一個歸類,synchronized操作內部的鎖是:悲觀鎖、非公平鎖、可重入鎖、獨享鎖、就對象而言不是分段鎖、內部策略中存在自旋鎖。接下來詳細說下synchronized的鎖機制。
任何一個對象都有一個Monitor與之關聯,當一個Monitor被某一線程持有后,它將處于鎖定狀態。Synchronized在JVM里的實現都是基于進入和退出Monitor對象來實現方法同步和代碼塊同步,雖然具體實現細節不一樣,但是都可以通過成對的MonitorEnter和MonitorExit指令來實現。MonitorEnter指令插入在同步代碼塊的開始位置,當代碼執行到該指令時,將會嘗試獲取該對象Monitor的所有權,即嘗試獲得該對象的鎖,而monitorExit指令則插入在方法結束處和異常處,JVM保證每個MonitorEnter必須有對應的MonitorExit。
3.1 Java對象頭
synchronized使用的鎖是存放在Java對象頭里面,具體位置是對象頭里面的MarkWord。MarkWord是java對象數據結構中的一部分。markword數據的長度在32位和64位的虛擬機(未開啟壓縮指針)中分別為32bit和64bit,它的最后2bit是鎖狀態標志位,用來標記當前對象的狀態,對象的所處的狀態,決定了markword存儲的內容。MarkWord里默認數據是存儲對象的HashCode等信息,但是會隨著對象的運行改變而發生變化,不同的鎖狀態對應著不同的記錄存儲方式,如下表所示:
狀態 | 標志位 | 存儲內容 |
---|---|---|
未鎖定 | 01 | 對象哈希碼、對象分代年齡、偏向鎖標志位 |
偏向鎖 | 01 | 偏向線程ID、偏向時間戳、對象分代年齡、偏向鎖標志位 |
輕量級鎖定 | 00 | 指向鎖記錄的指針 |
重量級鎖定) | 10 | 執行重量級鎖定的指針 |
GC標記 | 11 | 空(不需要記錄信息) |
3.2 Monitor Record
Monitor Record是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor record關聯(對象頭的MarkWord中的LockWord指向monitor record的起始地址),同時monitor record中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程占用。
3.3 CAS
CAS,compare and swap的縮寫,中文翻譯成比較并交換。在java語言之前,并發就已經廣泛存在并在服務器領域得到了大量的應用。所以硬件廠商老早就在芯片中加入了大量處理并發操作的原語,從而在硬件層面提升效率。在intel的CPU中,使用cmpxchg指令。在Java發展初期,java語言是不能夠利用硬件提供的這些便利來提升系統的性能的。而隨著java不斷的發展,Java本地方法(JNI)的出現,使得java程序越過JVM直接調用本地方法提供了一種便捷的方式,因而java在并發的手段上也多了起來。而在Doug Lea提供的cucurenct包中,CAS理論是它實現整個java包的基石。
CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。 如果內存位置的值與預期原值相匹配,那么處理器會自動將該位置值更新為新值 。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。(在 CAS 的一些特殊情況下將僅返回 CAS 是否成功,而不提取當前值。)CAS 有效地說明了“我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。”通常將 CAS 用于同步的方式是從地址 V 讀取值 A,執行多步計算來獲得新 值 B,然后使用 CAS 將 V 的值從 A 改為 B。如果 V 處的值尚未同時更改,則 CAS 操作成功。類似于 CAS 的指令允許算法執行讀-修改-寫操作,而無需害怕其他線程同時 修改變量,因為如果其他線程修改變量,那么 CAS 會檢測它(并失敗),算法 可以對該操作重新計算。
3.4 鎖的不同狀態
對于synchronized,鎖一共有四種狀態,無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態,它會隨著競爭情況逐漸升級。鎖可以升級但不能降級,目的是為了提高獲得鎖和釋放鎖的效率。
3.4.1 偏向鎖
大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖,減少不必要的CAS操作。當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖,而只需簡單的測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖,如果測試成功,表示線程已經獲得了鎖,如果測試失敗,則需要再測試下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖),如果沒有設置,則使用CAS競爭鎖,如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程(此時會引發競爭,偏向鎖會升級為輕量級鎖)。如果當前線程執行CAS獲取偏向鎖失敗(這一步是偏向鎖的關鍵),表示在該鎖對象上存在競爭并且這個時候另外一個線程獲得偏向鎖所有權。當那個競爭失敗的線程運行到全局安全點(safepoint)時會讓持有偏向鎖的線程暫停,并讓持有偏向鎖的線程的私有Monitor Record列表中獲取一個空閑的記錄,將對象設置為LightWeight Lock(輕量級鎖)狀態并且Mark Word中的LockRecord指向剛才持有偏向鎖線程的Monitor record,最后在安全點暫停的競爭失敗的線程進入競爭輕量級鎖的路徑中,同時之前持有偏向鎖的被暫停的線程恢復,繼續向下執行代碼。
3.4.2 輕量級鎖和重量級鎖
輕量級鎖實現的背后基于這樣一種假設,即在真實的情況下程序中的大部分同步代碼一般都處于無鎖競爭狀態(即單線程執行環境),在無鎖競爭的情況下完全可以避免調用操作系統層面的重量級互斥鎖,取而代之的是在monitorenter和monitorexit中只需要依靠一條CAS原子指令就可以完成鎖的獲取及釋放。當存在鎖競爭的情況下,執行CAS指令失敗的線程將調用操作系統互斥鎖進入到阻塞狀態,當鎖被釋放的時候被喚醒。也就是說其實輕量級鎖是一把自旋鎖。整個流程如下:
線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋操作來獲取鎖。當沒有獲取到鎖的線程自旋到一定程度,還沒有獲取到鎖,說明這個對象不能用輕量級鎖來處理了,這時候就會將鎖升級為重量級鎖。沒有獲取到鎖的線程會被阻塞。一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處于這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之后會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。
3.4.3 不同鎖狀態比較
鎖狀態 | 優點 | 缺點 | 適用場景 |
---|---|---|---|
偏向鎖 | 枷鎖解鎖沒有額外消耗,和執行非同步快的速度近乎一樣 | 如果線程間存在 競爭,會有額外的撤銷消耗 | 基本上不存在多個線程訪問同步快的場景 |
輕量級鎖 | 基于自旋鎖, 競爭的線程不會阻塞減少了用戶線程與核心線程間切換的消耗 | 自旋操作會消耗CPU | 同步快處理速度快,能馬上處理完畢讓出鎖的場景 |
重量級鎖 | 線程競爭不消耗CPU | 線程阻塞,響應時間慢 | 同步代碼塊執行慢的場景 |
4、死鎖
死鎖是指兩個或兩個以上的線程或進程在執行過程中,由于競爭資源或者由于彼此通信而造成的一種阻塞的現象,若無外力作用,它們都將無法推進下去。此時稱系統處于死鎖狀態或系統產生了死鎖,這些永遠在互相等待的進程稱為死鎖進程。死鎖產生必須滿足如下四個條件:
① 互斥條件:指進程對所分配到的資源進行排它性使用,即在一段時間內某資源只由一個進程占用。如果此時還有其它進程請求資源,則請求者只能等待,直至占有資源的進程用畢釋放。
② 請求和保持條件:指進程已經保持至少一個資源,但又提出了新的資源請求,而該資源已被其它進程占有,此時請求進程阻塞,但又對自己已獲得的其它資源保持不放。
③ 不剝奪條件:指進程已獲得的資源,在未使用完之前,不能被剝奪,只能在使用完時由自己釋放。
④ 循環等待:指在發生死鎖時,必然存在一個進程——資源的環形鏈,即進程集合{P0,P1,P2,···,Pn}中的P0正在等待一個P1占用的資源;P1正在等待P2占用的資源,……,Pn正在等待已被P0占用的資源。
而解除死鎖的辦法就是打破上述四個條件的任意一個或幾個條件。