文章目錄
由一個問題引發的思考
線程的合理使用能夠提升程序的處理性能,主要有兩個方面:
- 能夠利用多核 cpu 以及超線程技術來實現線程的並行執行
- 線程的異步化執行相比於同步執行來說,異步執行能夠很好的優化程序的處理性能提升併發吞吐量
提升處理性能的同時也帶來了很多麻煩
多線程對於共享變量訪問帶來的安全性問題
一個變量 i,假如一個線程去訪問這個變量進行修改,這個時候對於數據的修改和訪問沒有任何問題。但是如果多個線程對於這同一個變量進行修改,就會存在一個數據安全性問題
對於線程安全性,本質上是管理對於數據狀態的訪問,而且這個這個狀態通常是共享的、可變的
共享是指這個數據變量可以被多個線程訪問
可變是指這個變量的值在它的生命週期內是可以改變的
一個對象是否是線程安全的,取決於它是否會被多個線程訪問,以及程序中是如何去使用這個對象的。所以,如果多個線程訪問同一個共享對象,在不需額外的同步以及調用端代碼不用做其他協調的情況下,這個共享對象的狀態依然是正確的(正確性意味着這個對象的結果與我們預期規定的結果保持一致),那說明這個對象是線程安全的
public class IncreaseDemo {
private static int count = 0;
public static void increase() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
IncreaseDemo.increase();
}).start();
}
Thread.sleep(5000);
System.out.println("運行結果:count = " + count);
}
}
1000 個線程進行 i++ 操作,但是結果並不是 1000,線程在操作過程中時存在線程安全問題導致結果並不是 1000
在 Java 中如何解決由於線程並行導致的數據安全性問題呢?
思考如何保證線程並行的數據安全性
我們可以思考一下,問題的本質在於共享數據存在併發訪問。如果我們能夠有一種方法使得線程的並行變成串行,那是不是就不存在這個問題呢?
按照大家已有的知識,最先想到的應該就是鎖吧。
畢竟這個場景並不模式,我們在和數據庫打交道的時候,就瞭解過悲觀鎖、樂觀鎖的概念
什麼是鎖?它是處理併發的一種同步手段,而如果需要達到前面我們說的一個目的,那麼這個鎖一定需要實現互斥的特性。
Java 提供的加鎖方法就是 Synchroinzed 關鍵字。
synchronized 的基本認識
在多線程併發編程中 synchronized 一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是,隨着 Java SE 1.6 對 synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java SE 1.6 中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖
synchronized 的基本語法
synchronized 有三種方式來加鎖,分別是
- 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
- 靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖
- 修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖
synchronized 的應用
修改前面的案例,使用 synchronized 關鍵字後,可以達到數據安全的效果
public class IncreaseDemo {
private static int total = 0;
public static void increaseBySynchronized() {
synchronized (IncreaseDemo.class) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
total++;
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
IncreaseDemo.increaseBySynchronized();
}).start();
}
Thread.sleep(5000);
System.out.println("synchronized運行結果:total = " + total);
}
}
思考鎖是如何存儲的
可以思考一下,要實現多線程的互斥特性,那這把鎖需要哪些因素?
- 鎖需要有一個東西來表示,比如獲得鎖是什麼狀態、無鎖狀態是什麼狀態
- 這個狀態需要對多個線程共享
那麼我們來分析,synchronized 鎖是如何存儲的呢?觀察 synchronized 的整個語法發現,synchronized(lock) 是基於 lock 這個對象的生命週期來控制鎖粒度的,那是不是鎖的存儲和這個 lock 對象有關係呢?
於是我們以對象在 jvm 內存中是如何存儲作爲切入點,去看看對象裏面有什麼特性能夠實現鎖
對象在內存中的佈局
在 Hotspot 虛擬機中,對象在內存中的存儲佈局,可以分爲三個區域:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-vbdFD1jh-1585798040693)(http://huaweirookie.oss-cn-shenzhen.aliyuncs.com/1585661633629.png)]
探究 Jvm 源碼實現
當我們在 Java 代碼中,使用 new 創建一個對象實例的時候,(hotspot 虛擬機)JVM 層面實際上會創建一個 instanceOopDesc 對象
Hotspot 虛擬機採用 OOP-Klass 模型來描述 Java 對象實例,OOP(Ordinary Object Point)指的是普通對象指針,Klass 用來描述對象實例的具體類型。Hotspot 採用 instanceOopDesc 和 arrayOopDesc 來描述對象頭,arrayOopDesc 對象用來描述數組類型
instanceOopDesc 的定義在 Hotspot 源碼中的 instanceOop.hpp 文件中
class instanceOopDesc : public oopDesc {
public:
// aligned header size.
static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }
// If compressed, the offset of the fields of the instance may not be aligned.
static int base_offset_in_bytes() {
// offset computation code breaks if UseCompressedClassPointers
// only is true
return (UseCompressedOops && UseCompressedClassPointers) ?
klass_gap_offset_in_bytes() :
sizeof(instanceOopDesc);
}
static bool contains_field_offset(int offset, int nonstatic_field_size) {
int base_in_bytes = base_offset_in_bytes();
return (offset >= base_in_bytes &&
(offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
}
};
#endif // SHARE_VM_OOPS_INSTANCEOOP_HPP
arrayOopDesc 的定義對應 arrayOop.hpp
class arrayOopDesc : public oopDesc {
friend class VMStructs;
// Interpreter/Compiler offsets
// Header size computation.
// The header is considered the oop part of this type plus the length.
// Returns the aligned header_size_in_bytes. This is not equivalent to
// sizeof(arrayOopDesc) which should not appear in the code.
static int header_size_in_bytes() {
size_t hs = align_size_up(length_offset_in_bytes() + sizeof(int),
HeapWordSize);
#ifdef ASSERT
// make sure it isn't called before UseCompressedOops is initialized.
static size_t arrayoopdesc_hs = 0;
if (arrayoopdesc_hs == 0) arrayoopdesc_hs = hs;
assert(arrayoopdesc_hs == hs, "header size can't change");
#endif // ASSERT
return (int)hs;
}
.......
從 instanceOopDesc 代碼中可以看到 instanceOopDesc 繼承自 oopDesc,oopDesc 的定義載 Hotspot 源碼中的 oop.hpp 文件中
class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark;
union _metadata {
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
// Fast access to barrier set. Must be initialized.
static BarrierSet* _bs;
......
在普通實例對象中,oopDesc 的定義包含兩個成員,分別是 _mark 和 _metadata
_mark 表示對象標記、屬於 markOop 類型,也就是接下來要講解的 Mark World,它記錄了對象和鎖有關的信息
_metadata 表示類元信息,類元信息存儲的是對象指向它的類元數據(Klass)的首地址,其中 Klass 表示普通指針、_compressed_klass 表示壓縮類指針
MarkWord
在 Hotspot 中,markOop 的定義在 markOop.hpp 文件中,代碼如下
class markOopDesc: public oopDesc {
private:
// Conversion
uintptr_t value() const { return (uintptr_t) this; }
public:
// Constants
enum {
age_bits = 4,
lock_bits = 2,
biased_lock_bits = 1,
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
};
......
Mark word 記錄了對象和鎖有關的信息,當某個對象被 synchronized 關鍵字當成同步鎖時,那麼圍繞這個鎖的一系列操作都和 Mark word 有關係。Mark Word 在 32 位虛擬機的長度是 32bit、在 64 位虛擬機的長度是 64bit
Mark Word 裏面存儲的數據會隨着鎖標誌位的變化而變化,Mark Word 可能變化爲存儲以下 5 中情況
在markOop.hpp文件,註釋中存在這樣一段描述
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
在markOop.hpp文件中對鎖標誌位這樣枚舉:
enum {
locked_value = 0,//00 輕量級鎖
unlocked_value = 1,//01 無鎖
monitor_value = 2,//10 監視器鎖,也叫膨脹鎖,也叫重量級鎖
marked_value = 3,//11 GC標記
biased_lock_pattern = 5 //101 偏向鎖
};
爲什麼任何對象都可以實現鎖
- 首先,Java 中的每個對象都派生自 Object 類,而每個Java Object 在 JVM 內部都有一個 native 的 C++ 對象 oop/oopDesc 進行對應
- 線程在獲取鎖的時候,實際上就是獲得一個監視器對象 (monitor),monitor 可以認爲是一個同步對象,所有的 Java 對象是天生攜帶 monitor。在 hotspot 源碼的 markOop.hpp 文件中,可以看到下面這段代碼
ObjectMonitor* monitor() const {
assert(has_monitor(), "check");
// Use xor instead of &~ to provide one extra tag-bit check.
return (ObjectMonitor*) (value() ^ monitor_value);
}
多個線程訪問同步代碼塊時,相當於去爭搶對象監視器修改對象中的鎖標識,上面的代碼中 ObjectMonitor 這個對象和線程爭搶鎖的邏輯有密切的關係
synchronized 鎖的升級
在分析 markword 時,提到了偏向鎖、輕量級鎖、重量級鎖。在分析這幾種鎖的區別時,我們先來思考一個問題使用鎖能夠實現數據的安全性,但是會帶來性能的下降。不使用鎖能夠基於線程並行提升程序性能,但是卻不能保證線程安全性。這兩者之間似乎是沒有辦法達到既能滿足性能也能滿足安全性的要求
hotspot 虛擬機的作者經過調查發現,大部分情況下,加鎖的代碼不僅僅不存在多線程競爭,而且總是由同一個線程多次獲得。所以基於這樣一個概率,是的 synchronized 在 JDK1.6 之後做了一些優化,爲了減少獲得鎖和釋放鎖帶來的性能開銷,引入了偏向鎖、輕量級鎖的概念。因此大家會發現在 synchronized 中,鎖存在四種狀態分別是:
- 無鎖
- 偏向鎖
- 輕量級鎖
- 重量級鎖
鎖的狀態根據競爭激烈的程度從低到高不斷升級
偏向鎖的基本原理
大部分情況下,鎖不僅僅不存在多線程競爭,而是總是由同一個線程多次獲得,爲了讓線程獲取鎖的代價更低就引入了偏向鎖的概念。怎麼理解偏向鎖呢?
當一個線程訪問加了同步鎖的代碼塊時,會在對象頭中存儲當前線程的 ID,後續這個線程進入和退出這段加了同步鎖的代碼塊時,不需要再次加鎖和釋放鎖。而是直接比較對象頭裏面是否存儲了指向當前線程的偏向鎖。如果相等表示偏向鎖是偏向於當前線程的,就不需要再嘗試獲得鎖了
偏向鎖的獲取和撤銷邏輯
- 首先獲取鎖對象的 Markword,判斷是否處於可偏向狀態(biased_lock=1、且 ThreadId 爲空)
- 如果是可偏向狀態,則通過 CAS 操作,把當前線程的 ID 寫入到 MarkWord
- 如果 cas 成功,那麼 markword 就會變成這樣。表示已經獲得了鎖對象的偏向鎖,接着執行同步代碼塊
- 如果 cas 失敗,說明有其他線程已經獲得了偏向鎖,這種情況說明當前鎖存在競爭,需要撤銷已獲得偏向鎖的線程,並且把它持有的鎖升級爲輕量級鎖(這個操作需要等到全局安全點,也就是沒有線程在執行字節碼)才能執行
- 如果是已偏向狀態,需要檢查 markword 中存儲的 ThreadID 是否等於當前線程的 ThreadID
- 如果相等,不需要再次獲得鎖,可直接執行同步代碼塊
- 如果不相等,說明當前鎖偏向於其他線程,需要撤銷偏向鎖並升級到輕量級鎖
偏向鎖的撤銷
偏向鎖的撤銷並不是把對象恢復到無鎖可偏向狀態(因爲偏向鎖並不存在鎖釋放的概念),而是在獲取偏向鎖的過程中,發現 cas 失敗也就是存在線程競爭時,直接把被偏向的鎖對象升級到被加了輕量級鎖的狀態
對原持有偏向鎖的線程進行撤銷時,原獲得偏向鎖的線程有兩種情況:
- 原獲得偏向鎖的線程如果已經退出了臨界區,也就是同步代碼塊執行完了,那麼這個時候會把對象頭設置成無鎖狀態並且爭搶鎖的線程可以基於 CAS 重新偏向但前線程
- 如果原獲得偏向鎖的線程的同步代碼塊還沒執行完,處於臨界區之內,這個時候會把原獲得偏向鎖的線程升級爲輕量級鎖後繼續執行同步代碼塊
在我們的應用開發中,絕大部分情況下一定會存在 2 個以上的線程競爭,那麼如果開啓偏向鎖,反而會提升獲取鎖的資源消耗。所以可以通過 jvm 參數 UseBiasedLocking 來設置開啓或關閉偏向鎖
輕量級鎖的基本原理
輕量級鎖的加鎖和解鎖邏輯
鎖升級爲輕量級鎖之後,對象的 Markword 也會進行相應的的變化。升級爲輕量級鎖的過程:
- 線程在自己的棧楨中創建鎖記錄 LockRecord
- 將鎖對象的對象頭中的MarkWord複製到線程的剛剛創建的鎖記錄中
- 將鎖記錄中的 Owner 指針指向鎖對象
- 將鎖對象的對象頭的 MarkWord 替換爲指向鎖記錄的指針
自旋鎖
輕量級鎖在加鎖過程中,用到了自旋鎖,自旋就是指當有另外一個線程來競爭鎖時,這個線程會在原地循環等待,而不是把該線程給阻塞,直到那個獲得鎖的線程釋放鎖之後,這個線程就可以馬上獲得鎖的
注意,鎖在原地循環的時候,是會消耗 cpu 的,就相當於在執行一個啥也沒有的 for 循環
所以,輕量級鎖適用於那些同步代碼塊執行的很快的場景,這樣,線程原地等待很短的時間就能夠獲得鎖了
自旋鎖的使用,其實也是有一定的概率背景,在大部分同步代碼塊執行的時間都是很短的。所以通過看似無異議的循環反而能提升鎖的性能
但是自旋必須要有一定的條件控制,否則如果一個線程執行同步代碼塊的時間很長,那麼這個線程不斷的循環反而會消耗 CPU 資源。默認情況下自旋的次數是 10 次,可以通過 preBlockSpin 來修改
在 JDK1.6 之後,引入了自適應自旋鎖,自適應意味着自旋的次數不是固定不變的,而是根據前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀態來決定
如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也是很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源
輕量級鎖的解鎖
輕量級鎖的鎖釋放邏輯其實就是獲得鎖的逆向邏輯,通過 CAS 操作把線程棧幀中的 LockRecord 替換回到鎖對象的 MarkWord 中,如果成功表示沒有競爭。如果失敗,表示當前鎖存在競爭,那麼輕量級鎖就會膨脹成爲重量級鎖
重量級鎖的基本原理
當輕量級鎖膨脹到重量級鎖之後,意味着線程只能被掛起阻塞來等待被喚醒了
創建一個類如下
public class MonitorDemo {
public static synchronized void test() {
}
public static void main(String[] args) {
synchronized (MonitorDemo.class) {
}
test();
}
}
運行以後通過 javap 工具查看生成的 class 文件信息分析 synchronized 關鍵字的實現細節 javap -v MonitorDemo.class
加了同步代碼塊以後,在字節碼中會看到一個 monitorenter 和 monitorexit
每一個 JAVA 對象都會與一個監視器 monitor 關聯,我們可以把它理解成爲一把鎖,當一個線程想要執行一段被 synchronized 修飾的同步方法或者代碼塊時,該線程得先獲取到 synchronized 修飾的對象對應的 monitor。
monitorenter 表示去獲得一個對象監視器。monitorexit 表示釋放 monitor 監視器的所有權,使得其他被阻塞的線程可以嘗試去獲得這個監視器 monitor 依賴操作系統的 MutexLock(互斥鎖) 來實現的, 線程被阻塞後便進入內核(Linux)調度狀態,這個會導致系統在用戶態與內核態之間來回切換,嚴重影響鎖的性能
任意線程對 Object(Object 由 synchronized 保護)的訪問,首先要獲得 Object 的監視器。如果獲取失敗,線程進入同步隊列,線程狀態變爲 BLOCKED。當訪問 Object 的前驅(獲得了鎖的線程)釋放了鎖,則該釋放操作喚醒阻塞在同步隊列中的線程,使其重新嘗試對監視器的獲取
回顧線程的競爭機制
再來回顧一下線程的競爭機制對於鎖升級這塊的一些基本流程。方便大家更好的理解
加入有這樣一個同步代碼塊,存在 Thread#1、Thread#2 等多個線程
synchronized (lock) {
// do something
}
- 情況一:只有 Thread#1 會進入臨界區;
- 情況二:Thread#1 和 Thread#2 交替進入臨界區,競爭不激烈;
- 情況三:Thread#1/Thread#2/Thread3… 同時進入臨界區,競爭激烈
偏向鎖
此時當 Thread#1 進入臨界區時,JVM 會將 lockObject 的對象頭 Mark Word 的鎖標誌位設爲“01”,同時會用 CAS 操作把 Thread#1 的線程 ID 記錄到 Mark Word 中 此時進入偏向模式。所謂“偏向”,指的是這個鎖會偏向於 Thread#1,若接下來沒有其他線程進入臨界區,則 Thread#1 再出入臨界區無需再執行任何同步操作。也就是說,若只有 Thread#1 會進入臨界區,實際上只有 Thread#1 初次進入臨界區時需要執行 CAS 操作,以後再出入臨界區都不會有同步操作帶來的開銷
輕量級鎖
偏向鎖的場景太過於理想化,更多的時候是 Thread#2 也會嘗試進入臨界區, 如果 Thread#2 也進入臨界區但是 Thread#1 還沒有執行完同步代碼塊時,會暫停 Thread#1 並且升級到輕量級鎖。Thread#2 通過自旋再次嘗試以輕量級鎖的方式來獲取鎖
重量級鎖
如果 Thread#1 和 Thread#2 正常交替執行,那麼輕量級鎖基本能夠滿足鎖的需求。但是如果 Thread#1 和 Thread#2 同時進入臨界區,那麼輕量級鎖就會膨脹爲重量級鎖,意味着 Thread#1 線程獲得了重量級鎖的情況下,Thread#2 就會被阻塞
Synchronized 結合 Java Object 對象中的wait,notify,notifyAll
前面我們在講 synchronized 的時候,發現被阻塞的線程什麼時候被喚醒,取決於獲得鎖的線程什麼時候執行完同步代碼塊並且釋放鎖。那怎麼做到顯示控制呢?我們就需要藉助一個信號機制:在 Object 對象中,提供了 wait/notify/notifyall,可以用於控制線程的狀態
wait/notify/notifyall 基本概念
- wait:表示持有對象鎖的線程 A 準備釋放對象鎖權限,釋放 cpu 資源並進入等待狀態
- notify:表示持有對象鎖的線程 A 準備釋放對象鎖權限,通知 jvm 喚醒某個競爭該對象鎖的線程 X。線程 A synchronized 代碼執行結束並且釋放了鎖之後,線程 X 直接獲得對象鎖權限,其他競爭線程繼續等待(即使線程 X 同步完畢,釋放對象鎖,其他競爭線程仍然等待,直至有新的 notify ,notifyAll 被調用)
- notifyAll:notifyall 和 notify 的區別在於,notifyAll 會喚醒所有競爭同一個對象鎖的所有線程,當已經獲得鎖的線程 A 釋放鎖之後,所有被喚醒的線程都有可能獲得對象鎖權限
需要注意的是:三個方法都必須在 synchronized 同步關鍵字所限定的作用域中調用,否則會報錯 java.lang.IllegalMonitorStateException,意思是因爲沒有同步,所以線程對對象鎖的狀態是不確定的,不能調用這些方法
另外,通過同步機制來確保線程從 wait 方法返回時能夠感知到感知到 notify 線程對變量做出的修改
wait/notify 的基本使用
線程 A
public class ThreadA extends Thread {
private Object lock;
public ThreadA(Object lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock) {
System.out.println("start ThreadA");
try {
lock.wait(); //實現線程的阻塞
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end ThreadA");
}
}
}
線程 B
public class ThreadB extends Thread {
private Object lock = new Object();
public ThreadB(Object lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock) {
System.out.println("start ThreadB");
lock.notify(); //喚醒被阻塞的線程
System.out.println("end ThreadB");
}
}
}
測試類
public class WaitNotifyDemo {
public static void main(String[] args) {
Object lock = new Object();
ThreadA threadA = new ThreadA(lock);
threadA.start();
ThreadB threadB = new ThreadB(lock);
threadB.start();
}
}
運行結果
start ThreadA
start ThreadB
end ThreadB
end ThreadA
ThreadA 與 ThreadB 爭搶鎖,當 ThreadA 獲取鎖後,在打印出 start ThreadA
後,調用了 lock.wait();
使得 ThreadA 處於 Waiting 狀態,釋放對象鎖和 CPU 資源,此時 ThreadB 獲取鎖成功,執行完後調用 lock.notify();
,釋放鎖喚醒處於處於等待的 ThreadA 獲取鎖繼續執行
wait/notify 的基本原理
資源信息
本文設計的代碼可前往下載:https://github.com/huaweirookie/toalibaba/tree/master/concurrent-programming/thread-basic