并发系列——JUC高级篇(四)Lock原理剖析

一、 LockSupport 工具类

        JDK 中的 rt.jar 包里面的 LockSupport 是个工具类,它的主要作用是挂起和唤醒线程, 该工具类是创建锁和其他同步类的基础。LockSupport 和 CAS 是Java并发包中很多并发工具控制机制的基础,它们底层其实都是依赖Unsafe实现。
        LockSupport 类与每 个使用它的线程都会关联一 个许可证(许可为false则阻塞,为true则继续执行),在默认情况下调用 LockSupport 类的方法的线程是不持有许可证的。

          LockSupport是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,当然阻塞之后肯定得有唤醒的方法。

LockSupport 是使用 Unsafe 类实现的, 下面介绍 LockSupport 中的几个主要函数。

1. void park()  

如果调用 park 方法的线程已经拿到了与 LockSupport 关联的许可证,则调用 Locksupport.park()时会马上返回(即获取到锁则运行),否则调用线程会被禁止参与线程的调度,也就是会被阻塞挂起。
调用park方法,最终只会输出 "begin park!",然后当前线程被挂起,这时因为在默认情况下调用线程是不持有许可证的。

public static void main(String[] args) {
    System.out.println("begin park!");
    LockSupport.park();
    System.out.println("end park!");
}

在其他线程调用unpark(Thread thread)方法并且将当前线程作为参数时,调用park方法而被阻塞的线程会返回。另外,如果其他线程调用了阻塞线程的interrupt()方法,设置了中断标志或者线程被虚假唤醒,则线程也会返回。所以在调用park方法时最好也使用循环条件判断方式。

jdk的文档描述:

注意: 因调用park方法而被阻塞的线程被其他线程中断而返回时并不会抛出InterruptedException异常。

2. void unpark(Thread thread)

jdk的文档描述:

当一个线程调用unpark时,如果参数thread线程没有持有thread与LockSupport类相关联的许可证,则让thread线程持有(false改为true)。如果thread因调用park()而被挂起,则unpark方法会使其被唤醒。如果thread之前没有调用park,则调用unpark方法(许可为true再调用park发现许可为true不阻塞(为false才阻塞))后再调用park方法会立即返回,代码如下。

public static void main(String[] args) {
    System.out.println("begin park!");
    LockSupport.unpark(Thread.currentThread());
    LockSupport.park();
    System.out.println("end park!");
}

输出如下:

begin park!
end park!

下面再来看一个例子来加深对park和unpark的理解。

public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("child thread begin park!");
                // 挂起自己
                LockSupport.park();
                System.out.println("child thread unpark!");
            }
        });

        thread.start();

        // 确保调用unpark前子线程已经将自己挂起
        Thread.sleep(1000);

        System.out.println("main thread begin unpark!");

        LockSupport.unpark(thread);
    }

子线程将自己挂起,主线程中调用了unpark方法使得子线程得以继续运行。

3. void parkNanos(long nanos)

和park方法类似,如果调用park方法的线程已经拿到了与LockkSupport关联的许可证,则调用LockSupport.parkNanos(long nanos)方法会立即返回。不同之处在于,如果没有拿到许可证,则调用线程会被挂起nanos时间后自动返回。

4. park(Object blocke)方法

public static void park(Object blocker) { 

//获取调用线程 

Thread t = Thread. current Thread(); 
//设置该线希呈的blocker变量 

setBlocker(t, blocker) ; 
//挂起线程 

UNSAFE .park(false, 01) ; 
//线程被激活后清除blocker变量,因为一般都是在线程阻塞时才分析原因 

setBlocker(t, null) ; 

}

Thread 类里面有个变量 volatile Object parkBlocker, 用来存放 park 方法传递的 blocker 对象,也就是把 blocker 变量存放到了调用 park 方法的线程的成员变量里面。(ocker的作用是在dump线程的时候看到阻塞对象的信息)


5. void parkNanos(Object blocke,long nanos)方法:

相比 park(Object blocker) 方法多了个超时时间。



6. void parkUntil(Object blocke, long deadline)方法

public static void parkUntil(Object blocker, long deadline) {

 Thread t = Thread.currentThread() ; 

setBlocker(t, blocker);

//isAbsolute=true, time=deadline;表示到deadline时间后返回 

UNSAFE .park(true, deadline) ; 

setBlocker(t, null) ; 

}

其中参数 deadline 的时间单位为 ms,该时间是从 1970 年到现在某一个时间点的毫秒 值。这个方法和 parkNanos(Object blocker, long nanos)方法的区别是,后者是从当前算等待 nanos 秒时间,而前者是指定一个时间点,比如需要等到 2017.12.11 日 12:00:00,则把 这个时间点转换为从 1970 年到这个时间点的总毫秒数。

public static void park(Object blocker); // 暂停当前线程
public static void parkNanos(Object blocker, long nanos); // 暂停当前线程,不过有超时时间的限制
public static void parkUntil(Object blocker, long deadline); // 暂停当前线程,直到某个时间
public static void park(); // 无期限暂停当前线程
public static void parkNanos(long nanos); // 暂停当前线程,不过有超时时间的限制
public static void parkUntil(long deadline); // 暂停当前线程,直到某个时间
public static void unpark(Thread thread); // 恢复指定线程
public static Object getBlocker(Thread t);

总结: LockSupport 类与每 个使用它的线程都会关联一 个许可证,默认情况是false。park方法即查看是否持有许可,如为false则阻塞该线程,如为true则不阻塞;unpark则是将许可改为true/唤醒因park受阻塞的线程。所以调用park之后发现许可为false则会被挂起如该线程再调用unpark方法将许可改为true则会放行该线程。(这里类似wait和notify唤醒机制,但不同的是这不涉及对象锁的概念,且这里的唤醒是指定唤醒某线程,而notify是由cpu自己抉择的。且如果该阻塞被打断不会报异常。park和unpark的使用不会出现死锁的情况)

参考https://www.jianshu.com/p/f1f2cd289205 、并发编程之美一书

 

二、AQS(抽象同步队列)

1、锁的底层支持:

1、结构说明

AbstractQueuedSynchronizer 抽象同步队列简称 AQS,它是实现同步器的基础组件, 并发包中锁的底层就是使用 AQS 实现的。 另外,大多数开发者可能永远不会直接使用 AQS,但是知道其原理对于架构设计还是很有帮助的。 下面看下 AQS 的类图结构:

由该图可以看到,

(1)AQS 是一个 FIFO 的双向队列,其内部通过节点 head 和 tail 记录队首和队尾元素,队列元素的类型为 Node。

