AQS原理及应用

AbstractQueuedSynchronizer

LockSupport

unpark函数为线程提供“许可(permit)”,线程调用park函数则等待“许可”。

“许可”是不能叠加的:当对一个线程执行park操作时,本次调用前的所有对该线程的unpark操作只存在一次“许可”。例如线程B连续三次被执行unpark操作,此时B没有被任何park操作阻塞,之后线程B被执行park操作时就会使用并清除这个许可,如果线程B再次被执行park操作,就进入等待状态。

对一个线程unpark操作可以发生在park操作前:如果park时发现已经存在“许可”,则不需要阻塞可直接接续执行。

park操作会让当前线程进入WAITING状态。在此状态下,有两种途径可以唤醒该线程:1.unpark();2.interrupt()。

一、简介

抽象队列同步器(AQS)是CLH队列的变种实现,AQS的每个节点代表一个锁的请求,每个节点记录当前请求锁的线程、前驱节点和后继节点等信息,支持共享锁模式和独占锁模式

Node结点是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。变量waitStatus则表示当前Node结点的等待状态,共有5种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE、0。

  • CANCELLED(1):表示当前结点已取消调度。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
  • SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
  • CONDITION(-2):表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
  • 0:新结点入队时的默认状态。

负值表示结点处于有效等待状态,而正值表示结点已被取消。所以源码中很多地方用>0、<0来判断结点的状态是否正常

二、独占模式

1、加锁逻辑

acquire方法用于独占锁的申请:

该方法首先会调用tryAcquire(arg)尝试获取锁,如果获取成功则返回加锁成功,否则当前线程请求进入等待队列等待被持有锁的线程唤醒。

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

第一步:调用tryAcquire(arg),如果获取成功则返回加锁成功。该方法在AQS中被调用会抛出异常,必须由子类将其重写,之所以没有定义成abstract,是因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模式下的接口。

第二步:当调用tryAcquire(arg)加锁失败后,当前线程的申请需要进入等待队列等待持有锁的线程释放锁,即调用addWaiter(Node.EXCLUSIVE)。当新节点入队列时,如果发现队列为空,就会初始化一个虚拟节点作为当前持有锁的节点,其前驱结点和线程信息为空,此时头节点和尾节点都指向该虚拟节点;然后再将当前节点入队列,此时队列中存在两个节点。

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;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
}

private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            //当头节点为空时需要初始化
            if (t == null) { // Must initialize
                //cas将头节点设置成一个新的Node对象,其属性都为默认值
                if (compareAndSetHead(new Node()))
                    //cas设置成功,再将尾节点也设置为新的节点
                    tail = head;
            } else {
                node.prev = t;
                //节点入队列
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
}

第三步:当节点入队以后如果直接将其阻塞,为了尽可能减少阻塞的线程,AQS需要确认是否有必要将当前线程park,如果不需要则当前线程会一直尝试获取锁,这一步在acquireQueued(addWaiter(Node.EXCLUSIVE), arg))中实现。

该方法正常的退出方式是return语句,但是由于tryAcquire方法由开发者自己实现,因此有可能会出现一些异常场景,例如方法抛出异常。因此acquireQueued方法在自旋外加上了try-catch语句块保证当方法执行出错时会将当前节点取消并从等待移除。

//节点入队列以后如果自身是第一个排队的节点,则再次尝试获取锁,如果获取锁失败或者其并非是第一个排队的节点,检测是否需要park
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;
                }
                //检测是否需要park
                if (shouldParkAfterFailedAcquire(p, node) &&
                    //如果需要park则在该方法中park
                    parkAndCheckInterrupt())
                    interrupted = true;
            	}
        } finally {
            if (failed)
                cancelAcquire(node);
        }
}

在该方法中首先会获取等待队列中该节点的前驱节点,如果前驱节点为头节点,则表明当前节点是第一个排队的节点,尝试申请一次加锁,如果加锁成功则将自身设置为头节点(出队列),否则需要进入shouldParkAfterFailedAcquire(p, node)判断是否需要阻塞线程。

加锁失败以后是否需要阻塞当前线程取决于shouldParkAfterFailedAcquire(p, node)返回的值:

  1. true:则线程调用后续的park逻辑并在被唤醒的时候检查当前线程是否已经被中断
  2. false:线程不需要park,继续自旋尝试获取锁
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            return true;
    	//如果前驱节点被取消,查找最接近的一个为被取消的节点,将当前节点作为其后继节点
        if (ws > 0) {
            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中

waitStatus在节点初始化的时候并没有指定值,因此为默认值0,因此acquireQueued(final Node node, int arg)方法至少需要两次调用shouldParkAfterFailedAcquire(Node pred, Node node)方法:

  1. 第一次由于waitStatus=0, 使用CAS操作将其设置为Node.SIGNAL,返回false
  2. 如果第一次设置成功,则第二次调用返回true,当前线程阻塞

节点进入等待队列中节点的可能情况:

  1. 2个节点:当新节点入队列,如果发现队列为空,就会初始化一个虚拟节点作为当前持有锁的节点,其前驱结点和线程信息为空,此时头节点和尾节点都指向该虚拟节点;然后再将当前节点入队列,此时队列中存在两个节点。

  2. n个节点(n>2)

    其他节点继续入队。

  3. 1个节点:假设当前队列中有一个节点在等待锁,即队列中存在两个节点,此时等待资源的节点加锁成功,就会将自己置为新的对头,并移除前驱结点,此时队列中只存在一个节点,并且此时会调用setHead(node)方法将节点的线程信息置空表示头节点已经持有锁。

2、解锁逻辑

release方法首先会调用tryRelease方法释放锁,tryRelease逻辑同样必须有用户自己实现

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方法解锁成功,则调用unparkSuccessor方法唤醒等待队列中的节点获取锁。

private void unparkSuccessor(Node node) {

    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 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);
}

unparkSuccessor会从尾节点开始查找指定node第一个后继待唤醒节点。这是由于node.next有可能为null:在新增节点时,会首先将新节点的prev指针指向队列tail节点,然后使用CAS操作新节点入队,入队成功后新节点成为尾节点并且将原来的尾节点next指针指向新节点,在这个过程中如果节点入队成功但是还没来得及将原来的尾节点next指针指向新节点,就会出现node.next为null的情况,采用前驱节点查找可以避免这个问题。

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) {
        //将新节点的prev指针指向队列tail节点
        node.prev = pred;
		//CAS操作入队
        if (compareAndSetTail(pred, node)) {
            //新节点成为尾节点并且将原来的尾节点next指针指向新节点
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

三、共享模式

相较于独占模式的加锁解锁逻辑,AQS共享模式下并没有加锁和解锁的概念,只有资源的获取和释放,即使用该锁的所有线程共享一部分资源,当资源充足时可直接获取使用,当资源不足时需要等待其他线程释放资源,因此当线程释放资源时会唤醒等待资源的线程。

1、获取资源

tryAcquireShared类似于tryAcquire方法,同样需要由子类重写实现逻辑。tryAcquireShared有两种返回值分别表示不同的涵义:

  • 大于0或者等于0:表示当前线程获取资源成功,返回值表示剩余的资源数
  • 小于0:表示当前线程获取资源失败
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

当获取资源失败时,当前线程就需要等待其他线程释放资源,因此需要自旋或者进入等待队列等待

doAcquireShared类似于独占模式下的acquireQueued方法,不同之处在于当线程获取资源成功以后,可能还会有剩余资源可以满足其他线程的需求,因此还需要唤醒后继节点。

private void doAcquireShared(int arg) {
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                //如果资源满足当前线程需求,则分配完成后继续唤醒后继节点继续获取资源,否则需要park或者自旋等待资源
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

setHeadAndPropagate相较于setHead方法多了一个唤醒后继节点获取资源的步骤

private void setHeadAndPropagate(Node node, int propagate) {
    Node h = head; // Record old head for check below
    setHead(node);
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

2、释放资源

releaseShared释放资源,如果释放成功会调用doReleaseShared唤醒后继节点获取资源

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

doReleaseShared方法会获取AQS队列头节点并检查其状态来决定对后继节点的操作,如果状态为SIGNAL,则说明后继节点等待被唤醒,因此尝试通过CAS操作将状态设置为0,如果设置成功则唤醒后继节点。关于这一步的CAS操作,其实在unparkSuccessor方法中也会判断前驱节点的状态,同样也会在状态小于0时将其设置为0。

为何这里要做这一步,结合注释来看,我的理解是:doReleaseShared运行在共享模式下,因此该方法有可能在某一时刻有多个线程进入,如果不加CAS操作检验状态是否已经被修改,unparkSuccessor调用过程中或者调用前,head节点已经被变更,例如头节点已经被另一个线程唤醒并且获取到资源。为了避免这种情况,在调用unparkSuccessor前先将head节点的状态置为0,此时其他节点修改就会失败而自旋。PROPAGATE其实是为了不影响线程其他地方对该结点的操作,而又为了标识doReleaseShared在该方法中已经被修改的状态,当节点的状态被修改为PROPAGATE后,线程就不会执行任何操作,只检测头节点是否发生变更,如果未发生变更就退出自旋。在该方法中有可能会发生不必要的唤醒或者多余的唤醒,因为该方法检测到头节点发生变更会继续唤醒新的后继节点,但是在setHeadAndPropagate方法中也调用了该方法,也就是被唤醒的线程同样会尝试唤醒后继节点,因此会发生一个现象:所有被唤醒的线程都在唤醒后继节点。因此一个节点可能已经被唤醒然后再次被唤醒,或者因为资源不足而阻塞,当再次被唤醒后还有可能会因为资源不足而阻塞。

private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

关于上述现象的一次复现,借助Semaphore类来实现。

第一步、在如图所示位置打上断点,并将挂起方式设置为Thread,所有的唤醒操作都将在断点处挂起。

第二步、创建Semaphore对象并直接申请所有的资源,资源总数100。

第三部、创建100个获取资源的线程,因为第一步完成后已经没有资源可获取,因此这100个线程都将阻塞。

第四步、释放所有的资源,交个上述这100个线程去竞争。

第五步、观察断点处的线程和线程栈信息,相当多的线程处于RUNNING状态,这也应证了上述的想法:被唤醒的线程同样会尝试唤醒后继节点。

四、ConditionObject

ConditionObject是AQS的内部类,实现了Condition接口,提供条件锁的同步实现和可中断和非中断方式下等待、唤醒的方法。ConditionObject是为并发编程中的同步提供了等待通知的实现方式,可以在不满足某个条件的时候挂起线程等待。直到满足某个条件的时候在唤醒线程。

ConditionObject内部维护了由一个单向链表实现的等待队列,单向链表复用了AQS的Node节点,但是不设置prev指针。

private transient Node firstWaiter;
/** Last node of condition queue. */
private transient Node lastWaiter;

等待队列是一个FIFO的队列,在队列的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程。每个调用了await方法的线程都会进入到等待队列中去。

一个同步器中可以有多个等待队列,他们等待的条件是不一样的。

调用await方法会将当前线程添加到队列并尝试唤醒AQS队列中的节点,唤醒成功后当前线程被park,唤醒失败抛出IllegalMonitorStateException。当一个线程被ConditionObjectsignal方法唤醒时,会调用acquireQueued方法重新获取锁或者挂起等待资源。

public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    //添加节点到等待队列
    Node node = addConditionWaiter();
    //当前线程进入队列后,唤醒等待队列的节点
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    //挂起当前线程
    while (!isOnSyncQueue(node)) {
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    //当前线程被signal方法唤醒或者被中断,重新获取锁或者挂起等待资源
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null) // clean up if cancelled
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}

private Node addConditionWaiter() {
    Node t = lastWaiter;
    // If lastWaiter is cancelled, clean out.
    if (t != null && t.waitStatus != Node.CONDITION) {
        unlinkCancelledWaiters();
        t = lastWaiter;
    }
    Node node = new Node(Thread.currentThread(), Node.CONDITION);
    if (t == null)
        firstWaiter = node;
    else
        t.nextWaiter = node;
    lastWaiter = node;
    return node;
}

final int fullyRelease(Node node) {
    boolean failed = true;
    try {
        int savedState = getState();
        //唤醒AQS等待队列中的节点
        if (release(savedState)) {
            failed = false;
            return savedState;
        } else {
            throw new IllegalMonitorStateException();
        }
    } finally {
        if (failed)
            node.waitStatus = Node.CANCELLED;
    }
}

acquireQueued方法并不会将当前节点转移到AQS阻塞队列中,因此必须保证当acquireQueued调用时,节点必须已经存在于AQS队列。因此在await方法中有了如下代码片段:

while (!isOnSyncQueue(node)) {
    LockSupport.park(this);
    //前面说过park可以被打断和唤醒,为了保证后续操作正常执行,当park被打断时会将节点转移到AQS队列中,如果被唤醒则说明其他线程已经准备就绪,可以直接执行
    if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
        break;
}

//检查节点是否已经存在于AQS队列中,如果存在返回true,如果不存在返回false
final boolean isOnSyncQueue(Node node) {
    //如果node还是condition节点或者前驱节点为空,则表示不在AQS队列中
    //因为condition节点只能存在于ConditionObject单独维护的FIFO队列中,而队列中存在前驱节点为空时只能是头节点,头节点是一个虚拟节点
    if (node.waitStatus == Node.CONDITION || node.prev == null)
        return false;
    //不满足以上的if分支条件并且该节点的后继节点不为null,则说明该节点已经存在于AQS队列
    if (node.next != null) // If has successor, it must be on queue
        return true;
    //如果以上条件都不满足,则需要遍历队列查找这个节点是否存在,查找时需要使用prev指针,具体原因在解锁逻辑部分已详细说明
    return findNodeFromTail(node);
}

上面提到必须保证当acquireQueued调用时,被唤醒节点必须已经存在于AQS队列。当park线程是被其他线程打断时是由await方法保证唤醒节点存在于AQS队列中,但是如果程序正常执行时,这一点则是由调用signal方法的线程保证的。

唤醒AQS节点的线程必须是当前持有锁的线程,也就是说调用await方法之前必须获取到锁,否则抛出IllegalMonitorStateException

调用signal方法会唤醒ConditionObject队列中第一个等待的节点,并将该节点从等待队列中移除,然后将其添加到AQS队列中

public final void signal() {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    Node first = firstWaiter;
    if (first != null)
        doSignal(first);
}

private void doSignal(Node first) {
    //将节点转移到AQS节点中并从当前队列移除该节点,如果转移失败,则说明该节点已被取消,直接移除
    do {
        if ( (firstWaiter = first.nextWaiter) == null)
            lastWaiter = null;
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

final boolean transferForSignal(Node node) {
    //如果节点被取消返回false
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
        return false;
	//将节点添加到AQS队列中
    Node p = enq(node);
    //如果前驱节点的状态为取消则恢复线程继续执行取消操作
    //如果CAS将前驱节点的状态设置为SIGNAL失败,则前驱节点状态发生变更,前驱节点可能已经被取消,则需要唤醒当前节点的线程让其执行读取更新前驱节点的状态
    int ws = p.waitStatus;
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
        LockSupport.unpark(node.thread);
    return true;
}

五、AQS应用

ReentrantLock

ReentrantLock实现了tryAcquire的公平锁和非公平锁

  • 公平锁:获取锁时如果当前是无锁状态,如果AQS等待队列中存在阻塞的节点则加锁失败并随后进入等待队列。如果持有锁的是当前线程,则不需要再次获取锁,增加锁的状态标识即可。

    final void lock() {
        acquire(1);
    }
    
    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;
    }
    
  • 非公平锁:获取锁时如果当前是无锁状态直接尝试获取锁,不管等待队列是否已经有阻塞的节点。如果持有锁的是当前线程,则不需要再次获取锁,增加锁的状态标识即可。

    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    
    protected final boolean tryAcquire(int acquires) {
    	return nonfairTryAcquire(acquires);
    }
    
    final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        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;
    }
    
CountDownLatch

每次调用countDown方法都会将资源减1,当资源-1操作等于0后,说明所有的资源都恰好被消耗,该方法返回true。根据AQS共享锁的释放资源的逻辑,会唤醒后继等待的节点。

public void countDown() {
    sync.releaseShared(1);
}

protected boolean tryReleaseShared(int releases) {
    // Decrement count; signal when transition to zero
    for (;;) {
        int c = getState();
        if (c == 0)
            return false;
        int nextc = c-1;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}
Semaphore

非公平模式:当remaining<0时说明资源不够分配,直接返回即可,如果remaining>0则资源足够分配,CAS设置剩余资源并返回剩余资源数。按照CAS实现逻辑,当tryAcquireShared方法返回值小于0时,线程需要等待其他线程释放资源。

protected int tryAcquireShared(int acquires) {
    return nonfairTryAcquireShared(acquires);
}

final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}

公平模式:相较于非公平模式下的资源获取,公平模式下资源获取首先需要检查AQS等待队列是否有节点正在等待资源,如果有则返回-1让当前线程排队等待资源。

protected int tryAcquireShared(int acquires) {
    for (;;) {
        if (hasQueuedPredecessors())
            return -1;
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值