JUC-AQS详解(十八)

一、AQS 前置知识

  1. 公平锁和非公平锁
  2. 可重入锁
  3. LockSupport
  4. 自旋锁
  5. 数据结构之链表
  6. 设计模式之模板设计模式

二、AQS 是什么?

1、字面意思

AQS(AbstractQueuedSynchronizer):抽象的队列同步器

一般我们说的 AQS 指的是 java.util.concurrent.locks 包下的 AbstractQueuedSynchronizer,但其实还有另外三种抽象队列同步器:AbstractOwnableSynchronizer、AbstractQueuedLongSynchronizer 和 AbstractQueuedSynchronizer

在这里插入图片描述

2、技术翻译

AQS 是用来构建锁或者其它同步器组件的重量级基础框架及整个JUC体系的基石, 通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量(state)表示持有锁的状态

CLH:Craig、Landin and Hagersten 队列,是一个双向链表,AQS中的队列是CLH变体的虚拟双向队列FIFO

在这里插入图片描述

三、AQS 是 JUC 的基石

和AQS有关的并发编程类

在这里插入图片描述

举几个常见的例子

1.ReentrantLock
在这里插入图片描述
2.CountDownLatch
在这里插入图片描述
3.ReentrantReadWriteLock
在这里插入图片描述
4.Semaphore
在这里插入图片描述
5. …

进一步理解锁和同步器的关系

锁,面向锁的使用者。定义了程序员和锁交互的使用层API,隐藏了实现细节,你调用即可,可以理解为用户层面的 API。

同步器,面向锁的实现者。比如Java并发大神Douglee,提出统一规范并简化了锁的实现,屏蔽了同步状态管理、阻塞线程排队和通知、唤醒机制等,Java 中有那么多的锁,就能简化锁的实现啦。

四、AQS 能干嘛

AQS:加锁会导致阻塞

有阻塞就需要排队,实现排队必然需要有某种形式的队列来进行管理

抢到资源的线程直接使用办理业务,抢占不到资源的线程的必然涉及一种排队等候机制,抢占资源失败的线程继续去等待(类似办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。

既然说到了排队等候机制,那么就一定 会有某种队列形成,这样的队列是什么数据结构呢?如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node) ,通过CAS、自旋以及LockSuport.park()的方式,维护state变量的状态,使并发达到同步的效果。
在这里插入图片描述

五、AQS 初步认识

1、AQS初识

