併發編程的實現原理-Lock-筆記(AQS)

同步鎖java

  • 鎖是用來控制多個線程訪問共享資源的方式
  • 通常來講,一個鎖可以防止多個線程同時訪問共享資源,
  • 在Lock接口出現以前,Java應用程序只能依靠synchronized關鍵字來實現同步鎖的功能,
  • 在java5之後,增長了JUC(java.util.concurrent)的併發包且提供了Lock接口用來實現鎖的功能,
    • 它提供了與synchroinzed關鍵字相似的同步功能,
    • 只是它比synchronized更靈活,可以顯式的獲取和釋放鎖。

Lock的初步使用面試

  • Lock是一個接口,核心的兩個方法lock和unlock,
  • 它有不少的實現,好比ReentrantLock、ReentrantReadWriteLock;

ReentrantLock緩存

  • 重入鎖,表示支持從新進入的鎖,
  • 也就是說,若是當前線程t1經過調用lock方法獲取了鎖以後,
  • 再次調用lock,是不會再阻塞去獲取鎖的,
  • 直接增長重試次數就好了。
public class AtomicDemo {
    private static int count = 0;
    static Lock lock = new ReentrantLock();

    public static void inc() {
        lock.lock();
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
        lock.unlock();
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                AtomicDemo.inc();
            }).start();
        }
        Thread.sleep(3000);
        System.out.println("result:" + count);
    }
}

ReentrantReadWriteLock安全

  • 咱們之前理解的鎖,基本都是排他鎖,
  • 也就是這些鎖在同一時刻只容許一個線程進行訪問,
  • 而讀寫鎖在同一時刻能夠容許多個線程訪問,
    • 可是在寫線程訪問時,全部的讀線程和其餘寫線程都會被阻塞。
  • 讀寫鎖維護了一對鎖,一個讀鎖、一個寫鎖;
  • 通常狀況下,讀寫鎖的性能都會比排它鎖好,
    • 由於大多數場景讀是多於寫的。
  • 在讀多於寫的狀況下,讀寫鎖可以提供比排它鎖更好的併發性和吞吐量.
public class LockDemo {
    static Map<String, Object> cacheMap = new HashMap<>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock read = rwl.readLock();
    static Lock write = rwl.writeLock();

    public static final Object get(String key) {
        System.out.println("開始讀取數據");
        read.lock(); //讀鎖
        try {
            return cacheMap.get(key);
        } finally {
            read.unlock();
        }
    }

    public static final Object put(String key, Object value) {
        write.lock();
        System.out.println("開始寫數據");
        try {
            return cacheMap.put(key, value);
        } finally {
            write.unlock();
        }
    }
}
  • 在這個案例中,經過hashmap來模擬了一個內存緩存,
    • 而後使用讀寫鎖來保證這個內存緩存的線程安全性。
  • 當執行讀操做的時候,須要獲取讀鎖,在併發訪問的時候,讀鎖不會被阻塞,
    • 由於讀操做不會影響執行結果。
  • 在執行寫操做是,線程必需要獲取寫鎖,當已經有線程持有寫鎖的狀況下,
    • 當前線程會被阻塞,只有當寫鎖釋放之後,其餘讀寫操做才能繼續執行。
  • 使用讀寫鎖提高讀操做的併發性,也保證每次寫操做對全部的讀寫操做的可見性
    • l 讀鎖與讀鎖能夠共享
    • l 讀鎖與寫鎖不能夠共享(排他)
    • l 寫鎖與寫鎖不能夠共享(排他)

Lock和synchronized的簡單對比數據結構

  • 經過咱們對Lock的使用以及對synchronized的瞭解,基本上能夠對比出這兩種鎖的區別了。
  • 由於這個也是在面試過程當中比較常見的問題::
    • Ø 從層次上,一個是關鍵字、一個是類, 這是最直觀的差別
    • Ø 從使用上,lock具有更大的靈活性,能夠控制鎖的釋放和獲取;
      • 而synchronized的鎖的釋放是被動的,當出現異常或者同步代碼塊執行完之後,纔會釋放鎖
    • Ø lock能夠判斷鎖的狀態、而synchronized沒法作到
    • Ø lock能夠實現公平鎖、非公平鎖;
      • 而synchronized只有非公平鎖

