🔥【高頻面試】鎖🔒與CAS詳解
關于作者
- 作者介紹
🍓 博客主頁:作者主頁
🍓 簡介:JAVA領域優質創作者🥇、一名在校大三學生🎓、在校期間參加各種省賽、國賽,斬獲一系列榮譽🏆,
🍓 關注我:關注我學習資料、檔案下載統統都有,每日定時更新文章,勵志做一名JAVA資深程式猿👨?💻,
文章目錄
- 🔥【高頻面試】鎖🔒與CAS詳解
- 關于作者
- 一. 悲觀鎖與樂觀鎖
- 二、實作方式
- 1)CAS(Compare And Swap)
- 2)版本號機制
- 三、面試官問:樂觀鎖加鎖嗎?
- 四、面試官問:CAS有哪些缺點?
- 1)一次性只能保證一個共享變數的原子性
- 2)回圈會耗時
- 3)存在ABA問題(重點)
- 五、適用場景
- 1)功能限制
- 2)競爭激烈程度
此篇博文對Java學習理解底層很有幫助!
一. 悲觀鎖與樂觀鎖
? 樂觀鎖和悲觀鎖問題,是出現頻率比較高的面試題,本文將由淺入深,逐步介紹它們的基本概念、實作方式(含實體)、適用場景,以及可能遇到的面試官追問,希望能夠幫助你打動面試官,
? 樂觀鎖和悲觀鎖是兩種思想,主要是解決并發場景下的資料爭奪的問題,
- 樂觀鎖:樂觀鎖在操作資料時非常樂觀,認為別人不會同時修改資料,因此樂觀鎖不會上鎖,只是在執行更新的時候判斷一下在此期間別人是否修改了資料:如果別人修改了資料則放棄操作,否則執行操作,
- 悲觀鎖:悲觀鎖在操作資料時比較悲觀,認為別人會同時修改資料,因此操作資料時直接把資料鎖住,直到操作完成后才會釋放鎖;上鎖期間其他人不能修改資料,
二、實作方式
? 悲觀鎖的實作方式是加鎖,加鎖既可以是對代碼塊加鎖(如Java的synchronized關鍵字),也可以是對資料加鎖(如MySQL中的排它鎖),
? 樂觀鎖的實作方式有兩種:CAS機制和版本號機制,
1)CAS(Compare And Swap)
? CAS的原理很簡單,包含三個值
- 需要讀寫的記憶體位置(V)
- 預期原來的值(A)
- 期待更新的值(B),
如果記憶體位置V的值與預期原值A相匹配,那么處理器會自動將該位置值更新為新值B,回傳true,否則處理器不做任何操作,回傳false,
實作CAS最重要的一點,就是比較和交換操作的一致性,否則就會產生歧義,
CAS操作邏輯如下:如果記憶體位置V的值等于預期的A值,則將該位置更新為新值B,否則不進行任何操作,許多CAS的操作是自旋的:如果操作不成功,會一直重試,直到操作成功為止,
這里引出一個新的問題,既然CAS包含了Compare和Swap兩個操作,它又如何保證原子性呢?答案是:CAS是由CPU支持的原子操作,其原子性是在硬體層面進行保證的,
比如當前執行緒比較成功后,準備更新共享變數值的時候,這個共享變數值被其他執行緒更改了,那么CAS函式必須回傳false,
要實作這個需求,java中提供了Unsafe類,它提供了三個函式,分別用來操作基本型別int和long,以及參考型別Object,

引數的意義:
var1和 var2:表示這個共享變數的記憶體地址,這個共享變數是var1物件的一個成員屬性,var2表示這個共享變數在var1類中的記憶體偏移量,所以通過這兩個引數就可以直接在記憶體中修改和讀取共享變數值,
var4: 表示預期原來的值,
var5: 表示期待更新的值,
并發比較低的時候用CAS比較合適,并發比較高用synchronized比較合適,
接下來以Java中的自增操作( i++ )為例,看一下悲觀鎖和CAS分別是如何保證執行緒安全的,在Java中自增操作不是原子操作,它實際上包含三個獨立的操作:第一步是讀取i值;第二步是加1;第三步是將新值賦值給i
package com.zmz.lock;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @ProjectName: Juc
* @Package: com.zmz.lock
* @ClassName: LockTest
* @Author: 張晟睿
* @Date: 2021/10/17 14:50
* @Version: 1.0
*/
public class LockTest {
//執行緒不安全
private static int num1 = 0;
//使用樂觀鎖
private static AtomicInteger num2 = new AtomicInteger(0);
//使用悲觀鎖
private static int num3 = 0;
private static synchronized void addNum3(){
num3++;
}
public static void main(String[] args) throws Exception {
//開啟2000個執行緒 自增
for(int i = 0; i < 2000; i++){
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
num1++;
num2.getAndIncrement();
addNum3();
}
}).start();
}
Thread.sleep(2000);//休眠2s
System.out.println("1、執行緒不安全:" + num1);
System.out.println("2、樂觀鎖(AtomicInteger):" + num2);
System.out.println("3、悲觀鎖(synchronized):" + num3);
}
}

通過實驗,我們發現并發執行自增操作,導致計算結果的不準確,在上面的代碼測驗中:num1沒有進行任何執行緒安全方面的保護,num2使用了樂觀鎖(CAS),num3使用了悲觀鎖(synchronized),運行程式,使用2000個執行緒同時對num1、num2和num3進行自增操作,可以發現:num2和num3的值總是等于2000,而num1的值常常小于2000,
首先來介紹AtomicInteger,AtomicInteger是java.util.concurrent.atomic包提供的原子類,利用CPU提供的CAS操作來保證原子性;這個包里面提供了一組原子變數類,其基本的特性就是在多執行緒環境下,當有多個執行緒同時執行這些類的實體包含的方法時,具有排他性,即當某個執行緒進入方法,執行其中的指令時,不會被其他執行緒打斷,而別的執行緒就像自旋鎖一樣,一直等到該方法執行完成,才由JVM從等待佇列中選擇一個另一個執行緒進入,這只是一種邏輯上的理解,實際上是借助硬體的相關指令來實作的,不會阻塞執行緒(或者說只是在硬體級別上阻塞了),可以對基本資料、陣列中的基本資料、對類中的基本資料進行操作,原子變數類相當于一種泛化的volatile變數,能夠支持原子的和有條件的讀-改-寫操作,除了AtomicInteger外,還有AtomicBoolean、AtomicLong、AtomicReference等眾多原子類,
下面看一下AtomicInteger的原始碼,了解下它的自增操作getAndIncrement()是如何實作的(JAVA8)

public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
//Unsafe用于實作對底層資源的訪問
private static final Unsafe unsafe = Unsafe.getUnsafe();
//valueOffset是value在記憶體中的偏移量
private static final long valueOffset;
/**
* 通過Unsafe獲得valueOffset
* Unsafe類是用來在任意記憶體地址位置處讀寫資料,可見,對于普通用戶來說,使用起來還是比較危險的,
* public native long objectFieldOffset(Field var1);方法用于獲取某個欄位相對Java物件的“起始地址”的偏移量,方法回傳值和引數如下
* AtomicInteger.class.getDeclaredField("value")是拿到atomicInteger的value欄位的field物件
* valueoffset是拿到value的相對于AtomicInteger物件的地址偏移量
*
*/
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
/**
* 以原子方式將值設定為給定的更新值
*
* @param expect 預期值
* @param update 新值
* @return 成功回傳true 回傳false表明實際值不等于預期值,設定失敗
*/
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
/**
* 原子上增加一個當前值,
*
* @return 之前的值
*/
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
/*
getAndAddInt函式的方法體,傳進來的var4是1,每呼叫一次增加1.compareAndSwapInt前面解釋過了
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;
}
*/
}
2)版本號機制
? 版本號機制也可以用來實作樂觀鎖,版本號機制的主要思想是在資料中增加一個欄位version,表示該資料的版本號,每當資料被修改時,同時讀取版本號version的值,若剛才讀取到的version值為當前資料庫中的version值相等時才更新,則版本號加1;否則重試更新操作,直到更新成功,當某個執行緒查詢資料時,將該資料的版本號一起查出來;當該執行緒更新資料時,判斷當前版本號與之前讀取的版本號是否一致,如果一致才進行操作,
舉一個簡單的銀行取錢的例子:
假設資料庫中帳戶資訊表中有一個 version 欄位,當前值為 1 ;而當前帳戶余額欄位( balance )為 $100 ,
- 操作員 A 此時讀出版本號( version=1 ),并從其帳戶余額中扣除 $50( $100-$50 ),
- 接下來在操作員 A 操作的程序中,操作員B 也讀入此余額及版本號( version=1 ),并從其帳戶余額中扣除 $30 ( $100-$30 ),
- 操作員 A 完成了修改作業,將資料版本號加一,此時版本號( version=2 )、帳戶余額( balance=$50 ),提交至資料庫更新,此時,提交資料版本 > 資料庫記錄當前版本,資料被更新,并且資料庫記錄 version 更新為 2 ,
- 操作員 B 完成了操作,也將版本號+1( version=2 )試圖向資料庫提交資料( balance=$70 ),但此時比對資料庫記錄版本時發現,操作員 B 提交的資料版本號為 2 ,資料庫記錄當前版本也為 2 ,不滿足 “ 當前最后更新的version與操作員第一次的版本號相等 “ 的樂觀鎖策略,因此,操作員 B 的提交被駁回,
- 這樣,就避免了操作員 B 用基于 version=1 的舊資料修改的結果覆寫操作員A 的操作結果的可能,
三、面試官問:樂觀鎖加鎖嗎?
在面試時,曾遇到面試官如此追問,下面是我對這個問題的理解:
(1)樂觀鎖本身是不加鎖的,只是在更新資料的時候會判斷一下資料是否被其他執行緒已經更新過了
(2)有時樂觀鎖可能與加鎖操作兩者同時使用
四、面試官問:CAS有哪些缺點?
面試到這里,我可能就要恭喜你大概率是面試通過了🥰🥰🥰🥰,面試官可能已經中意你了,不過面試官準備對你發起最后的進攻:你知道CAS這種實作方式有什么缺點嗎?
1)一次性只能保證一個共享變數的原子性
? 當對一個共享變數執行操作時,我們可以使用回圈CAS的方式來保證原子操作,但是對多個共享量操作時,回圈CAS就無法保證操作的原子性,這個時候就可以用鎖來保證原子性,
2)回圈會耗時
? 我們可以看到getAndAddInt方法執行時,如果CAS失敗,會一直進行嘗試,如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷,
? 在并發沖突概率大的高競爭環境下,如果CAS一直失敗,會一直重試,CPU開銷較大,針對這個問題的一個思路是引入退出機制,如重試次數超過一定閾值后失敗退出,當然,更重要的是避免在高競爭環境下使用樂觀鎖,
3)存在ABA問題(重點)
? 先簡單解釋一下什么是ABA
? 假設有兩個執行緒——執行緒1和執行緒2,兩個執行緒按照順序進行以下操作:
? (1)執行緒1讀取記憶體中資料為A;
? (2)執行緒2將該資料修改為B;
? (3)執行緒2將該資料修改為A;
? (4)執行緒1對資料進行CAS操作
? 在第(4)步中,由于記憶體中資料仍然為A,因此CAS操作成功,但實際上該資料已經被執行緒2修改過了,這就是ABA問題,
? 在AtomicInteger的例子中,ABA似乎沒有什么危害,但是在某些場景下,ABA卻會帶來隱患,例如堆疊頂問題:一個堆疊的堆疊頂經過兩次(或多次)變化又恢復了原值,但是堆疊可能已發生了變化,
? 對于ABA問題,比較有效的方案是引入版本號,記憶體中的值每發生一次變化,版本號都+1;在進行CAS操作時,不僅比較記憶體中的值,也會比較版本號,只有當二者都沒有變化時,CAS才能執行成功,所以JAVA中提供了AtomicStampedReference/AtomicMarkableReference來處理會發生ABA問題的場景,主要是在物件中額外再增加一個標記來標識物件是否有過變更,
? 問題:如果記憶體地址V初次讀取的值是A,并且在準備賦值的時候檢查到它的值仍然為A,那我們就能說它的值沒有被其他執行緒改變過了嗎?
? 如果在這段期間它的值曾經被改成了B,后來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過,這個漏洞稱為CAS操作的“ABA”問題,Java并發包為了解決這個問題,提供了一個帶有標記的原子參考類“AtomicStampedReference”(原子標記參考 ),它可以通過控制變數值的版本來保證CAS的正確性,因此,在使用CAS前要考慮清楚"ABA"問題是否會影響程式并發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更高效,
?
五、適用場景
? 樂觀鎖和悲觀鎖并沒有優劣之分,它們有各自適合的場景;下面從兩個方面進行說明,
1)功能限制
? 與悲觀鎖相比,樂觀鎖適用的場景受到了更多的限制,無論是CAS還是版本號機制,
? 例如,CAS只能保證一個共享變數的原子操作,當涉及到多個變數時,CAS是無能為力的,而 synchronized則可以通過對整個代碼塊加鎖來處理,再比如版本號機制,如果query的時候是針對表1,而update的時候是針對表2,也很難通過簡單的版本號來實作樂觀鎖,
2)競爭激烈程度
? 如果悲觀鎖和樂觀鎖都可以使用,那么選擇就要考慮競爭的激烈程度:
? 1??當競爭不激烈 (出現并發沖突的概率小)時,樂觀鎖更有優勢,因為悲觀鎖會鎖住代碼塊或資料,其他執行緒無法同時訪問,影響并發,而且加鎖和釋放鎖都需要消耗額外的資源,
? 2??當競爭激烈(出現并發沖突的概率大)時,悲觀鎖更有優勢,因為樂觀鎖在執行更新時頻繁失敗,需要不斷重試,浪費CPU資源,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/323424.html
標籤:java
