面試官:聊聊你知道的鎖

開篇思考

  1. 你知道哪些鎖?
  2. 鎖解決了哪些應用場景的問題?
  3. 鎖的底層實現?
  4. java 中的併發包瞭解嗎?
  5. CAS 會有哪些問題?如何解決?
  6. AQS 是併發包的基礎,實現原理是什麼?
  7. synchronize 是可重入鎖嗎?

如果上面的思考題都能直接準確回答,直接去面試吧。

1. 悲觀鎖

並不是某一個鎖,是一個鎖類型,無論是否併發競爭資源,都會鎖住資源,並等待資源釋放下一個線程才能獲取到鎖。

這明顯很悲觀,所以就叫悲觀鎖。這明顯可以歸納為一種策略,只要符合這種策略的鎖的具體實現,都是悲觀鎖的範疇。

2. 樂觀鎖

與悲觀鎖相對的,也是一個鎖類型。當線程開始競爭資源時,不是立馬給資源上鎖,而是進行一些前後值比對,以此來操作資源。

3. 無鎖

顯然,就是不給資源上鎖,線程可以直接獲取資源。

4. 自旋鎖

自旋通俗的講就是輪詢,for(;;) 很好理解,就是一直循環等待資源釋放後獲取鎖。

5. 偏向鎖

初次執行到synchronized代碼塊的時候,鎖對象變成偏向鎖(通過CAS修改對象頭裡的鎖標誌位),字面意思是

“偏向於第一個獲得它的線程”的鎖。執行完同步代碼塊後,線程並不會主動釋放偏向鎖。當第二次到達同步代碼塊時,

線程會判斷此時持有鎖的線程是否就是自己(持有鎖的線程ID也在對象頭裡),如果是則正常往下執行。由於之前沒有釋放鎖,

這裡也就不需要重新加鎖。如果自始至終使用鎖的線程只有一個,很明顯偏向鎖幾乎沒有額外開銷,性能極高。

6. 輕量級鎖

一旦有第二個線程加入鎖競爭,偏向鎖就升級為輕量級鎖(自旋鎖)。這裡要明確一下什麼是鎖競爭:

如果多個線程輪流獲取一個鎖,但是每次獲取鎖的時候都很順利,沒有發生阻塞,那麼就不存在鎖競爭。只有當某線程嘗試獲取鎖的時候,

發現該鎖已經被佔用,只能等待其釋放,這才發生了鎖競爭。在輕量級鎖狀態下繼續鎖競爭,沒有搶到鎖的線程將自旋,

即不停地循環判斷鎖是否能夠被成功獲取。獲取鎖的操作,其實就是通過CAS修改對象頭裡的鎖標誌位。

先比較當前鎖標誌位是否為“釋放”,如果是則將其設置為“鎖定”,比較並設置是原子性發生的。這就算搶到鎖了,

然後線程將當前鎖的持有者信息修改為自己。長時間的自旋操作是非常消耗資源的,一個線程持有鎖,其他線程就只能在原地空耗CPU,

執行不了任何有效的任務,這種現象叫做忙等(busy-waiting)。如果多個線程用一個鎖,但是沒有發生鎖競爭,或者發生了很輕微的鎖競爭,

那麼synchronized就用輕量級鎖,允許短時間的忙等現象。這是一種折衷的想法,短時間的忙等,換取線程在用戶態和內核態之間切換的開銷。

7. 重量級鎖

如果鎖競爭情況嚴重,某個達到最大自旋次數的線程(一般10次),會將輕量級鎖升級為重量級鎖

(依然是CAS修改鎖標誌位,但不修改持有鎖的線程ID)。

當後續線程嘗試獲取鎖時,發現被佔用的鎖是重量級鎖,則直接將自己掛起(而不是忙等),等待將來被喚醒。在JDK1.6之前,

synchronized直接加重量級鎖,很明顯現在得到了很好的優化。

8. 可重入鎖

可重入鎖的字面意思是“可以重新進入的鎖”,即允許同一個線程多次獲取同一把鎖。比如一個遞歸函數里有加鎖操作,

遞歸過程中這個鎖會阻塞自己嗎?如果不會,那麼這個鎖就是可重入鎖(因為這個原因可重入鎖也叫做遞歸鎖)。

9. 公平鎖

多個線程競爭同一把鎖,如果依照先來先得的原則,那麼就是一把公平鎖。

10. 非公平鎖

多個線程競爭鎖資源,如果是搶佔式的,誰都可以先上,那麼顯然不公平,我先來的憑什麼要插隊,無恥。

11. 可中斷鎖

字面意思是“可以響應中斷的鎖”。這裡的關鍵是理解什麼是中斷。Java並沒有提供任何直接中斷某線程的方法,

只提供了中斷機制。何謂“中斷機制”?線程A向線程B發出“請你停止運行”的請求(線程B也可以自己給自己發送此請求),

但線程B並不會立刻停止運行,而是自行選擇合適的時機以自己的方式響應中斷,也可以直接忽略此中斷。也就是說,