AQS(AbstractQueuedSynchronizer多線程

  • Lock之因此能實現線程安全的鎖,
  • 主要的核心是AQS(AbstractQueuedSynchronizer),
  • AbstractQueuedSynchronizer提供了一個FIFO隊列,
    • 能夠看作是一個用來實現鎖以及其餘須要同步功能的框架。
  • AQS的使用依靠繼承來完成,
    • 子類經過繼承自AQS並實現所需的方法來管理同步狀態。
    • 例如常見的ReentrantLockCountDownLatch等AQS的兩種功能
  • 從使用上來講,AQS的功能能夠分爲兩種:獨佔和共享。
  • 獨佔鎖模式下,每次只能有一個線程持有鎖,
    • 好比前面給你們演示的ReentrantLock就是以獨佔方式實現的互斥鎖
  • 共享鎖模式下,容許多個線程同時獲取鎖,併發訪問共享資源,
    • 好比ReentrantReadWriteLock。
  • 很顯然,獨佔鎖是一種悲觀保守的加鎖策略,它限制了讀/讀衝突,
    • 若是某個只讀線程獲取鎖,則其餘讀線程都只能等待,
    • 這種狀況下就限制了沒必要要的併發性,
    • 由於讀操做並不會影響數據的一致性。
  • 共享鎖則是一種樂觀鎖,它放寬了加鎖策略,
    • 容許多個執行讀操做的線程同時訪問共享資源

AQS的內部實現併發

  • 同步器(AQS依賴內部的同步隊列(一個FIFO雙向隊列)來完成同步狀態的管理,
  • 當前線程獲取同步狀態失敗時,
    • 同步器會將當前線程以及 等待狀態 等信息
    • 構形成爲一個節點(Node)並將其加入同步隊列,
    • 同時會阻塞當前線程,
  • 當同步狀態釋放時,會把首節點中的線程喚醒,
    • 使其再次嘗試獲取同步狀態。

Node的主要屬性以下app

static final class Node {
        int waitStatus; //表示節點的狀態,包含cancelled(取消);condition 表示節點在等待condition 也就是在condition隊列中
        Node prev; //前繼節點
        Node next; //後繼節點
        Node nextWaiter; //存儲在condition隊列中的後繼節點
        Thread thread; //當前線程
    }
  • AQS類底層的數據結構是使用雙向鏈表,是隊列的一種實現。
    • 包括一個head節點和一個tail節點,
    • 分別表示頭結點和尾節點,
    • 其中頭結點不存儲Thread,僅保存next結點的引用。

  • 當一個線程成功地獲取了同步狀態(或者鎖),
    • 其餘線程將沒法獲取到同步狀態,
    • 轉而被構形成爲節點並加入到同步隊列中,
  • 而這個加入隊列的過程必需要保證線程安全,
    • 所以同步器提供了一個基於CAS的設置尾節點的方法:compareAndSetTail(Node expect,Nodeupdate)
      • 它須要傳遞參數當前線程「認爲」的尾節點當前節點,只有設置成功後,
      • 當前節點才正式與以前的尾節點創建關聯。

  • 同步隊列遵循FIFO,
    • 首節點是獲取同步狀態成功的節點,
  • 首節點的線程在釋放同步狀態時,
    • 將會喚醒後繼節點,
    • 然後繼節點將會在獲取同步狀態成功時
      • 將本身設置爲首節點。

  • 設置首節點是經過獲取同步狀態成功的線程來完成的,
    • 因爲只有一個線程可以成功獲取到同步狀態,
    • 所以設置頭節點的方法並不須要使用CAS來保證,
    • 它只須要將首節點設置成爲原首節點的後繼節點
    • 並斷開原首節點的next引用便可

compareAndSet框架

  • AQS中,除了自己的鏈表結構之外,
    • 還有一個很關鍵的功能,就是CAS,
    • 這個是保證在多線程併發的狀況下、保證線程安全的前提下
      • 去把線程加入到AQS中的方法,能夠簡單理解爲樂觀鎖
private final boolean compareAndSetHead(Node update) {
     return unsafe.compareAndSwapObject(this, headOffset, null, update);
}
  • 首先,用到了unsafe類,
    • Unsafe類是在sun.misc包下,不屬於Java標準。
    • 可是不少Java的基礎類庫,包括一些被普遍使用的高性能開發庫都是基於Unsafe類開發的,
    • 好比Netty、Hadoop、Kafka等;
    • Unsafe可認爲是Java中留下的後門,提供了一些低層次操做,
      • 直接內存訪問線程調度
  • 而後調用了compareAndSwapObject這個方法
    • public final native boolean compareAndSwapObject(Object var1, long var2, Object var4,Object var5);

       

      • 這個是一個native方法,
      • 第一個參數爲須要改變的對象,
      • 第二個爲偏移量(即以前求出來的headOffset的值),
      • 第三個參數爲期待的值,
      • 第四個爲更新後的值
    • 整個方法的做用是
      • 若是當前時刻的值等於預期值var4相等,
        • 則更新爲新的指望值 var5,
      • 若是更新成功,則返回true,不然返回false;

這裏傳入了一個headOffset,這個headOffset是什麼呢?oop

  • 在下面的代碼中,經過unsafe.objectFieldOffset

  • 而後經過反射獲取了AQS類中的成員變量,
    • 而且這個成員變量被volatile修飾的

unsafe.objectFieldOffset

  • headOffset這個是指類中相應字段在該類的偏移量,
    • 在這裏具體便是指head這個字段
    • 在AQS類的內存中相對於該類首地址的偏移量
  • 一個Java對象能夠當作是一段內存,
    • 每一個字段都得按照必定的順序放在這段內存裏,
    • 經過這個方法能夠準確地告訴你
      • 某個字段相對於對象的起始內存地址的字節偏移。
    • 用於在後面的compareAndSwapObject中,
      • 去根據偏移量找到對象在內存中的具體位置

這個方法在unsafe.cpp文件中,代碼以下::

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapObject(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jobject e_h, jobject x_h))
UnsafeWrapper("Unsafe_CompareAndSwapObject");
oop x = JNIHandles::resolve(x_h); // 新值
oop e = JNIHandles::resolve(e_h); // 預期值
oop p = JNIHandles::resolve(obj);
HeapWord* addr = (HeapWord *)index_oop_from_field_offset_long(p, offset);// 在內存中的具體位置
oop res = oopDesc::atomic_compare_exchange_oop(x, addr, e, true);// 調用了另外一個方法,實際上就是經過cas操做來替換內存中的值是否成功
jboolean success = (res == e); // 若是返回的res等於e,則斷定知足compare條件(說明res應該爲內存中的當前值),但實際上會有ABA的問題
if (success) // success爲true時,說明此時已經交換成功(調用的是最底層的cmpxchg指令)
update_barrier_set((void*)addr, x); // 每次Reference類型數據寫操做時,都會產生一個Write Barrier暫時中斷操做,配合垃圾收集器
return success;
UNSAFE_END
  • 因此其實compareAndSet這個方法,
    • 最終調用的是unsafe類的compareAndSwap,
    • 這個指令會對內存中的共享數據作原子的讀寫操做。
      • 1. 首先, cpu會把內存中將要被更改的數據與指望值作比較
      • 2. 而後,當兩個值相等時,cpu纔會將內存中的對象替換爲新的值。
        • 不然,不作變動操做
      • 3. 最後,返回操做執行結果
  • 很顯然,這是一種樂觀鎖的實現思路。
相關文章
相關標籤/搜索