(2)竞争资源失败的线程转换成node节点阻塞挂起存放进入 AQS 队列的,Node节点有两种类型,一种是 EXCLUSIVE类型(独占)一种是SHARED(共享)。waitStatus 记录当前线程等待状态(可以为 CANCELLED (线程被取消了)、 SIGNAL (线程需要被唤醒)、 CONDITION (线程在条件队列里面等待〉、 PROPAGATE (释 放共享资源时需要通知其他节点〕); prev 记录当前节点的前驱节点, next 记录当前节点的 后继节点。
(3)在 AQS 中 维持了 一 个 单 一 的状态信息 state(lock就是基于state实现的),可以通过 getState、 setState、 compareAndSetState 函数修改其值。 对于 ReentrantLock 的实现来说, state 可以用来表示 当前线程获取锁的可重入次数 ;对于读写锁 ReentrantReadWriteLock 来说, state 的高 16 位表示读状态,也就是获取该读锁的次数,低 16 位表示获取到写锁的线程的可重入次数; 对于 semaphore 来说, state 用来表示当前可用信号的个数:对于 CountDownlatch 来说, state 用来表示计数器当前的值。
(4)AQS 有个内 部类 ConditionObject, 用来结合锁实现线程同步。 ConditionObject 可以 直接访问 AQS 对象 内部的变量,比如 state 状态值和 AQS 队列。 ConditionObject 是条件 变量, 每个条件变量对应一个条件队列 (单向链表队列),其用来存放调用条件变量的 await 方法后被阻塞的线程,如类图所示, 这个条件队列的头、尾元素分别为 自rstWaiter 和last Waiter。

2、原理

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。

在这里插入图片描述

AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。

private volatile int state;//共享变量,使用 volatile 修饰保证线程可见性

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

  不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

  以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1(默认初始为0,cas操作)。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。而当另外一个线程获取锁时发现自己并不是该锁的持有者就会被放入 AQS 阻塞队列后挂起。

  再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

  一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

下面分别分析不同操作state值的两种方式做简单介绍(不对源码详细分析):

(1)独占方式获取资源方法:void acquire(int arg)              void acquireInterruptibly(int arg)

        释放资源方法:boolean release(int arg)

(acquire是不对中断作响应,即如果在线程竞争资源/阻塞时进行中断不会做出反应,而是在获取到资源后再响应;而acquireInterruptibly则会抛出异常并返回)

(独占方式的同步器需要自己写tryAcquire(int)和tryRelease(int)进行获取和释放资源这些方法会被上面的代码调用)

获取资源方法acquire(int arg)源码:

1 public final void acquire(int arg) {
2     if (!tryAcquire(arg) &&
3         acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
4         selfInterrupt();
5 }

总结下它的流程吧:

  1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;(这里需要同步器自己实现)
  2. 没成功,则addWaiter()将该线程转换为node节点加入等待队列的尾部,并标记为独占模式;(入队列采用CAS自旋volatile变量
  3. acquireQueued()使线程在等待队列中休息(调用park()进行线程挂起),有机会时(轮到自己,会被unpark(),唤醒后还要去看符不符合,不符合往下找其他节点)会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。(会自旋去尝试获取资源)
  4. 如果线程在等待过程中被中断过,它是不响应的(acquireInterruptibly会做出响应)。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

由于此函数是重中之重,我再用流程图总结一下:(该图也包含释放资源部分)

至此,acquire()的流程终于算是告一段落了。这也就是ReentrantLock.lock()的流程,不信你去看其lock()源码吧,整个函数就是一条acquire(1)!!!

释放资源方法 release(int)

   上一小节已经把acquire()说完了,这一小节就来讲讲它的反操作release()吧。此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()。下面是release()的源码:

1 public final boolean release(int arg) {
2     if (tryRelease(arg)) {
3         Node h = head;//找到头结点
4         if (h != null && h.waitStatus != 0)
5             unparkSuccessor(h);//唤醒等待队列里的下一个线程(底层用unpark唤醒队列最前的线程节点)
6         return true;
7     }
8     return false;
9 }

  逻辑并不复杂。它调用tryRelease()来释放资源。有一点需要注意的是,它是根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了!所以自定义同步器在设计tryRelease()的时候要明确这一点!!

(2)共享方式获取资源方法:

acquireShared(int)

  此方法是共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。下面是acquireShared()的源码:

1 public final void acquireShared(int arg) {
2     if (tryAcquireShared(arg) < 0)
3         doAcquireShared(arg);
4 }

  这里tryAcquireShared()依然需要自定义同步器去实现。但是AQS已经把其返回值的语义定义好了:负值代表获取失败;0代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。所以这里acquireShared()的流程就是:

1、tryAcquireShared()尝试获取资源,成功则直接返回;

2、失败则通过doAcquireShared()进入等待队列park(),直到被unpark()/interrupt()并成功获取到资源才返回。整个等待过程也是忽略中断的。

  其实跟acquire()的流程大同小异,只不过多了个自己拿到资源后,还会去唤醒后继队友的操作(这才是共享嘛)

releaseShared()释放资源

  上一小节已经把acquireShared()说完了,这一小节就来讲讲它的反操作releaseShared()吧。此方法是共享模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。下面是releaseShared()的源码:

1 public final boolean releaseShared(int arg) {
2     if (tryReleaseShared(arg)) {//尝试释放资源
3         doReleaseShared();//唤醒后继结点
4         return true;
5     }
6     return false;
7 }

  此方法的流程也比较简单,一句话:释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。例如,资源总量是13,A(5)和B(7)分别获取到资源并发运行,C(4)来时只剩1个资源就需要等待。A在运行过程中释放掉2个资源量,然后tryReleaseShared(2)返回true唤醒C,C一看只有3个仍不够继续等待;随后B又释放2个,tryReleaseShared(2)返回true唤醒C,C一看有5个够自己用了,然后C就可以跟A和B一起运行。而ReentrantReadWriteLock读锁的tryReleaseShared()只有在完全释放掉资源(state=0)才返回true,所以自定义同步器可以根据需要决定tryReleaseShared()的返回值。

值得注意的是,acquire()和acquireShared()两种方法下,线程在等待队列中都是忽略中断的。AQS也支持响应中断的,acquireInterruptibly()/acquireSharedInterruptibly()即是,相应的源码跟acquire()和acquireShared()差不多,这里就不再详解了。

这部分解析参考https://www.cnblogs.com/waterystone/p/4920797.html

2、条件变量的支持

(conditionObject条件对象的await-signal唤醒机制,该机制底层还是用了park和unpark方法实现的)

以下是使用条件变量的例子:

ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

lock.lock();
try{
    System.out.println("begin wait");
    condition.await();
    System.out.println("end wait");
} catch (InterruptedException e) {
    e.printStackTrace();
}finally {
    lock.unlock();
}

lock.lock();
try{
    System.out.println("begin signal");
    condition.signal();
    System.out.println("end signal");
}catch (Exception e){
    e.printStackTrace();
}finally {
    lock.unlock();
}

上述代码中,condition是由Lock对象调用newCondition方法创建的条件变量,一个Lock对象可以创建多个条件变量。

lock.lock()方法相当于进入synchronized同步代码块,用于获取独占锁;await()方法相当于Object.wait()方法,用于阻塞挂起当前线程,当其他线程调用了signal方法(相当于Object.notify()方法)时,被阻塞的线程才会从await处返回。

lock.newCondition()作用是new一个在AQS内部类ConditionObject对象。每个条件变量内部都维护了一个条件队列,用来存放调用该条件变量的await方法时被阻塞的线程。

注意: 这个条件队列(单向队列)和AQS队列(双向队列)不是一回事。大概就是AQS队列是一起竞争资源失败所挂起的队列(底层用park),而条件队列是线程的codition对象调用await而进入的阻塞队列(底层也是park,而park有时利用unsafe类方法的)。当一个线程竞争到资源时(获取到lock锁)则可以获取condition条件对象,然后条件对象可以根据需要调用await去挂起本线程释放锁资源并插入条件队列,等待其他线程signal唤醒则可以进入到AQS队列中。而AQS队列里的线程需要资源被释放了才能一起去竞争。(sychronized操作的是对象监视器monitor,而lock操作的是资源的state状态参数)

以下是await的源码:

    public final void await() throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        // 创建新的node节点,并插入到条件队列末尾    
        Node node = addConditionWaiter();
        // 释放当前线程的锁
        int savedState = fullyRelease(node);
        int interruptMode = 0;
        // 调用park方法阻塞挂起当前线程
        while (!isOnSyncQueue(node)) {
            LockSupport.park(this);
            if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                break;
        }
        ...
    }

首先会构造一个类型为Node.CONDITION的node节点,然后将该节点处插入条件队列末尾,之后当前线程会释放获取的锁,并被阻塞挂起。这时如果有其他线程调用lock.lock()方法尝试获取锁,就会有一个线程获取到锁。

如图所示,该过程可分为三个步骤:

  1. 新建Condition Node包装线程,加入Condition队列
  2. 释放当前线程占用的锁
  3. 阻塞当前线程

再来看signal源码:

public final void signal() {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    Node first = firstWaiter;
    if (first != null)
        // 将条件队列头元素移动到AQS队列等待执行
        doSignal(first);
}

调用signal时,会把条件队列队首元素放入AQS中并激活队首元素对应的线程。

 signal方法更简单一些,就是从firstWaiter开始,找到一个没有取消的Node放入release队列。但是即使一开始找到的Node没被取消,但是入队列的时候也可能会被取消,因此代码对这个情况做了点特殊处理。

对整体的获取锁--阻塞--获取锁--释放锁流程图:

Condition的await()和signal()过程

可参考:https://www.cnblogs.com/rainy-shurun/p/5766108.html

https://www.cnblogs.com/insaneXs/p/12219097.html

https://www.jianshu.com/p/28387056eeb4

3、基于AQS自定义一个同步器

本节我们基于 AQS 实现一个不可重入的独占锁, 正如前文所讲的,自定义 AQS 需要 重写一系列函数,还需要定义原子变量 state 的含义。 这里我们定义, state 为 0 表示目前锁没有被线程持有, state 为 l 表示锁己经被某一个线程持有, 由于是不可重入锁,所以不 需要记录持有锁的线程获取锁的次数。另外, 我们 自定义的锁支持条件变量。

public class NonReentrantLock implements Lock, Serializable {

    // 内部帮助类
    private static class Sync extends AbstractQueuedSynchronizer {

        // 锁是否被持有
        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        // 尝试获取锁
        @Override
        protected boolean tryAcquire(int arg) {
            if (compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // 尝试释放锁
        @Override
        protected boolean tryRelease(int arg) {
            if(getState() == 0) {
                throw new IllegalMonitorStateException();
            }
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        // 提供条件变量接口
        Condition newCondition() {
            return new ConditionObject();
        }

    }

    // 创建一个Sync来做具体工作
    private final Sync sync = new Sync();

    @Override
    public void lock() {
        sync.acquire(1);
    }


    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(time));
    }


    @Override
    public void unlock() {
        sync.tryRelease(1);
    }


    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }


    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }
}

NonReentrantLock定义了一个内部类Sync用来实现具体的锁的操作,Sync继承了AQS。由于是独占锁,:Sync只重写了tryAcquire、tryRelease、isHeldExclusively。此外,Sync提供了newCondition方法来支持条件变量。

下面使用自定义的锁来实现简单的生产-消费模型

final static NonReentrantLock lock = new NonReentrantLock();
final static Condition notFull = lock.newCondition();
final static Condition notEmpty = lock.newCondition();

final static Queue<String> queue = new LinkedBlockingQueue<>();
final static int queueSize = 10;

public static void main(String[] args) {
    Thread producer = new Thread(new Runnable() {
        @Override
        public void run() {
            // 获取独占锁
            lock.lock();
            try {
                while (true) {
                    // 队列满了则等待
                    while (queue.size() >= queueSize) {
                        notEmpty.await();
                    }
                    queue.add("ele");
                    System.out.println("add...");
                    notFull.signalAll();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
    });

    Thread consumer = new Thread(new Runnable() {
        @Override
        public void run() {
            // 获取独占锁
            lock.lock();
            try {
                while (true) {
                    // 队列空则等待
                    while (queue.size() == 0) {
                        notFull.await();
                    }
                    String ele = queue.poll();
                    System.out.println("poll...");
                    notEmpty.signalAll();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
    });

    producer.start();
    consumer.start();
}

代码使用了NonReentrantLock来创建lock,并调用lock.newCondition创建了两个条件变量用来实现生产者和消费者线程的同步。

讲到底同步器使用的结构还是:CAS+volatile+自旋+队列(单双向)

该篇文章主要讲解了实现一个同步器的基础知识点。后面会对于juc包下实现的同步器的原理做分析

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值