java大廠面試題整理(十一)AQS詳解

請說intern方法。和猜測下面代碼的運行結果。

    public static void main(String[] args) {
        String str1 = new StringBuffer("58").append("tongcheng").toString();        
        String str2 = new StringBuffer("ja").append("va").toString();
        System.out.println(str1);
        System.out.println(str1.intern());
        System.out.println(str1 == str1.intern());
        System.out.println(str2);
        System.out.println(str2.intern());
        System.out.println(str2 == str2.intern());
    }

首先這個intern方法是一個native本地方法,它的作用是如果字符串常量池中已經包含一個等於此String對象的字符串,則返回代表池中這個字符串的String對象的引用。否則將此String對象包含的字符串添加到常量池中,並返回此String對象的引用。
而上面的代碼其實考點就是這兩個等於的結果。答案是第一個是true,第二個是false。
而且這裏只有java會false。因爲在jdk中有個類sun.misc.Version.加載這個類的時候會自動把一個launcher_num的靜態變量注入。這個變量的值就是java。
所以說上面代碼執行之前,常量池中已經存在java字符串了。所以str2的指向和java字符串的指向是不一樣的。所以false。
這個題是深入理解JVM虛擬機第三版的原題:

談談AQS和LockSupport

這裏可以先聊聊可重入鎖:可重入鎖又叫遞歸鎖。是指在同一個線程的外層方法中獲取鎖的時候,再進入該線程的內層方法會自動獲取鎖(前提鎖的是同一個對象)。不會因爲之前已經過去過鎖還沒釋放而阻塞。
java中ReentrantLock和Synchronized都是可重入鎖。可重入鎖的一個優點是一定程度避免死鎖。

Synchronized

下面是一個簡單的可重入demo:



上面代碼中m1,m2都是由synchronized鎖住的。在進入m1的時候持有鎖了,方法裏調用m2直接進入m2了。這個時候m1的鎖還沒釋放。因爲m2也是這個鎖,所以能靠這把未釋放的鎖進入m2。證明了可重入性。
同理,其實這裏同步代碼塊更明瞭:



沒有死鎖本身就說明了synchronized的同步塊可重入。下面貼上完整demo代碼:

public class LockDemo {
    public synchronized void m1() {
        System.out.println("進入到m1方法!"+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
        m2();
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (Exception e) {         
            e.printStackTrace();
        }       
    }
    public synchronized void m2() {
        System.out.println("進入到m2方法!"+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (Exception e) {
            e.printStackTrace();
        }       
    }
    public void m3() {
        synchronized (this) {
            System.out.println("進入m3的一層同步塊");
            synchronized (this) {
                System.out.println("進入m3的二層同步塊");
                synchronized (this) {
                    System.out.println("進入m3的三層同步塊");
                }
            }
        }
    }
    public static void main(String[] args) {
        LockDemo lockDemo = new LockDemo();
        lockDemo.m3();
        new Thread(()->lockDemo.m1()).start();
        new Thread(()->lockDemo.m2()).start();
    }
}

可重入鎖實現原理:每個鎖對象擁有一個鎖計數器和一個指向持有該鎖的線程的指針。
當執行monitor-enter的時候,如果該對象計數器爲0說明當前鎖沒有被其他線程鎖佔有,java虛擬機會將該鎖對象的持有線程設置爲當前線程。並將計數器+1.
在目標鎖對象不爲0的情況下:

  • 如果鎖的持有者是當前線程。則鎖對象的計數器+1.
  • 如果鎖對象不是當前線程則要等待鎖對象的計數器歸0,才能獲得鎖。

當執行monitor-exit的時候,java虛擬機將鎖對象的計數器-1.計數器歸0代表鎖已經被釋放。

ReentrantLock

測試可重入的demo如下:

public class LockDemo {
    Lock lock = new ReentrantLock();
    public String getTime() {
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
    }
    public void m1() {
        lock.lock();
        try {           
            System.out.println("進入m1方法"+getTime());
            m2();
            TimeUnit.SECONDS.sleep(5);
        } catch (Exception e) {
        }finally {
            lock.unlock();
        }
    }
    public void m2() {
        lock.lock();
        try {
            System.out.println("進入m2方法"+getTime());
            TimeUnit.SECONDS.sleep(5);
        } catch (Exception e) {
        }finally {
            lock.unlock();
        }
    }
    public static void main(String[] args) {
        LockDemo lockDemo = new LockDemo();
        lockDemo.m1();

    }
}

如上測試,這兩個打印語句是同時調用的。說明不用等m1的鎖釋放,就能進入到m2的方法中,也說明了ReentrantLock的可重入。而lock和synchronized最主要的區別的synchronized是隱式獲取鎖和釋放鎖。而reentrantLock的是要手動lock,unlock的。而lock和unlock就是syncronized原理是+1,-1的操作。記住加幾次鎖就要放幾次鎖。否則會導致鎖無法釋放。這個我就不測試了。畢竟是很基礎的知識點。
說完了可重入鎖,下面直接說LockSupport.

LockSupport

如果覺得這個類很陌生,我們學習的最佳方式就是官網:所以可以去jdk文檔中找找看:


一句話總結:是線程等待喚醒機制(wait/notify)的改良加強版.
LockSupport中park()和unpark()的作用分別是阻塞線程和解除阻塞線程.

這塊其實之前學juc的時候就學到過.下面一張圖表示java線程中等待喚醒機制的發展進步:



我們目前知道的三個版本的等待喚醒:

  • Object的wait/notify
    這種做法的侷限性:兩者必須在synchronized方法/同步塊中執行.wait和notify的順序必須嚴格執行.如果先notify再wait,那麼會無限制等待下去.
  • Condition的await和signal
    這種做法的侷限性:必須和lock/unlock之間使用.同時也必須先await在signal.否則await會無限制等下去(不設置時間的.).

這兩種情況都有兩個約束:1.先持有鎖.2.先等待再喚醒.

  • LockSupport的park和unpark
    這兩個方法底層調用的unsafe類.具體用法如下demo:
public class LockDemo {
    public static void main(String[] args) {
        Thread a = new Thread(()->{
            System.out.println("進入到a線程");
            LockSupport.park();
            System.out.println("a線程被喚醒");
        },"a");
        a.start();
        new Thread(()->{
            System.out.println("進入到b線程");
            LockSupport.unpark(a);
        },"b") .start();
    }
}

首先看這個代碼很明顯:阻塞喚醒不需要先獲取鎖.
其次我們可以代碼測試一下順序:


先喚醒後等待也支持.當然了這個要一對一的.就是先unpark一次,就可以解park一次,如果unpark一次,park兩次,也還是要等待的:

接下來我們簡單看下LockSupport源碼:

歸根結底LockSupport是一個線程阻塞工具類.所有的方法都是靜態方法.可以讓線程在任意位置阻塞.阻塞之後也有對應的喚醒方法.其本質上調用的是Unsafe類的native方法.
其實現原理是這樣的:每個使用它的線程都有一個許可(permit)關聯.permit相當於1,0的開關.默認是0.
調用一次unpark就加1變成1.
調用一次park就把1變成0.同時park立即返回.
注意的是隻有0,1兩種狀態.也就是連續調用unpark多次,也只能讓許可證變成1.能解一次park而已.
形象點理解:

  • 線程阻塞需要消耗憑證permit.這個憑證最多隻有一個.
  • 調用park時:
    • 如果有憑證.則消耗這個憑證並且正常退出
    • 如果沒憑證,則要等待有憑證纔可以退出
  • 調用unpark時,會增加一個憑證,但是憑證的上限是1.

AQS詳解

AQS全稱是AbstractQueuedSynchronizer。中文翻譯過來其實就是三個單詞:抽象的,隊列,同步器。
AQS是用來構建鎖或者其他同步器組件的重量級基礎框架以及整個JUC體系的基石。通過內置的先進先出(first in first out,簡稱FIFO)隊列來完成資源獲取線程的排隊工作。並通過一個int類型變量表示持有鎖的狀態。
爲什麼說AQS是juc體系的基石呢?
簡單來說,ReentrantLock,CountDownLatch,ReentrantReadWriteLock,Semaphore這些類,都用到了搶鎖放鎖等。這些都用到了AQS,如下源碼截圖:

鎖和同步器的關係?
鎖-面向鎖的使用者。定義了使用層的api,隱藏了實現細節,調用即可。
同步器-面向鎖的實現者。提出了統一規範並簡化了鎖的實現。屏蔽了同步狀態管理,阻塞線程排隊和通知,喚醒機制等。

有阻塞就需要排隊,而實現排隊必然需要有某種形式的隊列來進行管理。
一堆線程搶一個鎖的時候,搶到資源的線程處理業務邏輯,搶不到的排隊。但是等待線程仍然保留着獲取鎖的可能並且獲取鎖的流程還在繼續。這就是排隊等候機制。
如果共享資源被佔用,就需要一定的阻塞等待喚醒機制來保證鎖分配。這個機制主要用的是CLH隊列的變體實現。將暫時獲取不到鎖的線程加入到隊列中,這個隊列就是AQS的抽象表現。
它將請求共享資源的線程封裝成隊列的節點。通過CAS,自旋以及LockSupport.park()的方式,維護state變量的狀態,使併發達到同步的控制效果。
AQS底層使用了一個volatile的int類型的成員變量來表示同步狀態。通過內置的FIFO隊列來完成資源獲取的排隊工作。將每條要搶佔資源的線程封裝成一個Node結點來實現鎖的分配。通過CAS完成對state值的修改。



由上面兩個代碼說明了AQS的等待隊列的數據類型是Node,而Node中裝的是Thread。
AQS類中有個volatile修飾的state變量。當state是0的時候說明沒線程佔有資源。大於等於1的時候說明有線程佔有資源。再有後來的線程是要排隊的。我們繼續看AQS是源碼會發現雖然方法很多,看似挺複雜的。但是AQS本身最外層的屬性就是一個state變量和一個clh變種的雙端隊列。如下截圖:

而我們之所以說Node是雙端隊列也很容易看出來,我們可以看Node的源碼:


Node其實可以看成一個單獨的類。雖然是內部的。然後其屬性也都是很有用的。除了上面簡單說的頭尾節點,還有別的,首先作爲雙向鏈表,上一個下一個元素的指針必有的,其次首尾節點上面就說了,都是鏈表的基本知識,就不說了。還有一個屬性比較有用:waitStatus:表示的是排隊的每一個節點的狀態。

  • 0是初始化Node的時候的默認值。
  • 1 表示線程獲取鎖的請求已經取消了
  • -2 表示節點在等待隊列中,等着喚醒
  • -3 當前線程處於shared情況下該字段纔會使用
  • -1表示線程已經準備好就等着資源釋放了


AQS源碼解讀

現在爲止簡單的理解了下AQS的體系和大致類結構屬性。下面一步一步源碼解讀:
還是從ReentrantLock說起,Lock接口的實現類,基本都是通過聚合了一個隊列同步器的子類完成線程訪問控制的。
這句話我們看着代碼更好理解:



上面兩段代碼就可以看出來:lock.lock本質上是在lock類中聚合一個AQS的實現類,然後調用lock和unlock都是調用這個AQS的實現類的方法來實現(unlock是調用sysn.release(1);)的。

然後注意,我們知道ReentrantLock默認是非公平鎖的,可以創建的時候傳參設置爲公平鎖,那麼這個公平還是非公平對於AQS的實現類有什麼區別呢?


講真,這個就好像是在套娃。根據傳參的不同去實現不同的配置的Sync類型,Sync類型又是AQS的實現類。其實這些只要看代碼雖然不一定能理解人家爲什麼這麼寫,但是還挺好看懂的。這兩種實現有什麼不同呢?繼續在源碼中找答案:



因爲顯示原因就這麼看,明顯是兩個方法,我們去對比這兩個方法的區別:



很容易能看出來兩個方法只有一個區別:公平鎖多了一個判斷,方法如下:

很明顯這個方法是判斷當前隊列是不是有元素。如果這個鎖的等待隊列中已經有了線程,則方法放回true,在嘗試獲取鎖的時候條件是非true也就是false。因爲這裏用的是&&,一個false則全部false,所以不往下走了,直接返回false,當前線程要進入等待隊列去排隊。
而非公平鎖則不用進行這個判斷,直接嘗試獲取鎖。獲取到了就true,獲取不到走false。
下面我們用debug的方式一步一步走一下代碼:
從lock開始:





也就是如果當前是0.state是0說明當前資源沒人佔用,這個時候設置值爲1並且返回true,調用設置當前線程爲資源擁有者:

而如果當前資源有線程佔有了,則cas返回false,所以走else分支,調用acquire方法:

繼續往下走這個方法:

這個方法直接看是就拋了個異常,我們可以點進實現類裏看,因爲我們最開始就是非公平鎖,所以看NonfairSync的實現:



其實代碼邏輯也挺簡單的,先看看資源狀態是不是0,是0則試圖用cas搶資源。不是0判斷線程是不是當前資源持有線程,是的話返回true(這裏證明了鎖的可重入)。不是返回false。
現在如果資源被佔用且不是當前線程佔用的,這個方法肯定是返回false,這個時候繼續往下走代碼:因爲在acquire方法tryAcquire是取反的,返回false,!false是true,則繼續下一個判斷:

這個方法也分兩步:一個是acquireQueued方法,一個是addWaiter方法。addWaiter其實很明顯,因爲acquireQueued方法的兩個參數第一個是Node,第二個是int。而addWaiter方法的結果作爲第一個參數。所以我們可以合理的猜測addWaiter方法是將當前線程轉化爲Node方法。猜測完畢下面我們用代碼去確定:



分析代碼,addWaiter第一行代碼就是創建了一個Node對象,並且把當前線程當參數構建的Node。然後把這個Node對象掛到雙端隊列上去。掛的邏輯是pre指向之前的末尾元素。而tail指向的是當前元素。因爲是雙向隊列,所以之前的最後一個節點的下一個指向新添加的。就是一個很簡單的邏輯。然後這裏有兩個分支:一個是添加到隊列,還有一個是單獨的enq(node)方法,區別是如果隊列存在則添加。如果隊列不存在則先創建再添加。
然後重點就是入隊的方法:

這個方法的重點其實是要看下紅框裏的兩個方法:前面的比較眼熟了應該,還是嘗試獲取鎖。問題是獲取不到鎖會走下面的兩個方法:

這個比較容易理解,就是判斷當前這個線程還是不是在等着呢。其實重點在第二個方法:

這個線程想搶鎖,但是沒搶到,所以阻塞了。(注意之前的方法是自旋的。也就是這個線程被喚醒以後還會重複這個方法的操作)。
至於什麼時候會被喚醒。其實猜也能猜到,肯定是獲取這個資源的線程釋放鎖以後喚醒所有隊列中的線程。我們也可以順着代碼去找一下喚醒的步驟:



而且這個方法中如果是正常情況下,state會變成0.並且這裏還有個判斷:
if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();

如果當前線程未持有鎖則不可以釋放。所以說如果沒有lock先unlock會報錯。就是這句代碼起的作用。
繼續往下說這個tryRelease方法如果c等於0說明鎖徹底被釋放,會返回true,然後代碼往下走,走到了另一個方法:


這裏的兩點就是unpark。之前所有等待的線程都被park了,現在在解鎖以後,這個unpark就把之前掛起來的等待線程叫醒了。
並且注意入隊我們知道了,但是出隊的過程是在一個很意想不到的地方:也就是掛起線程的哪個自旋結束後。因爲自旋中有兩個分支:一個是搶佔成功了還有一個是搶佔失敗了,而如果搶佔成功了的話會直接將當前線程出隊的。

至此,所有的邏輯都串起來了。AQS的大部分邏輯都是這樣的。中間可能有一些方法略過了或者沒說,但是總體流程就是這樣。
非公平鎖是每次tryAcquire時如果當前資源處於0,沒有被佔有的狀態,每個線程都有機會去獲取鎖,而公平鎖在tryAcquire中哪怕資源沒被佔有,也只有隊首的元素有資格去獲取鎖。
本篇筆記就記到這裏,如果稍微幫到你了記得點個喜歡點個關注,也祝大家工作順順利利,生活健健康康!其實偶爾我會覺得看源碼是種很有意思的事,去看人家的邏輯,流程走向,代碼的書寫等。我記得都說看一本好書開拓視野,回味無窮。其實一個好的源碼也可以如此。願我們在求索的路上一往無前吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章