AQS全稱:AbstractQueuedSynchronizer,抽象的隊列同步器,和synchronized
不一樣的是,它是使用Java編寫實現的一個同步器,開發者能夠基於它進行功能的加強和擴展。java
AQS堪稱J.U.C
包的半壁江山,不少併發工具類都是使用AQS來實現的,例如:ReentrantLock、Semaphore、CountDownLatch等。node
使用synchronized
實現同步的原理是:每一個Java鎖對象都有一個對應的Monitor對象(對象監視器),Monitor對象是由C++實現的。對象監視器維護了一個變量Owner
,指向的是當前持有鎖的線程,還維護了一個Entry List
集合,存放的是競爭鎖失敗的線程。線程在這個集合裏會被掛起休眠,直到Owner線程釋放鎖,JVM纔去Entry List集合中喚醒線程來繼續競爭鎖,循環往復。面試
AQS的任務就是使用Java代碼的方式,去完成synchronized
中由C代碼實現的功能。Java開發者不必定熟悉C語言,要讀懂synchronized
的源碼實現並不是易事。不過好在JDK提供了AQS,經過閱讀AQS的源碼也能讓你對併發有更深的理解。算法
AQS的核心思想是:經過一個volatile修飾的int屬性state
表明同步狀態,例如0是無鎖狀態,1是上鎖狀態。多線程競爭資源時,經過CAS的方式來修改state,例如從0修改成1,修改爲功的線程即爲資源競爭成功的線程,將其設爲exclusiveOwnerThread
,也稱【工做線程】,資源競爭失敗的線程會被放入一個FIFO
的隊列中並掛起休眠,當exclusiveOwnerThread
線程釋放資源後,會從隊列中喚醒線程繼續工做,循環往復。 邏輯是否是和synchronized
底層差很少?對吧。markdown
理論說的差很少了,本篇文章就經過ReentrantLock
結合AbstractQueuedSynchronizer
,經過閱讀源碼的方式來看一下AQS究竟是如何工做的,順便膜拜一下Doug Lea
大佬。多線程
閱讀源碼前,先來簡單瞭解一下AQS的架構: 架構仍是比較簡單的,除了實現Serializable接口外,就只繼承了
AbstractOwnableSynchronizer
父類。 AbstractOwnableSynchronizer
父類中維護了一個exclusiveOwnerThread
屬性,是用來記錄當前同步器資源的獨佔線程的,沒有其餘東西。架構
AQS有一個內部類Node
,AQS會將競爭鎖失敗的線程封裝成一個Node節點,Node類有prev
和next
屬性,分別指向前驅節點和後繼節點,造成一個雙向鏈表的結構。除此以外,每一個Node節點還有一個被volatile
修飾的int變量waitStatus
,它表明的是節點的等待狀態,有以下幾種值:併發
能夠看到,waitStatus是以0爲臨界值的,大於0表明節點無效,例如AQS在喚醒隊列中的節點時,waitStatus大於0的節點會被跳過。app
AQS內部還維護了int類型的state
變量,表明同步器的狀態。例如,在ReentrantLock
中,state
就表明鎖的重入次數,每lock一次,state就+1,每unlock一次,state就-1,當state等於0時,表明沒有上鎖。ide
AQS內部還維護了head
和tail
屬性,用來指向FIFO
隊列中的頭尾節點,被head
指向的節點,老是工做線程。線程在獲取到鎖後,是不會出隊的。只有當head釋放鎖,並將其後繼節點喚醒並設爲head後,纔會出隊。
示例程序:開啓三個線程:A、B、C,按順序依次調用lock()方法,這期間到底發生了什麼???
一、剛開始沒有任何線程競爭鎖,AQS內部結構是這樣的: 二、線程A調用lock()方法:
實際上是交給sync對象去上鎖了,Sync類就是一個繼承了AQS的類。
ReentrantLock默認採用的是非公平鎖,無論隊列中是否有等待線程,上來直接就嘗試利用CAS搶鎖,若是搶成功了,就將當前線程設爲exclusiveOwnerThread
並返回。 若是沒有成功,則調用acquire(1)
去獲取鎖。
// 非公平鎖的lock,上來直接就搶鎖,無論隊列中有沒有線程在等待。
final void lock() {
// CAS的方式將修改state,若是修改爲功,表示沒有其餘線程持有鎖,將當前線程設爲獨佔鎖的持有者
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
// CAS失敗,表明其餘線程已經持有鎖了,此時去競爭鎖
acquire(1);
}
複製代碼
公平鎖就顯得很是有禮貌,上來先詢問隊列中是否有線程在等待,若是有,則讓它們先獲取,本身入隊等待。
final void lock() {
acquire(1);
}
// 公平鎖-獲取鎖
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
/* 即便當前是無鎖狀態,也要判斷隊列中是否有線程已經在等待了。 若是有其餘線程在等待,要讓其餘線程先獲取鎖,本身入隊掛起。 若是隊列中無線程,則嘗試CAS競爭。 */
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;
}
複製代碼
無論是公平鎖仍是非公平鎖,線程A此時就能夠獲取到鎖並返回了,此時AQS的內部結構以下:
假設線程A還未釋放鎖,線程B調用lock(),競爭鎖失敗,則調用acquire(1)
去獲取鎖,這是AQS的模板方法。
/* 競爭鎖的流程: 1.tryAcquire():再次嘗試去獲取鎖。 2.addWaiter():若是還獲取不到,在隊列的尾巴添加一個Node。 3.acquireQueued():去排隊。 */
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 是否須要進行一次自我中斷,來補上線程等待期間發生的中斷。
selfInterrupt();
}
複製代碼
在acquire()方法中,首先會調用tryAcquire()去嘗試獲取鎖,若是獲取不到,則經過addWaiter()將當前線程封裝爲一個Node節點入隊,再調用acquireQueued()去排隊。 這裏有一點須要注意,AQS在排隊的過程當中,是不響應中斷的,若是排隊期間發生了中斷,只能等排隊結束後,AQS自動補上一個自我中斷:selfInterrupt()。
非公平鎖嘗試獲取鎖的流程以下:
// 非公平鎖嘗試獲取鎖
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// state==0,表明其餘線程已經釋放鎖了,再次CAS的方式修改state,成功則表明搶到鎖,返回。
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 其餘線程還沒釋放鎖,判斷持有鎖的線程是不是當前線程,若是是,則重入,state++。
// 可重入鎖,state就表明鎖重入的次數,0說明鎖釋放了。
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // 鎖不可能無限重入,重入的次數超過了int最大值後,就會拋異常。
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 其餘線程沒釋放鎖,當前線程又不是持有鎖的線程,則搶鎖失敗。
return false;
}
複製代碼
因爲線程A沒有釋放鎖,且線程B不是鎖的持有線程,所以tryAcquire()會返回false。
嘗試獲取鎖失敗,則開始建立Node節點,併入隊。addWaiter
代碼以下:
// 若是嘗試獲取鎖失敗,則入隊。
private Node addWaiter(Node mode) {
// 建立一個和當前線程綁定的Node節點
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
/* 若是tail不爲null,則經過CAS的方式將tail指向當前Node。若是失敗,會調用enq()重試。 1.當前節點的prev指向前任tail 2.CAS將tail指向當前節點 3.前任tail的next指向當前節點 這三個步驟不是原子的,若是執行到第2步時間片到期,持有鎖的線程釋放鎖喚醒節點時, 若是從head向tail找,此時前任tail節點的next仍是null,會存在漏喚醒問題。 而prev的賦值先於CAS執行,因此在喚醒隊列時,從tail向head找就沒問題了。 */
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 若是CAS入隊失敗,則自旋重試
enq(node);
return node;
}
複製代碼
若是tail不爲null,則將當前節點的prev指向現任tail,再經過CAS的方式將tail指向當前節點,最後前任tail的next指向當前節點便可。 這裏有一點須要注意:
這三個步驟不是原子的,若是執行到第2步時間片到期,持有鎖的線程釋放鎖喚醒節點時,若是從head向tail找,此時前任tail節點的next仍是null,會存在漏喚醒問題。而prev的賦值先於CAS執行,因此在喚醒隊列時,從tail向head找就沒問題了。
若是CAS入隊失敗也不要緊,下面會調用enq()
進行自旋重試,直到成功爲止:
// CAS入隊失敗,自旋重試
private Node enq(final Node node) {
for (;;) { // 這比while(true)好在哪裏???
Node t = tail;
if (t == null) {
// tail==null,說明隊列是空的,作初始化。
// 新建一個節點,head和tail都指向它。再進循環時,tail就不爲null了。
if (compareAndSetHead(new Node()))
tail = head;
} else {
/* 隊列不爲空 1.當前節點的prev指向前任tail 2.CAS將tail指向當前節點 3.前任tail的next指向當前節點 不斷重試,直到成功爲止 */
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
複製代碼
若是tail爲null,說明隊列還沒初始化,這時會建立一個Node節點,head和tail都指向這個空節點。再次循環時,因爲已經初始化了,進入else邏輯,仍是執行那三個步驟。
線程B入隊後,此時AQS的內部結構以下:
節點B成功入隊後,就是排隊的操做了。線程B是繼續競爭仍是Park掛起呢?
// Node入隊後,開始排隊
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;// 是否獲取鎖失敗
try {
/* 線程等待的過程當中是不響應中斷的,若是期間發生中斷, 則必須等到線程搶到鎖後進行自我中斷:selfInterrupt()。 */
boolean interrupted = false;//獲取鎖的過程當中是否發生中斷。
for (;;) {
final Node p = node.predecessor();// 獲取當前節點的前驅節點
/* 若是本身的前驅是head,本身就有資格去搶鎖,有兩種狀況: 一、做爲第一個節點入隊。 二、head釋放鎖了,喚醒了當前節點。 */
if (p == head && tryAcquire(arg)) {
/* 若是搶鎖成功,說明是head釋放鎖並喚醒了當前節點。 將head指向當前節點,failed = false表示成功獲取到鎖。 */
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 競爭鎖失敗,判斷是否須要掛起當前線程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 獲取不到鎖就掛起線程,不斷死循環,所以只要不出意外,最終必定能獲取到鎖。
// 若是沒有獲取到鎖就跳出循環了,說明線程不想競爭了,例如:鎖超時。
// 此時須要修改
if (failed)
cancelAcquire(node);
}
}
複製代碼
若是當前節點的前驅節點是head,則表明當前節點擁有競爭鎖的資格。分兩種狀況:
此時線程B並非被線程A喚醒了,而是第一種狀況,線程B會再次嘗試獲取鎖,可是因爲線程A還沒釋放,所以會失敗。 線程B獲取鎖失敗後,會執行shouldParkAfterFailedAcquire()
,判斷是否應該被Park掛起。
// 線程競爭鎖失敗是否要掛起
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
// 若是前驅節點的waitStatus=-1,當前節點就能夠安心掛起了。
return true;
if (ws > 0) {
// waitStatus以0爲分界點。0是默認值,小於0表明節點爲有效狀態,大於0表明節點無效,例如:被取消了。
// 若是前驅節點無效,就繼續向前找,直到找到有效節點,並將其next指向本身。中間無效的節點會被GC回收。
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// CAS的方式將前驅節點的waitStatus改成-1,表明當前節點在等待被前驅節點喚醒。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
複製代碼
線程被Park掛起的前提條件是:必須將前驅節點的waitStatus
設爲SIGNAL(-1)
,這樣當前驅節點釋放鎖時,纔會喚醒後繼節點。
因爲此時head節點的waitStatus
等於0,不知足條件,因此線程B會嘗試使用CAS的方式將其改成SIGNAL
,且這一次不會線程B不會被Park。 此時,AQS的內部結構是:
再次循環,因爲線程A還沒釋放鎖,線程B在此獲取鎖失敗,再次執行shouldParkAfterFailedAcquire()
,此時前驅節點的waitStatus
已是SIGNAL(-1)
了,因此線程B能夠安心Park了,返回true。
shouldParkAfterFailedAcquire()
返回true表明須要將線程B掛起,所以會執行parkAndCheckInterrupt()
:
// 掛起當前線程,等待被前驅節點喚醒
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
// 阻塞過程當中不響應中斷,期間若是發生了中斷,則補上自我中斷:selfInterrupt()。
return Thread.interrupted();
}
複製代碼
掛起的過程就很簡單了,調用了LockSupport.park()
方法。前面已經說過,AQS排隊的過程是不響應中斷的,若是期間發生了中斷,只能等待線程被喚醒後,補上自我中斷,因此這裏會返回線程的一箇中斷標誌。
線程B如今已經被Park掛起了,只能等待線程A的喚醒才能繼續運行。
acquireQueued()
方法中,有一個finally語句塊,它的做用是,若是線程沒有獲取到鎖就退出了循環,說明線程獲取鎖超時或者發生中斷了,那麼節點就無效了,須要將它出隊,調用cancelAcquire()
:
// 節點取消競爭鎖
private void cancelAcquire(Node node) {
// 忽略不存在的節點
if (node == null)
return;
node.thread = null;//取消綁定的線程
// 往前找,跳過CANCELLED的節點
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
Node predNext = pred.next;
// 將當前節點設爲CANCELLED,這樣即便出隊失敗也不要緊,喚醒節點時會跳過CANCELLED的節點
node.waitStatus = Node.CANCELLED;
if (node == tail && compareAndSetTail(node, pred)) {
/* 若是node是尾巴,就使用CAS將tail指向前驅節點,當前節點直接出隊。 出隊成功,將tail的next置空。 */
compareAndSetNext(pred, predNext, null);
} else {
/* 若是前面的操做失敗了,有兩種狀況: 1.當前node原來是尾巴,取消過程當中有新節點插入,現已不是尾巴。 2.當前node原來就是中間節點。 當前node是中間節點的話,就須要作兩件事: 1.修改前驅節點的waitStatus爲SIGNAL,讓其釋放鎖後記得喚醒後繼節點。 2.將前驅節點的next指向後繼節點,當前node出列。 出列的過程是容許失敗的,即便沒有出列,只要node的waitStatus設爲CANCELLED, head在喚醒後繼節點時也會跳過CANCELLED的節點。 修改前驅節點爲SIGNAL的過程也是容許失敗的,只要失敗了就會喚醒node的後繼節點, 讓後繼節點本身去修改前驅節點爲SIGNAL。 */
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
/* 修改前驅節點爲SIGNAL成功,將前驅節點的next指向當前節點的後繼節點,當前節點出列。 */
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
/* 失敗有兩種狀況: 1.當前節點的prev爲head,那老二就有資格去競爭了,喚醒當前節點的後繼節點。 2.修改前驅節點爲SIGNAL失敗,喚醒後繼節點,讓它本身去修改前驅節點的狀態。 */
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
複製代碼
節點取消獲取鎖的狀況有兩種須要考慮:
第一種狀況處理就簡單了,直接將tail指向當前節點的prev,而後prev的next置空,當前節點就出隊了。 第二種狀況就比較複雜,若是當前節點的前驅節點不是head的話,那麼就必須將前驅節點的waitStatus
設爲SIGNAL(-1)
,而後將前驅節點的next指向當前節點的next,當前節點的next的prev,指向前驅節點。 若是說當前節點的前驅節點是head,那麼就直接喚醒當前節點的後繼節點,由於老三變老二了,它有資格去競爭鎖了。
若是CAS修改節點的指向失敗了,也不要緊,喚醒當前節點的後繼節點,讓它本身去修改前驅節點的waitStatus
,當前節點能夠安心出隊。
很顯然,線程B是不會觸發cancelAcquire()
方法的。
假設,此時線程A依然沒有unlock
,此時線程C也要來獲取鎖。顯然線程C會競爭失敗,AQS會將其封裝爲Node節點入隊,並將線程B的Node節點的waitStatus
改成SIGNAL(-1)
,而後Park休眠。 此時,AQS的內部結構是:
線程B、C成功入隊並Park後,假設此時線程A執行unlock
釋放鎖: 釋放鎖的過程,實際上是調用了sync的
release()
,這也是AQS的模板方法:
// 釋放鎖
public final boolean release(int arg) {
/* 調用子類的tryRelease(),返回true表明成功釋放鎖。 對於ReentrantLock來講,state減小至0表明須要釋放鎖。 */
if (tryRelease(arg)) {
/* head就表明持有鎖的節點。 若是head的waitStatus!=0,說明有後繼節點在等待被其喚醒。 還記得線程入隊時,若是要掛起,必須將其前驅節點的waitStatus改成-1嗎??? 若是節點入隊不改前驅節點的waitStatus,它將沒法被喚醒。 */
Node h = head;
if (h != null && h.waitStatus != 0)
// 釋放鎖後要去喚醒後繼節點
unparkSuccessor(h);
return true;
}
return false;
}
複製代碼
AQS會調用子類實現的tryRelease()
,當它返回true就表明成功釋放了資源,AQS就會去喚醒隊列中的節點。
/* 嘗試釋放鎖,返回true表明鎖成功釋放。 只有持有鎖的線程才能釋放鎖,所以不存在併發問題。 */
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
// 不是持有鎖的線程執行釋放鎖,拋異常。
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
// 當state==0才須要真正的釋放鎖
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
複製代碼
ReentrantLock是可重入鎖,state表明鎖的重入次數,tryRelease()
就是state自減的過程。當state減至0就表明鎖成功釋放了,同時會將exclusiveOwnerThread
置空。
必須是持有鎖的線程才能調用tryRelease(),不然會拋異常。
線程A執行完tryRelease()
後,此時AQS的內部結構是:
tryRelease()
返回true,AQS就要去喚醒隊列中的節點了,執行unparkSuccessor()
:
// 喚醒後繼節點
private void unparkSuccessor(Node node) {
// 將waitStatus置爲0
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/* 若是後繼節點的waitStatus>0,表明節點是CANCELLED的無效節點,會跳過。 而後從尾巴開始向頭找,直到找到waitStatus <= 0的有效節點,並將其喚醒。 爲何要從尾巴找? 是由於節點入隊時,須要執行三個操做: 1.當前節點的prev指向前任tail 2.CAS將tail指向當前節點 3.前任tail的next指向當前節點 若是執行到步驟2時,時間片到期,此時前驅節點的next仍是null,會存在漏喚醒的問題。 而prev的賦值操做先於CAS執行,所以經過prev向前找總能找到。 */
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);
}
複製代碼
這裏有一個頗有意思的操做,當next節點無效時,AQS會跳過它,從新尋找有效的節點。AQS會從tail開始向head找,而不是從head向tail找,這是爲何呢???
從尾部向頭部找,是由於節點入隊時,須要執行三個操做:1.當前節點的prev指向前任tail、2.CAS將tail指向當前節點、3.前任tail的next指向當前節點。若是執行到步驟2時,時間片到期,此時前驅節點的next仍是null,會存在漏喚醒的問題。而prev的賦值操做先於CAS執行,所以經過prev向前找總能找到。
當節點被喚醒時,會從AQS的parkAndCheckInterrupt()
方法裏繼續執行,從新獲取鎖。
線程A釋放鎖,並將線程B喚醒,線程B會繼續去競爭。 此時線程B會競爭成功,同時會將head指向當前節點。 此時AQS內部結構是:
線程B運行一段時間後,也釋放鎖了,接着會喚醒線程C。
線程C會成功獲取到鎖:
線程C釋放鎖後,最後一個Node節點並不會出列,而是會保留。當下一次有線程來競爭鎖時,成功後會自動將前任head覆蓋。
最後再總結一下關於AQS幾個比較重要的問題。
當FIFO
隊列中的一個節點競爭到資源時,它並非就立刻出隊了,而是將head
指向本身。節點釋放鎖後依然不會主動出隊,而是等待下一個節點競爭鎖成功後修改head
的指向,將前任head踢出去。
當head
指向的節點成功釋放資源後,首先會判斷當前節點的waitStatus
是否等於0,若是等於0就不會去喚醒後繼節點了,這也就是爲何新的節點入隊休眠的前提是必須將前驅節點的waitStatus
改成SIGNAL(-1)
的緣由,若是不改,後繼節點將不會被喚醒,就會致使死鎖。
AQS首先會喚醒當前節點的直接後繼節點next
,若是next爲null,有兩種狀況:
next
指向它。第一種狀況好辦,後繼節點爲null,不喚醒就是了。 第二種狀況就須要從tail向head尋找了,找到了有效節點再喚醒。
若是存在直接後繼節點,可是節點的waitStatus
大於0,AQS也是會選擇跳過它的。前面已經說過,waitStatus
大於0的節點表明無效節點,如CANCELLED(1)
是已經取消競爭的節點。若是直接後繼節點是無效節點的話,AQS會從tail開始向head遍歷,直到找到有效節點,再將其喚醒。
總結:存在直接後繼節點且節點有效,則優先喚醒後繼節點。不然,從tail向head遍歷,直到找到有效節點再喚醒。
這是由於,新節點入隊時,須要執行三個步驟:
這三個操做AQS並無作同步處理,若是在執行步驟2後CPU時間片到期了,此時的節點指向是這樣的: 前驅節點的next尚未賦值,若是從頭向尾找,就可能會存在漏喚醒的問題。 而prev的賦值先於tail的CAS操做以前執行,所以從尾向頭找,就能夠避免這個問題。
佔個坑,問題能夠持續更新,想到了再更。有疑惑的同窗能夠評論告訴我,我會把我知道的記錄下來,你們一塊兒再探討一下。
到如今還很印象深入,年初的時候有一次面試,就被問到AQS,當時沒有靜下心來研究,有些概念仍是很模糊,答得不是很好,面試官也不太滿意。
剛好上週加班,今天調休,能夠寫篇文章放鬆一下。因而我決定挑戰一下個人軟肋AQS,靜下心來閱讀源碼才發現,過去看網上的博客,一直比較模糊的概念,其實代碼裏已經寫的很是清楚了。
過去讀不懂的源碼,日後慢慢都會讀懂!!!
你可能感興趣的文章: