閒話
看ThreadPoolExecutor 源碼的時候,其中 Worker 類是基於 AbstractQueuedSynchronizer 構建的,所以順便把這個類一起看了。另外,這個類也是ReenTrantLock 和 Semaphore 的底層的機制,說明足夠重要了。爲了方便,下文統一使用AQS 指代 AbstractQueuedSynchronizer 。
一、簡單瞭解
1.1、AQS —爲了同步而生
AQS 由 Doug Lea 大神編寫,主要目的是提供同步功能。AQS 內部維護了一個 int 類型的變量 state。這個 state 的不同值,可以代表不同的狀態,AQS 操作 state 變量時,使用 CAS,保證了原子性(多線程環境下安全)。通過在多線程環境下,對 state 不同的值代表的不同狀態進行賦予不同的含義,AQS 就可以當做一個比較完備的同步器來使用。這裏,對”狀態“這個詞做下解釋,舉兩個例子。第一個例子是互斥鎖:state 爲0,代表互斥鎖被佔用,state 爲1,代表互斥鎖可用。第二個例子是信號量(對共享資源的訪問):state 爲10,代表當前有10個資源(例如數據庫連接)可用,state 爲5,代表當前有 5個資源(數據庫連接)可用,state <= 0,則代表當前沒有資源可用,必須等待其他資源釋放後,才能使用。除了上述兩個場景外,其他同步場景,AQS 都可能作爲一個比較完美的方案來使用。
AQS 是一個抽象類,並提供了 互斥 和 共享 兩種同步場景。但是由於,AQS 的核心在於維護 state 變量,至於怎麼通過 state 來實現 互斥 或者 共享,AQS 並沒有過多幹涉,只是提供了一些空實現的方法,來讓子類具體實現。而且,AQS 提供了對條件變量的支持,可以說是很通用了。
我對與 AQS 的理解目前只到這一步,所以我認爲,AQS 比較重要的部分是,如何實現對狀態變量的操作和維護,以及中間的一些場景,例如,互斥 和 共享場景是如何實現的;如何支持的條件變量。這些點,也是下文要着重關注的一些點。
1.2、AQS 的繼承關係
AQS 繼承了 AbstractOwnableSynchronizer 類,AbstractOwnableSynchronizer 這個類提供了互斥的語義,比較簡單,這裏不做贅述。
二、AQS 的機制和源碼
2.1、AQS 類的結構
2.1.1、AQS 中 CLH 隊列節點結構
AQS 使用了 CLH 自旋鎖,去解決 互斥 和 共享 場景下的等待問題。
CLH CLH(Craig, Landin, and Hagersten locks): 是一個自旋鎖,能確保無飢餓性,提供先來先服務的公平性。
CLH鎖也是一種基於鏈表的可擴展、高性能、公平的自旋鎖,申請線程只在本地變量上自旋,它不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。
節點的四個狀態:
- a. CANCELLED = 1:因爲超時或者中斷,結點會被設置爲取消狀態,被取消狀態的結點不應該去競爭鎖,只能保持取消狀態不變,不能轉換爲其他狀態。處於這種狀態的結點會被踢出隊列,被GC回收;
- b. SIGNAL = -1:表示這個結點的繼任結點被阻塞了,到時需要通知它;
- c. CONDITION = -2:表示這個結點在條件隊列中,因爲等待某個條件而被阻塞;
- d. PROPAGATE = -3:使用在共享模式頭結點有可能處於這種狀態,表示鎖的下一次獲取可以無條件傳播;
- e. 0: None of the above,新結點會處於這種狀態。
Node 類的結構:
static final class Node {
/** 共享模式 */
static final Node SHARED = new Node();
/** 獨佔模式 */
static final Node EXCLUSIVE = null;
/** 狀態位:表示線程已經取消的狀態值 */
static final int CANCELLED = 1;
/** 狀態位:表示後一個節點的線程需要喚醒的狀態值 */
static final int SIGNAL = -1;
/** 狀態位:線程(處在Condition休眠狀態)在等待Condition喚醒 */
static final int CONDITION = -2;
/**
* 使用在共享模式頭結點有可能牌處於這種狀態,表示鎖的下一次獲取可以無條件傳播;
*/
static final int PROPAGATE = -3;
/**
* CLH 節點的狀態位
*/
volatile int waitStatus;
/**
* 前驅結點
*/
volatile Node prev;
/**
* 後置節點
*/
volatile Node next;
/**
* 當前線程
*/
volatile Thread thread;
// 下一個等待條件(Condition)的節點,由於Condition是獨佔模式,因此這裏有一個簡單的隊列來描述Condition上的線程節點。
Node nextWaiter;
/**
* 返回是否是共享模式
*/
final boolean isShared() {
return nextWaiter == SHARED;
}
/**
* 返回前一個節點,如果爲空則拋出NullPointerException。當前任不能爲空時使用。可以省略null檢查,但它是用來幫助VM的。
*/
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() {
}
// addWaiter 使用
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
// Condition 使用
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
}
2.1.2、AQS 的成員變量
// 等待隊列的頭結點
private transient volatile Node head;
// 等待隊列的尾結點
private transient volatile Node tail;
// 狀態
private volatile int state;
AQS 有3個比較重要的成員變量。
- head CLH 隊列的頭結點
- tail CLH 隊列的尾結點
- AQS 的狀態變量
這3個變量都使用了 volatile 修飾,volatile 可以保證有序性、可見性,但不能保證原子性。爲了在多線程下,安全的使用這些變量,AQS 使用了 CAS 來操作這些變量。
2.2、互斥場景的實現
2.2.1、AQS 互斥場景下的相關方法
- acquire(int arg) 申請獲取狀態(無法中斷)
- acquireInterruptibly(int arg) 申請獲取狀態(支持中斷)
- release(int arg) 釋放狀態
先附一張找到的流程圖,幫助理解代碼
下面我們着重看下 acquire(int arg) 和 release(int arg) 這兩個方法
2.2.2、acquire(int arg)
acquire(int arg) 源碼
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
// 生成節點,並加入隊列
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
其中主要有tryAcquire(arg) 、acquireQueued(addWaiter(Node.EXCLUSIVE),arg)、selfInterrupt() 三個操作,下面挨個看下 ,首先是 tryAcquire(arg)
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
很明顯這個方法需要由子類自己去實現,實際上,AQS 狀態的變化也是在這個方法中完成的,明顯的模板方法模式。下面繼續看 addWaiter(Node.EXCLUSIVE) :
private Node addWaiter(Node mode) {
// 生成節點
Node node = new Node(Thread.currentThread(), mode);
// 獲取當前的尾結點
Node pred = tail;
// 如果尾結點不爲 null
if (pred != null) {
// 將新節點的前驅引用指向這個尾結點
node.prev = pred;
// CAS 將新節點設置爲尾結點
if (compareAndSetTail(pred, node)) {
// 將原來尾結點的 next 指針指向 新節點
pred.next = node;
return node;
}
}
// 執行到這裏,說明要麼是 尾結點爲 null,要麼是 CAS 設置尾結點的時候失敗了
enq(node);
return node;
}
下面看下 enq(node) 這個方法執行了:
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
// 如果尾結點 爲 null ,那麼首先要初始化 head 節點,然後將 tail 節點 指向 head,然後繼續循環
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 如果尾結點不爲 null 了
// 將新節點的 前驅結點 指向 尾結點
node.prev = t;
// 設置新節點爲 尾結點
if (compareAndSetTail(t, node)) {
// 將尾結點的 next 指針指向 新節點
t.next = node;
return t;
}
}
}
}
這樣,一個完整的 addWaiter(Node.EXCLUSIVE) 流程就走完了,主要涉及到節點的創建,以及head、tail節點的初始化以及新節點的入隊,並不是很麻煩。這個時候,我們回到 acquire(int arg) 方法,繼續看 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 做了什麼:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 獲取前驅結點
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
// 如果當前節點的前驅節點是 head 節點,並且成功申請了狀態,將進行以下操作:
// 將當前節點設置爲頭節點
setHead(node);
// 釋放 next 指針,幫助 GC
p.next = null;
// 修改標記位
failed = false;
// 返回中斷標記
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
// 如果 判斷當前節點需要阻塞 && 阻塞線程後,判斷該線程被中斷 ,則將中斷標記改爲 true
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
比較重要的是 shouldParkAfterFailedAcquire 和 parkAndCheckInterrupt 這兩個方法,下面一起看下:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 獲取 前置節點的狀態
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* 如果狀態是 SIGNAL ,則說明需要前置節點 pred 來 喚醒 node,直接休眠
*/
return true;
if (ws > 0) {
/*
*ws >0 說明是 CANCELLED 狀態,則一直往前找,直到找到一個節點,而且這個節點的ws 非 CANCELLED 狀態的
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
// 捨棄中間的節點
pred.next = node;
} else {
/*
* waitStatus 一定是 0 或者 PROPAGATE。 這個時候,將前置節點的 ws 設置爲 SIGNAL
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
private final boolean parkAndCheckInterrupt() {
// 阻塞當前線程
LockSupport.park(this);
return Thread.interrupted();
}
shouldParkAfterFailedAcquire 方法,其實就是根據 node 的前置節點的 waitStatus 狀態位,來判斷是否需要休眠,當 waitStatus 爲 SIGNAL 時,線程將被休眠。這裏其實就是 AQS 對 CLH 鎖進行的變種,即後置節點並不會自旋,而是進行休眠,等可以申請狀態的時候,由前置節點進行喚醒。parkAndCheckInterrupt 方法就很簡單了,直接使用 LockSupport.park,將當前線程掛起。
到這裏,完整的互斥申請狀態位邏輯就結束了,下面,附一張找到的相關的流程圖,以幫助理解代碼。
2.2.3、release(int arg)
release 源碼:
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 由子類實現,在這個方法裏修改 state
- unparkSuccessor 喚醒後置節點
由於 tryRelease 需要子類實現,我們主要看下 unparkSuccessor 是如何喚醒後置節點的。
unparkSuccessor 源碼:
private void unparkSuccessor(Node node) {
// 獲取 節點的 waitStatus
int ws = node.waitStatus;
if (ws < 0)
// 如果 ws <0 ,則cas 操作 ws 更新成 0
compareAndSetWaitStatus(node, ws, 0);
/*
* 獲取後置節點
*/
Node s = node.next;
if (s == null || s.waitStatus > 0) {
// 如果沒有後置節點,或者後置節點的 waitStatus >0 (爲CANCELLED),則 從隊列尾部向前遍歷找到最前面的一個 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);
}
unparkSuccessor 代碼並不複雜,但是注意,當前節點的後置節點的 waitStatus 爲 CANCELLED 時,這時需要找到離當前節點最近的一個非 CANCELLED 狀態的節點,這個時候,需要從隊尾進行遍歷。具體原因可以看下 cancelAcquire 這個方法,這個方法,最後有一行 node.next = node; 相當於將被取消的節點的next 指針指向自己,這個時候如果從 head 遍歷,則會出現死循環,而從 tail 開始遍歷,則可以正常遍歷。
2.3、共享場景的實現
2.3.1、AQS 共享場景下的相關方法
- acquireShared(int arg) 申請獲取狀態(無法中斷)
- acquireSharedInterruptibly(int arg) 申請獲取狀態(支持中斷)
- releaseShared(int arg) 釋放狀態
我們主要關注 acquireShared(int arg) 、 releaseShared(int arg) 兩個方法
2.3.2、acquireShared(int arg)
先附一張流程圖(來源:https://ddnd.cn/2019/03/15/java-abstractqueuedsynchronizer/index.html)
acquireShared 源碼
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
acquireShared 調用了 tryAcquireShared 和 doAcquireShared 兩個方法,同樣的,tryAcquireShared 需要由子類實現,在這個方法中改變state ,下面來看下 doAcquireShared 這個方法做了什麼。
doAcquireShared 源碼
private void doAcquireShared(int arg) {
// 生成節點,併入隊
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 獲取前置節點
final Node p = node.predecessor();
if (p == head) {
// 如果前置節點是 head,則嘗試獲取狀態
int r = tryAcquireShared(arg);
if (r >= 0) {
// 如果成功,則設置頭結點,並根據 Propagate ,來判斷是否需要喚醒其他線程
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
// 如果申請狀態失敗,則進行線程阻塞相關的邏輯
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
與互斥場景下的狀態申請相比,acquireShared(int arg) 將 acquireQueued(addWaiter(Node.EXCLUSIVE),arg)、selfInterrupt() 統一寫在了doAcquireShared 方法中。但是,不同之處是,setHeadAndPropagate 中,會根據傳入的 propagate 進行等待節點的喚醒。下面看下 setHeadAndPropagate 的源碼。
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus either before
* or after setHead) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
// 這裏應該是判斷是否需要喚醒後置節點,但這裏的判斷我不是很理解,所以保留了原生的doc,下面是我理解的
//1.propagate > 0 表示調用方指明瞭後繼節點需要被喚醒
//2.頭節點後面的節點需要被喚醒(waitStatus<0),不論是老的頭結點還是新的頭結點
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
// 喚醒後置的共享節點
doReleaseShared();
}
}
其中的doReleaseShared(); 會喚醒線程,這個方法在下面的 releaseShared(int arg) 一起看。
2.3.3、 releaseShared(int arg)
releaseShared(int arg) 源碼
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
releaseShared(int arg) 中調用了 tryReleaseShared 、 doReleaseShared,其中tryReleaseShared 需要子類重寫,所以我們只關注 doReleaseShared ,看到底是怎麼進行線程喚醒的。
doReleaseShared 源碼.
private void doReleaseShared() {
for (;;) {
// 獲取當前頭結點
Node h = head;
if (h != null && h != tail) {
// 如果頭結點不爲null + 不是尾結點 + 頭結點的waitStatus是SIGNAL+CAS 設置waitStatus成功,就喚醒第二個節點
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
//如果頭結點的waitStatus是0,則 CAS 設置 waitStatus 爲 PROPAGATE,不成功,則繼續循環
continue;
}
if (h == head) // loop if head changed
break;
}
}
注意下,退出循環的條件是 h == head,這樣就會出現如下的情況:
當前 :head->a->b->c->d,此時,a 處於阻塞狀態
然後,有狀態釋放,這個時候,a被喚醒,同時,a成功獲取了狀態,成爲了首節點:head(a)->b->c->d 。由於setHeadAndPropagate 時,會喚醒 a 的後置節點 b,b成功獲取了狀態,隨後通過 setHeadAndPropagate 喚醒了 c,但是,c沒有獲取到狀態,重新回到休眠狀態。這個時候 a 釋放了持有的狀態。來個簡易圖 (爲了表示方便,使用 setHAP 代表 setHeadAndPropagate,使用 doRS 代表 doReleaseShared)
a->setHAP->doRS(此時,head 仍爲 a) ->releaseShared->doRS(此時,head 爲b)
b ->setHAP->doReleaseShared->(此時,head爲b)
很明顯,a節點所在的線程,在做 releaseShared 時,隊列的 head 並不是它,而是 b。a 所在的線程,喚醒的其實是 c。
2.4、對條件變量(Condition)的支持
AQS 提供了Condition 接口的實現類,ConditionObject,每個 ConditionObject 中都維護了條件隊列。在分析 ConditionObject 類之前,我們需要理解 AQS 中的同步隊列(syn queue)和 ConditionObject 中的條件隊列(condition queue)之間的關係。
2.4.1、同步隊列 VS 條件隊列
同步隊列 和 條件隊列的鎖狀態以及聯繫
同步隊列節點:入隊(無鎖) —> 隊列中(獲取鎖) —>出隊(擁有鎖)
條件隊列節點:入隊(擁有鎖) —> 隊列中 (釋放鎖) —> 出隊(同其他線程爭奪鎖)
可以明顯的看出,同步隊列,入隊的時候是線程是沒有鎖的,但是出隊的時候,是擁有鎖的;相反,條件隊列,入隊的時候,線程是擁有鎖的,在隊列中將鎖釋放,出隊的時候,線程已經不再擁有鎖了。
一次普通的請求鎖+條件等待過程中,節點和隊列的變化:
上述的過程中,除了最後的條件達成(signal)時,條件隊列中的節點轉移到同步隊列之外,基本上同步隊列 和 條件隊列是沒有太多交集的。
2.4.2、ConditionObject 的 類結構
// 等待隊列的開始節點
private transient Node firstWaiter;
// 等待隊列的尾結點
private transient Node lastWaiter;
ConditionObject 只有這兩個成員變量,即條件隊列的首尾節點,很簡單
2.4.3、ConditionObject 的等待(await())
看下,await() 方法的實現,await() 支持中斷的處理。
public final void await() throws InterruptedException {
if (Thread.interrupted())
// 檢測到中斷,直接拋出異常
throw new InterruptedException();
// 將節點加入到條件隊列中(源碼放在這個方法下面了)
Node node = addConditionWaiter();
// 釋放當前的狀態
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
// 如果這個節點不在當前在同步隊列中,則掛起線程
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// 節點加入到 同步隊列 中,需要調用 acquireQueued 方法,嘗試獲取鎖
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
// 這裏已經獲取到了鎖,說明,節點已經從 同步隊列中移除,現在需要把通過 unlinkCancelledWaiters 把節點從 條件隊列中移除
unlinkCancelledWaiters();
if (interruptMode != 0)
// 根據interruptMode 處理中斷
reportInterruptAfterWait(interruptMode);
}
private Node addConditionWaiter() {
// 獲取尾結點
Node t = lastWaiter;
// 如果最後一個節點被取消了,則清除取消節點
if (t != null && t.waitStatus != Node.CONDITION) {
// 清除取消節點(源碼放下面了)
unlinkCancelledWaiters();
t = lastWaiter;
}
// 創建節點
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null)
// 初始化頭結點
firstWaiter = node;
else
// 將節點放在尾結點後面
t.nextWaiter = node;
// 更新尾結點指針
lastWaiter = node;
return node;
}
// 取消取消狀態的節點,這個方法需要在持有鎖的情況下,才調用,整個過程並不會出現併發情況,也就沒有了線程安全的問題
private void unlinkCancelledWaiters() {
// 獲取首節點
Node t = firstWaiter;
// 保存離 firstWaiter 最近的一個(包含 firstWaiter ),狀態爲 CONDITION 的節點
Node trail = null;
while (t != null) {
// 獲取下一個節點
Node next = t.nextWaiter;
if (t.waitStatus != Node.CONDITION) {
// 如果節點的waitStatus 不是 CONDITION,就將這個節點清除掉
// 將節點的後置指針情況
t.nextWaiter = null;
if (trail == null)
// 如果當前尚未找到 trail (說明頭結點的狀態不是 CONDITION )
firstWaiter = next;
else
// 如果已經找到了 trail,則將 trail 的 nextWaiter 指針指向當前節點的下一個節點(跳過了本節點,相當於把本節點踢出了隊列)
trail.nextWaiter = next;
if (next == null)
// 如果當前節點已經是最後一個節點了,則更新 lastWaiter 指針
lastWaiter = trail;
}
else
trail = t;
// 繼續下個節點
t = next;
}
}
需要注意的地方:
- await 方法在獲取到互斥鎖之後調用
- 條件成立後,節點直接從條件隊列轉移到同步隊列
2.4.4、ConditionObject 的喚醒(signal())
signal() 源碼:
public final void signal() {
// 判斷線程是否持有鎖
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 獲取條件隊列的首節點
Node first = firstWaiter;
if (first != null)
// 喚醒首節點(源碼在下邊)
doSignal(first);
}
private void doSignal(Node first) {
do {
if ( (firstWaiter = first.nextWaiter) == null)
// 如果需要喚醒的節點沒有後置節點,則直接更新 lastWaiter 指針爲null
lastWaiter = null;
// 將first 的 nextWaiter 置爲null,幫助GC
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
// 將節點從條件隊列 轉移到 同步隊列
final boolean transferForSignal(Node node) {
/*
* cas 操作失敗(已經被轉移),返回false
*/
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
/*
* 節點加到同步隊列,並返回前驅結點
*/
Node p = enq(node);
int ws = p.waitStatus;
// 如果被取消或者 前驅結點的 CAS 操作失敗,則直接喚醒節點
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}
2.4.5、圖解 await 和 signal 過程中發生的事
三、參考
- https://segmentfault.com/a/1190000016462281#item-6-11
- https://ddnd.cn/2019/03/15/java-abstractqueuedsynchronizer/
- https://blog.csdn.net/yyzzhc999/article/details/96917878