4 千變萬化的鎖
4.1 Lock 接口
鎖是一種工具,用于控制對共享資源的訪問,我們已經有了 synchronized 鎖,為什么還需要 Lock 鎖呢?
synchronized 鎖存在以下問題:
- 效率低:試圖獲取鎖時不能設定超時,會一直等待
- 不夠靈活:加鎖釋放鎖時機單一
- 無法知道是否已經獲取了鎖
4.2 Lock 常用 5 個方法
在Lock 中聲明了 4 個方法來獲取鎖:
-
lock()
獲取鎖,如果鎖被其他線程獲取,則進行等待。Lock 不會像synchronized 自動釋放鎖,即使發生異常,也能爭取釋放鎖,Lock 需要在 finally 中釋放鎖,以保證在發生異常時鎖被正確釋放。lock()
方法不能被中斷,獲取不到鎖則會一直等待,一旦陷入死鎖lock()
會進入永久等待。 -
unlock()
釋放鎖,如果當前線程沒有持有該鎖調用該方法會拋出 IllegalMonitorStateException 異常
public class LockDemo {
public static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
LockDemo lockDemo = new LockDemo();
// 只有第一個線程能獲取鎖,另一個線程會一直等待
// new Thread(() -> lockDemo.testLock()).start();
// new Thread(() -> lockDemo.testLock()).start();
// 兩個線程都能獲取到鎖
new Thread(() -> lockDemo.testLock2()).start();
new Thread(() -> lockDemo.testLock2()).start();
}
// 發生異常沒有正確釋放鎖,線程2會一直等待獲取鎖
public void testLock() {
lock.lock();
System.out.println("已經獲取到了鎖");
// 模擬異常,看能否正確釋放鎖
int a = 1 / 0;
lock.unlock();
}
// 在finally中釋放鎖,即使發生異常,也能正確釋放
public void testLock2() {
lock.lock();
try {
System.out.println("已經獲取到了鎖");
// 模擬異常,看能否正確釋放鎖
int a = 1 / 0;
} finally {
// 在finally中釋放鎖
lock.unlock();
}
}
}
-
tryLock()
嘗試獲取鎖,如果當前鎖沒有被其他線程持有,則獲取成功返回true,否則返回false。該方法不會引起當先線程阻塞,非公平鎖。 -
tryLock(long time)
嘗試獲取鎖,獲取成功返回true,如果超時時間到仍沒有獲取到鎖則返回false。相比lock()
更加強大,我們可以根據是否能夠獲取到鎖來決定后續行為。 -
lockInterruptibly()
嘗試獲取鎖,等待鎖過程中允許被中斷,可以被thread.interrupt()
中斷,中斷后拋出InterruptedException。
4.3 Lock 的可見性
Monitor 鎖 Happen-Before 原則(synchronized和Lock):對一個鎖的解鎖,對于后續其他線程同一個鎖的加鎖可見,這里的“后續”指的是時間上的先后順序,又叫管程鎖定原則。
Java編譯器會在生成指令序列時在適當位置插入內存屏障指令來禁止處理器重排序,來保證可見性。
4.4 鎖的分類
鎖有多種分類方法,根據不同的分類方法,相同的一個鎖可能屬于不同的類別,比如 ReentrantLock 既是互斥鎖,又是可重入鎖。
根據不同的分類標準,鎖大致可以分為以下 6 種:
4.4.1 樂觀鎖和悲觀鎖
根據線程要不要鎖住同步資源,鎖可以分為樂觀鎖與悲觀鎖。樂觀鎖不鎖住資源,樂觀的認為沒有人與自己競爭資源。
為什么誕生樂觀鎖?
悲觀鎖又稱互斥同步鎖,具有以下缺點:
- 阻塞和喚醒帶來的性能劣勢,用戶態核心態切換,檢查是否有阻塞線程需要被喚醒
- 可能永久阻塞,如果持有鎖的線程被永久阻塞,比如死鎖等,那么等待鎖的線程將永遠不會被執行
- 優先級反轉
悲觀鎖指對數據被外界修改持悲觀態度,認為數據很容易被其他線程修改,所以在數據處理前需要對數據進行加鎖。Java 中典型的悲觀鎖是 synchronized 和 Lock。
樂觀鎖認為修改數據在一般情況下不會造成沖突,所以在修改記錄前不會加鎖,但在數據提交更新時,才會對數據沖突與否進行檢測,檢查我修改數據期間,有沒有其他線程修改過,一般通過加 version 字段或 CAS 算法實現。Java中的典型樂觀鎖是原子類和并發容器。自旋鎖(CAS)是樂觀鎖的一種實現方式。
在數據庫中就有對樂觀鎖的典型應用:要更新一條數據,首先查詢數據的version:select * from table;
然后更新語句,update set num=2,version=version+1 where version=1 and id=5,如果數據沒有被其他人修改,則version與查詢數據時的version一直都為1,則可以修改成功,并返回version=2,如果被其他人修改了,則重新查詢和更新數據。
開銷對比:
- 悲觀鎖的原始開銷要高于樂觀鎖,但是特點是一勞永逸。適合資源競爭激烈的情況,持有鎖時間長的情況。
- 樂觀鎖如果自旋時間很長或不停重試,消耗的資源也會越來越多。適用于資源競爭不激烈的情況。
- 悲觀鎖要掛起和喚醒,悲觀的認為資源很難輪到自己使用,所以傻傻阻塞自己等待資源,等別人用完了喚醒自己。樂觀鎖相信資源很快會輪到自己,所以不停的詢問自己能不能用。
適用場景:
悲觀鎖,適合并發寫入多,持有鎖時間較長的情況,如臨界區有IO操作,臨界區代碼復雜循環量大,臨界區競爭激烈等情況
樂觀鎖,適合并發寫入少,不加鎖能提高效率。如少寫多讀數據的場景,數據改變概率小,自旋次數少。
4.4.2 可重入鎖與非可重入鎖
根據同一個線程能否重復獲取同一把鎖,鎖可以分為可重入鎖與非可重入鎖。
為什么需要可重入鎖?
可重入鎖主要用在線程需要多次進入臨界區代碼時,需要使用可重入鎖。具體的例子,比如一個synchronized方法需要調用另一個synchronized方法時。可以避免死鎖,也可以在同步方法被遞歸調用時使用。
當一個線程要獲取被其他線程持有的獨占鎖時,該線程會被阻塞,那么當一個線程可以再次獲取它自己已經持有的鎖,即不被阻塞則稱為可重入鎖,不可以再次獲取,即獲取時被阻塞,則稱為不可重入鎖,。一定要注意是同一個線程。Java 中典型的可重入鎖是 ReentrantLock 和 synchronized。
synchronized 的可重入性質見 Github示例,下面是對于 ReentrantLock 可重入性質的演示,main線程多次獲取已經持有的lock鎖,getHoldCount() 表示:
public class ReentrantLockDemo {
public static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
ReentrantLockDemo demo = new ReentrantLockDemo();
// 該線程可以多次獲取可重入鎖,并且不釋放鎖
new Thread(() -> demo.testReetrant()).start();
Thread.sleep(1000);
// 該線程嘗試獲取可重入鎖失敗,因為鎖被上一個線程持有
new Thread(() -> demo.getLock()).start();
}
private void getLock() {
lock.lock();
System.out.println(Thread.currentThread().getName() + "獲取到了鎖");
}
/**
* 多次獲取可重入鎖
*/
private void testReetrant() {
// 輸出當前線程持有鎖lock的次數
System.out.println(lock.getHoldCount());
// 當前線程對lock加鎖
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
}
}
線程獲取可重入鎖有三種情況:
- 如果鎖已經被其他線程持有,則進入阻塞等待狀態;
- 如果鎖沒有被其他線程持有,則獲得鎖,并將鎖的當前持有者設置為當前線程
- 如果鎖被當前線程持有,則獲得鎖,并將鎖的重入計數器+1,釋放鎖時會將計數器-1;
根據以上特點,可重入鎖的簡單實現如下:
public class Lock{
boolean isLocked = false; // 表示當前鎖是否被線程持有
Thread lockedBy = null; // 表示當前鎖被哪個線程持有
int lockedCount = 0;
public synchronized void lock()
throws InterruptedException{
Thread thread = Thread.currentThread();
while(isLocked && lockedBy != thread){
// 被鎖住且鎖的持有者不是當前線程,則進入阻塞等待狀態
this.wait();
}
isLocked = true;
// 可重入鎖,需要記錄當前線程獲取該鎖的次數
lockedCount++;
// 標記該鎖被當期線程持有
lockedBy = thread;
}
public synchronized void unlock(){
if(Thread.currentThread() == this.lockedBy){
// 釋放鎖,重入計數器-1
lockedCount--;
if(lockedCount == 0){
isLocked = false;
// 釋放鎖,并喚醒其他等待獲取該鎖的線程
this.notify();
}
}
}
}
4.4.3 公平鎖與非公平鎖
根據多個線程獲取一把鎖時是否先到先得,可以分為公平鎖和不公平鎖。
公平鎖表示線程獲取鎖的順序是按照線程請求鎖的時間順序決定的,也就是請求鎖早的線程更早獲取到鎖,即先來先得。而非公平鎖則在運行時插隊,也就是先來不一定先得,但也不等于后來先得。
Java 中 ReentrantLock 提供了公平鎖與非公平鎖的實現:
- 公平鎖:
ReentrantLock fairLock = new ReetrantLock(true);
- 非公平鎖:
ReentrantLock unFairLock = new ReetrantLock(false);
不傳遞參數,默認非公平鎖。
為什么需要非公平鎖?
為了提高效率。假設線程 A 已經持有了鎖,此時線程B請求該鎖則會阻塞被掛起Suspend。當線程A釋放該鎖后,此時恰好有線程C也來請求此鎖,如果采取公平方式,則線程B獲得該鎖;如果采取非公平方式,則線程B和C都有可能獲取到鎖。
Java中這樣設計是為了提高效率,在上面的例子中,線程B被掛起,A釋放鎖后如果選擇去喚醒B,則需要性能消耗和等待時間;如果直接給此時來請求鎖(未被掛起)的線程C,則避免了喚醒操作的性能消耗,利用了這段空檔時間,性能更好。總之,一定是能提升效率才會出現插隊情況,否則一般不允許插隊。
下圖中 Thread1 持有鎖時,等待鎖的隊列中有三個線程,當Thread1 釋放鎖時,恰好 Thread5 請求了鎖,此時Thread5 就插隊到最前面獲取了鎖。
在現實中也有類似的例子,比如排隊買早餐,攤主正在給A準備早餐,B則去旁邊座位等待了,A的早餐剛好做完時,C來了,老板可能不會去花時間去叫并等待B,而會直接給C做,提高自己的效率。
在沒有公平性需求的前提下盡量使用非公平鎖,因為公平鎖會帶來性能開銷。
公平鎖與非公平鎖的驗證見 Github示例
4.4.4 共享鎖與排它鎖
根據多個線程是否能夠共享同一把鎖,可以分為共享鎖與排它鎖。
共享鎖,又稱為讀鎖,可以同時被多個線程獲取。獲得共享鎖后可以查看但無法修改和刪除數據,其他線程也可以同時獲得該共享鎖,也只能查看不能修改和刪除數據。ReentrantReadWriteLock 中的讀鎖就是共享鎖,可以被多個線程同時獲取
排它鎖,又稱為獨占鎖,不能被多個線程同時獲取,平時最常見的都是排它鎖,比如 synchronized,ReentrantLock都是排它鎖。
為什么需要共享鎖?
多個線程同時讀數據,如果使用 ReentrantLock 則多個線程不能同時讀,降低了程序執行效率。
如果在讀的地方使用共享鎖,寫的地方使用排它鎖。如果沒有寫鎖的情況下,讀是無阻塞的,提高了執行效率。
讀寫鎖的規則
- 多個線程可以一起讀
- 一個線程寫的同時,其他線程不能讀也不能寫。
- 一個線程讀的同時,其他線程不能寫
Java中讀寫鎖的定義如下所示,完整的代碼示例見 Github
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void readText() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到讀鎖,正在讀取...");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "釋放讀鎖");
readLock.unlock();
}
}
private static void writeText() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到寫鎖,正在寫入...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "釋放寫鎖");
writeLock.unlock();
}
}
讀寫鎖 ReentrantReadWriteLock 也可以通過構造參數設置為公平鎖和非公平鎖,默認是非公平鎖。讀鎖插隊能和其他讀鎖共享,可以提升效率,寫鎖是排它鎖,不會出現插隊情況,所以下面討論的均是讀鎖插隊的情況:
公平鎖是不允許插隊的,即先到先得,不區分讀寫鎖。但是讀寫鎖 ReentrantReadWriteLock 作為非公平鎖時插隊策略有以下兩種:
1. 誰能獲取誰優先策略,如下圖所示,當 Thread2(R) 在獲得了讀鎖后,依次來了三個線程,Thread3(W) 請求寫鎖,Thread4(R) 和 Thread5(R) 請求讀鎖。
誰能獲取誰優先策略就是誰能獲取誰優先,Thread2(R) 持有讀鎖時,Thread3(W)雖然來的早但是無法獲得寫鎖,Thread4(R) 和 Thread5(R) 來的晚但是可以獲取讀鎖,所以優先出隊。寫鎖因為是排它鎖,所以不存在插隊的情況。(注意這里與公平鎖章節所說的恰好釋放時請求鎖提高效率情況不同)
但是該策略存在一個缺點,就是容易導致 Thread3(W) 出現饑餓問題,如果一直有其他線程來獲取讀鎖,那么 Thread3(W) 可能永遠請求不到寫鎖,導致饑餓問題。從用戶角度出發,我先修改后讀取,但是修改晚于讀取生效也是不合理的。
2. 避免饑餓策略,如下圖所示,當 Thread2(R) 在獲得了讀鎖后,依次來了兩個線程,Thread3(W) 請求寫鎖,Thread4(R) 請求讀鎖。
避免饑餓策略就是等待隊列頭元素是請求寫時,請求讀不能插隊,Thread2(R) 持有讀鎖時,Thread3(W)來的早但是無法獲得寫鎖,Thread4(R) 雖然可以獲取讀鎖,但是來的比寫鎖 Thread3(W) 晚,所以也加入等待隊列。直至 Thread3(W) 獲取并釋放了寫鎖,Thread4(R) 才可以獲取讀鎖。
避免饑餓策略雖然犧牲了一些效率,但是解決了饑餓問題,并且更加符合人們的認知,所以這也是 JDK 中讀寫鎖使用的插隊策略。
Java 中 ReentrantReadWriteLock 讀鎖插隊能提升效率,寫鎖是排它鎖,不會出現插隊情況,所以關于讀鎖插隊策略總結如下:
- 公平鎖:不允許插隊,不區分讀寫鎖,一律先到先得
- 非公平鎖:讀鎖僅在可以等待隊列頭部不是請求寫鎖的線程時可以插隊;如果等待隊列頭部是請求讀鎖,而當目前持有寫鎖的線程恰好釋放寫鎖是,則新來的讀線程會插隊獲得讀鎖,這點與非公平鎖選擇接受新線程而不去喚醒等待線程出策略一致。請求讀線程插隊代碼示例見 Github
// ReentrantReadWriteLock內部類公平鎖源碼
// 公平鎖請求讀和請求寫都需要去排隊,除非隊列中沒有元素才去嘗試獲取鎖
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
// 判斷寫入線程是否應該阻塞Block,如果隊列不為空,返回true表示應該阻塞去排隊
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
// 判斷讀取線程是否應該阻塞Block,如果隊列不為空,返回true表示應該阻塞去排隊
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
// ReentrantReadWriteLock內部類非公平鎖源碼
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
// 寫線程不阻塞掛起,直接嘗試插隊
final boolean writerShouldBlock() {
return false;
}
// 查看等待隊列第一個元素是不是排他鎖(寫鎖)
// 如果是返回true,表示當前請求讀線程應該阻塞掛起
// 讀鎖不能插寫鎖的隊,
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
}
ReentrantReadWriteLock 鎖的升降級
為什么需要鎖的升降級?
一個方法開始時需要寫入,后面需要讀取,為了減小鎖的粒度,且方法執行過程中不希望被打斷。如果支持寫鎖降級為讀鎖,就可以減小寫鎖的粒度,在讀的部分,其他線程也可以一起來讀取,并且方法執行不會被打斷。(釋放寫鎖重新獲取讀鎖可能會阻塞等待)
ReentrantReadWriteLock 鎖寫鎖可以降級為讀鎖提高執行效率,但不支持讀鎖升級為寫鎖,升級會將線程阻塞。ReentrantReadWriteLock 鎖升級降級示例代碼見Github
面試題:為什么ReentrantReadWriteLock不支持鎖的升級?
線程A和B目前都持有讀鎖,且都想升級為寫鎖,線程A會等待線程B釋放讀鎖后進行升級,線程B也會等待線程A釋放讀鎖后進行升級,這樣就造成了死鎖。當然,并不是說鎖是無法升級的,比如可以限制每次都只有一個線程可以進行鎖升級,這個需要具體的鎖去進行實現。
4.4.5 自旋鎖與阻塞鎖
根據等待鎖的方式可以分為自旋鎖和阻塞鎖。
阻塞或喚醒一個 Java 線程需要操作同切換CPU狀態來完成,這個狀態
為什么需要自旋鎖?
java的線程是與操作系統原生線程一一對應的,掛起和恢復線程都需要切換到內核態中完成。 當一個線程獲取鎖失敗后,會被切換到內核態掛起。當該線程獲取到鎖時,又需要將其切換到內核態來喚醒該線程,用戶態切換到核心態會消耗大量的系統資源。
自旋鎖則是線程獲取鎖時,如果發現鎖已經被其他線程占有,它不會馬上阻塞自己,而會進入忙循環(自旋)多次嘗試獲取(默認循環10次),避免了線程狀態切換的開銷。
面試題:自旋鎖其實就是死循環,死循環會導致 CPU 一個核心的使用率達到100%,那為什么多個自旋鎖并沒有導致 CPU 使用率到達100%系統卡死呢?
默認自旋最多10次,可以使用-XX:PreBlockSpin
(-XX:PreInFlateSpin
)修改該值,在JDK1.6 中引入了自適應的自旋鎖,自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。因此,自旋等待的時間必須有一定限度,如果自旋超過了限定的次數仍沒有成功獲取鎖,則會升級為重量級鎖,即使用傳統的方式去掛起線程了。
如果在同一個鎖對象上,自旋等待剛剛成功獲取過鎖,并且持有鎖的線程正在運行中,那么JVM會認為這次自旋也很有可能再次成功,進而將允許自旋等待更長時間,如果100次循環。
如果對于某個鎖,自旋很少成功獲得過,那么以后獲取這個鎖時將可能省略掉自旋過程,避免浪費處理器資源。
以上參考《深入理解JVM p298》
4.4.6 可中斷鎖與不可中斷鎖
根據在等待鎖的過程中是否可以中斷分為可中斷鎖與不可中斷鎖。
在Java中,synchronized 就是不可中斷鎖,一旦線程開始請求synchronized鎖,就會一直阻塞等待,直至獲得鎖。Lock 就是可中斷鎖,tryLock(time) 和 lockInterruptibly() 都能在請求鎖的過程中響應中斷,實現原理就是檢測線程的中斷標志位,如果收到中斷信號則拋出異常。
ReentrantLock#tryLock(time) 使用AQS獲取鎖,每次AQS循環都會檢測中斷標志位,若標志位被修改,則拋出異常,中斷獲取鎖,源碼如下所示:
// ReentrantLock源碼
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
// 嘗試獲取鎖
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
throws InterruptedException {
// 判斷中斷標志位是否被修改,若被修改則拋出異常中斷獲取鎖
if (Thread.interrupted())
throw new InterruptedException();
// doAcquireNanos中使用AQS嘗試獲取鎖,每次循環也都會檢測中斷標志位
return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout);
}
4.5 鎖優化
JDK1.6 實現了各種鎖優化技術,如自適應自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coaresening)、輕量級鎖(LightWeight Locking)和偏向鎖(Biased Locking)等技術。這些技術都是為了線程之間更高效的共享數據,以及解決競爭問題。
4.5.1 自適應自旋鎖
互斥同步中對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操作都需要操作系統切換到內核態來完成,這些操作對并發性能帶來很大壓力,
4.5.2 鎖消除
鎖消除指的是在保證線程安全的前提下,JVM 刪除一些不必要的鎖。
鎖消除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享資源的競爭的鎖進行消除。鎖消除主要判定依據是來源于逃逸分析的數據支持,如果判斷一段代碼中,堆上的所有數據都不會逃逸出去而被其他線程訪問到,那就可以把他們當做棧上數據來對待,認為是線程私有的,同步加鎖自然無需進行。(逃逸分析見深入理解JVM)
比如下面拼接字符串的代碼中,JDK5之前Javac編譯器會將"+"拼接優化為StringBuffer,JDK5之后會優化為StringBuilder。
public String concatString0(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
查看 StringBuffer#append源碼如下所示,使用了 synchronized 來保證拼接操作的線程安全。但是在上面的例子中,變量 sb 作用于被限制在 concatString() 方法內部,即變量 sb 是線程私有的,不會出現線程安全問題(如果sb定義到屬性中則會出現線程安全問題)。雖然這里有鎖,經過JIT編譯之后,這段代碼會忽略掉 synchronized 鎖來執行。
// StringBufferr#append源碼
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
4.5.3 鎖粗化
鎖消除指的是在保證線程安全的前提下,JVM 擴大鎖的同步范圍,來避免對同一對象的反復加鎖解鎖。
原則上,編寫代碼時推薦將同步塊的范圍盡量縮小,這樣能夠保證線程安全的同時讓等待線程盡快拿到鎖并發執行。但是如果當前線程一系列操作都是對同一個對象的反復加鎖和解鎖,甚至加鎖解鎖操作出現在循環體中,那即使沒有線程競爭,頻繁加鎖解鎖也會導致不必要的性能損耗。
比如下面的拼接字符串操作,每次執行 StringBuffer#append 方法都會對同一個對象 sb 加鎖,下面代碼共執行了 3 次加鎖解鎖操作。如果 JVM 檢測到這樣一串操作都對同一個對象反復加鎖解鎖,則會把加鎖同步的范圍擴展(粗化)到整個操作的外部,就會將下面代碼的加鎖同步范圍擴展到第一個 append() 之前和最后一個 append() 之后。
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
4.5.4 重量級鎖
在并發編程中 synchronized 一直是元老級角色,很多人都會稱呼synchronized 為重量級鎖。在Java中每一個對象都可以作為鎖,synchronized 實現同步分為以下 3 種情況:
- 對于普通同步方法,鎖是當前實例對象
- 對于靜態同步方法,鎖是當前類的 Class 對象
- 對于同步方法快,鎖是 synchronized 括號中設置的對象
JVM 會將 synchronized 翻譯成兩條指令:
monitorenter
和 monitorexit
,分別表示獲取鎖與釋放鎖,這兩個命令總是成對出現。
但是在JDK6中為了對 synchronized 進行優化,引入了輕量級鎖和偏向鎖,減少獲取鎖和釋放鎖帶來的性能消耗。
4.5.4 輕量級鎖
輕量級鎖是在沒有多線程競爭的前提下,減少傳統重量級鎖使用操作系統互斥量Mutex產生的性能消耗。輕量級鎖的加鎖解鎖是通過CAS完成的,避免了使用互斥量的開銷,但是如果存在鎖競爭,輕量級鎖除了互斥量的操作還發生了CAS操作,性能反而更慢。總之,輕量級鎖是使用CAS方式修改鎖對象頭
Java中每一個對象都可以作為鎖,對象頭主要分為兩部分,MarkWord 和 指向方法區對象類型數據的指針,如果是數組對象,還會存儲數組長度。MarkWord 中存儲對象的哈希碼,對象分代年齡,鎖狀態。
重量級鎖與輕量級鎖
重量級鎖加鎖方式:monitorenter -> 執行同步代碼塊 -》 monitorexit
輕量級鎖加鎖方式:
CAS(設置對象頭輕量級鎖標志)-》執行代碼塊-》CAS(重置對象頭輕量級鎖標志)
CAS相對于monitorenter和monitorexit的代價更小,如果輕量級鎖自旋超過一定次數,為了避免自旋浪費CPU性能,則升級為重量級鎖。
自旋鎖與輕量級鎖
自旋鎖是為了減少線程掛起次數;
而輕量級鎖是在加鎖的時候,如何使用一種更高效的方式來加鎖。是 synchronized 的一種形態,使用CAS也就是自旋鎖來實現。
鎖升級
synchronized 鎖一共有4種狀態,鎖升級過程依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。這幾種狀態會隨著競爭逐漸升級,鎖可以升級但不能降級。
面試題:為什么 synchronized 鎖能升級不能降級?
4.5.5 偏向鎖
偏向鎖會偏向第一個獲取它的線程,如果接下來執行過程中,該鎖沒有被其他線程獲取,則持有偏向鎖的線程永遠不需要再同步。如果說輕量級鎖是在無競爭情況下用CAS避免同步中使用互斥量,而偏向鎖是等到競爭出現才釋放鎖,減少釋放鎖帶來的性能消耗。
HotSpot 作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低引入了偏向鎖。
當一個線程訪問同步塊并獲取鎖時,會在鎖對象頭存儲鎖偏向的線程ID,以后該線程進入和退出同步塊都不需要進行CAS操作來加鎖和解鎖,只需要檢查對象頭中存儲的線程ID是否為當前線程。
如果偏向線程ID等于當前線程,表示線程已經獲取了鎖。
如果偏向線程ID不等于當前線程,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
如果不是偏向鎖,則使用CAS競爭鎖。
偏向鎖是等到競爭出現才釋放鎖,減少釋放鎖帶來的性能消耗,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。
并沒有完全搞懂,更多參考《Java并發編程的藝術 p13》和在《深入理解JVM p402》
4.6 ReentrantLock
學完AQS再來補充
4.7 ReentrantReadWriteLock
推薦閱讀
Java并發編程之美 - 翟陸續 內容和慕課網玩轉Java并發類似,可以配合閱讀,有豐富的源碼分析,實踐部分有10個小案例
Java并發編程實戰 - 極客時間 內容有深度,并發設計模式,分析了 4 個并發應用案例 Guava RateLimiter,Netty,Disrupter 和 HiKariCP,還介紹了 4 種其他類型的并發模型 Actor,協程,CSP等
精通Java并發編程 - 哈維爾 非常多的案例,幾乎每個知識點和章節都有案例,學習后能更熟悉Java并發的應用
傳智播客8天并發 筆記有并發案例,CPU原理等筆記,非常深入,后面畫時間學習一下精
https://www.cnblogs.com/nmwyqw/p/12787680.html