彻底弄懂ReentrantLock —— 超详细的原码分析
共 900字,需浏览 2分钟
·
2020-10-13 05:35
点击上方蓝色字体,选择“标星公众号”
优质文章,第一时间送达
ReentrantLock 是基于 AQS
框架实现,JDK 中线程并发访问的同步技术,
它是一个互斥锁,也叫独占锁,支持可重入,支持锁的公平性。
大神Doug Lea
写的,整个AQS框架都是他一个人撸出来的,牛!
Doug Lea有多牛
就像:
谈喜剧电影,不可能不提星爷
谈相声,绕不开郭德钢
说中国教育,总得聊聊蔡元培
说到心理学,一定得说弗洛伊德
说到并发编程,Doug Lea不得不提
java1.6 之前, synchronized效率太低了,大神Doug Lea
就开发了AQS框架,就是解决并发问题。
JDK的出品公司SUN公司,一看,呀,这AQS性能那是相当可以,好吧,那就吸收进来,直接放JDK里吧。
synchronized可是SUN公司亲生的呀,可性能太差,SUN公司琢磨,要不优化下synchronized
于是乎,SUN公司的开发团队,历经数年,优化了synchronized,其性能大大提升,和AQS不相上下了。
从某种程度上说,Doug Lea 以一已之力,PK SUN公司整个开发团队,牛人!
这里从ReentrantLock
加锁解锁机制,由浅入深,让你彻底弄懂其原理。
一、应用场景
public static void main(String[] args) throws InterruptedException {
CountDownLatch downLatch = new CountDownLatch(1);
for(int i = 0; i < 10; i++){
new Thread(() -> {
try {
downLatch.await();
for(int j = 0; j < 1000; j++){
total++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
Thread.sleep(2000);
downLatch.countDown();
Thread.sleep(2000);
System.out.println(total);
}
这段代码,起10个线程,每个线程加 1000 次,期望值是 10000,但因为并发,最后会小于 10000
public static void main(String[] args) throws InterruptedException {
CountDownLatch downLatch = new CountDownLatch(1);
ReentrantLock lock = new ReentrantLock();
for(int i = 0; i < 10; i++){
new Thread(() -> {
try {
downLatch.await();
lock.lock(); // 加锁
for(int j = 0; j < 1000; j++){
total++;
}
lock.unlock(); // 解锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
Thread.sleep(2000);
downLatch.countDown();
Thread.sleep(2000);
System.out.println(total);
}
控制了并发访问,算出来的结果就是 10000
二、什么是AQS
简单讲,AQS(AbstractQueuedSynchronizer)
,是大神Doug Lea
写的,一个同步框架。
JDK 中 java.util.concurrent 包下,很多处理并发的工具类都直接或间接引用了AQS,比如ReentrantLock,Semaphore,CountDownLatch
……
AQS 这个抽象类中,有几个重要属性,咱先放在前面说一下。
首先说下,AQS 里面的
内部类Node
的几个属性
volatile int waitStatus; // Node里,记录状态用的
volatile Thread thread; // Node里,标识哪个线程
volatile Node prev; // 前驱节点(这个Node的上一个是谁)
volatile Node next; // 后继节点(这个Node的个一个是谁)
AQS 本身的属性
private transient Thread exclusiveOwnerThread; // 标识拿到锁的是哪个线程
private transient volatile Node head; // 标识头节点
private transient volatile Node tail; // 标识尾节点
private volatile int state; // 同步状态,为0时,说明可以抢锁
这里画了张图,很直观,咱们照着图来说
当多个线程来竞争锁的时候,若抢锁失败,则生成一个Node,与 Thread 绑定,进入队列,该线程就被阻塞。
当锁释放时,唤醒节点去抢锁。
这个由Node对象组成的队列,叫CLH队列,是一个先进先出的队列,双向指针。
三、ReentrantLock加锁逻辑
这里以非公平锁为例来说明,这里画了个简化的流程图,先有个印象
ReentrantLock这个类,并没有直接继承AQS,而在ReentrantLock有一个内部类,sync
,它继承了AQS。
ReentrantLock lock = new ReentrantLock();
// 当执行这段代码时,就会创建一个AQS 对象,其status是0,具体原码如下
public ReentrantLock() {
sync = new NonfairSync(); // 默认是非公平锁
}
lock.lock(); // 加锁
执行这行代码时,会涉及到 自旋、 CAS、 入队、 阻塞,下面照着原始说逻辑,具体先不展开。
// 非公平锁实现
final void lock() {
if (compareAndSetState(0, 1)) // 成功将state 由0改为1的线程,直接就可以拿到锁
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 没拿到锁,做特殊处理
}
// 获取独占锁
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
selfInterrupt(); // 在线程上打一个阻断标志
}
}
acquire()
是获取独占锁的核心方法,咱先把整体逻辑讲完,然后再对原码进行逐行分析。
tryAcquire(arg)
尝试获取锁,成功返回true,否则返回false
addWaiter(Node.EXCLUSIVE)
自旋的方式入队,直到成功入队为止,否则重试
acquireQueued(final Node node, int arg)
再次尝试获取锁,若成功则返回,失败了就阻塞线程。其中阻塞线程是调用了 LockSupport.park()
方法
四、ReentrantLock解锁逻辑
public void unlock() {
sync.release(1);
}
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(arg)
修改 state 属性,state = 0 时,即解锁成功
unparkSuccessor(h)
唤醒头节点,使其去抢锁,其中解除阻塞,是调用了LockSupport.unpark()
方法
用本文开头的代码,来个第一阶段的总结
lock.lock(); // 假设10个线程同时走到这行,只可能有一个线程拿到锁,继续往下走,其他线程阻塞,停在这一行。
for(int j = 0; j < 1000; j++){
total++;
}
lock.unlock(); // 执有锁的线程释放了锁,阻塞的线程有机会抢锁,
// 抢锁成功的可以往下走,其余的继续阻塞。
在加锁解锁过程中,用了CAS
,用了自旋
,简要说下它俩啥意思,然后再开始讲原码。
CAS 可以保证,不论并发有多高,只可能有一个线程执行成功。
比如说,现在账户里有10块,现在要给账户上加5块钱,三个线程同时执行这个操作。
CAS要求传两个值过去 原来的值10,改动后的值15。底层执行的时候,先比较一下,当前值是不是10。
如果不是,就直接返回false,如果是10,那就将10改为15。
这里比较并修改是原子操作,底层语言保证这一点。
刚刚说的那三个线程,只有一个线程会修改账户的钱,并返回true,其它两个会返回失败,不修改账户的钱。
自旋 简而言之就是死循环,比如 while(true){},名字很高大上,其实就那么回事。
至此,ReentrantLock加锁解锁,最基本的知识讲完了。若还不太理解,从头现看,不要看下边的。
如果说是初次接触ReentrantLock,那就不要往下看了,那是会看吐的。听话,别看!
欢迎转载,码字不易,请注明出处:https://editor.csdn.net/md/?articleId=108818343。
五、加锁原码分析
先看下,加锁的流程图,有个具体的印象,再看源码
假设有ABCD四个线程,同时执行到加锁这行代码。前文说过,state是0,代表可抢锁。
final void lock() {
if (compareAndSetState(0, 1)) // 假设A执行此代码成功
setExclusiveOwnerThread(Thread.currentThread()); // 标识哪个线程执有该锁
else
acquire(1);
}
// 这里compareAndSetState(0, 1),就是进行CAS操作
protected final boolean compareAndSetState(int expect, int update) {
// unsafe类调用的是native方法
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
// 线程A拿到了锁就直接返回了,那线程B C D 就进入 acquire(1) 方法
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
selfInterrupt();
}
}
先说 tryAcquire(arg) 就是抢锁
// tryAcquire 这个方法,非公平锁调用这个方法,传入参数是1,这个与可重入相关,待会再细说。
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); // 获取 state状态,
if (c == 0) { // state是0,继续抢锁。刚进入lock 没抢到,现在可能锁已释放,有可能这次就抢成功了。
if (compareAndSetState(0, acquires)) { // B C D 线程再次竞争拿锁
setExclusiveOwnerThread(current);
return true; // 拿到了锁,lock 方法就结束了。
}
}
// 来抢锁的线程,本身就执有锁,说明是再次加锁,state 再加 1
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow 内在溢出了,抛出异常
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false; // 抢锁失败
}
这个方法没有难理解的逻辑,不过,setState(nextc); 这个方法没有用 CAS,这里会不会是bug
明确的告诉你,不是,因为这里不存在并发
current == getExclusiveOwnerThread()
即当前线程是执有锁的线程,只可能有一个线程进入这人条件分支,不需要用CAS
这里就是ReentrantLock可重入的体现,state=0,指锁不属于任何线程,
当某线程首次抢到锁,state=1,
此线程未释放锁的情况下,再次抢到锁,state=2,
这种情况下,只有连续释放两次锁,其它线程才可能抢到该锁。
这就是 ReentrantLock 锁的可重入。
详细说说 acquireQueued(addWaiter(Node.EXCLUSIVE), arg),
addWaiter(Node.EXCLUSIVE),入队,自旋能保证,一定入队成功
addWaiter(Node.EXCLUSIVE) 这个方法就是用自旋的方式,保证线程入队
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode); // 线程与Node绑定
Node pred = tail;
if (pred != null) { // 队列已经初始化,直接将new 出来的node 放到队尾
node.prev = pred;
if (compareAndSetTail(pred, node)) { // CAS 设置队尾
pred.next = node;
return node;
}
}
// 走到这里,说明队列未初始化,或者上面并发入队,入队失败了。
enq(node);
return node;
}
// 自旋方式入队
private Node enq(final Node node) {
for (;;) { // 死循环,保证入队一定成功
Node t = tail;
if (t == null) { // 队尾是null,说明得初始化队列
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
还记得上文画的那个AQS的图么
还是刚刚的4个线程,假设此刻 A 还没有释放锁,那state一定不等于0,exclusiveOwnerThread记录的就是线程A,
当B C D线程都进入addWaiter方法时,图中 head==null , tail == null,
想入队就要先初始化,即 t == null那个分支的代码。
从原码来看,队列用了哨兵,初始化就是new一个node不与任何线程绑定,
如下图所示,这个就是头节点,thread==null,之后入队的,thread一定有值
若 B C D线程中 B线程进入if (t == null) { } 这段代码,就完成了初始化,
那 C D 线程进入else分支,
它们俩个,谁执行compareAndSetTail()成功,谁就入队,
假设是C入队成功(如下图),那 B D 线程进入下一轮循环,
那 B D 线程进入下一轮循环,若存在并发,失败那个得再循环入队一次,
若不存在并发,就顺次入队。但最终大概都是这个样子。
这个方法里的逻辑是巨复杂的,不太好理解。
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)) { // 是队列的第二个节点,并且抢锁成功
setHead(node);
p.next = null; // 从队列中剔除,等待GC 回收
failed = false;
return interrupted; // lock 方法结束
}
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) {
interrupted = true;
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}
// 设置头节点
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
先解释下这个条件 p == head && tryAcquire(arg) 前驱节点是head,且此节点抢锁成功,
结合上面那个图,B C D 三个节点,只有 C 节点才可以 抢锁,不要问为什么,这是规矩。
假设在 B C D 执行acquireQueued方法时,恰好A释放了锁,C 线程刚好抢到了锁,队列就变成上图的样子。
B D 线程进入shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt() 这两上方法。
原来代表C线程的node成为新的头节点(抢锁成功,就要出队)。
head 指向新的头节点,头节点thread 设置为null,
新旧头节点之间的指针去掉,旧的头节点等待GC回收。
在讲解shouldParkAfterFailedAcquire(p, node)
、 parkAndCheckInterrupt()
这两个方法之前,
说点题外话。
B C D 三个节点,只有 C 节点才可以 抢锁,Why ? 大神Doug Lea 就是这么写的,你想咋地!
个人认为,这样设计,代码实现简单,线程都已经进入等待队列了,说明并发比较高,
抢锁就派个代表出去就行了,别的继续在队列中等,出去抢锁的线程多了,CPU有意见。
再说,去的再多,也是只能是一个抢到锁。
派谁去,当然是头节点,去掉代码实现简单易懂,去其中任意一个,代码更加复杂。
不是说非公平锁么,那队列里,排在前面的先出队列去抢锁,很公平啊,哪里来的非公平?
这是个好问题,公平与非公平锁,不是在这里体现的。
不管公平锁还是非公平锁,队列里,都是前面那个出队抢锁,没区别,
公平与非公平,主要差别是tryAcquire() 这个方法上,
公平锁,若队列不为空,没入队的线程不得抢锁,
非公平锁,若队列不为空,没入队的线程却可以抢锁,
这时后到的线程可能先抢到锁,即不公平。
言归正传
shouldParkAfterFailedAcquire(p, node) 判断是否要阻塞线程
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus; // 前驱节点的waitStatus
if (ws == Node.SIGNAL) // Node.SIGNAL 表示 -1
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
这段代码逻辑很复杂,一句话,前驱节点 waitStatus是-1,就返回true.
不过,今天讲 lock() unlock() ,waitStatus都是初始状态0。
之后的博客中,还会再次讲到这个方法。
结合上图,以线程 B为例 waitStatus状态都是 0,前驱节点是头节点,其waitStatus也是0
首次进入shouldParkAfterFailedAcquire方法,
执行compareAndSetWaitStatus(pred, ws, Node.SIGNAL)这段代码之后,前驱节点waitStatus=-1,
之后走下一行 return false, compareAndSetWaitStatus 方法结束。
那在方法acquireQueued() 中 for (;;) {},第一次循环就结束了, B 没拿到锁
for (;;) {} 第二次循环开始,再次抢锁,
假设还没拿到,会第二次进入shouldParkAfterFailedAcquire()
此时B 线程的前驱节点,waitStatus=-1 该方法返回 true;(解锁时会讲到这一点)
那程序会走到 parkAndCheckInterrupt()这个方法里,阻塞线程
也就是说,经过两次循环,才会去阻塞线程,每次循环都会去抢锁的
Doug Lea
设计的真是好,发现这个线程需要阻塞,还要再给次抢锁的机会。
万一抢到了呢!真的是抢不到,再真正阻塞线程。
parkAndCheckInterrupt() 阻塞线程
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 阻塞线程
return Thread.interrupted(); // 清除线程中断标记
}
题外话
park()阻塞了线程,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。
Thread.interrupted()当且仅当 线程被阻断时返回true,它还会清除当前线程的中断标记位,
若要了解 unpark() interrupt() 唤醒有何不同,请参看我的另外一篇博客——park后是如何被唤醒的
至此,lock 调用的内层代码讲完了,再回头看下抢锁的总逻辑
// 线程A拿到了锁就直接返回了,那线程B C D 就进入 acquire(1) 方法
public final void acquire(int arg) {
// 抢锁,直到抢到为止,没抢到就在acquireQueued一直自旋转。
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
selfInterrupt(); // 如果线程被中断过,给线程打个标记
}
}
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
上面这段代码很有意思,作为题外话说说,当然你可以不看,有点绕
。最好别看,看了会看糊涂的。
想想这段代码 Thread.currentThread().interrupt(); 什么时候会执行?
只有tryAcquire(arg)返回 false 且 acquireQueued() 返回 true 的时候才可以。
再看下,acquireQueued()什么时候才会返回 true 呢? 咱再看下原码
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)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
接着上文说,B线程 在for (;;)中,第二次进入shouldParkAfterFailedAcquire(),返回true,
进入parkAndCheckInterrupt(),调用 LockSupport.park(this)后,
返回 return Thread.interrupted();
而 Thread.interrupted() 结果是false,因为目前 B线程 未调用过 interrupt(),继续自旋,
若在自旋过程中,其它的代码调用了interrupt(),那么 parkAndCheckInterrupt()会返回 true,
interrupted = true; 会被执行。
那 B线程在自旋过程中 parkAndCheckInterrupt() 会清除掉中断标记,但 interrupted = true是不会。
最终在acquire() 方法里,会明确的知道拿到锁的线程,曾经是否被中断过,若中断过,会重新在线程上做标记。
还有一点要说的
线程B 在自旋过程中,第二次 for 循环,会调用LockSupport.park(this)方法,程序就暂停了,不会再占用CPU资源了。
当被unpark()或interrupt()唤醒时,会接着自旋,要么拿到了锁,
要么重新调用LockSupport.park(this)方法,继续等待。
具体是被unpark()唤醒的,还是被interrupt()唤醒的,程序上做了区分。
区分不区分,lock(), unlock() 用不到,啥时候乃至,之后的博客会写。
好了,加锁的原码,已讲解完了。真的是很复杂,相对应的,解锁的原码简单多了。
六、解锁原码分析
先看图,加锁与解锁画在一起,直观感受下
public void unlock() {
sync.release(1);
}
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(int releases)
是修改state值的
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;
}
这段代码比较容易理解,也不存在并发,
调用一次 unlock(),state 值减1,当state = 0,即解锁成功,清除线程。
也就是说,连续加锁三次,即调用了三次lock() 方法,state=3,
解锁也得是3次,否则锁不会被释放。
unparkSuccessor(Node node)
是唤醒节点的
if (h != null && h.waitStatus != 0) // h == null 无等待队列,h.waitStatus == 0,说明后面没有阻塞的队列,即不需要唤醒。
unparkSuccessor(h);
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); // 负值设置为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); // 将后继节点唤醒。
}
// 这段代码其实很有意思的,说点题外话
shouldParkAfterFailedAcquire() 这个方法还记得不,
要阻塞节点的前提是,该节点的前驱节点 waitStatus= -1
unparkSuccessor 这个方法中,s 是后继节点,若是null,说明不存在等待队列,无需唤醒。
for() 循环中代码,可保证,s 是最前面的那个排除节点,唤醒它抢锁,因为锁释放了。
七、公平锁的实现逻辑
前面介绍是以非公平锁为例子来说明的,公平锁实现与这类似。
先记住差别,前面也说过了,
公平锁,等待线程不为空,只有入队才可以抢锁
非公平锁,等待线程不为空,也可以抢锁。
现在看原码级别的实现
ReentrantLock lock = new ReentrantLock(true); // 传入参数 true 即是公平锁
final void lock() { // 公平锁lock方法,直接调用acquire(), 非公平锁是先抢锁,失败用调用acquire()
acquire(1);
}
公平锁与非公平锁,调用的 actuire() 方法是一样的,不一样的是tryAcquire()
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
// 公平锁tryAcquire()逻辑,与非公平锁区别是多了!hasQueuedPredecessors() 这个判断
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;
}
公平锁与非公平锁,差别就一行代码 hasQueuedPredecessors() ,看下它的原码
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
这个方法很精炼,直接说逻辑,
若等待队列为空,即未初始化,返回 false;
若等待队列已初始化,哨兵结点没有后继结点,返回false;
若哨兵结点有后继结点,后继结点的线程是当前线程,返回false;
其它情况返回 true
返回false就是可以抢锁。
理解了这个之后,再看 tryAcquire()
if (c == 0) { // state 是 0 ,可以抢锁
if (!hasQueuedPredecessors() && // 等待队列中有线程在等待时,只有头节点的后继线程可抢锁,其它没资格。
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
由此看出,tryAcquire() 方法保证了,抢锁时,若等待队列中有线程在等待,外来的线程就不能抢锁,只能先入队,即排在后面。
八、小结
欢迎转载,码字不易,请注明出处:https://editor.csdn.net/md/?articleId=108818343。
ReentrantLock 中 lock(), unlock() 方法的分析,至此结束,从中可以看出以下几点
ReentrantLock 可实现公平锁和非公平锁,其差别是,等待队列有线程等待时,抢锁逻辑不同
ReentrantLock 手动加锁,解锁。加锁解锁次数必需相等,否则锁不会被释放
ReentrantLock 可实现锁的重入,这与state数值有关
另外,ReentrantLock 锁有可中断的特性,本文没有涉及到,随后的博客会讲解这个。
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:
https://blog.csdn.net/every__day/article/details/108818343
感谢点赞支持下哈