深入圖解AQS實現原理和源碼分析


AQS底層實現原理用一句話總結就是:volatile + CAS + 一個虛擬的FIFO雙向隊列(CLH隊列)。所以在了解AQS底層實現時,需要先深入了解一下CAS實現原理。

#名詞解釋
(1)CAS:無鎖的策略使用一種比較交換的技術(Compare And Swap)來鑒線程修改沖突,一旦檢測到沖突產生,就重試當前操作直到沒有沖突為止。
(2)AQS:AbstractQuenedSynchronizer抽象的隊列式同步器,主要提供了一些鎖操作的模板方法。J.U.C都是基於AQS實現的。
1.CAS底層原理
java中CAS操作都是通過Unsafe類實現的,Unsafe類是在sun.misc包下,不屬於Java標准,但是很多Java基礎類庫的CAS操作都是使用Unsafe。包括一些被廣泛使用的高性能開發庫都是基於Unsafe類開發的,比如Netty、Hadoop、Kafka等。

 使用Unsafe的CAS進行一個變量進行修改,實質是直接操作變量的內存地址來實現的,CPU需要通過尋找變量的物理地址來讀取或修改變量。那么我們需要先了解一下CPU是怎么尋址物理地址的,這會涉及到計算機底層的段地址、偏移量和邏輯地址(即物理地址)相關概念,下面以8086 CPU處理器為例來講解。

(1)段地址、偏移量和邏輯地址關系
#段地址、偏移量和邏輯地址(即物理地址)的關系:
(1)關系:8086 CPU的邏輯地址由段地址和段內偏移量兩部分組成
物理地址 = 段地址*16 + offset偏移量。

(2)段地址
8086 CPU能提供20位的地址信息,可直接對1M(1M = 1024 * 1024 = 2^20)個存儲單元進行訪問,而CPU內部可用來
提供地址信息的寄存器都是16位,那怎樣用16位寄存器來實現20位地址尋址呢? 8086 CPU的20位的地址信息可以對1M個內存單元進行訪問,
也就是說編址00000H~FFFFFH,而段寄器CS,DS,SS,ES即存放了這些地址的高4位。
如:123456H,則某個段寄存器便會存儲1234H高4位信息,這即為段地址。

(3)偏移量:段內偏移地址就是移動后相對於段地址的偏移量。

(4)邏輯地址(物理地址):物理地址就是地址總線上提供的20位地址信息。
物理地址 = 段地址*10H + 段內偏移地址。段地址乘以10H是因為段地址當時是取高四位得到的,
所以還原后要讓段地址左移4位(10H = 10000B)。

例如:(cs)= 20A8H,(offset)=2008H,則物理地址為20A8H*10H+2008H = 22A88H。
(2)Unsafe中CAS實現原理
Unsafe的CAS操作方法基本都是native方法,具體的實現是由C實現的,下面以compareAndSwapInt()方法為例看看處理器低層是怎么實現CAS操作的。

#1.源碼
public final native boolean compareAndSwapInt(Object o,long offset,int expect,int update)

#2.調用:該方法一般的調用形式是:object對象傳的是this
unsafe.compareAndSwapInt(this, Offset, expect, update)
CAS操作的比較原理:

1)CPU的尋址方式:處理器會找到this寄存器cs里的段地址,通過段地址*16 + Offset偏移量得到物理地址。
2)將物理地址處的存儲值 和 期望值expect比較,如果相等,則進行update操作后返回true;不相等返回false。
3)比較操作 和 更新賦值操作是原子進行的,CPU處理器是通過lock鎖實現的(總線鎖和緩存鎖)。
CAS操作在intel X86處理器的源代碼的片段

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest,jint compare_value) {
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
#程序會根據當前處理器的類型來決定是否為cmpxchg指令添加lock前綴,lock鎖的實現:總線鎖和緩存鎖
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
CAS操作指令解析

1)is_MP()函數判斷當前處理器是多核,還是單核。
2)如果程序是在多處理器上運行,就為cmpxchg指令加上lock前綴(Lock Cmpxchg)。如果程序是在單處理器上運行,就省略lock前綴。
intel處理器lock前綴作用

1)確保對內存的讀-改-寫操作原子執行。處理器會使用總線鎖 或 緩存鎖來保持原子性。
2)禁止該指令,與之前和之后的讀和寫指令重排序。
3)把CPU寫緩沖區中的所有數據刷新到內存中,使其它CPU緩存失效。
2.AQS實現原理
AQS全稱為AbstractQueuedSynchronizer,AQS定義了一套多線程訪問共享資源的同步器框架,為java並發同步組件提供統一的底層支持。常見的有:Lock、ReentrantLock、ReentrantReadWriteLock、CyclicBarrier、CountDownLatch、ThreadPoolExecutor等都是基於AQS實現的。