Java的中斷不能直接終止線程,而是需要被中斷的線程自己決定怎麼處理。如果線程A持有鎖,線程B等待獲取該鎖。由於線程A持有鎖的時間過長,

線程B不想繼續等待了,我們可以讓線程B中斷自己或者在別的線程裡中斷它,這種就是可中斷鎖。在Java中,synchronized就是不可中斷鎖,

而Lock的實現類都是可中斷鎖,可以簡單看下Lock接口。

<code>
/* Lock接口 */

public interface Lock {

void lock(); // 拿不到鎖就一直等,拿到馬上返回。

void lockInterruptibly() throws InterruptedException; // 拿不到鎖就一直等,如果等待時收到中斷請求,則需要處理InterruptedException。

boolean tryLock(); // 無論拿不拿得到鎖,都馬上返回。拿到返回true,拿不到返回false。

boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 同上,可以自定義等待的時間。

void unlock();

Condition newCondition();

}

/<code>

12. 讀寫鎖,互斥鎖,共享鎖

讀寫鎖其實是一對鎖,一個讀鎖(共享鎖)和一個寫鎖(互斥鎖、排他鎖)

<code>
/** @see ReentrantReadWriteLock

* @see Lock

* @see ReentrantLock

*

* @since 1.5

* @author Doug Lea

*/

public interface ReadWriteLock {

/**

* Returns the lock used for reading.

*

* @return the lock used for reading

*/

Lock readLock();

/**

* Returns the lock used for writing.

*

* @return the lock used for writing

*/

Lock writeLock();

}

/<code>

記得之前的樂觀鎖策略嗎?所有線程隨時都可以讀,僅在寫之前判斷值有沒有被更改。

讀寫鎖其實做的事情是一樣的,但是策略稍有不同。很多情況下,線程知道自己讀取數據後,是否是為了更新它。

那麼何不在加鎖的時候直接明確這一點呢?如果我讀取值是為了更新它(SQL的for update就是這個意思),

那麼加鎖的時候就直接加寫鎖,我持有寫鎖的時候別的線程無論讀還是寫都需要等待;如果我讀取數據僅為了前端展示,

那麼加鎖時就明確地加一個讀鎖,其他線程如果也要加讀鎖,不需要等待,可以直接獲取(讀鎖計數器+1)。

雖然讀寫鎖感覺與樂觀鎖有點像,但是讀寫鎖是悲觀鎖策略。因為讀寫鎖並沒有在更新前判斷值有沒有被修改過,

而是在加鎖前決定應該用讀鎖還是寫鎖。樂觀鎖特指無鎖編程。

JDK提供的唯一一個ReadWriteLock接口實現類是ReentrantReadWriteLock。看名字就知道,它不僅提供了讀寫鎖,

而是都是可重入鎖。 除了兩個接口方法以外,ReentrantReadWriteLock還提供了一些便於外界監控其內部工作狀態的方法,

這裡就不一一展開。

java 中的悲觀鎖、樂觀鎖

我們在Java裡使用的各種鎖,幾乎全都是悲觀鎖。synchronized從偏向鎖、輕量級鎖到重量級鎖,全是悲觀鎖。

JDK提供的Lock實現類全是悲觀鎖。其實只要有“鎖對象”出現,那麼就一定是悲觀鎖。因為樂觀鎖不是鎖,

而是一個在循環裡嘗試CAS的算法。

那JDK併發包裡到底有沒有樂觀鎖呢?很多。

java.util.concurrent.atomic包裡面的原子類都是利用樂觀鎖實現的。

有。java.util.concurrent.atomic包裡面的原子類都是利用樂觀鎖實現的。

<code>
/**

* Atomically sets the value to the given updated value

* if the current value {@code ==} the expected value.

*

* @param expectthe expected value

* @param updatethe new value

* @return {@code true} if successful. False return indicates that

* the actual value was not equal to the expected value.

*/

public final boolean compareAndSet(int expect, int update) {

return unsafe.compareAndSwapInt(this, valueOffset, expect, update);

}

/<code>

ABA問題及解決方案

上面講到 CAS 是通過比較替換的方式來做實際的更新。

這裡存在一個問題,就是一個值從A變為B,又從B變回了A。這種情況下,CAS可能會認為值沒有發生過變化,但實際上是有變化的。

對此,併發包下有AtomicStampedReference提供根據版本號判斷的實現。

基本思路就是通過版本號來控制,這個也是樂觀鎖的常用解決方案。

數據庫同樣可以通過這種版本號的控制方式來實現樂觀鎖。

AbstractQueuedSynchronizer (AQS )抽象的隊列式同步器,併發包的基礎

簡單解釋一下J.U.C,是JDK中提供的併發工具包,java.util.concurrent。

裡面提供了很多併發編程中很常用的實用工具類,比如atomic原子操作、比如lock同步鎖、fork/join等。

AQS 的核心功能,就是用來將當前工作的線程設置為佔有資源狀態,並將資源狀態設置為鎖定,如果其他線程繼續訪問共享資源,

則需要使用隊列將其他線程進行管理,這個隊列並不是實例化的某種隊列,只是一個 Node 節點的雙向關聯。

