ReentrantLock源码阅读

参考:【死磕Java并发】-----J.U.C之AQS:AQS简介
参考:源码阅读.

1.AQS

1.AQS作用

        在讲ReentrantLock之前科普一下AQS的概念,ReentrantLock是基于AQS的方法实现了线程解锁和解锁的管理。
        AQS,即队列同步器。它是构建锁或者其他同步组件的基础框架,能够成为实现大部分同步需求的基础。它是JUC并发包中的核心基础组件。
        AQS使用一个int类型成员变量state来表示同步状态当state>0时表示已经获取了锁,当state = 0时表示释放了锁。它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对同步状态state进行操作,当然AQS可以确保对state的操作是安全的。

        AQS通过内置的FIFO同步队列来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。
AQS大量使用Unsafe类方法实现CAS操作,关于Unsafe请参考:Java中的Unsafe

2.Node:同步队列节点

        在CLH同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next):

    static final class Node {
        /** Marker to indicate a node is waiting in shared mode */
        //共享式节点
        static final Node SHARED = new Node();
        /** Marker to indicate a node is waiting in exclusive mode */
        //独占式节点
        static final Node EXCLUSIVE = null;

		//因为超时或者中断,节点会被设置为取消状态
        static final int CANCELLED = 1;
		//后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,
		//将会通知后继节点,使后继节点的线程得以运行
        static final int SIGNAL = -1;
        static final int CONDITION = -2;
        static final int PROPAGATE = -3;

        volatile int waitStatus;

        //前驱结点
        volatile Node prev;

        //后续节点
        volatile Node next;
        
        //获取同步状态的线程
        volatile Thread thread;
}

2.构造方法

        ReentrantLock支持两种锁,一种是非公平可重入锁,一种是公平可重入锁。非公平可重入锁可能出现一个线程长时间占有锁的情况,而公平锁对于锁的持有先对平均,正常情况下使用非公平锁,效率更高,因为减少了线程切换的消耗:
        Unsafe类的说明可以参考:Java中的Unsafe
        他同样是使用CAS的做法去实现设值,其中有三个变量:origin、expect、update,这边的名字可能不是很准确,我的理解是在内存中某个变量的原始值为origin,在没有修改之前,我的期望except应该是等于origin的。而最终落地到内存的值应该是update.
        举个例子,本来内存中的A地址存储着0,这个时候需要把0改成1,1就是update,而0就是except.而在我们修改之前有一个线程进来把0改成了2,这个时候2不等于我们的except,0,所以修改失败了.当然底层的逻辑和实现肯定更加复杂.

3.公平与非公平

        之前对于公平与非公平的概念一直没理解清除,这边希望标注一下,保证理解了这个公平的概念再往下看:
所谓公平其实是相对的,因为AQS维护了一个队列,所以对于队列中的节点一定是有序的,这个是毋庸置疑的,那么怎么实现非公平呢?就是直接越过排队,不关心队列是不是有人在排队,直接插入去尝试获取锁,获取成功就是挣到了,获取失败就进队列乖乖等待了。
        所以相对而言,公平就是保持队列的完整性,如果有人在排队,不允许插队,必须乖乖排队.

4.非公平锁

1.加锁过程

        因为AQS提供了一个FIFO的队列,先对完整的支持了不同情况的锁,所以直接查看AQS的方法可能会懵逼,因为一些处理是针对特殊的锁,比如说等待超时的处理,并不适用与所有继承AQS的锁.
所以这边我的思路是直接去查看ReentrantLock的方法实现.

1.加锁成功

        这边的加锁:
        当一个线程进来,如果通过CAS的方式修改state成功,说明他获取到了锁。如果通过CAS的方式加锁失败(返回false)表示已经有人在此之前把我的锁拿走了,这个时候我只能去等待获取锁。就进入了下面的acquire方法。持有的参数1表示,希望加锁一次.

	final void lock() {
            //非公平锁的Lock先通过调用Unsafe类通过CAS方式去设置status
            //当status为0的时候表示无锁,大于0为有锁,并且可支持重入,后面可以看到.
            if (compareAndSetState(0, 1)){
                //设置拥有者为当前线程.
                setExclusiveOwnerThread(Thread.currentThread());
            }
            else{
                //如果上述的CAS修改状态(获取锁)失败了,则会尝试继续获取或者加入队列
                acquire(1);
            }
        }

2.加锁失败

        我们需要看一下这边的实现,这是AQS提供的实现方式,而ReentrantLock实现了自己的tryAcquire方法。这边大概的做法就是通过独占锁方式(当然还有共享式)尝试获取锁,失败之后加入队列(addWaiter),在队列中等待时机。

    public final void acquire(int arg) {
        //tryAcquire方法看实现类具体实现,这边的注释说明是 尝试以独占模式获取。
        //当这个条件不满足(即返回false,尝试失败之后)先把节点加入到队列,然后再调用acquireQueued
        //如果acquireQueued返回证明这个节点是被中断唤醒,并且之前的尝试独占锁的获取失败了,此时应该声明需要中断.
        if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
            selfInterrupt();
        }
    }

3.尝试独占式获取

        看一下tryAcquire在ReentrantLock中的实现:
        只有在两种情况下返回true(竞争锁成功):
        1、通过CAS方式获取锁成功(前一个获取锁的操作正好完成)
        2、获取锁的线程就是锁的持有者,这个就是可重入的一个设计,所以state的值应该是0和>0.

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
        final boolean nonfairTryAcquire(int acquires) {
            //获取当前线程.
            final Thread current = Thread.currentThread();
            //获取状态
            int c = getState();
            //如果状态为0,表示为无锁状态(当前这一刻)
            if (c == 0) {
                //通过CAS方式设置state,设置成功则得锁
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //这边就是可重入的操作,因为state!=0并且拥有锁的线程为当前线程
            //所以这边会给状态加上加锁操作的次数,当前线程会继续拥有该锁.
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0){
                    throw new Error("Maximum lock count exceeded");
                }
                setState(nextc);
                return true;
            }
            //如果当前线程没有竞争到锁,则返回false.
            return false;
        }

4.添加到队列尾部

        也就是说如果不是可重入或者是CAS获取锁成功,就会执行后面的操作,先来看一下addWaiter:
        第一次快速尝试看是否能通过CAS的方式把节点添加到队列的尾部,如果这一次尝试失败了,再往下会进入死循环,一直尝试把节点加入到队列,造成的结果就是说,节点一定会被加到队列的尾部

 private Node addWaiter(Node mode) {
        //创建一个包含当前线程的节点
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            //当tail引用指向的尾结点变量存在,尝试将当前节点添加到尾结点(这一步是CAS操作.)
            if (compareAndSetTail(pred, node)) {
                //如果成功,则设置之前尾结点的next.
                pred.next = node;
                return node;
            }
        }
        //如果上面的尝试失败了,会进入这边通过死循环去尝试添加.
        enq(node);
        return node;
    }

    private Node enq(final Node node) {
        //进入死循环,
        for (; ; ) {
            Node t = tail;
            //通过CAS的方式尝试加入头结点
            if (t == null) {
                if (compareAndSetHead(new Node())) {
                    tail = head;
                }
            } else {
                //通过CAS的方式尝试加入尾结点
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

5.在队列中获取锁

        当节点被加载到队列尾部的时候,就要开始执行获取锁的操作,可以想象这一步其实也应该是一个死循环,不断去尝试直到获取到锁为止。
        这边可以看到循环中唯一一处返回的就是当前置节点为head的时候,当前节点通过CAS方式尝试获取锁成功.为什么说当前置节点为head的时候还需要通过CAS的方式去获取一次锁,我想这边应该就是为了应对不同锁的情况。
        这边有个参数是faild,这边能看到的就是在 node.predecessor() 这一步会发生报错,因为得到的前置节点为null。而另一个参数interrupted我的理解是一个中断信号量,后面会讲到

    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)) {
                    //设置头结点.并且这边会设置failed = false,这个failed的参数应该是相对于 acquireQueued来说
                    //这边的false标识获取队列失败,也就是说我当前已经在执行了,不需要再加入队列,所以是false.
                    setHead(node);
                    // help GC
                    p.next = null;
                    failed = false;
                    return interrupted;
                }
                //第一步判断需要阻塞(waitStatus),第二步线程先会进入休眠状态,然后当其被唤醒的时候,判断是不是中断唤醒的.
                if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) {
                    interrupted = true;
                }
            }
        } finally {
            //failed为true只可能是发生异常的情况,非正常退出,要对这个node节点进行取消操作.
            if (failed) {
                cancelAcquire(node);
            }
        }
    }

6.是否需要休眠判断

        如果没有获取到锁,程序继续往下走进入shouldParkAfterFailedAcquire
        这边分几种情况:
        1.当前置节点waitStatus为SIGNAL的时候,后继节点需要等待被唤醒.所以node需要休眠,返回true
        2.如果waitStatus>0,表示有节点被取消,需要移出队列,这边的做法是循环去查找到非取消的节点,也就是把这部分被取消的节点抛弃掉。
        3.如果不满足上述两个情况,会把前置的waiStatus置为SIGNAL,返回false,这样第二次进来的时候就是返回true,说明需要休眠了.

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        //节点的waitStatus为SIGNAL,表示需要被其他线程unparking唤醒。直接返回true.
        if (ws == Node.SIGNAL) {
            return true;
        }
        if (ws > 0) {
            //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
            //获取同步状态失败后判断该阶段是否应该睡眠,主要根据的是waitStatus来判断(关于waitStatus的状态意义)。\
            // 如果前驱节点处于SIGNAL,唤醒状态的。表示当前节点可以安心的休眠。否则判断状态是否大于0(只有取消状态才会大于0)。
            // 如果大于0说明是取消状态,队列中删除该节点,然后取到prev的前驱继续判断,直到状态不为取消,然后CAS设置节点状态为SINGAL,返回false.
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            //设置waitStatus不为0的节点的next为当前节点.
            pred.next = node;
        } else {
            //尝试把状态位修改为SIGNAL.
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

7.线程休眠

        如果当前线程需要休眠,就会进入parkAndCheckInterrupt方法,这里其实调用了Unsafe的park方法,让线程进入休眠,当他下一次醒来的时候,需要判断是中断叫醒的还是unpark叫醒的。如果是中断叫醒的就会返回中断标识,上面的方法会调用selfIntercept方法,给当前线程一个中断标识。

    private final boolean parkAndCheckInterrupt() {
        /**
         * 查看类上的注释:
         * 禁用当前线程,除非许可证可用
         * 如果可用会立即返回,否则处于休眠状态(注意:线程在这个地方会停止了.)
         * 除非发生三种情况:
         *  1.其他线程调用 #unpark方法
         *  2.其他线程 中断( interrupts)该线程.
         *  3.还有一种情况我也没懂(这个调用时假的,没理由.)
         * 这个方法不会告知是那种方式使得线程从休眠状态恢复的.
         */
        LockSupport.park(this);
        /**
         * 上述的情况,可能是被中断的方式唤醒该线程的休眠.
         */
        return Thread.interrupted();
    }

        在final中,总会去判断failed是不是true,如果是true,就需要取消当前节点获取锁的操作了。
        这边参考:https://www.jianshu.com/p/01f2046aab64 给出了当节点非尾结点并且下一个节点需要被唤醒的操作,如果是头结点,会尝试去唤醒下一个节点.

8.异常退出,取消锁获取

private void cancelAcquire(Node node) {
        //如果节点不存在,则返回.
        if (node == null)
            return;

        //不再关联到线程
        node.thread = null;

        //释放 前置节点waitStatus被设置为>0的节点.直到找到有效的节点.
        Node pred = node.prev;
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;

        // predNext is the apparent node to unsplice. CASes below will
        // fail if not, in which case, we lost race vs another cancel
        // or signal, so no further action is necessary.
        Node predNext = pred.next;

        // Can use unconditional write instead of CAS here.
        // After this atomic step, other Nodes can skip past us.
        // Before, we are free of interference from other threads.
        node.waitStatus = Node.CANCELLED;

        // If we are the tail, remove ourselves.
        //如果是尾结点,通过CAS方式把当前节点清除掉,并且最后一个节点的next应该指向null
        if (node == tail && compareAndSetTail(node, pred)) {
            compareAndSetNext(pred, predNext, null);
        } else {
            // If successor needs signal, try to set pred's next-link
            // so it will get one. Otherwise wake it up to propagate.
            int ws;
            //如果node的下一个节点需要SIGNAL,并且没有(被cancel的节点pred)不是head的后继节点.则这边会设置node的前置节点指向node的后继节点.
            // 不过,还少了一步呀。将successor指向pred是谁干的?
            // 是别的线程做的。当别的线程在调用cancelAcquire()或者shouldParkAfterFailedAcquire()时,会根据prev指针跳过被cancel掉的前继节点,同时,会调整其遍历过的prev指针。
            //参考:https://www.jianshu.com/p/01f2046aab64
            if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) {
                Node next = node.next;
                if (next != null && next.waitStatus <= 0) {
                    compareAndSetNext(pred, predNext, next);
                }
            } else {
                //如果node的前继节点是head(node是head的后继节点)
                unparkSuccessor(node);
            }

            // help GC
            node.next = node;
        }
    }

9.唤醒休眠中的线程(重点)

        这边unparkSuccessor方法判断下一个节点被取消了或者为null,会通过tail往前查找,我的想法是,如果正向的查找可能找到的是null,没有办法指向正确的下一个节点,而通过尾节点找到反向的第一个节点(可能前面还有正常的节点),这个时候这个节点的线程被唤醒了,如果他的前置节点为head,那就皆大欢喜,但是如果他的前置节点不为head.看到死循环循环体里面的shouldParkAfterFailedAcquire有一步是循环去除掉前置的被取消的节点,然后找到真正的节点设置为SIGNAL,并且休眠当前节点,这个时候,CPU的执行权又让给了别人,整条线就串起来了。我想这样的做法应该是有两个原因:
        1.通过Head查找,可能找到null的节点,而丢失所有的后继节点
        2.通过tail找到第一个不是被取消的节点,因为acquireQueued的流程可以串起来,并且他有一步是删除掉其他CANCELED节点所以这样应该是最合理的.

    private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        //这个方法主要是唤醒node节点的后继节点,所以这边如果node节点的waitStatus<0,设置它为0,失败也没关系.
        if (ws < 0){
            compareAndSetWaitStatus(node, ws, 0);
        }

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        Node s = node.next;
        //空或者被cancel了(>0),会往前查找不是node,并且waitStatus<=0的节点
        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);
        }
    }

10.加锁过程图解

在这里插入图片描述

2.解锁过程

        解锁的过程相对简单,我们分两个方面来看,一个是解锁成功返回true,一个是解锁失败返回false.

    public void unlock() {
        /**
         * 解锁操作
         */
        sync.release(1);
    }
    public final boolean release(int arg) {
        //尝试解锁成功.
        if (tryRelease(arg)) {
            Node h = head;
            //如果Head指向的节点不为空,并且waitStatus不为0 ,可能是>0已经失效,也可能是<0.
            if (h != null && h.waitStatus != 0){
                //如果当前只有一个现成获取了锁,则没有必要去唤醒下一个线程.
                // 因为没有下一个线程,如果是两个以上的线程获取锁,那么后一个线程会把前一个线程的waitState状态改成SIGNAL。
                // 可以理解为持有信号量,需要唤醒后续的节点,所以才会调用unpark去唤醒后继节点.
                unparkSuccessor(h);
            }
            //返回true表示解锁成功.
            return true;
        }
        //返回false表示解锁失败.
        return false;
    }

1.尝试解锁

        正确是用锁,因为加锁成功之后,线程独占,解锁操作应该是该独占线程独有的,所以这边看到其实解锁的过程是没有使用CAS的方式去设值的。
        这个地方会判断持有锁的线程非当前线程报错,应该是声明锁的错误调用,比如说可能没有调用lock方法而直接调用unlock
        同样解锁的过程也是可重入的逆向过程,因为status的值是0或者>0的正数,所以释放锁需要直到status = 0,才证明锁释放成功了。所以这边尝试释放锁的过程就是对status的设置和判断

        protected final boolean tryRelease(int releases) {
            //因为支持可重入,所以这边通过减的方式去获取state值.
            int c = getState() - releases;
            //持有者非当前线程报错.
            if (Thread.currentThread() != getExclusiveOwnerThread()){
                throw new IllegalMonitorStateException();
            }
            boolean free = false;
            //如果state为0,表示当前线程不持有锁,进行释放.
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            //设置state.这边可能c还不等于0,说明还是锁住的状态
            setState(c);
            //返回解锁结果,解锁成功true,解锁失败false.
            return free;
        }

2.解锁成功

        看一下刚才这个解锁成功之后的操作,如果这边只有一个线程获取到锁,获取head指向null,那么完全不需要去唤醒下一个节点(因为根本没这个节点了),但是如果有下一个节点,并且state不是0(无锁),会调用unparkSuccessor去唤醒下一个线程.

	if (tryRelease(arg)) {
            Node h = head;
            //如果Head指向的节点不为空,并且waitStatus不为0 ,可能是>0已经失效,也可能是<0.
            if (h != null && h.waitStatus != 0){
                //如果当前只有一个现成获取了锁,则没有必要去唤醒下一个线程.
                // 因为没有下一个线程,如果是两个以上的线程获取锁,那么后一个线程会把前一个线程的waitState状态改成SIGNAL。
                // 可以理解为持有信号量,需要唤醒后续的节点,所以才会调用unpark去唤醒后继节点.
                unparkSuccessor(h);
            }
            //返回true表示解锁成功.
            return true;
        }

3.下一个线程的唤醒

        这个和上面加锁操作的异常中唤醒是一样的代码,这边不多做解释,其实就是唤醒下一个没有被取消的节点。

private void unparkSuccessor(Node node) {

        int ws = node.waitStatus;
        //这个方法主要是唤醒node节点的后继节点,所以这边如果node节点的waitStatus<0,设置它为0,失败也没关系.
        if (ws < 0){
            compareAndSetWaitStatus(node, ws, 0);
        }
        Node s = node.next;
        //空或者被cancel了(>0),会往前查找不是node,并且waitStatus<=0的节点
        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);
        }
    }

4.解锁失败

        直接返回false

5.解锁过程图解

在这里插入图片描述

4.公平锁

1.加锁过程

        公平锁的加锁过程相对简单,需要关注的还是公平锁重写的tryAcquire方法怎么去实现公平加锁。

        final void lock() {
            acquire(1);
        }
    public final void acquire(int arg) {
		//这边的tryAcquire会判断是否有线程等待,而不是想加锁就加锁,通过这个方式来实现公平锁操作
        if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
            selfInterrupt();
        }
    }

1.公平尝试获取锁:tryAcquire

        protected final boolean tryAcquire(int acquires) {
            //先拿到当前线程和state。
            final Thread current = Thread.currentThread();
            int c = getState();
            //如果state为0
            if (c == 0) {
                //如果队列中没有前置等待节点,通过CAS的方式修改state.如果成功说明得到锁
                if (!hasQueuedPredecessors() &&  compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //status不为0,如果线程就是当前持有者,state可重入加上持有锁的次数.
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            //如果有前置节点,并且当前没有占有锁, 获取锁失败应该返回false.
            return false;
        }
    }

2.队列是否有线程在等待:hasQueuedPredecessors

        我们要关注一下hasQueuedPredecessors这个方法怎么实现公平锁的判断的。

    public final boolean hasQueuedPredecessors() {
        Node t = tail;
        Node h = head;
        Node s;
        //这边其实可以这样看:当前表达式成立,那么成立的情况就是 !(当前表达式)
        //!(h != t && ((s = h.next) == null || s.thread != Thread.currentThread()))
        //即h==t || (h.next !=null && h.next.thread == Thread.currentThread());这个条件是不成立的.
        //当head == tail说明没有节点 || head指向的节点就是当前锁的持有者.这两个条件说明前继没有等待的节点
        //而这个方法想要的是判断队列前继有等待的节点,所以就是 !的情况,就是刚才看到的.
        return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
    }

分析一下这个方法,这边通过反向和正向两种方式进行分析一下:
在这里插入图片描述
        首先是反向的分析,因为这个方法的初衷是:Queries whether any threads have been waiting to acquire longer than the current thread.
        说白了就是判断队列里面有没有在等待的,我不强行越过队列的顺序(非公平锁就是这么随意),乖乖的等.
        反向: ! (没有现成在等待) = 队列为空 || 当前线程正好占有锁。
        正向:队列中有线程在等待 = head != taill(队列不为空) && ((s = h.next) == null || s.thread != Thread.currentThread())
情况1((s = h.next) == null ):这里要参考enq方法,入队列的时候先会初始化好pre节点,而还没有初始化好next节点,说明有节点在进队列初始化,但是没有初始化完全:

在这里插入图片描述
        情况2(s.thread != Thread.currentThread()):锁不是被当前线程持有,说明有其他线程等待

2.解锁过程

        参考上面非公平锁的解锁过程.

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

了-凡

你的鼓励是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值