AQS是一個抽象類,主要是通過繼承的方式實現其模版方法,它本身沒有實現任何的同步接口,僅僅是定義了同步狀態的獲取以及釋放的方法來提供自定義的同步組件。

(1)AQS的獨占鎖和共享鎖
獨占鎖:每次只能有一個線程持有鎖,比如ReentrantLock就是以獨占方式實現的互斥鎖。

共享鎖:允許多個線程同時獲取鎖,並發訪問共享資源,比如ReentrantReadWriteLock。

(2)AQS內部實現
AQS的實現依賴內部的FIFO的雙向隊列同步(只是一個虛擬的雙向隊列)和共享鎖state變量。當線程競爭鎖(state變量)失敗時,就會把AQS把當前線程封裝成一個Node加入到同步隊列中,同時再阻塞該線程;當獲取鎖的線程釋放鎖以后,會從隊列中喚醒一個阻塞的節點(線程)。 ASQ具體實現如下圖:

 

#1.AQS底層實現原理
volatile + CAS + 一個虛擬的FIFO雙向隊列(CLH隊列)。

#2.AQS同步隊列特點:
(1)AQS同步隊列內部維護的是一個FIFO的雙向鏈表,雙向鏈表可以從任意一個節點開始很方便的訪問前驅和后繼。
(2)添加節點:每個Node其實是由線程封裝的(node里面包含一個thread變量),當線程競爭鎖失敗后會封裝成Node加入到ASQ同步隊列尾部。
(3)移除節點:AQS同步隊列中每個node都在等待同一個資源(鎖狀態state變量)釋放,鎖釋放后每次只有隊列的第二個節點(head的后繼節點)才有機會搶占鎖,如果成功獲取鎖,則此節點晉升為頭節點。
(3)AQS的源碼分析
為了方便后面的源碼理解,我們先了解一下AQS和Node類的主要內部變量的功能。

//1.AQS的主要結構
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer{
//AQS的雙向鏈表隊列的頭節點
private transient volatile Node head;
//AQS的雙向鏈表隊列的未節點
private transient volatile Node tail;
//鎖的狀態
private volatile int state;
//AQS的內部類node節點,由線程封裝而來的
static final class Node {}
}

//2.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調用了signal()后節點將會從等待隊列中轉移到同步隊列中,參與鎖獲取。
static final int CONDITION = -2;
//表示下一次共享式同步狀態獲取將會無條件地傳播下去
static final int PROPAGATE = -3;
//等待狀態
volatile int waitStatus;
//等待隊列中的后繼節點
Node nextWaiter;
//前驅節點
volatile Node prev;
//后繼節點
volatile Node next;
//獲取鎖狀態的線程
volatile Thread thread;
}
為了弄清楚AQS的基本框架,我們以ReentrantLock的非公平鎖為例來分析AQS的源碼。先通過一個示例看看ReentrantLock鎖是怎么使用的,后面我們再進一步分析ReentrantLock加鎖 和 解鎖的過程。