下面只列出一些 AQS 核心的東西,後面有機會詳細解讀 AQS

  1. FIFO 先入先出隊列
  2. 狀態控制(volatile修飾共享變量state)
  3. lock獲取鎖的過程:本質上是通過CAS來獲取狀態值修改,如果當場沒獲取到,會將該線程放在線程等待鏈表中
  4. lock釋放鎖的過程:修改狀態值,調整等待鏈表。

下面這段代碼就是 AQS 靜態內部類,Node:

<code>
static final class Node {

/** Marker to indicate a node is waiting in shared mode */

static final Node SHARED = new Node();

/** Marker to indicate a node is waiting in exclusive mode */

static final Node EXCLUSIVE = null;

/** waitStatus value to indicate thread has cancelled */

static final int CANCELLED = 1;

/** waitStatus value to indicate successor's thread needs unparking */

static final int SIGNAL = -1;

/** waitStatus value to indicate thread is waiting on condition */

static final int CONDITION = -2;

/**

* waitStatus value to indicate the next acquireShared should

* unconditionally propagate

*/

static final int PROPAGATE = -3;

/**


* Status field, taking on only the values:

* SIGNAL: The successor of this node is (or will soon be)

* blocked (via park), so the current node must

* unpark its successor when it releases or

* cancels. To avoid races, acquire methods must

* first indicate they need a signal,

* then retry the atomic acquire, and then,

* on failure, block.

* CANCELLED: This node is cancelled due to timeout or interrupt.

* Nodes never leave this state. In particular,

* a thread with cancelled node never again blocks.

* CONDITION: This node is currently on a condition queue.

* It will not be used as a sync queue node

* until transferred, at which time the status

* will be set to 0. (Use of this value here has

* nothing to do with the other uses of the

* field, but simplifies mechanics.)

* PROPAGATE: A releaseShared should be propagated to other

* nodes. This is set (for head node only) in

* doReleaseShared to ensure propagation

* continues, even if other operations have

* since intervened.

* 0: None of the above

*

* The values are arranged numerically to simplify use.


* Non-negative values mean that a node doesn't need to

* signal. So, most code doesn't need to check for particular

* values, just for sign.

*

* The field is initialized to 0 for normal sync nodes, and

* CONDITION for condition nodes. It is modified using CAS

* (or when possible, unconditional volatile writes).

*/

volatile int waitStatus;

volatile Node prev;

volatile Node next;

/**

* The thread that enqueued this node. Initialized on

* construction and nulled out after use.

*/

volatile Thread thread;

Node nextWaiter;

/**

* Returns true if node is waiting in shared mode.

*/

final boolean isShared() {

return nextWaiter == SHARED;

}

/**

* Returns previous node, or throws NullPointerException if null.


* Use when predecessor cannot be null. The null check could

* be elided, but is present to help the VM.

*

* @return the predecessor of this node

*/

final Node predecessor() throws NullPointerException {

Node p = prev;

if (p == null)

throw new NullPointerException();

else

return p;

}

Node() { // Used to establish initial head or SHARED marker

}

Node(Thread thread, Node mode) { // Used by addWaiter

this.nextWaiter = mode;

this.thread = thread;

}

Node(Thread thread, int waitStatus) { // Used by Condition

this.waitStatus = waitStatus;

this.thread = thread;

}

}

/<code>

重點看下這個volatile int waitStatus;,這是一個volatile 修飾的int 狀態類型,volatile 就是確保該變量是對其他線程可見的,

是java 內存模型中的重要概念,不理解的可以去看下 JMM 。

SIGNAL(-1):表示後繼結點在等待當前結點喚醒。後繼結點入隊時,會將前繼結點的狀態更新為SIGNAL。

CANCELLED(1):表示當前結點已取消調度。當timeout或被中斷(響應中斷的情況下),會觸發變更為此狀態,進入該狀態後的結點將不會再變化。

CONDITION(-2):表示結點等待在Condition上,當其他線程調用了Condition的signal()方法後,CONDITION狀態的結點將從等待隊列轉移到同步隊列中,等待獲取同步鎖。

PROPAGATE(-3):共享模式下,前繼結點不僅會喚醒其後繼結點,同時也可能會喚醒後繼的後繼結點。

0:初始狀態

synchronize 是不是可重入鎖

這個需要先理解可重入鎖是怎麼設計的。

重入鎖實現可重入性原理或機制是:每一個鎖關聯一個線程持有者和計數器,當計數器為 0 時表示該鎖沒有被任何線程持有,

那麼任何線程都可能獲得該鎖而調用相應的方法;當某一線程請求成功後,JVM會記下鎖的持有線程,並且將計數器置為 1;

此時其它線程請求該鎖,則必須等待;而該持有鎖的線程如果再次請求這個鎖,就可以再次拿到這個鎖,同時計數器會遞增;

當線程退出同步代碼塊時,計數器會遞減,如果計數器為 0,則釋放該鎖。

面試官:聊聊你知道的鎖


分享到:


相關文章: