前言
本文將對常用的synchronized圍繞常見的一些問題進行展開。以下為我們將圍繞的問題:
- 樂觀鎖和悲觀鎖?
- synchronized的底層是怎么實現的?
- synchronized可重入是怎么實現的?
- synchronized鎖升級?
- synchronized是公平鎖還是非公平鎖?
- synchronized和volatile對比有什么區別?
- synchronized在使用時有何需要注意事項?
注意:下面都是在JDK1.8中進行的。
樂觀鎖和悲觀鎖?
關於樂觀鎖和悲觀鎖的定義和使用場景,可以看《Mysql InnoDB之各類鎖》中,本質都是一樣的,這里就不再贅述。
關於悲觀鎖,下面再進行介紹,synchronized和Lock都屬於悲觀鎖,下面我們來具體看看樂觀鎖。
樂觀鎖的實現-CAS
樂觀鎖的核心就是CAS(Compare And Swap-比較與交換,是一種不搶占的同步方式),是一種無鎖算法。CAS算法涉及三個操作數:
- 需要讀寫的內存值V。
- 進行比較的值A。
- 要寫入的新值B。
當前僅當當前內存值V等於值A時,才進行寫入新值B,有人會問我在比較相等后的同時更新了值V咋辦?寫入的新值B不是覆蓋了別人剛寫入的值嗎?是的比較和寫入需要保證是一個原子操作,這里通過CPU的cmpxchg指令,去比較寄存器中的A和內存中的值V,如果相等的話就寫入B,如果不等的話就值V賦值給寄存器中的值A,如果想繼續自旋就繼續不想繼續可以拋出相應錯誤。
下面我們來看看常見的AtomicInteger是如何自旋的。
AtomicInteger
一個可以被原子更新的int值,關於原子變量屬性描述具體可以參考{@link java.util.concurrent.automic}包。AutomicInteger用於原子遞增計數器等應用程序中,不能被使用替代Integer。然而這個類擴展了Number允許被一些處理數值的工具或者公共代碼統一訪問。
字段和構造函數
package java.util.concurrent.atomic;
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// 設置使用Unsafe.compareAndSwapInt進行更新。
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
// 獲得value對象內存分布中的偏移量用於找到value
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// 保證內存可見效和禁止指令重排。
private volatile int value;
public AtomicInteger(int initialValue) {
value = initialValue;
}
/**
* 初始值為0
*/
public AtomicInteger() {
}
incrementAndGet
/**
* 以原子方式將當前值遞增1
* @return 更新后的值
*/
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
復制代碼
var1:為AtomicInteger對象,用於unsafe結合valueOffset獲得對象中的最新的value。
var2:value值在AtomicInteger對象中偏移量。
var4:增加的值為1。
package sun.misc;
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
//獲得AutomicInteger的value值
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
樂觀鎖的缺點
- 如果並發比較高,CAS一直比較自旋,將會一直占用CPU,如果自旋的線程多了CPU就會飆升。
- 只能保證一個共享變量的原子操作。對一個共享變量執行操作時,CAS能保證原子操作,但是對多個共享變量操作時,CAS時無法保證操作的原子性的。
- java從1.5開始JDK提供了AtomicReference類來保證引用之間的原子性,可以把多個變量放在一個對象中來進行CAS操作。
synchronized的底層是怎么實現的?
sychronized是通過對象頭部的Mark Word中的鎖標識+monitor實現的。
java對象頭
對象頭由Mark Word和Klass組成,在沒有壓縮指針的時候都占8個字節。
- Mark Word:標記字段-運行時數據,如哈希碼、GC信息以及鎖信息。
- Klass:對象鎖代表的類的元數據指針。
鎖標志位+是否是偏向鎖(biased_lock)共同表示對象的幾種狀態
monitor
synchronized通過Monitor來實現線程同步和協作。
- 同步依賴的是操作系統的Mutex(互斥鎖量)只有擁有互斥量的線程才能進入臨界區,不擁有的只能阻塞等待,會維護一個阻塞的隊列。
- 協作依賴的是synchronized持有的對象,對象可以讓多個線程方便同步,還可以通過對象調用wait方法釋放鎖讓線程進入等待隊列,等其他線程調用對象的notify和notifyAll方法進行喚醒可以重新獲取鎖。
Monitor用來進行監聽鎖的持有和調度鎖的持有的。持有的對象可以理解為鎖的一個媒介,可以使用它方便操作同步和協作。
具體例子可以參考《Thread源碼閱讀及相關問題》中的例子。
monitor這套監聽鎖和調度鎖包括使用的互斥量其實都是比較消耗資源的,所以使用它的成為“重量級鎖”。JDK 6中為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,下面我們分別會進行分配介紹。
無鎖
當對象頭中鎖標志位為01,是否偏向鎖為0時表示使無鎖的狀態。想要在無鎖的時候實現同步可以使用上面樂觀鎖中實現-CAS。
偏向鎖
偏向鎖是一個鎖優化的產物,在對象頭中進行標記,表示有線程進入了臨界區,在只有一個線程訪問的時候既不用使用CAS也不用引入較重的monitor。
線程不會主動釋放偏向鎖,只有遇到其他線程進嘗試競爭偏向鎖時,需要等待全局安全點(在這個時間點上沒有執行的字節碼),它會首先暫停擁有偏向鎖的的線程,判斷它是否還活着,如果死亡了就恢復到無鎖狀態其他線程就可以占用,如果還在臨界區就對鎖進行升級成"輕量鎖"。
每個線程進入臨界區的時候都會查看對象頭鎖標識是否是偏向鎖,是偏向鎖的話,會判斷當前線程和對象頭中的線程id是同一個線程則直接進入,如果不是則進行CAS看是不是能比較替換成功(防止馬上就釋放了),如果沒成功就會暫停持有偏向鎖的線程,看線程是否已經不再用鎖了,如果沒用就釋放,給新進來的線程占用,如果在用就進行鎖升級生成輕量鎖"。
可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序會默認進入輕量級鎖狀態。 筆者思考
可以發現在只有一個線程進入臨界區的時候確實能避免使用互斥量帶來的開銷,但是可以發現線程不會主動釋放偏向鎖。為啥不當有偏向鎖的時候離開臨界區進行釋放?還要等其他線程來的時候要等全局點的時候嘗試對線程暫停之后再看該線程持有鎖的狀態?這些疑問我們考慮不全,可能是設計的問題,也可能是因為一些其他原因,我們對JVM源碼不夠熟悉的情況下會比較費解。或許后面迭代會進行優化。
所以我們只需要明白一點:偏量鎖是一種鎖的優化,它本質上不是鎖,只是對象頭中進行了標記,如果沒有多線程並發訪問臨界區的時候可以減少開銷,如果出現多並發的時候會進行升級。
輕量級鎖
輕量鎖發生在偏向鎖升級或者-XX:-UseBiasedLocking=false
的時候,線程在執行同步塊之前,JVM會現在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaces Mark Word。然后線程嘗試使用CAS將對頭像中的Mark Word替換為之指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗將通過自旋來進行同步。
重量級鎖
重量鎖的鎖標志位為10,就是上面介紹的monitor機制,開銷最大。
synchronized鎖升級?
如上面的synchronized的底層實現章節。
synchronized可重入是怎么實現的?
我們先用代碼證明下:
public class SynchronizedReentrantTest extends Father {
public synchronized void doSomeThing1() {
System.out.println("doSomeThing1");
doSomeThing2();
}
public synchronized void doSomeThing2() {
System.out.println("doSomeThing2");
super.fatherDoSomeThing();
}
public static void main(String[] args) {
SynchronizedReentrantTest synchronizedReentrantTest = new SynchronizedReentrantTest();
synchronizedReentrantTest.doSomeThing1();
}
}
class Father {
public synchronized void fatherDoSomeThing() {
System.out.println("fatherDoSomeThing");
}
}
復制代碼
輸出:
doSomeThing1
doSomeThing2
fatherDoSomeThing
說明synchronized是可重入的。
重量級鎖使用的是monitor對象中的計數字段來實現的,偏向鎖應該沒有只有表示當前被那個線程持有,輕量鎖在每次進入的時候都會添加一個Lock Record來表示鎖的重入次數。
筆者思考
為啥偏向鎖不記錄重入次數,重入的時候只需要看是否是當前線程,對象頭中沒有地方存放次數,所以偏向鎖不會主動釋放(應該是判斷嵌套臨界區比較麻煩),需要另外一個線程來判斷當前線程是否活躍死亡了才釋放還會嘗試暫停持有的線程。這點其實不如輕量級鎖和重量級鎖。
synchronized是公平鎖還是非公平鎖?
非公平的,直接下面打飯的例子:
import lombok.SneakyThrows;
public class SyncUnFairLockTest {
//食堂
private static class DiningRoom {
//獲取食物
@SneakyThrows
public void getFood() {
System.out.println(Thread.currentThread().getName() + ":排隊中");
synchronized (this) {
System.out.println(Thread.currentThread().getName() + ":@@@@@@打飯中@@@@@@@");
Thread.sleep(200);
}
}
}
public static void main(String[] args) {
DiningRoom diningRoom = new DiningRoom();
//讓5個同學去打飯
for (int i = 0; i < 5; i++) {
new Thread(() -> {
diningRoom.getFood();
}, "同學編號:00" + (i + 1)).start();
}
}
}
輸出:
同學編號:001:排隊中
同學編號:001:@@@@@@打飯中@@@@@@@
同學編號:005:排隊中
同學編號:003:排隊中
同學編號:004:排隊中
同學編號:002:排隊中
同學編號:002:@@@@@@打飯中@@@@@@@
同學編號:004:@@@@@@打飯中@@@@@@@
同學編號:003:@@@@@@打飯中@@@@@@@
同學編號:005:@@@@@@打飯中@@@@@@@
注意到這里我加了sleep,因為對於公平鎖來說無所謂,先來的肯定先執行,但是非公平鎖時后面來的線程會先進行嘗試獲得鎖,獲取不到再進入隊列,這樣就能避免同一進入隊列再被CPU喚醒,能提高效率,但是非公平鎖會出現餓死的情況。
synchronized和volatile對比有什么區別?
都能保證可見效,synchronized因為是鎖所以能保證原子性。
可見效主要指的是線程共享時工作內存和主內存能否及時同步。
JMM關於synchronized的兩個規定:
- 線程解鎖前,必須把共享變量的最新值刷新到主內存中。
- 線程加鎖時,將清空工作內存中共享變量的值,從而使變量共享時,需要從主內存中重新讀取最新的值。