@Test
public void testLock() {
ReentrantLock lock = new ReentrantLock();
try {
lock.lock();
System.out.println("其他同步業務操作");
} catch (Exception e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
ReentrantLock構造器

//1.默認情況下ReentrantLock使用的非公平鎖NonfairSync
public ReentrantLock() {
sync = new NonfairSync();
}

//2.可以指定ReentrantLock使用NofairSync(非公平鎖) 還是 FailSync(公平鎖)
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
ReentrantLock中的非公平鎖(NofairSync)、公平鎖(FailSync)都是Sync這個類的實現,Sync又是繼承了AQS抽象類。

公平鎖:表示所有線程嚴格按照FIFO來獲取鎖。
非公平鎖:可以直接參與搶占鎖,也就是說不管當前同步隊列上是否存在其他線程等待,新線程都有機會搶占鎖。
1)加鎖過程
ReentrantLock中非公平鎖的加鎖lock()方法源碼調用過程的時序圖如下:

 

從上圖可以看出,當鎖獲取失敗時,會調用addWaiter()方法將當前線程封裝成Node節點加入到AQS同步隊列尾部;然后再調用acquireQueued()方法將加入尾部隊列的node進行阻塞。

ReentrantLock.lock()源碼

//ReentrantLock加鎖時,獲取鎖的入口是調用抽象類sync里面的方法。sync的具體實現在ReentrantLock構造時已經指定實例:NofairSync(非公平鎖) 或 FailSync(公平鎖)。
public void lock() {
sync.lock();
}
NofairSync.lock()源碼實現

//(1)獲取鎖lock()
final void lock() {
//非公平鎖只要加鎖,當前線程就會去競爭鎖state,通過compareAndSetState()嘗試鎖的競爭
if (compareAndSetState(0, 1))
//獲取鎖成功,設置鎖擁有的線程
setExclusiveOwnerThread(Thread.currentThread());
else
//獲取失敗
acquire(1);
}

//(2)使用CAS進行獲取鎖的狀態,stateOffset是AQS里鎖狀態state的偏移地址
protected final boolean compareAndSetState(int expect, int update) {
//原子性操作:通過cas樂觀鎖的方式來做比較並替換。如果當前內存中的state的值和預期值expect相等,則替換為update。更新成功返回true,否則返回false。
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
(1)鎖狀態state在AQS是一個volatile的int變量,當state=0時,表示鎖沒被占用,其它線程可以獲取鎖資源;state>0時,表示鎖已經被占用。AQS使用了volatile+CAS來保證鎖狀態state的原子性和可見性。

(2)ReentrantLock是可重入鎖,同一個線程多次獲得同步鎖的時候,state會遞增;釋放鎖時state會遞減。比如重入3次,那么state=3,對應釋放鎖也會釋放3次直到state=0后,其他線程才有資格獲取鎖。

acquire(int arg)源碼

主要完成了鎖狀態獲取、節點構造、加入到同步隊列中以及同步隊列中節點被喚醒時自旋獲取鎖資源的相關工作。同步鎖狀態獲取流程,也就是acquire(int arg)方法調用流程如下圖:

 

(1)調用tryAcquire再嘗試鎖的獲取。

(2)如果獲取失敗調用addWaiter將當前線程封裝成node加到AQS同步隊列的尾部。

(3)最后再調用acquireQueued使節點以自旋方式來獲取鎖狀態。如果獲取不到則阻塞當前節點中的線程,而阻塞線程的喚醒主要依靠前驅節點的出隊 或 阻塞線程被中斷來實現。

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

(1)tryAcquire方法在AQS中是個模板方法,具體實現在NonfairSync中。

(2)方法主要作用是判斷state鎖是否被占用,如果沒被占用會用CAS嘗試獲取鎖;如果被占用了再判斷是否是同一個線程鎖重入,如果是重入鎖就將重入鎖的次數加1。

protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}

final boolean nonfairTryAcquire(int acquires) {
//先獲取當前線程
final Thread current = Thread.currentThread();
//獲取鎖state狀態
int c = getState();
//如果鎖state=0說明鎖沒被占用,再用CAS嘗試修改鎖的狀態來獲取鎖
if (c == 0) {
if (compareAndSetState(0, acquires)) {
//獲取鎖成功后,設置鎖擁有的線程
setExclusiveOwnerThread(current);
return true;
}
}
//鎖state被占用時,如果是同一個線程多次重入鎖,則直接增加重入次數
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires; //增加重入次數
if (nextc < 0) // overflow溢出int的最大值時,拋出異常
throw new Error("Maximum lock count exceeded");
//重新設置鎖state值
setState(nextc);
return true;
}
return false;
}
addWaiter()源碼

(1)addWaiter()方法是將當前線程封裝成node,然后通過自旋使用CAS操作添加到AQS同步隊列的尾部(tail)。

(2)如果是AQS同步隊列為空,表示第一次添加node,需要初始化AQS同步隊列,即初始化隊列的head頭;如果是cas失敗,則調用enq自旋將節點添加到AQS同步隊列。

