面試題系列–樂觀鎖和悲觀鎖

  • 2020 年 3 月 16 日
  • 筆記

一,基本概念

  • 樂觀鎖:樂觀鎖在操作數據時非常樂觀,認為別人不會同時修改數據。因此樂觀鎖不會上鎖,只是在執行更新的時候判斷一下在此期間別人是否修改了數據:如果別人修改了數據則放棄操作,否則執行操作。
  • 悲觀鎖:悲觀鎖在操作數據時比較悲觀,認為別人會同時修改數據。因此操作數據時直接把數據鎖住,直到操作完成後才會釋放鎖;上鎖期間其他人不能修改數據。

注意:樂觀鎖本質沒有鎖,因此使用它可以提高代碼執行效率,不會阻塞,不會等待,會重試。

二,鎖的實例

樂觀鎖:1.在Java中java.util.concurrent.atomic包下面的原子變量類(採用CAS機制)。

    2.版本號機制。   

悲觀鎖:悲觀鎖的實現方式就是加鎖,通過給代碼塊加鎖,或數據加鎖。

    1.給數據加鎖–傳統的關係型數據庫中的鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。

    2.給代碼塊加鎖–比如Java裏面的同步原語synchronized關鍵字的實現也是悲觀鎖。

1,CAS機制

  CAS操作方式:即compare and swap 或者compare and set ,涉及到三個操作數,數據所在的內存地址(V),預期值(A),新值(B)。當需要更新時,判斷當前內存地址的值與之前取到的值是否相等,若相等,則用新值更新,若不等則重試,一般情況下是一個自旋操作,即不斷的重試。

  CAS 操作中包含三個操作數 —— 需要讀寫的內存位置(V)、進行比較的預期原值(A)和擬寫入的新值(B)。如果內存位置V的現值與預期原值A相匹配,那麼處理器會自動將該位置值更新為新值B,否則處理器將重複匹配。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。(在 CAS 的一些特殊情況下將僅返回 CAS 是否成功,而不提取當前值。)

  CAS是樂觀鎖技術,當多個線程同時嘗試使用CAS更新同一個變量時,只有其中一個線程能成功更新變量的值,其它線程均會失敗,但失敗的線程並不會被掛起,只是被告知這次競爭失敗,並且允許失敗的線程再次嘗試,當然也允許失敗的線程放棄操作。這裡再強調一下,樂觀鎖是一種思想,CAS是這種思想的一種實現方式。雖然與加鎖相比,CAS比較交換會使程序看起來更加複雜一些。但由於其非阻塞性,對死鎖問題天生免疫,更重要的是,使用無鎖的方式完全沒有鎖競爭帶來的系統開銷,也沒有線程間頻繁調度帶來的開銷,因此,它要比基於鎖方式的實現擁有更優越的性能。  

  在Java1.5之前是靠synchronized關鍵字保證同步的,這是一種獨佔鎖,也是一種悲觀鎖,是一個重量級的操作,因為加鎖需要消耗額外的資源,還因為線程狀態的切換會涉及操作系統核心態和用戶態的轉換;所以在1.5之後Java增加了並發包Java.util.concurrent.*(j.u.c)。J.U.C就是建立在CAS之上的,相對於對於 synchronized 這種阻塞算法,CAS是非阻塞算法的一種常見實現。所以J.U.C在性能上有了很大的提升。不過隨着JVM對鎖進行的一系列優化(如自旋鎖、輕量級鎖、鎖粗化等),synchronized的性能表現也已經越來越好。

  現在來介紹AtomicInteger。AtomicInteger是java.util.concurrent.atomic包提供的原子類,利用CPU提供的CAS操作來保證原子性;除了AtomicInteger外,還有AtomicBoolean、AtomicLong、AtomicReference等眾多原子類。原子操作類大致可以分為4類:原子更新基本類型,原子更新數組類型,原子更新引用類型,原子更新屬性類型。這些原子類中都是用了無鎖的概念,有的地方直接使用了CAS機制。

  下面以 java.util.concurrent 中的 AtomicInteger 為例,看一下在不使用鎖的情況下是如何保證線程安全的。主要理解 getAndIncrement 方法,該方法的作用相當於 ++i 操作。

public class AtomicInteger extends Number implements java.io.Serializable {      //在沒有鎖的機制下,字段value要藉助volatile原語,保證線程間的數據是可見性。      private volatile int value;      //Unsafe用於實現對底層資源的訪問      private static final Unsafe unsafe = Unsafe.getUnsafe();        //valueOffset是value在內存中的偏移量      private static final long valueOffset;      //通過Unsafe獲得valueOffset      static {          try {              valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));          } catch (Exception ex) { throw new Error(ex); }      }        public final boolean compareAndSet(int expect, int update) {          return unsafe.compareAndSwapInt(this, valueOffset, expect, update);      }        public final int getAndIncrement() {//相當於++i操作          for (;;) {              int current = get();//獲取值              int next = current + 1;//+1操作              if (compareAndSet(current, next))//current是預期值,即從主存中取來還未操作過的值,next更新後的值                  return current;          }      }  }

源碼分析說明如下:

(1)getAndIncrement()實現的自增操作是自旋CAS操作:每次獲取數據時,都要循環進行compareAndSet判斷,如果執行成功則退出,否則一直執行。

(2)其中compareAndSet是CAS操作的核心,它是利用Unsafe對象實現的。其中unsafe.compareAndSwapInt(this, valueOffset, expect, update);是藉助C來調用CPU底層指令實現的。

1 public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

類似如下邏輯:

1  if (o == expected) {//this=expect  2      o = x   //this=updata  3      return true;  4  } else {  5      return false;  6  }

(3)Unsafe是用來幫助Java訪問操作系統底層資源的類(如可以分配內存、釋放內存),通過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),這裡不詳細展開。

 

2.版本號機制

  一般是在數據表中加上一個數據版本號version字段,表示數據被修改的次數,當數據被修改時,version值會加一,當線程A要更新數據時,在讀取數據的同時也會讀取version值,在提交更新時,若剛才讀取到的version值與當前主內存中的version值相同,則提交更新,若不相同,則重複更新,直到更新成功。

eg:updata  table  set  x=x+1,version=version+1 where id=#{id}  and  version=#{version};

  對於這句代碼來說,如果能夠使用id+version查到數據,說明該數據沒有再被修改過。如果查詢不到,則說明數據被修改過,則會重複查詢。

  需要注意的是,這裡使用了版本號作為判斷數據變化的標記,實際上可以根據實際情況選用其他能夠標記數據版本的字段,如時間戳等。

悲觀鎖機制存在以下問題:  

  1. 在多線程競爭下,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,引起性能問題。

  2. 一個線程持有鎖會導致其它所有需要此鎖的線程掛起。

  3. 如果一個優先級高的線程等待一個優先級低的線程釋放鎖會導致優先級倒置,引起性能風險。

  對比於悲觀鎖的這些問題,另一個更加有效的鎖就是樂觀鎖。其實樂觀鎖就是:每次不加鎖而是假設沒有並發衝突而去完成某項操作,如果因為並發衝突失敗就重試,直到成功為止。

要注意樂觀鎖的實現本質是沒有使用鎖的:

(1)樂觀鎖本身是不加鎖的,只是在更新時判斷一下數據是否被其他線程更新了;AtomicInteger便是一個例子。

(2)有時樂觀鎖可能與加鎖操作合作,例如,在前述updateCoins()的例子中,MySQL在執行update時會加排它鎖。但這只是樂觀鎖與加鎖操作合作的例子,不能改變“樂觀鎖本身不加鎖”這一事實。

三,樂觀鎖和悲觀鎖的使用場景

1、功能限制

與悲觀鎖相比,樂觀鎖適用的場景受到了更多的限制,無論是CAS還是版本號機制。

例如,CAS只能保證單個變量操作的原子性,當涉及到多個變量時,CAS是無能為力的,而synchronized則可以通過對整個代碼塊加鎖來處理。再比如版本號機制,如果query的時候是針對錶1,而update的時候是針對錶2,也很難通過簡單的版本號來實現樂觀鎖。

2、競爭激烈程度

如果悲觀鎖和樂觀鎖都可以使用,那麼選擇就要考慮競爭的激烈程度:

  • 當競爭不激烈 (出現並發衝突的概率小)時,樂觀鎖更有優勢,因為悲觀鎖會鎖住代碼塊或數據,其他線程無法同時訪問,影響並發,而且加鎖和釋放鎖都需要消耗額外的資源。
  • 當競爭激烈(出現並發衝突的概率大)時,悲觀鎖更有優勢,因為樂觀鎖在執行更新時頻繁失敗,需要不斷重試,浪費CPU資源。

四,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才能執行成功。從Java1.5開始JDK的atomic包里提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置為給定的更新值。

2、高競爭下的開銷問題

在並發衝突概率大的高競爭環境下,如果CAS一直失敗,會一直重試,CPU開銷較大。針對這個問題的一個思路是引入退出機制,如重試次數超過一定閾值後失敗退出。當然,更重要的是避免在高競爭環境下使用樂觀鎖。

3、功能限制

CAS的功能是比較受限的,例如CAS只能保證單個變量(或者說單個內存值)操作的原子性,這意味着當涉及到多個變量(內存值)時,CAS也無能為力。

除此之外,CAS的實現需要硬件層面處理器的支持,在Java中普通用戶無法直接使用,只能藉助atomic包下的原子類使用,靈活性受到限制。

 

推薦:https://www.cnblogs.com/qjjazry/p/6581568.html