前言
樂觀鎖和悲觀鎖問題,是出現頻率比較高的面試題。
本文將由淺入深,逐步介紹它們的基本概念、實現方式(含實例)、適用場景。
一、基本概念
樂觀鎖和悲觀鎖是兩種思想,用於解決並發場景下的數據競爭問題。
樂觀鎖:樂觀鎖在操作數據時非常樂觀,認為別人不會同時修改數據。
因此樂觀鎖不會上鎖,只是在執行更新的時候判斷一下在此期間別人是否修改了數據:如果別人修改了數據則放棄操作,否則執行操作。
悲觀鎖:悲觀鎖在操作數據時比較悲觀,認為別人會同時修改數據。
因此操作數據時直接把數據鎖住,直到操作完成后才會釋放鎖;上鎖期間其他人不能修改數據。
二、實現方式(含實例)
在說明實現方式之前,需要明確:樂觀鎖和悲觀鎖是兩種思想,它們的使用是非常廣泛的,不局限於某種編程語言或數據庫。
悲觀鎖的實現方式是加鎖,加鎖既可以是對代碼塊加鎖(如Java的synchronized關鍵字),也可以是對數據加鎖(如MySQL中的排它鎖)。
樂觀鎖的實現方式主要有兩種:CAS機制和版本號機制,下面詳細介紹。
1、CAS(Compare And Swap)
CAS操作包括了3個操作數:
-
需要讀寫的內存位置(V)
-
進行比較的預期值(A)
-
擬寫入的新值(B)
CAS操作邏輯如下:如果內存位置V的值等於預期的A值,則將該位置更新為新值B,否則不進行任何操作。
許多CAS的操作是自旋的:如果操作不成功,會一直重試,直到操作成功為止。
這里引出一個新的問題,既然CAS包含了Compare和Swap兩個操作,它又如何保證原子性呢?
答案是:CAS是由CPU支持的原子操作,其原子性是在硬件層面進行保證的。
下面以Java中的自增操作(i++)為例,看一下悲觀鎖和CAS分別是如何保證線程安全的。
我們知道,在Java中自增操作不是原子操作,它實際上包含三個獨立的操作:
-
讀取i值;
-
加1;
-
將新值寫回i
因此,如果並發執行自增操作,可能導致計算結果的不准確。
在下面的代碼示例中:value1沒有進行任何線程安全方面的保護,value2使用了樂觀鎖(CAS),value3使用了悲觀鎖(synchronized)。
運行程序,使用1000個線程同時對value1、value2和value3進行自增操作,可以發現:value2和value3的值總是等於1000,而value1的值常常小於1000。
1 public class Test { 2 3 //value1:線程不安全 4 private static int value1 = 0; 5 //value2:使用樂觀鎖 6 private static AtomicInteger value2 = new AtomicInteger(0); 7 //value3:使用悲觀鎖 8 private static int value3 = 0; 9 10 private static synchronized void increaseValue3() { 11 value3++; 12 } 13 14 public static void main(String[] args) throws Exception { 15 //開啟1000個線程,並執行自增操作 16 for (int i = 0; i < 1000; ++i) { 17 new Thread(new Runnable() { 18 @Override 19 public void run() { 20 try { 21 Thread.sleep(100); 22 } catch (InterruptedException e) { 23 e.printStackTrace(); 24 } 25 value1++; 26 value2.getAndIncrement(); 27 increaseValue3(); 28 } 29 }).start(); 30 } 31 //查看活躍線程 ,因守護線程的原因[基於工具問題windows:idea run 啟動用 >2,debug 用>1] 32 while (Thread.activeCount() > 2) { 33 //Thread.currentThread().getThreadGroup().list(); 34 Thread.yield();//讓出cpu 35 } 36 37 //打印結果 38 Thread.sleep(1000); 39 System.out.println("線程不安全:" + value1); 40 System.out.println("樂觀鎖(AtomicInteger):" + value2); 41 System.out.println("悲觀鎖(synchronized):" + value3); 42 } 43 }
首先來介紹AtomicInteger。
AtomicInteger是java.util.concurrent.atomic包提供的原子類,利用CPU提供的CAS操作來保證原子性;
除了AtomicInteger外,還有AtomicBoolean、AtomicLong、AtomicReference等眾多原子類。
下面看一下AtomicInteger的源碼,了解下它的自增操作getAndIncrement()是如何實現的(源碼以Java7為例,Java8有所不同,但思想類似)。
1 public class AtomicInteger extends Number implements java.io.Serializable { //存儲整數值,volatile保證可視性 private volatile int value; //Unsafe用於實現對底層資源的訪問 private static final Unsafe unsafe = Unsafe.getUnsafe(); 2 3 //valueOffset是value在內存中的偏移量 4 private static final long valueOffset; 5 //通過Unsafe獲得valueOffset 6 static { 7 try { 8 valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value")); 9 } catch (Exception ex) { throw new Error(ex); } 10 } 11 12 public final boolean compareAndSet(int expect, int update) { 13 return unsafe.compareAndSwapInt(this, valueOffset, expect, update); 14 } 15 16 public final int getAndIncrement() { 17 for (;;) { 18 int current = get(); 19 int next = current + 1; 20 if (compareAndSet(current, next)) 21 return current; 22 } 23 } 24 }
源碼分析說明如下:
1.getAndIncrement()實現的自增操作是自旋CAS操作:在循環中進行compareAndSet,如果執行成功則退出,否則一直執行。
2.其中compareAndSet是CAS操作的核心,它是利用Unsafe對象實現的。
3.Unsafe又是何許人也呢?
Unsafe是用來幫助Java訪問操作系統底層資源的類(如可以分配內存、釋放內存,在netty中大量用到它,屬於C++層面的native方法,我們一般使用反射獲取,哈哈~),通過Unsafe,Java具有了底層操作能力,可以提升運行效率;
強大的底層資源操作能力也帶來了安全隱患(類的名字Unsafe也在提醒我們這一點),因此正常情況下用戶無法使用。
AtomicInteger在這里使用了Unsafe提供的CAS功能。
4.valueOffset可以理解為value在內存中的偏移量,對應了CAS三個操作數(V/A/B)中的V;偏移量的獲得也是通過Unsafe實現的。
5.value域的volatile修飾符:Java並發編程要保證線程安全,需要保證原子性、可視性和有序性;
CAS操作可以保證原子性,而volatile可以保證可視性和一定程度的有序性;
在AtomicInteger中,volatile和CAS一起保證了線程安全性。
關於volatile作用原理的說明涉及到Java內存模型(JMM),這里不詳細展開。
說完了AtomicInteger,再說synchronized。
synchronized通過對代碼塊加鎖來保證線程安全:在同一時刻,只能有一個線程可以執行代碼塊中的代碼。
synchronized是一個重量級的操作,不僅是因為加鎖需要消耗額外的資源,還因為線程狀態的切換會涉及操作系統核心態和用戶態的轉換;
不過隨着JVM對鎖進行的一系列優化(如自旋鎖、輕量級鎖、鎖粗化等),synchronized的性能表現已經越來越好。
2、版本號機制
除了CAS,版本號機制也可以用來實現樂觀鎖。
版本號機制的基本思路是在數據中增加一個字段version,表示該數據的版本號,每當數據被修改,版本號加1。
當某個線程查詢數據時,將該數據的版本號一起查出來;
當該線程更新數據時,判斷當前版本號與之前讀取的版本號是否一致,如果一致才進行操作。
需要注意的是,這里使用了版本號作為判斷數據變化的標記,實際上可以根據實際情況選用其他能夠標記數據版本的字段,如時間戳等。
下面以“更新玩家金幣數”為例(數據庫為MySQL,其他數據庫同理),看看悲觀鎖和版本號機制是如何應對並發問題的。
考慮這樣一種場景:游戲系統需要更新玩家的金幣數,更新后的金幣數依賴於當前狀態(如金幣數、等級等),因此更新前需要先查詢玩家當前狀態。
下面的實現方式,沒有進行任何線程安全方面的保護。如果有其他線程在query和update之間更新了玩家的信息,會導致玩家金幣數的不准確。
1 @Transactional 2 public void updateCoins(Integer playerId){ 3 //根據player_id查詢玩家信息 4 Player player = query("select coins, level from player where player_id = {0}", playerId); 5 //根據玩家當前信息及其他信息,計算新的金幣數 6 Long newCoins = ……; 7 //更新金幣數 8 update("update player set coins = {0} where player_id = {1}", newCoins, playerId); 9 }
為了避免這個問題,悲觀鎖通過加鎖解決這個問題,代碼如下所示。在查詢玩家信息時,使用select …… for update進行查詢;
該查詢語句會為該玩家數據加上排它鎖,直到事務提交或回滾時才會釋放排它鎖;
在此期間,如果其他線程試圖更新該玩家信息或者執行select for update,會被阻塞(所以...如果你for update了,但是沒有commit,那就一直鎖着...鎖住了青春~~~)。
1 @Transactional 2 public void updateCoins(Integer playerId){ 3 //根據player_id查詢玩家信息(加排它鎖) 4 Player player = queryForUpdate("select coins, level from player where player_id = {0} for update", playerId); 5 //根據玩家當前信息及其他信息,計算新的金幣數 6 Long newCoins = ……; 7 //更新金幣數 8 update("update player set coins = {0} where player_id = {1}", newCoins, playerId); 9 }
版本號機制則是另一種思路,它為玩家信息增加一個字段:version。在初次查詢玩家信息時,同時查詢出version信息;
在執行update操作時,校驗version是否發生了變化,如果version變化,則不進行更新。
1 @Transactional 2 public void updateCoins(Integer playerId){ 3 //根據player_id查詢玩家信息,包含version信息 4 Player player = query("select coins, level, version from player where player_id = {0}", playerId); 5 //根據玩家當前信息及其他信息,計算新的金幣數 6 Long newCoins = ……; 7 //更新金幣數,條件中增加對version的校驗 8 update("update player set coins = {0} where player_id = {1} and version = {2}", newCoins, playerId, player.version); 9 }
三、優缺點和適用場景
樂觀鎖和悲觀鎖並沒有優劣之分,它們有各自適合的場景;下面從兩個方面進行說明。
1、功能限制
與悲觀鎖相比,樂觀鎖適用的場景受到了更多的限制,無論是CAS還是版本號機制。
例如,CAS只能保證單個變量操作的原子性,當涉及到多個變量時,CAS是無能為力的,而synchronized則可以通過對整個代碼塊加鎖來處理。
再比如版本號機制,如果query的時候是針對表1,而update的時候是針對表2,也很難通過簡單的版本號來實現樂觀鎖。
2、競爭激烈程度
如果悲觀鎖和樂觀鎖都可以使用,那么選擇就要考慮競爭的激烈程度:
當競爭不激烈 (出現並發沖突的概率小)時,樂觀鎖更有優勢,因為悲觀鎖會鎖住代碼塊或數據,其他線程無法同時訪問,影響並發,而且加鎖和釋放鎖都需要消耗額外的資源。
當競爭激烈(出現並發沖突的概率大)時,悲觀鎖更有優勢,因為樂觀鎖在執行更新時頻繁失敗,需要不斷重試,浪費CPU資源。
四、面試官追問:樂觀鎖加鎖嗎?
筆者在面試時,曾遇到面試官如此追問。下面是我對這個問題的理解:
1.樂觀鎖本身是不加鎖的,只是在更新時判斷一下數據是否被其他線程更新了;AtomicInteger便是一個例子。
2.有時樂觀鎖可能與加鎖操作合作,例如,在前述updateCoins()的例子中,MySQL在執行update時會加排它鎖。
但這只是樂觀鎖與加鎖操作合作的例子,不能改變“樂觀鎖本身不加鎖”這一事實。
五、面試官追問:CAS有哪些缺點?
面試到這里,面試官可能已經中意你了。
不過面試官准備對你發起最后的進攻:你知道CAS這種實現方式有什么缺點嗎?
下面是CAS一些不那么完美的地方:
1、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類便是使用版本號來解決ABA問題的。
2、高競爭下的開銷問題
在並發沖突概率大的高競爭環境下,如果CAS一直失敗,會一直重試,CPU開銷較大。
針對這個問題的一個思路是引入退出機制,如重試次數超過一定閾值后失敗退出。
當然,更重要的是避免在高競爭環境下使用樂觀鎖。
3、功能限制
CAS的功能是比較受限的,例如CAS只能保證單個變量(或者說單個內存值)操作的原子性,這意味着:
(1)原子性不一定能保證線程安全,例如在Java中需要與volatile配合來保證線程安全;
(2)當涉及到多個變量(內存值)時,CAS也無能為力。
除此之外,CAS的實現需要硬件層面處理器的支持,在Java中普通用戶無法直接使用,只能借助atomic包下的原子類使用,靈活性受到限制。
六、總結
本文介紹了樂觀鎖和悲觀鎖的基本概念、實現方式(含實例)、適用場景,以及可能遇到的面試官追問,希望能夠對你面試有幫助。