private Node addWaiter(Node mode) {
//將當前線程組裝成一個node
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
//判斷AQS同步隊列是否需要初始化,只有第一次添加node時需要初始化head節點。
if (pred != null) {
//如果AQS不是空隊列,會將新的node節點通過CAS添加到隊里的尾部
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//如果隊列為空或者cas失敗,進入enq初始化隊列或將節點添加到AQS同步隊列中
enq(node);
return node;
}

//初始化隊列或通過自旋將node添加到隊列的尾部
private Node enq(final Node node) {
//自旋添加節點到隊列尾部
for (;;) {
Node t = tail;
//AQS為空時使用CAS初始化隊列
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
//隊列不為空就將node節點追加到AQS同步隊列的尾部
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
addWaiter通過自旋向隊列中添加節點時,會涉及到三步操作,例如下圖向只有兩個node的AQS同步隊列中添加一個node3

 

第一步:先將舊隊列的尾節點node2賦給新加節點node3的前驅prev,即node3.prev指向節點node2。

第二步:再通過CAS操作,將tail重新指向新加節點node3。

第三步:如果上面的CAS成功,將舊隊列尾節點node2的next指向新加節點node3,即完成雙向指針操作。

acquireQueued()源碼

節點進入同步隊列之后,會判斷節點(或者說線程)的前驅prev節點是不是head節點,如果是就獲取鎖資源方法結束;如果不是節點就被park()掛起(即阻塞)。當節點被unpark()喚醒時,會繼續判斷前驅prev節點是不是head節點,如果是就獲取鎖資源方法結束;如果不是又被park()掛起,等待下一次的park()。所以等待隊列中的節點就一直這樣循環(自旋)下去,直到獲取鎖成功。等待隊列中的節點可以理解成如下圖的“自旋”:

 

(1)acquireQueued方法主要是進行自旋搶占鎖的操作 ,如果當前節點node的前驅節點搶占鎖失敗時,會根據前驅節點等待狀態(waitStatus)來決定是否需要掛起線程。

(2)每個節點線程在“自旋”中嘗試獲取同步狀態,而只有前驅節點是頭節點才能夠嘗試獲取鎖的同步狀態(state)。

(3)如果搶占鎖的操作拋出異常,會通過cancelAcquire方法取消獲得鎖的操作,並將當前node進行出隊操作。

final boolean acquireQueued(final Node node, int arg) {
//操作失敗標記,操作出現異常時需要將node移除隊列
boolean failed = true;
try {
//中斷標記位
boolean interrupted = false;
//自旋
for (;;) {
//獲取當前節點的前驅prev節點
final Node p = node.predecessor();
//如果前驅prev節點是head節點時,才有資格進行鎖搶占
if (p == head && tryAcquire(arg)) {
//前驅prev節點搶占鎖成功后,重新設置head頭:將舊head的后繼節點next設置為新head頭,所以鎖釋放后每次只有隊列的第二個節點(head的后繼節點)才有機會搶占鎖。
setHead(node);
//斷開舊head節點:凡是head節點head.thread與head.prev永遠為null, 但是head.next不為null,所以只需要斷開head.next。
p.next = null; // help GC
failed = false;
return interrupted;
}
//如果獲取鎖失敗,會根據節點等待狀態waitStatus來決定是否掛起線程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())//若前面為true,則執行掛起,待下次喚醒的時候會檢測中斷的標志
interrupted = true;
}
} finally {
//如果拋出異常則取消鎖的獲取,再將node進行出隊操作
if (failed)
cancelAcquire(node);
}
}

//(1).shouldParkAfterFailedAcquire()方法主要作用是:把隊列中node前的CANCELLED的節點給剔除掉。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
//獲取node前繼節點pred的等待狀態
int ws = pred.waitStatus;
//如果是SIGNAL狀態,意味着node前繼節點的線程需要被unpark喚醒
if (ws == Node.SIGNAL)
return true;
//如果node前繼節點pred的等待狀態大於0,即為CANCELLED狀態時,則會從pred節點往前一直找到一個沒有被CANCELLED的節點設置為pred,即當前node節點的前驅節點。在尋找的過程中會把隊列中CANCELLED的節點剔除掉(下面會用圖進行講解)
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//如果node的前繼節點pred為初始狀態0或者“共享鎖”狀態,則設置前繼節點為SIGNAL狀態。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}

//(2).parkAndCheckInterrupt()阻塞當前線程,等待喚醒的時候再檢測中斷的標志
private final boolean parkAndCheckInterrupt() {
//park阻塞當前node線程,等待unpark喚醒
LockSupport.park(this);
//線程被喚醒時,再判斷是否是中斷狀態
return Thread.interrupted();
}
shouldParkAfterFailedAcquire()方法中while循環的作用是:從當前節點的node.prev向前遍歷,一直找到一個沒有被CANCELLED的節點,在尋找過程中會把狀態為CANCELLED的節點給剔除掉。如下圖當前節點是node4,AQS同步隊列里節點node2是取消狀態(CANCELLED)時,就會進入該while循環移除掉節點node2。

 

到此ReentrantLock加鎖的整個過程就分析完了,在加鎖過程中有幾個地方需要注意

(1)當同一個線程多次重入鎖,直接增加重入次數,即將鎖的狀態state加1。

(2)addWaiter使用自旋 + CAS將新node添加到AQS同步隊列中,其中自旋的目的是為了防止CAS失敗。

(3)每次只有head節點才有資格進行搶占鎖資源(state),head釋放鎖后只有隊列的第二個節點(head的后繼節點)才有機會搶占鎖。

(4)節點會根據等待狀態(waitStatus)來決定是否掛起線程,在進行掛起線程操作時,會移除掉狀態為CANCELLED的節點。

獲取鎖過程總結:在獲取同步狀態時,AQS同步器維護一個同步隊列,獲取鎖狀態失敗的線程都會被加入到隊列尾部、並在隊列中進行自旋;移出隊列(或停止自旋)的條件是前驅節點成為頭節點、且成功獲取了同步狀態。在釋放同步狀態時,同步器調用tryRelease(int arg)方法釋放同步狀態,然后喚醒頭節點的后繼節點。

2)解鎖過程
相對於加鎖過程,解鎖就更為簡單了,ReentrantLock中非公平鎖的解鎖unlock()方法調用的時序圖如下:

 

ReentrantLock.unlock()源碼

public void unlock() {
//將鎖的狀態state減1
sync.release(1);
}
release()源碼

調用這個release()方法干了兩件事:1.釋放鎖 ;2.喚醒AQS同步隊列里一個節點(park線程)。

public final boolean release(int arg) {
//嘗試釋放鎖state
if (tryRelease(arg)) {
//如果釋放鎖成功,喚醒同步隊列里一個節點(park線程)
Node h = head;
if (h != null && h.waitStatus != 0) //如果head是初始化節點時,不需要喚醒其他線程
//通過unpark喚醒同步隊列里一個阻塞線程
unparkSuccessor(h);
return true;
}
return false;
}
tryRelease()源碼

tryRelease釋放鎖時,如果是鎖重入情況下釋放鎖,則減少鎖state的重入次數(即減少state的值),直到鎖的狀態state=0時,才真正的釋放掉鎖資源,其他線程才能有資格獲取鎖。例如一個線程重入鎖3次,即鎖狀態state=3,每執行tryRelease一次就釋放鎖一次state就減1,直到state=0時鎖資源才真正釋放掉。

protected final boolean tryRelease(int releases) {
//同一個線程每釋放一次鎖state就減1
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
//鎖釋放標志
boolean free = false;
//鎖state=0時才真正釋放掉鎖,將鎖持有線程設置為null
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
//更新鎖的狀態(不需要CAS操作,因為釋放鎖操作是已經獲得鎖的情況下進行的)
setState(c);
return free;
}
unparkSuccessor()源碼

unparkSuccessor就是真正要釋放了后,傳入head節點喚醒下一個節點線程。線程喚醒邏輯可以總結成一下幾點:

(1)如果head節點其后繼next節點waitStatus在等待喚醒狀態(SIGNAL),則直接unpark后繼節點(同步隊列第二個節點) 。

(2)如果head節點其后繼next節點waitStatus不是在等待狀態(SIGNAL),就從隊列尾部向前遍歷找到一個waitStatus在等待喚醒狀態的節點進行喚醒 。留一個思考:為什么是從隊列尾部向前遍歷,而不是從前向尾部遍歷?

//傳入的參數node就是head節點
private void unparkSuccessor(Node node) {
//獲取節點的等待狀態
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
//如果head的后繼節點waitStatus為取消狀態(CANCELLED)時,進行從隊列尾部向前遍歷尋找等待狀態的node
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;
}
//unpark喚醒下一個線程
if (s != null)
LockSupport.unpark(s.thread);
}
unparkSuccessor()方法里循環遍歷從隊列尾部向前遍歷原因是防止死循環。因為在鎖競爭acquireQueued()方法中,異常處理cancelAcquire()方法中最后的node.next = node操作,會出現如下圖的環狀結構導致死循環。

 

到此lock加鎖和解鎖的源碼就分析結束了,看了這么多AQS源碼最值得借鑒的兩個思路就是:第一使用了CAS + volatile來保證鎖state操作的原子性和可見性;第二使用一個虛擬的FIFO雙向隊列來解決線程沖突問題。研究源碼會讓自己借鑒很多思維方式,並運用到自己的代碼中,時間久了你會發現level提升了不少。

每次痛苦的掙扎,都會迎來新的進步,越是吃力的時候越要堅持,過了這一陣會發現自己能力提升了不少,千萬不要在溫水中呆久了。
————————————————
版權聲明:本文為CSDN博主「有鹽先生」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/Seky_fei/article/details/106111832


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM