一、引言
這篇文章碼了小編***個小時,點個贊不過分吧~~
文本內容有點多,如果有寫錯或者不好地方,還請多多指教~~~~~~~
Table of Contents
二、倔強青銅
2.1 多線程一定快嗎?
我們先來看下面一段代碼,有兩個方法對各自a、b屬性進行累加操作,其中concurrency方法是採用多線程進行操作,結果如下:
/**
* @Auther: IT賤男
* @Date: 2020/3/9 10:37
* @Description:
*/
public class ConcurrencyTest {
// 累加次數
private static final long count = 10000L;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
/**
* 多線程累加
*
* @throws InterruptedException
*/
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
// 啓動新線程執行運行操作
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (int i = 0; i < count; i++) {
a += 5;
}
}
});
thread.start();
int b = 0;
for (int i = 0; i < count; i++) {
b--;
}
// 等線程執行完
thread.join();
long end = System.currentTimeMillis() - start;
System.out.println("concurrency 總共耗時" + end);
}
/**
* 單線程累加
*/
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (int i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (int i = 0; i < count; i++) {
b--;
}
long end = System.currentTimeMillis() - start;
System.out.println("serial 總共耗時" + end);
}
}
那這邊的答案是"不一定"的,小編測試了幾組數據如下(抽取部分結果):
循環次數 | 單線程執行 | 多線程執行 | 效率 |
---|---|---|---|
1萬 | 0 | 1 | 慢 |
1萬 | 0 | 0 | 相等 |
十萬 | 2 | 2 | 相等 |
十萬 | 1 | 1 | 相等 |
由以上的結果可以明確我們的答案是正確的,那爲什麼多線程在某些情況下會比單線程還要慢呢? 這是因爲多線程有創建和上下文切換的開銷。
2.2 上下文切換
那什麼是上下文切換呢?
目前來說即使是單核處理器也支持多線程執行代碼,CPU通過給個線程分配CPU時間片來實現這個機制。時間片是CPU分配給各個線程的時間,因爲時間片一般是幾十毫秒,所以CPU需要通過不停地切換線程來執行。 假設當我們線程A獲得CPU分配的時間片等於10毫秒,執行10毫秒之後,CPU需要切換到線程B去執行程序。等線程B的時間片執行完事了,又切回線程A繼續執行。
顯然易見,我們CPU相當於是循環的切換上下文,來達到同時執行的效果。當前執行完一個時間片後會切換下一個任務。但是在切換前會保存當前任務的狀態,方便下次切換會這個任務的時候,可以恢復這個任務之前的狀態。 所以任務從保存到再次被加載的過程就是一次上下文切換。
2.3 測試上下文切換次數
這裏我們需要使用一個命令叫做:"vmstat 1",這個命令是linux系統上的,可對操作系統的進程、虛擬內存、CPU活動進行監控。看下圖CS(Content Switch) 表示上下文切換的次數,從圖可見系統一般CS的值維持在600~800之間,當我們一直在運行ConcurrencyTest程序時,很明細發現CS飆升到1000以上。
2.4 Java內存模型
在我們學習sync原理之前,我們需要搞清楚Java內存模型的一個概念知識。很重要、很重要、很重要
Java內存模型全稱:Java Memory Model ,簡稱Java內存模型或者JMM,Java線程之間的通信由JMM來控制,JMM決定一個線程對共享變量的寫入,何時對另外一個線程可見。我們由圖可見,線程之間的共享變量是存儲在主內存當中,每一個線程都有一個屬於自己的本地內存(也可以叫做工作內存),這個本地內存中存儲了主內存當中的共享變量。就相當於把主內存的共享變量copy了一份給自己。爲了提供效率,線程是不會直接與主內存進行打交道,而是通過本地內存來進行數據的讀取。
如果線程A與線程B之間要通信,需要經歷下面兩個步驟:
1 )線程A把本地內存A中更新過的共享變量,刷新到主內存當中去。
2 )線程B到主內存中重新讀取更新後的共享變量。
2.5 主內存與工作內存之間的數據交互過程
那麼主內存與工作內存之間的交互經過了哪些步驟呢?
lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。
unlock(解鎖):作用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放之後的變量纔可以被其他線程鎖定。
read(讀取):作用於主內存的變量,讀取主內存變量的值。
load(載入):作用於主內存的變量,把read操作從主內存中得到的變量值放入到線程本地內存的變量副本中。
use(使用):作用於工作內存的變量,把工作內存中的一個變量傳遞給執行引擎。
assign(賦值):作用域工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量。
store(存儲):作用域工作內存的變量,把工作內存中的一個變量值傳輸到主內存中,以便隨後的write操作。
write(寫入):作用域工作內存的變量,把stroe操作從工作內存中一個變量的值傳送到主內存的變量中去。
上個筆記圖: 更加詳細的解釋如上幾個步驟
JMM是一種規範,其中定義幾條規則,小編挑選出相對本文比較重要的:
1、如果想要把一個變量從主內存複製到工作內存,就需要按照順序執行read和load操作,如果把變量從工作內存同步到主內存中,就要按照順序執行store和write操作。但Java內存模型只要求上述操作必須按照順序執行,而沒有保證必須是連續執行。
2、程序中如果有同步操作纔會有lock和unlock操作,一個變量在同一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,執行多次後,必須執行相對應次數但unlock操作,變量纔會被解鎖。lock和unlock必須成對出現。
3、如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或者assign操作初始化變量但值。
4、java內存模型同步規則小編暫時提到這麼多,感興趣的小夥伴可以自行去了解一下
三、秩序白銀
3.1 多線程帶來的可見性問題
什麼是可見性問題呢?
所謂可見性:一個線程對主內存的修改可以及時被其他線程觀察到。
當一個共享屬性,被線程二修改了,但是線程一無法獲得最新的值,導致死循環。原因Java內存模型也說清楚了,線程是和本地內存做交互的。
1、線程一把falg屬性讀取到線程私有的本地內存中,值爲true。
2、線程二把falg屬性修改爲false,並且刷新到主內存當中,但是線程一它是不知道falg被修改了。
public class SyncExample5 {
static boolean falg = true;
// 鎖對象
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// 線程一
new Thread(new Runnable() {
@Override
public void run() {
while (falg) {
// 默認不可見,死循環,放開以下注釋即可解決不可見操作
// 方式一,加上sycn操作即可解決可見性問題
// synchronized (lock){}
// 方式二, println 方法實現加上了同步機制,保證每次輸出都是最新值
// System.out.println(falg);
}
}
}).start();
// 睡眠兩秒
Thread.sleep(2000L);
// 線程二
new Thread(new Runnable() {
@Override
public void run() {
falg = false;
System.out.println("falg 值已修改");
}
}).start();
}
}
sync怎麼解決可見性問題呢?
這個就涉及到本地內存與工作內存交互的步驟了,還記得文本上面有講的8個步驟嗎?
如果程序中有加同步的機制,則會有Lock、Unlock操作,Lock操作會使本地內存中的屬性失效,從而去主內存中重新讀取數據。
3.2 多線程帶來的原子性問題
什麼是原子性問題呢?
所謂原子性:提供了互斥訪問,同一個時刻只能有一個線程來對它進行操作。
這裏一次任務累加1千次,同時啓動5個線程進行累加,最後的結果正常應該是5000纔對,但由於多線程會造成不一樣的結果。
public class SyncExample6 {
static int index = 0;
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// index 累加 1000次,使用lambda表達式
Runnable task = () -> {
// 不加sync則不能保證原子操作
// synchronized (lock) {
for (int i = 0; i < 1000; i++) {
index++;
}
// }
};
// 啓動五個線程來執行任務
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(task);
thread.start();
}
// 爲了代碼直觀直接睡眠等待結果,實際需要調用線程的join方法等待線程結束
Thread.sleep(2000L);
System.out.println("index = " + index);
}
}
我們使用java命令來編譯以上代碼:
javac SyncExample6.java
javap -p -v SyncExample6.class ,這樣我們就能看到sync到底在底層做了什麼事。
編譯代碼之後找到“lambda$main$0”,因爲我們同步機制是寫在main方法中,用lambda表達式所寫。
private static void lambda$main$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=3, args_size=0
0: iconst_0
1: istore_0
2: iload_0
3: sipush 1000
6: if_icmpge 39
9: getstatic #18 // Field lock:Ljava/lang/Object;
12: dup
13: astore_1
14: monitorenter
15: getstatic #14 // Field index:I
18: iconst_1
19: iadd
20: putstatic #14 // Field index:I
23: aload_1
24: monitorexit
25: goto 33
28: astore_2
造成原子性的問題的原因是什麼?
這個就涉及到文章一開始所講的上下文切換的知識點,index ++ 一共涉及到4條指令,如下
15: getstatic #14 // 步驟一:獲取index值
18: iconst_1 // 步驟二:準備常量1
19: iadd // 步驟三:相加操作
20: putstatic #14 // 步驟四:重新賦值
以上這4條指令就是index ++ 的四個步驟,假設我們線程一進來,執行到步驟三,這個時候CPU切換線程。切換到線程二,線程二執行步驟一,這個時候index的值還是等於0,因爲線程一併沒有執行步驟四就被切換上下文了。 等線程二執行完成,又切回到線程一,線程一會接着執行步驟三,並不會重新獲取index的值,這就導致計算結果不正確了。
sync怎麼解決原子性問題呢?
14: monitorenter
15: getstatic #14 // Field index:I
18: iconst_1
19: iadd
20: putstatic #14 // Field index:I
23: aload_1
24: monitorexit
當我們加上了sync同步機制之後, 會插入monitorenter、monitorexit兩條指令。
又到了假設環節:假設線程一執行到步驟三,被切換到線程二,當我們線程二執行monitorenter這個指令會發現,這個對象已經被其他線程佔用了,所以就只能等待着不會進行操作。現在又切回到線程一,線程一操作完整個步驟執行monitorexit來釋放鎖。這個時候線程二纔可以獲得鎖。 這樣一操作就能保證同一個時刻只能有一個線程來對它進行操作,從而保證原子性。
monitorenter指令是在編譯後插入到同步代碼塊到開始位置,而monitorexit是插入到同步代碼塊結束位置和異常位置。JVM需要保障每個monitorenter必須有對應的monitorexit。任何一個對象都會有一個monitor來關聯,當且一個monitor被持有後,它就處理鎖定狀態。當線程執行到monitorenter指令的時候,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲取鎖對象。
3.3 多線程帶來的有序性問題
什麼是有序性問題呢?
有序性,指的是程序中代碼的執行順序,Java在編譯時和運行時會對代碼進行優化,會導致程序最終的執行順序不一定就是我們編寫代碼時的順序。
// 指定使用併發測試
@JCStressTest
// 預測的結果與類型,附加描述信息,如果1,4 則是ok,如果結果有爲0也能勉強接受
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = {"0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "denger")
// 標註需要測試的類
@State
public class TestJMM {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
是時候貼一段代碼湊文章字數了,這裏代碼用了Jcstress高併發測試框架,目的是爲了能夠演示有序性所導致到問題。
小夥伴可以先仔細看以上代碼,假設actor1、actor2 各有一個線程進來,想想 r.r1 的值會產生幾種情況。
小編告訴你吧,其實答案有三種,分別是:1、4、0
出現1的情況:
1)假設 actor1先獲得執行權,ready = false ,則 r.r1 = 1;
2)假設 actor2先獲得執行權,執行到num = 2, 線程切換到actor1,ready還是爲false,r.r1 = 1;
出現4的情況:
1)假設actor2先獲得執行權,執行完,此時ready = true,num = 2 ,等到在執行actor1時,結果爲4;
出現0的情況:
1)這裏就是重點了,假設actor2獲得執行權,由於指令重排序導致actor2代碼順序更換。
這個時候執行到ready = true,線程切換到actor1,這個時候ready已經等於true了,但是num還是0,所以就出現了0的情況。
@Actor
public void actor2(I_Result r) {
// 由於指令重排序,導致下面代碼更換了順序,如下:
ready = true;
num = 2;
}
我們用壓測來執行以下代碼吧,使用maven 執行 clean install,會生成一個jar包,直接用命令啓動jar包就行了,Jcstress使用方式小編就不多說了,感興趣的小夥伴可以自行學習下, 執行的結果也符合我們預期的值。
sync怎麼解決有序性問題呢?
這個時候只需要在actor1和actor2分別加上鎖操作,由於它們的鎖對象都是同一個,哪怕由於指令重排序執行到actor2的ready = true,這個時候線程切換到actor1,但是有加鎖所以actor1也只能等着。 等到actor2 把 num = 2 執行完,actor1 纔可以拿到鎖對象。
// 指定使用併發測試
@JCStressTest
// 預測的結果與類型,附加描述信息
@Outcome(id = {"1"}, expect = Expect.ACCEPTABLE, desc = "ok")
// 因爲sync解決有序性問題,不會有0的出現,爲了方便觀察結果,我們把4設置成能勉強接受的值
@Outcome(id = {"4"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "denger")
@Outcome(id = {"0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "denger")
// 標註需要測試的類
@State
public class TestJMM {
int num = 0;
boolean ready = false;
Object lock = new Object();
@Actor
public void actor1(I_Result r) {
synchronized (lock) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
}
@Actor
public void actor2(I_Result r) {
synchronized (lock) {
num = 2;
ready = true;
}
}
}
測試結果如下:
四、榮耀黃金
4.1 sync可重入特性
什麼是可重入呢?
即一個線程可以多次執行synchronzied重複獲取同一把鎖。 sync底層鎖對象中包含了一個計數器(recursions 變量),會記錄線程獲得了幾次鎖。 當我們同一個線程獲得了鎖,計數器則會+1,執行完同步代碼塊,計數器-1。 直到計數器的數量爲0,就釋放這個鎖對象。
public class SyncExample8 {
public static void main(String[] args) {
new MyThread().start();
}
}
class MyThread extends Thread {
@Override
public void run() {
synchronized (MyThread.class) {
System.out.println(getName() + "進入了同步代碼塊1");
synchronized (MyThread.class) {
System.out.println(getName() + "進入了同步代碼塊2");
}
}
}
}
運行結果如下,我們可以很明細的看出在輸出“同步代碼塊1”之後,不需要等待鎖釋放,即可進入第二個同步代碼塊。這樣的一個特性可以避免死鎖的發生,也可以更好的封裝代碼(即:同步代碼塊中的代碼,可以分成多個方法來寫)。
輸入結果如下:
Thread-0進入了同步代碼塊1
Thread-0進入了同步代碼塊2
4.2 sync不可中斷特性
不可中斷只指,線程二在等待線程一釋放鎖的時候,是不可被中斷的。
當一個線程獲得鎖之後,另外一個線程一直處於堵塞或者等待狀態,前一個線程不釋放鎖,後一個線程會一直被阻塞或等待,所以sync是不可中斷鎖。
public class SyncExample9 {
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable run = () -> {
synchronized (lock) {
String name = Thread.currentThread().getName();
System.out.println(name + "進入同步代碼塊");
try {
// 讓線程一持有鎖
Thread.sleep(888888L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 創建線程一先執行同步代碼快
Thread t1 = new Thread(run);
t1.start();
// 主線程睡眠一下,保證上面線程先執行
Thread.sleep(1000L);
// 後開啓線程取執
Thread t2 = new Thread(run);
t2.start();
System.out.println("開始中斷線程二");
// 強行線程二中斷
t2.interrupt();
System.out.println("線程一狀態" + t1.getState());
System.out.println("線程二狀態" + t2.getState());
}
}
當我們線程一進入同步代碼之後,一直持有鎖,並且睡眠了(也證實了sleep方法睡眠不會釋放鎖對象)。
此時線程二啓動去嘗試獲取鎖,獲取失敗之後就變成堵塞狀態,哪怕我們強行中斷線程二,最後看到線程二的狀態仍是堵塞的。
Thread-0進入同步代碼塊
開始中斷線程二
線程一狀態TIMED_WAITING
線程二狀態BLOCKED
4.3 反彙編學習sync原理
使用javap反彙編java代碼,引入monitor概念。
public class SyncExample10 {
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
synchronized (lock) {
System.out.println("1");
}
}
public synchronized void test() {
System.out.println("1");
}
}
我們使用javac、javap 兩個命令對SyncExample10來進行編譯
javac SyncExample10.java
javap -v -p SyncExample10.class
編譯後的指令就如下啦,我們主要看main方法裏面的內容,着重看 monitorenter、monitorexit 兩個指令
public static void main(java.lang.String[]) throws java.lang.InterruptedException;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2
3: dup
4: astore_1
5: monitorenter // 這裏
6: getstatic #3
9: ldc #4
11: invokevirtual #5
14: aload_1
15: monitorexit // 這裏
16: goto 24
19: astore_2
20: aload_1
21: monitorexit // 這裏
22: aload_2
23: athrow
24: return
monitorenter 指令
當我們進入同步代碼塊的時候會先執行monitorenter指令,每一個對象都會和一個monitor監視器關聯,監視器被佔用時會被鎖住,其他線程無法來獲取該monitor。當其他線程執行monitorente指令時,它會嘗試去獲取當前對象對應的monitor的所有權。
monitor裏面有兩個很重要成員變量:
owner: 當一個線程獲取到該對象的鎖,就把線程當前賦值給owner。
recursions:會記錄線程擁有鎖的次數,重複獲取鎖當前變量也會+1,當一個線程擁有monitor後,其他線程只能等待。
monitorenter執行流程如下:
1)若monitor的進入次數爲0時,線程可以進入monitor,並將monitor進入的次數(recursions)+1,當前線程成爲montiro的owner(所有者);
2)若線程已擁有monitor的所有權,允許它重入monitor,進入一次次數+1 (可重複特性);
3)若其他線程已經佔有monitor,那麼當前嘗試獲取monitor的線程會被阻塞,一直到monitor進入次數爲變0,才能重新被再次獲取。
monitorexit 指令
既然我們同步代碼塊進入時計數器會執行+1操作,那麼我們退出的時候,計數器當然要執行-1;
要注意,能夠執行monitorexit指令的線程,一定是擁有當前對象的monitor所有權的線程。 當我們執行monitorexit指令計數器減到爲0時,當前線程就不再擁有monitor所有權。其他被阻塞的線程即可再一次去嘗試獲取這個monitor的所有權。
大家仔細看看上面編譯出來的指令,其實monitoreexit是有兩個的,爲什麼呢?
因爲需要保證如果同步代碼塊執行拋出了異常,則也需要釋放鎖對象。等到下次面試官問你,synchronized如果拋異常了,會不會釋放鎖對象,答案是:會的。
ACC_SYNCHRONIZED 修飾
剛剛我們所看到的是mian方法中同步代碼塊所編譯後的指令,以下是同步方法編譯後指令
可以看到同步方法在反彙編後,會增加ACC_SYNCHRONIZED修飾,會隱式調用monitorenter、mointorexit,在執行同步方法前會調用monitorenter,在方法結束之後會調用monitorexit。
public synchronized void test();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String 1
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 19: 0
line 20: 8
五、尊貴鉑金
5.1 montior 監視器鎖
剛剛上文有提到每一個對象都會和一個monitor監視器關聯,真正的鎖都是靠monitor監視器來完成,
那monitor到底是個啥玩意呢? 小編偷偷告訴你,其實monitor是用C++所寫。
http://hg.openjdk.java.net/jdk8/jdk8/hotspot/ 網址都給你們找好了,點擊左邊zip、gz下載都行。 網速不好的同學可以在網上“hotspot 源碼下載” ,下載之後文件如下圖:
下載之後爲了方便瀏覽,小編建議你們可以去下載一個CLion工具來看代碼,或者直接用文本編輯器打開也行。
java對象怎麼和monitor關聯的呢?
這裏就牽扯到另外一個知識點,我們每一個對象在內存中分爲三塊區域:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)。而這個對象頭就包含了一個monitor的引用地址,指向了一個具體的monitor對象。
monitor裏面包含了什麼?
我們先找到monitor對象對應的源文件:/src/share/vm/runtime/objectMonitor.hpp,往下翻可以看到ObjectMonitor的構造方法,裏面有一系列成員屬性。
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 記錄線程的重入次數
_object = NULL;
_owner = NULL; // 標識擁有該monitor的線程
_WaitSet = NULL; // 存儲正處於wait狀態的線程
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 存放競爭失敗線程的單向鏈表
FreeNext = NULL ;
_EntryList = NULL ; // 存儲等待鎖block狀態的線程
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
挑幾個比較重要的來說一下:
_recursions:這個在上文講monitorenter指令的時候有提到,就是記錄線程線程獲取鎖的次數,獲取到鎖該屬性則會+1,退出同步代碼塊則-1;
_owner:當一個線程獲得了monitor的所有權,則該對象會保存到_owner中。
_WaitSet:當線程入wait狀態,則會存儲到_WaitSet當中。
_cxq :當線程之間開始競爭鎖,如果鎖競爭失敗後,則會加入_cxq鏈表中。
_EntryList:當新線程進來嘗試去獲取鎖對象,又沒有獲取到對象的時候,則會存儲到_EntryList當中。
5.2 monitor 競爭
什麼情況下會競爭?
當多個線程執行同步代碼塊的時候,這個時候就會出現鎖競爭。
當線程執行同步代碼塊時,先執行monitorenter指令, 這個時候會調用interpreterRuntime.cpp中的函數
源文件如下:src/share/vm/interpreter/interpreterRuntime.cpp,搜索:monitorenter
IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
// 代碼省略
// 是否用偏向鎖
if (UseBiasedLocking) {
// Retry fast entry if bias is revoked to avoid unnecessary inflation
ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
} else {
// 重量級鎖
ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
}
// 代碼省略
IRT_END
線程之間如何競爭鎖的?
對於重量級鎖,monitorenter函數中會調用 :ObjectSynchronizer::slow_enter,
最終調用到這個函數上:ObjectMonitor::enter,源碼位於:/src/share/vm/runtime/objectMonitor.cpp
void ATTR ObjectMonitor::enter(TRAPS) {
// The following code is ordered to check the most common cases first
// and to reduce RTS->RTO cache line upgrades on SPARC and IA32 processors.
Thread * const Self = THREAD ;
void * cur ;
// 1、通過CAS操作嘗試把monitor的_owner設置成當前線程
cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;
if (cur == NULL) {
assert (_recursions == 0 , "invariant") ;
assert (_owner == Self, "invariant") ;
return ;
}
// 2、重入鎖
if (cur == Self) {
// 重入鎖計數器也需要+1
_recursions ++ ;
return ;
}
// 3、如果是當前線程第一次進入該monitor
if (Self->is_lock_owned ((address)cur)) {
assert (_recursions == 0, "internal state error");
// 計數器+1
_recursions = 1 ;
// 把當前線程設置賦值給_owner
_owner = Self ;
OwnerIsThread = 1 ;
return ;
}
// TODO-FIXME: change the following for(;;) loop to straight-line code.
for (;;) {
jt->set_suspend_equivalent();
// 4、獲取鎖失敗,則等待鎖釋放
EnterI (THREAD) ;
if (!ExitSuspendEquivalent(jt)) break ;
_recursions = 0 ;
_succ = NULL ;
exit (false, Self) ;
jt->java_suspend_self();
}
}
此處省略了鎖的自旋優化等操作,文章後面會講到
以上代碼具體的操作流程如下:
1)通過CAS嘗試把monitor的_owner屬性設置爲當前線程
2)如果之前設置的owner等於當前線程,說明當前線程再次進入monitor,即重入鎖,執行_recursions ++ ; 記錄重入次數。
3)如果當前線程是第一次進入monitor,設置_recursions = 1,_owner = 當前線程,該線程成功獲得鎖並返回。
4、如果獲取鎖失敗,等待鎖釋放
5.3. monitor 等待
上文有提到,如果鎖競爭失敗後,會調用EnterI (THREAD) 函數,還是在objectMonitor.cpp源碼中搜索:::EnterI
以下代碼小編省略了部分:
void ATTR ObjectMonitor::EnterI (TRAPS) {
Thread * Self = THREAD ;
assert (Self->is_Java_thread(), "invariant") ;
assert (((JavaThread *) Self)->thread_state() == _thread_blocked , "invariant") ;
// 嘗試獲取鎖
if (TryLock (Self) > 0) {
assert (_succ != Self , "invariant") ;
assert (_owner == Self , "invariant") ;
assert (_Responsible != Self , "invariant") ;
return ;
}
// 自旋操作嘗試獲取鎖
if (TrySpin (Self) > 0) {
assert (_owner == Self , "invariant") ;
assert (_succ != Self , "invariant") ;
assert (_Responsible != Self , "invariant") ;
return ;
}
// 當前線程封裝成ObjectWaiter對象node,狀態設置成ObjectWaiter::TS_CXQ
ObjectWaiter node(Self) ;
Self->_ParkEvent->reset() ;
node._prev = (ObjectWaiter *) 0xBAD ;
node.TState = ObjectWaiter::TS_CXQ ;
// 通過CAS把node節點push到_cxq隊列中
ObjectWaiter * nxt ;
for (;;) {
node._next = nxt = _cxq ;
if (Atomic::cmpxchg_ptr (&node, &_cxq, nxt) == nxt) break ;
// Interference - the CAS failed because _cxq changed. Just retry.
// As an optional optimization we retry the lock.
// 再次嘗試獲取鎖
if (TryLock (Self) > 0) {
assert (_succ != Self , "invariant") ;
assert (_owner == Self , "invariant") ;
assert (_Responsible != Self , "invariant") ;
return ;
}
}
// 掛起線程
for (;;) {
// 掛起之前再次嘗試獲取鎖
if (TryLock (Self) > 0) break ;
assert (_owner != Self, "invariant") ;
if ((SyncFlags & 2) && _Responsible == NULL) {
Atomic::cmpxchg_ptr (Self, &_Responsible, NULL) ;
}
// park self
if (_Responsible == Self || (SyncFlags & 1)) {
TEVENT (Inflated enter - park TIMED) ;
Self->_ParkEvent->park ((jlong) RecheckInterval) ;
// Increase the RecheckInterval, but clamp the value.
RecheckInterval *= 8 ;
if (RecheckInterval > 1000) RecheckInterval = 1000 ;
} else {
TEVENT (Inflated enter - park UNTIMED) ;
// 通過park將當前線程掛起,等待鎖釋放
Self->_ParkEvent->park() ;
}
// 嘗試獲取鎖
if (TryLock(Self) > 0) break ;
}
return ;
}
以上代碼具體流程概括如下:
1)進入EnterI後,先會再次嘗試獲取鎖對象
2)把當前線程封裝成ObjectWaiter對象node,狀態設置成ObjectWaiter::TS_CXQ ;
3)在for循環中,通過CAS把node節點push到_cxq(上文有提到這個屬性)列表中,同一時刻可能有多個線程把自己到node節點push到_cxq列表中。
4)node節點push到_cxq 列表之後,通過自旋嘗試獲取鎖,如果還是沒有獲取到鎖,則通過park將當前線程掛起,等待喚醒。
5)當前線程被喚醒時,會從掛起到點繼續執行,通過TryLock再次嘗試鎖。
5.4 monitor 釋放
什麼時候會釋放monitor?
當線程執行完同步代碼塊時,調用monitorexit指令釋放鎖,這個時候鎖就會被釋放。
還是在objectMonitor.cpp源碼中搜索:::exit
釋放monitor過程是什麼?
exit函數代碼如下,當然小編也有大部分的刪減,留下比較主要的代碼部分。
void ATTR ObjectMonitor::exit(bool not_suspended, TRAPS) {
// 判斷計數器,不等於0則執行-1
if (_recursions != 0) {
_recursions--; // this is simple recursive enter
TEVENT (Inflated exit - recursive) ;
return ;
}
// w = 最後被喚醒的線程
ObjectWaiter * w = NULL ;
int QMode = Knob_QMode ;
// QMode == 2,會繞過EntryList隊列,從cxq隊列中獲取線程用於競爭鎖
if (QMode == 2 && _cxq != NULL) {
w = _cxq ;
assert (w != NULL, "invariant") ;
assert (w->TState == ObjectWaiter::TS_CXQ, "Invariant") ;
// 喚醒線程
ExitEpilog (Self, w) ;
return ;
}
// QMode還有還好幾種策略,小編就不一一列舉了
// 最後拿到了要被喚醒的線程
w = _EntryList ;
if (w != NULL) {
guarantee (w->TState == ObjectWaiter::TS_ENTER, "invariant") ;
// 喚醒線程
ExitEpilog (Self, w) ;
return ;
}
}
觀察以上代碼,都需要調用ExitEpilog函數來喚醒線程, 還是在objectMonitor.cpp源碼中搜索:::ExitEpilog
void ObjectMonitor::ExitEpilog (Thread * Self, ObjectWaiter * Wakee) {
assert (_owner == Self, "invariant") ;
_succ = Knob_SuccEnabled ? Wakee->_thread : NULL ;
ParkEvent * Trigger = Wakee->_event ;
Wakee = NULL ;
// Drop the lock
OrderAccess::release_store_ptr (&_owner, NULL) ;
OrderAccess::fence() ; // ST _owner vs LD in unpark()
if (SafepointSynchronize::do_call_back()) {
TEVENT (unpark before SAFEPOINT) ;
}
DTRACE_MONITOR_PROBE(contended__exit, this, object(), Self);
// 最重要的時候這裏,調用unpark來進行喚醒
Trigger->unpark() ;
// Maintain stats and report events to JVMTI
if (ObjectMonitor::_sync_Parks != NULL) {
ObjectMonitor::_sync_Parks->inc() ;
}
}
以上代碼具體流程概括如下:
1)退出同步代碼塊時會讓_recursions - 1,當_recursions的值等於0的時候,說明線程釋放了鎖。
2)根據不同的策略(由QMode來指定),最終獲取到需要被喚醒的線程(代碼中是:w)
3)最後調用ExitEpilog函數中,最終由unpark來執行喚醒操作。
六、永恆鑽石
6.1 CAS 介紹
CAS的英文單詞CompareAndSwap的縮寫,比較並替換。CAS需要有3個操作數:內存地址V、舊的預期值A、即將要更新的目標值B。
CAS指令執行時,當內存地址V的值與預期值A相等時,將目標值B保存到內存當中,否則就什麼都不做。 整個比較並替換的操作是一個原子操作。
CAS是樂觀鎖技術,當多個線程嘗試使用CAS同時更新同一個變量時,只有其中一個線程能更新變量的值,而其他線程都失敗,失敗的線程並不會掛起,而是被告知這次競爭失敗,並可以再次嘗試。
優點:可以避免優先級倒置和死鎖等危險,競爭比較便宜,協調發生在更細的力度級別,允許更高程度的並行機制等等。
缺點:
1、循環時間長開銷很大,如果CAS失敗,會一直進行嘗試,如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷。
2、只能保證一個共享的原子操作,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖來保證原子性。
3、ABA問題,如果內存地址V初次讀取的值是A,並且在準備賦值的時候檢查仍然爲A,那我們就能說它的值沒有被其他線程改變過嗎?
如果在這段期間它的值曾被改成了B,後來又被改回A,那CAS就會誤認爲它從來沒有被改變過,這個漏洞稱之爲CAS操作的ABA問題。Java併發包爲了解決這個問題,提供了一個帶有標記的原子引用類 “AtomicStampendReference”,它可以通過控制變量值的版本來保證CAS的正確性。
因此,在使用CAS前要考慮清楚“ABA”問題是否會影響程序併發性的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能比原子類更高效
介紹完CAS,那麼肯定就多多少少介紹以下實現原理,我們以AtomicInteger爲例,它是JDK中提供能夠保障原子性操作的類。
/**
* Atomically increments by one the current value.
*
* @return the updated value
*/
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
我們點進去看它裏面的方法,拿incrementAndGet方法爲例子,這個方法是在原有值的基礎上進行+1操作,它的實現調用Unfafe類的方法,我們再點進去看。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
Unfafe類使Java擁有了像C語言的指針一樣操作內存空間的能力,同時也帶來了指針問題,過度的使用Unsafe類會使得出錯的機率變大。因此Java官方不建議使用的,Unsafe對象也不能直接調用,只能通過放射來獲取。
小編這裏說一下getandAddInt方法的執行流程,
var1:傳進來的是this,也就是AtomicInteger實例對象;
var2:偏移量,通過結合var1就能夠獲得在內存中的最新值;
var4:要進行累加的值,也就是 1 ;
先通過var1+var2 獲取到內存中最新的值,然後再調用compareAndSwapInt方法,這個方法又會通過var1+var2參數獲取內存中最新的值,與var5的值進行比較,如果比較成功,這把var5+var4的結果更新到內存中去。如果不成功,則繼續循環操作。也就是我們剛剛介紹CAS所說,比較並替換。
6.2 sync 鎖升級過程
在JDK1.5以前,sync是一個重量級的鎖,在1.6以後,對sync做了大量的各種優化,包含偏向鎖、輕量級鎖、適應性自旋、鎖消除、鎖粗化等等,這些技術都是爲了線程之間更加高效的共享數據,以及解決競爭問題,從而達到程序的執行效率。
當然鎖肯定升級的過程:無鎖 —— 偏向鎖 —— 輕量級鎖 —— 重量級鎖。
每個不同的鎖都有不同的使用藏場景,在瞭解各種鎖的特性之前,我們還需要搞清楚對象在內存中的佈局!
6.3 對象的佈局
我們每一個對象在內存中分爲三塊區域:對象頭(Header)、實例數據(Instance Data)、對齊填充(Padding)。
對象頭:
當一個線程嘗試訪問sync修飾的代碼塊時,它先要獲得鎖,這個鎖對象是存在對象頭中的。
以Hotspot虛擬機爲例,對象頭裏面主要包含了Mark Word(字段標記)、Klass Pointer (指針類型),如果對象是數組類型,還包含了數組的長度。
怎麼又扯到Hotspot虛擬機呢? 小夥伴可以這樣理解,JVM可以理解爲一套規範,而Hotspot是具體的虛擬機產品。 就好比如你們要找女朋友、或者男朋友,既然找朋友是不是就要有一定的要求或者規範,JVM就可以看作這個規範,而Hotspot就是具體的男朋友或者女朋友了。
你不信? System.out.println(System.getProperties()); 運行這個代碼吧,找找你們java.vm.name等於什麼。
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
Mark Word :裏默認存儲對象的HashCode、分代年齡和鎖位標記。 這個也是sync鎖實現的重要部分了,在運行期間,Mark Word 裏存儲的數據會隨着鎖標位置的變化而變化。 在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如圖:
鎖狀態 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
cms_free | 分代年齡 | 偏向鎖 | 鎖位標識 | |||
無鎖 | unused | HashCode | 0 | 01 | ||
偏向鎖 | ThreadID(54bit)、Epoch(2bit) | 1 | 01 | |||
輕量級鎖 | 指向佔中鎖記錄的指針 | 00 | ||||
重量級 | 指向互斥量(重量級鎖)的指針 | 10 |
以上這個表格數據不能亂來對不對,我們可以查看源碼:src/share/vm/oops/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)
Klass Pointer :用於存儲對象的類型指針,該指針指向它的類元數據,JVM通過這個指針確定是哪個對象的實例。
對象頭 = Mark Word + Klass Point 在未開啓指針壓縮對情況下所佔大小:
以64位系統爲例:Mark Word = 8 bytes,指針類型 = 8 bytes ,對象頭 = 16 bytes = 128bits;
實例數據:
類中定義的成員變量
對齊填充:
對齊填充並不是必然存在的,也沒有什麼特殊的意義,它僅僅只是佔位符的作用。由於HotPort VM的自動內存管理系統要求對象起始地址必須是8字節的整倍數,當對象的實例數據部分沒有對齊時,就需要通過對齊填充來不補齊。
說了這麼多,都是概念性的東西,說誰不會說對不對,接下來我們嘗試在把一個對象在內存中都佈局輸出看下:
先引入這個jar包,它能夠提供我們想要看到的東西,使用方式如下:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
public class SyncExample4 {
static Apple apple = new Apple();
public static void main(String[] args) {
// 這裏使用ClassLayout來查看
System.out.println(ClassLayout.parseInstance(apple).toPrintable());
}
}
class Apple {
private int count;
private boolean isMax;
}
以下內容就是我們Java對象內存分佈所查看到的內容,我們能直接看到內容有object header 翻譯過來就是對象頭呀, 再往下看就是loss due to the next object alignment,這個就是對齊填充,由於Apple 有一個boolean的屬性,佔了一個字節,所以計算機爲了提高執行效率和GC垃圾回收的效率,進行了7個字節的填充(這裏涉及到CPU運行小編就不多扯了)。
com.example.concurrency.sync.Apple object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c0 00 f8 (01000011 11000000 00000000 11111000) (-134168509)
12 4 int Apple.count 0
16 1 boolean Apple.isMax false
17 7 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total
看到這裏我們確實能夠確定對象頭的存在,那麼對象頭裏面不是說用31 bit存儲了HashCode嗎? 怎麼沒看見
我們再來執行一段代碼, 計算一下apple的HashCode是多少,看運行結果可知,本次運行apple的HashCode是7ea987ac,我們再看看對應VALUE值也發生了改變。這裏有一個概念,由於存在大小端存儲方式,我們需要從後往前看。
public class SyncExample4 {
static Apple apple = new Apple();
public static void main(String[] args) {
// 查看HashCode
System.out.println(Integer.toHexString(apple.hashCode()));
System.out.println(ClassLayout.parseInstance(apple).toPrintable());
}
}
class Apple {
private int count;
private boolean isMax;
}
7ea987ac
# WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
com.example.concurrency.sync.Apple object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 ac 87 a9 (00000001 10101100 10000111 10101001) (-1450726399)
4 4 (object header) 7e 00 00 00 (01111110 00000000 00000000 00000000) (126)
8 4 (object header) 43 c0 00 f8 (01000011 11000000 00000000 11111000) (-134168509)
12 4 int Apple.count 0
16 1 boolean Apple.isMax false
17 7 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 7 bytes external = 7 bytes total
細心一點的小夥伴就會發現,上文不是說了對象頭一共佔了16個字節嗎? 這裏三個object header 才12個字節也不對呀?
這裏JVM默認會開啓指針壓縮,我們可以通過參數把它關掉:
在打印看結果,就是16個字節。
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 80 68 f5 1f (10000000 01101000 11110101 00011111) (536176768)
12 4 (object header) 02 00 00 00 (00000010 00000000 00000000 00000000) (2)
最後總結以下: Java對象有三個部分組成:對象頭、實例數據、對齊填充,其中對象頭又包含Mark Word、Klass Pointer(如果對象是數組類型,還包含了數組的長度)。
七、至尊星耀
鎖狀態 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
cms_free | 分代年齡 | 偏向鎖 | 鎖位標識 | |||
無鎖 | unused | HashCode | 0 | 01 | ||
偏向鎖 | ThreadID(54bit)、Epoch(2bit) | 1 | 01 | |||
輕量級鎖 | 指向佔中鎖記錄的指針 | 00 | ||||
重量級 | 指向互斥量(重量級鎖)的指針 | 10 |
7.1 偏向鎖
偏向鎖的原理
在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一個線程多次獲得。爲了讓線程獲得的鎖的代價更低,從而引入偏向鎖的。
我們對照Mark Word存儲結構來看,當一個線程訪問同步代碼快之後,會把Mark Word中的偏向鎖標識由0改爲1,並且存儲當前線程的ID,以後該線程進入和退出同步代碼的的時候,則不需要進行CAS操作來加鎖和解鎖。只需要簡單的測試一下對象頭裏是否存儲着指向當先線程的偏向鎖,如果結果成功,表示線程已經獲得了鎖。如果失敗,需要再查看Mark Word中的偏向鎖標識是否設置成1,如果沒有,則使用CAS競爭鎖。
我們可以使用代碼來觀察下:
偏向鎖在Java 6 和Java 7中默認是開啓的,但是他在應用程序啓動幾秒鐘之後才激活,我們需要先來關閉延遲啓動。
public class SyncExample4 {
public static void main(String[] args) {
Apple apple = new Apple();
apple.start();
}
}
class Apple extends Thread {
private Object lock = new Object();
@Override
public void run() {
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 d8 86 22 (00000101 11011000 10000110 00100010) (579262469)
4 4 (object header) 9c 7f 00 00 (10011100 01111111 00000000 00000000) (32668)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
由於大小端存儲,原本偏向鎖和鎖位標識是在最後的,現在我們需要看最前8位數:00000101
第一個1 代表是偏向鎖,並且鎖標識01,和我們的表格也能夠對應上。
偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現了才釋放鎖的機制,所以當其他線程來進行爭奪鎖的時候,持有偏向鎖的線程纔會釋放鎖。但是偏向鎖的撤銷的時候,需要等到一個全局安全點,也就是在這個時間點上沒有正在執行的字節碼。 它首先會暫停所有線程(包括擁有偏向鎖的線程),然後在判斷當前是不是偏向鎖,如果偏向鎖標識等於1,就撤銷回0;
偏向鎖的好處
偏向鎖的好處也很顯而易見,只有同一個線程來訪問同步代碼塊的時候,效率是很高的,只需要判斷當先線程和Mark Word裏面存儲的線程是否是一致就行了。如果程序中大多數的鎖都是不同的線程來進行訪問,那麼這個時候偏向鎖就是多餘的了。
我們可以通過JVM參數來關閉偏向鎖:-XX:-UseBiasedLocking
7.2 輕量級鎖
什麼是輕量級鎖
輕量級鎖是在JDK6中加入的新型鎖機制,引入輕量級鎖的目的是爲了,在多線程交替執行同步代碼塊的情況下,儘量避免重量級鎖引起的性能消耗,但是如果多線程在同一時刻進入臨界區,會導致輕量級鎖膨脹升級爲重量級鎖,所以輕量級鎖的出現並非代替重量級鎖。
棧楨
我們在JVM虛擬中,有堆和棧,而在棧中還包含了我們對象的各種方法,一個方法就相當於一個“棧楨”。其中方法中也是可以存儲內容的,其中就包含了Displaced Mark Word,這個有什麼作用呢? 接着往下看
輕量級鎖原理
線程在執行同步代碼快之前,JVM會現在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word 複製到鎖記錄當中。這個就是我們剛剛所說Displaced Mark Word了。JVM利用CAS操作嘗試將對象的Mark Word更新爲指向鎖記錄的指針。如果成功,當先線程獲得鎖並且將鎖位標識改爲00,如果失敗了則需要判斷當前對象的Mark Word是否指向當前線程的指針,如果是則表示當線程已經持有對象的鎖,執行同步代碼快。如果不是隻能說明該鎖對象被其他線程佔用,這時的輕量級需要膨脹到重量級鎖,鎖位標識改爲10,後面的線程進入阻塞狀態。
輕量級鎖的釋放
解鎖的時候,會使用CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。
7.3 自旋鎖
自旋鎖是在JDK1.4中就已經引入了,默認是關閉的,在JDK1.6中默認幾句開啓了。
爲什麼要用自旋鎖呢?自旋鎖通俗易懂的來說,就是循環去獲取鎖。 因爲在我們鎖升級的過程中,如果線程競爭鎖失敗,就立即被掛起,然後等待被喚醒,其實這個時候性能開銷是比較大的。可能線程還正在被掛起的時候,鎖已經被釋放掉了,所以就有了自旋鎖的操作。
當線程競爭鎖失敗之後,先自旋來嘗試獲取鎖,如果鎖被佔用的時間很短,自旋等待的效果就非常好。反之,如果鎖被佔用的時間很長,那麼自旋的線程只會拜拜消耗處理器資源,而不會有任何的作用。自旋默認的默認值是10次,可使用參數-XX:PreBlockSpin來更改。
適應性自選鎖
由於我們自旋鎖可能迴帶來一定的性能消耗,但是我們又不清楚設置自旋次數多少合適,所以這個時候適應性自選鎖就來了。適應性自選就意味着自旋的時間不再固定了,而是由前一次在同一個鎖的自旋時間及所得擁有者的狀態來決定。假設在同一個同步代碼塊上自旋10次就能獲得鎖,那麼虛擬機就會認爲這次也能夠獲得鎖,還允許自旋的時間稍微長一點。 那麼再假設一個同步代碼塊從來都沒有自旋成功過,那麼虛擬機就可能省略自旋的過程,以免浪費性能。
光說還不如來點實際的代碼,源碼路徑:src/share/vm/runtime/objectMonitor.cpp ,搜索::TrySpin_VaryDuration
int ObjectMonitor::TrySpin_VaryDuration (Thread * Self) {
// 固定自旋次數
int ctr = Knob_FixedSpin ;
if (ctr != 0) {
while (--ctr >= 0) {
if (TryLock (Self) > 0) return 1 ;
SpinPause () ;
}
return 0 ;
}
// 適應式自旋
for (ctr = Knob_PreSpin + 1; --ctr >= 0 ; ) {
if (TryLock(Self) > 0) {
// 成功後,修改自旋的時間
int x = _SpinDuration ;
if (x < Knob_SpinLimit) {
if (x < Knob_Poverty) x = Knob_Poverty ;
_SpinDuration = x + Knob_BonusB ;
}
return 1 ;
}
SpinPause () ;
}
}
7.4 消除鎖
我們先來看以下代碼:
public String getContent() {
return new StringBuffer().append("a").append("b").append("c").toString();
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
StringBuffer中的append是同步的,但是我們這個getContent這個方法,每次都是新new一個對象來進行操作。所以不同的線程進來,鎖住的對象也是不同的,所以就根本不會造成線程上的問題。 這個時候虛擬機即使編譯器(JIT)在運行時,對一些代碼上的要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除,這個就是鎖消除。
7.5 鎖粗化
什麼是鎖粗化呢? JVM會探測一連串細小的操作都是用同一個對象加鎖,將同步代碼塊的範圍放大,放到這串操作的外面,這樣只需要加一次鎖即可。
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 100; i++) {
sb.append("a");
}
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
看上面代碼,StringBuffer的append的方法裏面是有加同步關鍵字的,然而我們在外面循環了100次,就要進入鎖和退出鎖各100次,所以這個時候JVM就會把鎖粗化。 把append方法同步關鍵字去掉,擴大在外面來,就只需要進入和退出1次即可。
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
synchronized (sb) {
for (int i = 0; i < 100; i++) {
sb.append("a");
}
}
}
八、最強王者
終章:平時寫代碼如何對synchroized優化
終於打上王者了,不要以爲打上王者就行啦,還有一些日常操作我們還需要注意到的。
減少sync的同步代碼塊的範圍:
同步代碼塊精簡,執行就會更快,可能輕量級鎖、自旋鎖就搞定了,不會升級爲重量級鎖。
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
synchronized (sb) {
System.out.println("a");
}
}
降低sync鎖的粒度:
鎖的對象也是有講究的,假設test01和02本身沒有任何業務相關的代碼,但是鎖的對象越是同一個,這樣豈不是併發效率就很低了。
public class SyncExample4 {
public void test01(){
synchronized (SyncExample4.class){}
}
public void test02(){
synchronized (SyncExample4.class){}
}
}
讀寫分離:
我們儘量可以做到,讀的時候不加鎖,寫入和刪除的時候加鎖,這樣就可以保證多個線程同時來讀取數據。
舉個例子:
HashTable容器競爭激烈的併發環境下,效率低是因爲多個線程競爭同一把鎖,假如容器有多把鎖,每一把鎖用於鎖住容器中一部分數據,那麼多線程訪問容器裏面不同的數據段的數據時,線程間不會存在鎖競爭,從而有效提高併發訪問率。這就是ConcurrentHashMap的鎖分段技術,將數據分成一段一段的存儲,然後把每一段數據分配一把鎖,當一個線程佔用鎖訪問其中一段數據的時候,其他段段數據也能被其他線程訪問。
小編我終於寫完了,溫馨提示光看一遍印象不會特別深刻,最好能夠實際動手操作以下,看下源碼如何實現之類的。