官网解释
在这里插入图片描述
有阻塞就需要排队,实现排队必然需要队列

  1. AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的
    FIFO队列来完成资源获取的排队工作将每条要去抢占资源的线程封装成 一个Node节点来实现锁的分配,通过CAS完成对State值的修改。
  2. Node 节点是啥?答:你有见过 HashMap 的 Node 节点吗?JDK 用 static class Node<K,V>
    implements Map.Entry<K,V> { 来封装我们传入的 KV 键值对。这里也是一样的道理,JDK 使用 Node
    来封装(管理)Thread
  3. 可以将 Node 和 Thread 类比于候客区的椅子和等待用餐的顾客

在这里插入图片描述

2、AQS内部体系架构

0、AQS 内部体系框架

在这里插入图片描述
1、AQS的int变量

AQS的同步状态State成员变量,类似于银行办理业务的受理窗口状态:零就是没人,自由状态可以办理;大于等于1,有人占用窗口,等着去

/**
 * The synchronization state.
 */
private volatile int state;

2、AQS的CLH队列

CLH队列(三个大牛的名字组成),为一个双向队列,类似于银行侯客区的等待顾客
在这里插入图片描述
3、内部类Node(Node类在AQS类内部)

Node的等待状态waitState成员变量,类似于等候区其它顾客(其它线程)的等待状态,队列中每个排队的个体就是一个Node

/**
 * 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;

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唤醒
    static final int CONDITION = -2;
    
    //共享式同步状态获取将会无条件地传播下去
    static final int PROPAGATE = -3;
    
    // 初始为e,状态是上面的几种
    volatile int waitStatus;
    
    // 前置节点
    volatile Node prev;
    
    // 后继节点
    volatile Node next;

    // ...
    

4、总结

有阻塞就需要排队,实现排队必然需要队列,通过state 变量 + CLH双端 Node 队列实现

3、AQS同步队列的基本结构

在这里插入图片描述

4、AQS底层是怎么排队的?

通过调用 LockSupport.park() 来进行排队

六、从 ReentrantLock 进入 AQS

6.1 ReentrantLock 锁

ReentrantLock 锁是个啥玩意儿?

ReentrantLock 类是 Lock 接口的实现类,基本都是通过【聚合】了一个【队列同步器】的子类完成线程访问控制的

ReentrantLock 的原理

ReentrantLock 实现了 Lock 接口,在 ReentrantLock 内部聚合了一个 AbstractQueuedSynchronizer 的实现类

在这里插入图片描述

6.2 公平锁 & 非公平锁

通过 ReentrantLock 的源码来讲解公平锁和非公平锁

在 ReentrantLock 内定义了静态内部类,分别为 NoFairSync(非公平锁)和 FairSync(公平锁)

在这里插入图片描述
ReentrantLock 的构造函数:不传参数表示创建非公平锁;参数为 true 表示创建公平锁;参数为 false 表示创建非公平锁

在这里插入图片描述
捞一眼 lock() 方法的执行流程:以 NonfairSync 为例

在这里插入图片描述
在 ReentrantLock 中,NoFairSync 和 FairSync 中 tryAcquire() 方法的区别,可以明显看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:
hasQueuedPredecessors()

在这里插入图片描述
在这里插入图片描述

hasQueuedPredecessors() 方法是公平锁加锁时判断等待队列中是否存在有效节点的方法,若队列中含有效节点线程则返回true,反之返回false。返回true时说明队列内有线程在等待,不会进入if内,程序正常往下走,当前线程最终会进入队列,遵循先进先出,如此实现了公平锁。

公平锁与非公平锁的总结

对比公平锁和非公平锁的tryAcqure()方法的实现代码, 其实差别就在于非公平锁获取锁时比公平锁中少了一个判断!hasQueuedPredecessors(),hasQueuedPredecessors()中判断了是否需要排队,导致公平锁和非公平锁的差异如下:

  1. 公平锁:公平锁讲究先来先到,线程在获取锁时,如果这个锁的等待队列中已经有线程在等待,那么当前线程就会进入等待队列中;
  2. 非公平锁:不管是否有等待队列,如果可以获取锁,则立刻占有锁对象。也就是说队列的第一
    个排队线程在unpark(),之后还是需要竞争锁(存在线程竞争的情况下)

在这里插入图片描述
而 acquire() 方法最终都会调用 tryAcquire() 方法
在这里插入图片描述
在 NonfairSync 和 FairSync 中均重写了其父类 AbstractQueuedSynchronizer 中的 tryAcquire() 方法

在这里插入图片描述

6.3 非公平锁的 lock() 深入解读AQS

先从示例代码入手

源码解读比较困难,我们这里举个栗子,假设 A、B、C 三个人都要去银行窗口办理业务,但是银行窗口只有一个个,我们使用 lock.lock() 模拟这种情况

/**
 * @ClassName AQSDemo
 * @Description TODO
 * @Author Oneby
 * @Date 2021/1/21 11:08
 * @Version 1.0
 */
public class AQSDemo {
    public static void main(String[] args) {

        ReentrantLock lock = new ReentrantLock();

        // 带入一个银行办理业务的案例来模拟我们的AQS如何进行线程的管理和通知唤醒机制
        // 3个线程模拟3个来银行网点,受理窗口办理业务的顾客
        // A顾客就是第一个顾客,此时受理窗口没有任何人,A可以直接去办理
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("-----A thread come in");
                try {
                    TimeUnit.MINUTES.sleep(20);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            } finally {
                lock.unlock();
            }
        }, "A").start();

        // 第二个顾客,第二个线程---》由于受理业务的窗口只有一个(只能一个线程持有锁),此时B只能等待,
        // 进入候客区
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("-----B thread come in");
            } finally {
                lock.unlock();
            }
        }, "B").start();

        // 第三个顾客,第三个线程---》由于受理业务的窗口只有一个(只能一个线程持有锁),此时C只能等待,
        // 进入候客区
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println("-----C thread come in");
            } finally {
                lock.unlock();
            }
        }, "C").start();
    }
}

先来看看线程 A(客户 A)的执行流程

之前已经讲到过,new ReentrantLock() 不传参默认是非公平锁,调用 lock.lock() 方法最终都会执行 NonfairSync 重写后的 lock() 方法

第一次执行 lock() 方法

由于第一次执行 lock() 方法,state 变量的值等于 0,表示 lock 锁没有被占用,此时执行 compareAndSetState(0, 1) CAS 判断,可得 state == expected == 0,因此 CAS 成功,将 state 的值修改为 1
在这里插入图片描述
再来复习下 CAS:通过 Unsafe 提供的 compareAndSwapXxx() 方法保证修改操作的原子性(通过 CPU 原语保证),如果变量的值等于期望值,则修改变量的值为 update,并返回 true;若不等,则返回 false。this 代表当前对象,stateOffset 表示 state 变量在该对象中的偏移量
在这里插入图片描述
再来看看 setExclusiveOwnerThread() 方法做了啥:将拥有 lock 锁的线程修改为线程 A
在这里插入图片描述

再来看看线程 B(客户 B)的执行流程

第二次执行 lock() 方法

由于第二次执行 lock() 方法,state 变量的值等于 1,表示 lock 锁没有被占用,此时执行 compareAndSetState(0, 1) CAS 判断,可得 state != expected,因此 CAS 失败,进入 acquire() 方法

在这里插入图片描述
acquire() 方法主要包含如下几个方法,下面我们一个一个来讲解

在这里插入图片描述
tryAcquire(arg) 方法的执行流程

先来看看 tryAcquire() 方法,诶,怎么抛了个异常?别着急,仔细一看是 AbstractQueuedSynchronizer 抽象队列同步器中定义的方法,既然抛出了异常,就证明父类强制要求子类去实现
在这里插入图片描述
在这里插入图片描述
这里以非公平锁 NonfairSync 为例,在 tryAcquire() 方法中调用了 nonfairTryAcquire() 方法,注意,这里传入的参数都是 1

在这里插入图片描述
nonfairTryAcquire(acquires) 正常的执行流程:

在 nonfairTryAcquire() 方法中,大多数情况都是如下的执行流程:线程 B 执行 int c = getState() 时,获取到 state 变量的值为 1,表示 lock 锁正在被占用;于是执行 if (c == 0) { 发现条件不成立,接着执行下一个判断条件 else if (current == getExclusiveOwnerThread()) {,current 线程为线程 B,而 getExclusiveOwnerThread() 方法返回正在占用 lock 锁的线程,为线程 A,因此 tryAcquire() 方法最后会 return false,表示并没有抢占到 lock 锁

在这里插入图片描述
补充:getExclusiveOwnerThread() 方法返回正在占用 lock 锁的线程(排他锁,exclusive)
在这里插入图片描述
nonfairTryAcquire(acquires) 比较特殊的执行流程:

第一种情况是,走到 int c = getState() 语句时,此时线程 A 恰好执行完成,让出了 lock 锁,那么 state 变量的值为 0,当然发生这种情况的概率很小,那么线程 B 执行 CAS 操作成功后,将占用 lock 锁的线程修改为自己,然后返回 true,表示抢占锁成功。其实这里还有一种情况,需要留到 unlock() 方法才能说清楚

第二种情况为可重入锁的表现,假设 A 线程又再次抢占 lock 锁(当然示例代码里面并没有体现出来),这时 current == getExclusiveOwnerThread() 条件成立,将 state 变量的值加上 acquire,这种情况下也应该 return true,表示线程 A 正在占用 lock 锁。因此,state 变量的值是可以大于 1 的

在这里插入图片描述

继续往下走,执行 ddWaiter(Node.EXCLUSIVE) 方法

在 tryAcquire() 方法返回 false 之后,进行 ! 操作后为 true,那么会继续执行 addWaiter() 方法

在这里插入图片描述
来看看 addWaiter() 方法做了些啥?

之前讲过,Node 节点用于封装用户线程,这里将当前正在执行的线程通过 Node 封装起来(当前线程正是抢占 lock 锁没有抢占到的线程)

判断 tail 尾指针是否为空,双端队列此时还没有元素呢~肯定为空呀,那么执行 enq(node) 方法,将封装了线程 B 的 Node 节点入队
在这里插入图片描述
enq(node) 方法:构建双端同步队列

也许看到这里的代码有点蒙,需要有些前置知识,在双端同步队列中,第一个节点为虚节点(也叫哨兵节点),其实并不存储任何信息,只是占位。 真正的第一个有数据的节点,是从第二个节点开始的。
在这里插入图片描述
第一次执行 for 循环:现在解释起来就不费劲了,当线程 B 进来时,双端同步队列为空,此时肯定要先构建一个哨兵节点。此时 tail == null,因此进入 if(t == null) { 的分支,头指针指向哨兵节点,此时队列中只有一个节点,尾节点即是头结点,因此尾指针也指向该哨兵节点

在这里插入图片描述
第二次执行 for 循环:现在该将装着线程 B 的节点放入双端同步队列中,此时 tail 指向了哨兵节点,并不等于 null,因此 if (t == null) 不成立,进入 else 分支。以尾插法的方式,先将 node(装着线程 B 的节点)的 prev 指向之前的 tail,再将 node 设置为尾节点(执行 compareAndSetTail(t, node)),最后将 t.next 指向 node,最后执行 return t结束 for 循环

在这里插入图片描述
补充:compareAndSetTail(t, node) 方法的实现
在这里插入图片描述
注意:哨兵节点和 nodeB 节点的 waitStatus 均为 0,表示在等待队列中

acquireQueued() 方法的执行

执行完 addWaiter() 方法之后,就该执行 acquireQueued() 方法了,这个方法有点东西,我们放到后面再去讲它
在这里插入图片描述

最后来看看线程 C(客户 C)的执行流程

线程 C 和线程 B 的执行流程很类似,都是执行 acquire() 中的方法
在这里插入图片描述
但是在 addWaiter() 方法中,执行流程有些区别。此时 tail != null,因此在 addWaiter() 方法中就已经将 nodeC 添加至队尾了
在这里插入图片描述
执行完 addWaiter() 方法后,就已经将 nodeC 挂在了双端同步队列的队尾,不需要再执行 enq(node) 方法
在这里插入图片描述

补前面的坑:acquireQueued() 方法的执行逻辑

先来看看看看 acquireQueued() 方法的源代码,其实这样直接看代码有点懵逼,我们接下来举例来理解。注意看:两个 if 判断中的代码都放在 for( ; ; ) 中执行,这样可以实现自旋的操作
在这里插入图片描述
线程 B 的执行流程

线程 B 执行 addWaiter() 方法之后,就进入了 acquireQueued() 方法中,此时传入的参数为封装了线程 B 的 nodeB 节点,nodeB 的前驱结点为哨兵节点,因此 final Node p = node.predecessor() 执行完后,p 将指向哨兵节点。哨兵节点满足 p == head,但是线程 B 执行 tryAcquire(arg) 方法尝试抢占 lock 锁时还是会失败,因此会执行下面 if 判断中的 shouldParkAfterFailedAcquire(p, node) 方法,该方法的代码如下:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * This node has already set status asking a release
         * to signal it, so it can safely park.
         */
        return true;
    if (ws > 0) {
        /*
         * Predecessor was cancelled. Skip over predecessors and
         * indicate retry.
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

哨兵节点的 waitStatus == 0,因此执行 CAS 操作将哨兵节点的 waitStatus 改为 Node.SIGNAL(-1)

在这里插入图片描述
注意:compareAndSetWaitStatus(pred, ws, Node.SIGNAL) 调用 unsafe.compareAndSwapInt(node, waitStatusOffset, expect, update); 实现,虽然 compareAndSwapInt() 方法内无自旋,但是在 acquireQueued() 方法中的 for( ; ; ) 能保证此自选操作成功(另一种情况就是线程 B 抢占到 lock 锁)

在这里插入图片描述
执行完上述操作,将哨兵节点的 waitStatus 设置为了 -1

在这里插入图片描述
执行完毕将退出 if 判断,又会重新进入 for( ; ; ) 循环,此时执行 shouldParkAfterFailedAcquire(p, node) 方法时会返回 true,因此此时会接着执行 parkAndCheckInterrupt() 方法
在这里插入图片描述
线程 B 调用 park() 方法后被挂起,程序不会然续向下执行,程序就在这儿排队等待

在这里插入图片描述
线程 C 的执行流程

线程 C 最终也会执行到 LockSupport.park(this); 处,然后被挂起,进入等待区

总结:

如果前驱节点的 waitstatus 是 SIGNAL 状态(-1),即 shouldParkAfterFailedAcquire() 方法会返回 true,程序会继续向下执行 parkAndCheckInterrupt() 方法,用于将当前线程挂起

根据 park() 方法 API 描述,程序在下面三种情况会继续向下执行:

  1. 被 unpark
  2. 被中断(interrupt)
  3. 其他不合逻辑的返回才会然续向下执行

因上述三种情况程序执行至此,返回当前线程的中断状态,并清空中断状态。如果程序由于被中断,该方法会返回 true

6.4 可总算要 unlock() 了

线程 A 执行 unlock() 方法

A 线程终于要 unlock() 了吗?真不容易啊!

在这里插入图片描述
unlock() 方法调用了 sync.release(1) 方法
在这里插入图片描述
release() 方法的执行流程

其实主要就是看看 tryRelease(arg) 方法和 unparkSuccessor(h) 方法的执行流程,这里先大概说以下,能有个印象:线程 A 即将让出 lock 锁,因此 tryRelease() 执行后将返回 true,表示礼让成功,head 指针指向哨兵节点,并且 if 条件满足,可执行 unparkSuccessor(h) 方法
在这里插入图片描述
tryRelease(arg) 方法的执行逻辑

又是 AbstractQueuedSynchronizer 类中定义的方法,又是抛了个异常
在这里插入图片描述
查看其具体实现
在这里插入图片描述
线程 A 只加锁过一次,因此 state 的值为 1,参数 release 的值也为 1,因此 c == 0。将 free 设置为 true,表示当前 lock 锁已被释放,将排他锁占有的线程设置为 null,表示没有任何线程占用 lock 锁
在这里插入图片描述
unparkSuccessor(h) 方法的执行逻辑

在 release() 方法中获取到的头结点 h 为哨兵节点,h.waitStatus == -1,因此执行 CAS操作将哨兵节点的 waitStatus 设置为 0,并将哨兵节点的下一个节点(s = node.next = nodeB)获取出来,并唤醒 nodeB 中封装的线程(if (s == null || s.waitStatus > 0) 不成立,只有 if (s != null) 成立)
在这里插入图片描述
执行完上述操作后,当前占用 lock 锁的线程为 null,哨兵节点的 waitStatus 设置为 0,state 的值为 0(表示当前没有任何线程占用 lock 锁)
在这里插入图片描述

杀个回马枪:继续来看 B 线程被唤醒之后的执行逻辑

再次回到 lock() 方法的执行流程中来,线程 B 被 unpark() 之后将不再阻塞,继续执行下面的程序,线程 B 正常被唤醒,因此 Thread.interrupted() 的值为 false,表示线程 B 未被中断

在这里插入图片描述
回到上一层方法中,此时 lock 锁未被占用,线程 B 执行 tryAcquire(arg) 方法能够抢到 lock 锁,并且将 state 变量的值设置为 1,表示该 lock 锁已经被占用
在这里插入图片描述
接着来研究下 setHead(node) 方法:传入的节点为 nodeB,头指针指向 nodeB 节点;将 nodeB 中封装的线程置为 null(因为已经获得锁了);nodeB 不再指向其前驱节点(哨兵节点)。这一切都是为了将 nodeB 作为新的哨兵节点
在这里插入图片描述
执行完 setHead(node) 方法的状态如下图所示

在这里插入图片描述
将 p.next 设置为 null,这是原来的哨兵节点就是完全孤立的一个节点,此时 nodeB 作为新的哨兵节点
在这里插入图片描述
哇哦,通透,线程 C 也是类似的执行流程

七、AQS 总结

AQS里面有个变量叫State,它的值有几种?

答:3个状态:没占用是0,占用了是1,大于1是可重入锁,线程每重入一次就加1。

锁正在被占用,AB两个线程进来了以后,请问这个总共有多少个Node节点?如何理解哨兵节点?

答:答案是3个,分别是哨兵节点、nodeA、nodeB;
这个哨兵节点可能是代码底层创建空节点,也可能是当前占用锁的线程节点。当锁释放后,假若A抢得了锁,哨兵节点会被释放回收,A会变成新的哨兵节点。

你能从AQS源码层来解释ReentrantLock 公平锁/非公平锁?

答:从表层代码ReentrantLock的构造方法看,只需传入true或false就能决定创建的是公平还是非公平锁。 但在底层AQS源码中实现公平/非公平锁的两个实现类分别是FairSync/NoFairSync ,而两个实现类的代码大致相同,唯一不同的是两者内的 tryAcquire() 方法实现,区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。
hasQueuedPredecessors() 方法是公平锁加锁时判断等待队列中是否存在有效节点的方法,若队列中含有效节点线程则返回true,反之返回false。返回true时说明队列内有线程在等待,当前线程最终会进入队列,遵循先进先出,如此实现了公平锁。
非公平锁没有此方法hasQueuedPredecessors的限制,入队前可以尝试几次去获取锁,不需要考虑队列中是否含有正在等待的队列。

简单总结说一下AQS源码的加锁流程?

略,后补

AQS 源码解读案例图示

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值