天天看點

近期面試總結:秒殺設計、AQS 、synchronized相關問題

近期面試,被問到下面幾個問題,這裡搜集一些大佬的文章資料: 敖 丙  、 彤哥源碼  ,來做一下筆記記錄,一點點查漏補缺。

近期面試總結:秒殺設計、AQS 、synchronized相關問題

1、面試官:如何設計一個秒殺系統?請你闡述流程?

這一面試題答案參考自三太子敖丙的文章:阿裡面試官問我:如何設計秒殺系統?我給出接近滿分的回答

秒殺系統要解決的幾個問題?

① 高并發

秒殺的特點是時間極短、 瞬間使用者量大。在秒殺活動持續時間内,Redis 伺服器需要承受大量的使用者請求,在大量請求條件下,緩存雪崩,緩存擊穿,緩存穿透這些問題都是有可能發生的。

一旦緩存失效,或者緩存無效,每秒上萬甚至十幾萬的QPS(每秒請求數)直接打到資料庫,基本上都要把庫打挂掉,而且你的服務不單單是做秒殺的還涉及其他的業務,你沒做降級、限流、熔斷啥的,别的一起挂,小公司的話可能全站崩潰404。

為此,在設計秒殺系統時,首先要考慮并發安全和使用者通路效率,二者缺一不可!

② 超賣

但凡設計到商品購買,秒殺購物問題,最需要注意的就是超賣問題,因為一旦由于程式不安全,導緻超賣問題産生,不光需要賠付商家損失,還需要追究秒殺系統的開發者的責任!

③ 惡意請求

在秒殺購物時,商品價格比較低,價值較高的商品可能會被一些惡意的第三方,開多台機器執行搶購腳本,機器搶購肯定要比我們人手動點選要快,是以,在設計秒殺系統時,要防止 不良程式員 的惡意搶購。

④ 連結暴露

假如設定定時秒殺開啟,在未到秒殺開啟時間之前,下單購買按鈕應該是禁用的(不可點選),但是,如果我們請求下單的連結沒有經過網關加密封裝,而是直接以原連結的方式依附于下單購買按鈕,那麼 F12 時,就可以擷取下單購買連結 URL,然後直接去請求下單連結,跳過點選方式直接購買商品!

如何解決上面遇到的幾個問題?

① 秒殺子產品微服務化

對于秒殺搶購系統,将其設計成單獨一個子產品,單獨部署一台或者多太伺服器,這樣可以避免服務崩潰時,公司其他項目可以正常運作不受影響。

與此同時,要單獨給秒殺系統建立一個資料庫,現在的網際網路架構部署都是分庫的,一樣的就是訂單服務對應訂單庫,秒殺我們也給他建立自己的秒殺資料庫,防止服務崩潰對其他資料庫造成影響。

② 秒殺連結加鹽

URL動态化,通過 MD5 之類的加密算法加密随機的字元串去做 URL,然後通過前端代碼擷取 URL 背景校驗後才能通過。

③ Redis叢集

如果在大請求量下,單機的Redis頂不住,那就多找幾個兄弟,秒殺本來就是讀多寫少,通過 Redis 叢集,主從同步、讀寫分離,再加上 哨兵、開啟 持久化,來保證 Redis 服務高可用!

近期面試總結:秒殺設計、AQS 、synchronized相關問題

④ 通過 Nginx 做負載均衡

Nginx 是 高性能的web伺服器,并發随便頂幾萬不是夢,但是我們的 Tomcat 隻能頂幾百的并發,我們可以通過Nginx 負載均衡,平分大并發量的請求給多台伺服器的 Tomcat,在秒殺開啟的時候可以多租點流量機。

近期面試總結:秒殺設計、AQS 、synchronized相關問題

⑤ 秒殺頁面資源靜态化

秒殺一般都是特定的商品還有頁面模闆,現在一般都是前後端分離的,是以頁面一般都是不會經過後端的,但是前端也要自己的伺服器啊,那就把能提前放入**cdn伺服器 **的東西都放進去,反正把所有能提升效率的步驟都做一下,減少真正秒殺時候伺服器的壓力。

⑥ 下單按鈕控制

在沒到秒殺開始時間之前,一般下單按鈕都是置灰的,隻有時間到了,才能點選。這是因為怕大家在時間快到的最後幾秒秒瘋狂請求伺服器,然後還沒到秒殺的時候基本上伺服器就挂了。

這個時候就需要前端的配合,定時去請求你的後端伺服器,擷取最新的中原標準時間,到時間點再給按鈕可用狀态。按鈕可以點選之後也得給他置灰幾秒,不然他一樣在開始之後一直點的。

⑦ 前後端限流

限流可以分為 前端限流 和 後端限流。

前端限流:這個很簡單,一般秒殺搶購,下單按鈕不會讓你一直點的,一般都是點選一下或者兩下然後幾秒之後才可以繼續點選,這也是保護伺服器的一種手段。

後端限流:秒殺的時候肯定是涉及到後續的訂單生成和支付等操作,但是都隻是成功的幸運兒才會走到那一步,那一旦 100 個産品賣光了,return 了一個 false,前端直接秒殺結束,然後你後端也關閉後續無效請求的介入了。

⑧ 庫存預熱

秒殺的本質,就是對庫存的搶奪。如果每個秒殺下單的使用者請求過來,都去資料庫查詢庫存校驗庫存,然後扣減庫存,這樣不光效率低下,而且資料庫壓力也是巨大的!

既然資料庫頂不住,但是他的兄弟非關系型的資料庫 Redis 能頂啊!

超賣問題:

我們要在開始秒殺之前,通過定時任務提前把商品的庫存加載到 Redis 中去,讓整個庫存校驗流程都在 Redis 裡面去做,然後等到秒殺活動結束了,再異步的去修改資料庫中庫存就好了。

但是用了 Redis 就有一個問題了,我們上面說了我們采用 主從 Redis,就是我們會先去讀取庫存,再判斷庫存,當有庫存時才會去減庫存,正常情況沒問題,但是高并發的情況問題就很大了。

就比如現在庫存隻剩下 1 個了,我們高并發嘛,4 個伺服器一起查詢了發現都是還有 1 個,那大家都覺得是自己搶到了,就都去扣庫存,那結果就變成了 -3,這種情況下,隻有一個請求是真的搶到商品了,其他 3 個都是超賣的。

如何解決?

可以通過使用 Lua 腳本來解決超賣問題。

**Lua 腳本是類似Redis事務,有一定的原子性,不會被其他指令插隊,可以完成一些 Redis 事務性的操作。**這點是關鍵!

把判斷庫存、扣減庫存的操作都寫在一個 Lua 腳本中,并将該腳本交給 Redis 去執行,當 Redis 中庫存數量減到 0 之後,後面扣庫存的請求都直接 return false。

⑨ 限流&降級&熔斷&隔離

不怕一萬就怕萬一,萬一秒殺系統真的頂不住了,限流,頂不住就擋一部分出去。但是不能說不行,降級,降級了還是被打挂了,熔斷,至少不要影響别的系統,隔離,你本身就獨立的,但是你會調用其他的系統嘛,你快不行了你别拖累兄弟們啊。

2、面試官:AQS源碼有了解過嗎?請你說一下加鎖和釋放鎖的流程

這一面試題答案參考自三太子敖丙的文章:我畫了35張圖就是為了讓你深入 AQS

① AQS 基本介紹

AQS中 維護了一個volatile int state(代表共享資源)和一個 FIFO 線程等待隊列(多線程争用資源被阻塞時會進入此隊列)。

這裡volatile能夠保證多線程下的可見性,當state = 1則代表目前對象鎖已經被占有,其他線程來加鎖時則會失敗,加鎖失敗的線程會被放入一個 FIFO的等待隊列中,并會被 UNSAFE.park() 操作挂起,等待其他擷取鎖的線程釋放鎖才能夠被喚醒。

另外state的操作都是通過CAS來保證其并發修改的安全性。

如圖所示:

近期面試總結:秒殺設計、AQS 、synchronized相關問題
近期面試總結:秒殺設計、AQS 、synchronized相關問題

AQS 中提供了很多關于鎖的實作方法:

getState():擷取鎖的标志 state 值。

setState():設定鎖的标志 state 值。

tryAcquire(int):獨占方式擷取鎖。嘗試擷取資源,成功則傳回 true,失敗則傳回 false。

tryRelease(int):獨占方式釋放鎖。嘗試釋放資源,成功則傳回 true,失敗則傳回 false。

② 加鎖與競争鎖使用場景分析

如果同時有三個線程并發搶占鎖,此時線程一搶占鎖成功,線程二和線程三搶占鎖失敗,具體執行流程如下:

近期面試總結:秒殺設計、AQS 、synchronized相關問題

此時

AQS

内部資料為:

近期面試總結:秒殺設計、AQS 、synchronized相關問題

具體看下搶占鎖代碼實作:

java.util.concurrent.locks.ReentrantLock .NonfairSync

static final class NonfairSync extends Sync {
    // 加鎖
    final void lock() {
        // CAS 修改 state 的值為 1
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }

    // 嘗試競争資源
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}
      

這裡使用的 ReentrantLock 非公平鎖,線程進來直接利用

CAS

嘗試搶占鎖,如果搶占成功

state

值回被改為 1,且設定獨占鎖線程對象為目前線程。

// CAS 嘗試搶占鎖
protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

// 設定獨占鎖線程對象為目前線程
protected final void setExclusiveOwnerThread(Thread thread) {
    exclusiveOwnerThread = thread;
}
      

線程一搶占鎖成功後,

state

變為 1,線程二通過

CAS

修改

state

變量必然會失敗。此時

AQS

FIFO

(First In First Out 先進先出)隊列中。

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
      

tryAcquire() 方法的具體實作是通過内部調用nonfairTryAcquire()方法,這個方法執行的邏輯如下:

首先會擷取state的值,如果不為0則說明目前對象的鎖已經被其他線程所占有。

接着判斷占有鎖的線程是否為目前線程,如果是則累加state值,這就是可重入鎖的具體實作,累加state值,釋放鎖的時候也要依次遞減state值。

如果state為 0,則執行CAS操作,嘗試更新state值為 1,如果更新成功則代表目前線程加鎖成功。

③ 釋放鎖使用場景分析

釋放鎖的過程,首先是線程一釋放鎖,釋放鎖後會喚醒head節點的後置節點,也就是我們現在的線程二,具體操作流程如下:

近期面試總結:秒殺設計、AQS 、synchronized相關問題
近期面試總結:秒殺設計、AQS 、synchronized相關問題

執行完後等待隊列資料如下:

近期面試總結:秒殺設計、AQS 、synchronized相關問題

此時線程二已經被喚醒,繼續嘗試擷取鎖,如果擷取鎖失敗,則會繼續被挂起。

線程釋放鎖的代碼:
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}
      

這裡首先會執行tryRelease()方法,這個方法具體實作在ReentrantLock中,如果tryRelease()執行成功,則繼續判斷 head 節點的 waitStatus 是否為 0,前面我們已經看到過,head的waitStatue為SIGNAL(-1),這裡就會執行 unparkSuccessor() 方法來喚醒 head 的後置節點,也就是上面圖中線程二對應的Node節點。

ReentrantLock.tryRelease():

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}
      

執行完ReentrantLock.tryRelease()後,state被設定成 0,Lock 對象的獨占鎖被設定為 null。

接着執行java.util.concurrent.locks.AbstractQueuedSynchronizer.unparkSuccessor()方法,喚醒head的後置節點:

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}
      

這裡主要是将head節點的waitStatus設定為 0,然後解除head節點next的指向,使head節點空置,等待着被垃圾回收。

此時重新将head指針指向線程二對應的Node節點,且使用LockSupport.unpark方法來喚醒線程二。

被喚醒的線程二會接着嘗試擷取鎖,用CAS指令修改state資料。

④ 公平鎖的實作原理

公平鎖執行流程:

公平鎖在加鎖的時候,會先判斷 AQS 等待隊列中是存在節點,如果存在節點則會直接入隊等待,具體代碼如下:

公平鎖在擷取鎖是也是首先會執行 acquire() 方法,隻不過公平鎖單獨實作了 tryAcquire() 方法:

java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire():

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
      

這裡會執行 ReentrantLock 中公平鎖的

tryAcquire()

方法:

java.util.concurrent.locks.ReentrantLock.FairSync.tryAcquire():

static final class FairSync extends Sync {
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}
      

這裡會先判斷 state 值,如果不為 0 且擷取鎖的線程不是目前線程,直接傳回false代表擷取鎖失敗,被加入等待隊列。如果是目前線程則可重入擷取鎖。

如果 state=0 則代表此時沒有線程持有鎖,執行 hasQueuedPredecessors() 判斷AQS 等待隊列中是否有元素存在,如果存在其他等待線程,那麼自己也會加入到等待隊列尾部,做到真正的先來後到,有序加鎖。具體代碼如下:

java.util.concurrent.locks.AbstractQueuedSynchronizer.hasQueuedPredecessors()

public final boolean hasQueuedPredecessors() {
    Node t = tail;
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}
      

這段代碼很有意思,傳回 false 代表隊列中沒有節點或者僅有一個節點是目前線程建立的節點。傳回 true 則代表隊列中存在等待節點,目前線程需要入隊等待。

非公平鎖和公平鎖的差別:非公平鎖性能高于公平鎖性能。非公平鎖可以減少CPU喚醒線程的開銷,整體的吞吐效率會高點,CPU也不必取喚醒所有線程,會減少喚起線程的數量。

非公平鎖性能雖然優于公平鎖,但是會存在導緻線程饑餓的情況。在最壞的情況下,可能存在某個線程一直擷取不到鎖。不過相比性能而言,饑餓問題可以暫時忽略,這可能就是 ReentrantLock 預設建立非公平鎖的原因之一了。

3、面試官:請你談一談synchronized的實作原理

這一面試題答案參考文章:死磕 java同步系列之synchronized解析

synchronized 加鎖解鎖原理分析

sychronized 鎖,在Java記憶體模型層面,涉及到 2 個指令(JMM 定義了8 個操作來完成主記憶體和工作記憶體的互動操作,參考文章:搜集了這麼多資料,不信你還了解不了 JMM 記憶體模型、volatile 關鍵字保證有序性和可見性實作原理!),lock 和 unlock:

lock,鎖定,作用于主記憶體的變量,它把主記憶體中的變量辨別為線程獨占狀态。

unlock,解鎖,作用于主記憶體的變量,它把鎖定的變量釋放出來,釋放出來的變量才可以被其它線程鎖定。

這兩個指令并沒有直接提供給使用者使用,而是提供了兩個更高層次的指令 monitorenter 和 monitorexit 來隐式地使用 lock 和 unlock 指令。而 synchronized 就是使用 monitorenter 和 monitorexit 這兩個指令來實作的。

根據JVM規範的要求,在執行 monitorenter 指令的時候,首先要去嘗試擷取對象的鎖,如果這個對象沒有被鎖定,或者目前線程已經擁有了這個對象的鎖,就把鎖的計數器加1,相應地,在執行 monitorexit 的時候會把計數器減 1,當計數器減小為 0 時,鎖就釋放了。

sychronized 鎖是如何保證,原子性、可見性、和一緻性呢?

還是回到Java記憶體模型上來,synchronized關鍵字底層是通過 monitorenter 和 monitorexit 實作的,而這兩個指令又是通過 lock 和 unlock 來實作的。

而 lock 和 unlock 在Java記憶體模型中是必須滿足下面四條規則的:

① 一個變量同一時刻隻允許一條線程對其進行 lock 操作,但 lock 操作可以被同一個線程執行多次,多次執行 lock 後,隻有執行相同次數的 unlock 操作,變量才能被解鎖。

② 如果對一個變量執行 lock 操作,将會清空工作記憶體中此變量的值,在執行引擎使用這個變量前,需要重新執行 load 或 assign 操作初始化變量的值;

③ 如果一個變量沒有被 lock 操作鎖定,則不允許對其執行 unlock 操作,也不允許 unlock 一個其它線程鎖定的變量;

④ 對一個變量執行 unlock 操作之前,必須先把此變量同步回主記憶體中,即執行 store 和 write 操作;

通過規則 ①,我們知道對于 lock 和 unlock 之間的代碼,同一時刻隻允許一個線程通路,是以,synchronized 是具有原子性的。

通過規則 ① ② 和 ④,我們知道每次 lock 和 unlock 時都會從主記憶體加載變量或把變量重新整理回主記憶體,而 lock 和 unlock 之間的變量(這裡是指鎖定的變量)是不會被其它線程修改的,是以,synchronized 是具有可見性的。

通過規則 ① 和 ③ ,我們知道所有對變量的加鎖都要排隊進行,且其它線程不允許解鎖目前線程鎖定的對象,是以,synchronized 是具有有序性的。

綜上所述,synchronized 是可以保證原子性、可見性和有序性的。

synchronized 鎖優化

Java在不斷進化,同樣地,Java 中像 synchronized 這種關鍵字也在不斷優化,synchronized 有如下三種狀态:

偏向鎖,是指一段同步代碼一直被一個線程通路,那麼這個線程會自動擷取鎖,降低擷取鎖的代價。

輕量級鎖,是指當鎖是偏向鎖時,被另一個線程所通路,偏向鎖會更新為輕量級鎖,這個線程會通過自旋的方式嘗試擷取鎖,不會阻塞,提高性能。

重量級鎖,是指當鎖是輕量級鎖時,當自旋的線程自旋了一定的次數後,還沒有擷取到鎖,就會進入阻塞狀态,該鎖更新為重量級鎖,重量級鎖會使其他線程阻塞,性能降低。

總結

(1)synchronized 在編譯時會在同步塊前後生成 monitorenter 和 monitorexit 位元組碼指令;

(2)monitorenter 和 monitorexit 位元組碼指令需要一個引用類型的參數,基本類型不可以哦;

(3)monitorenter 和 monitorexit 位元組碼指令更底層是使用Java記憶體模型的 lock 和 unlock 指令;

(4)synchronized 是可重入鎖;

(5)synchronized 是非公平鎖;

(6)synchronized 可以同時保證原子性、可見性、有序性;

(7)synchronized 有三種狀态:偏向鎖、輕量級鎖、重量級鎖;