一文深入分析AQS(原理篇)

之前写过AQS的文章 AbstractQueuedSynchronizer整体解析),当时只是简单介绍了下,没有涉及源码分析和实现细节,但这毕竟是java同步中最重要的类,于是重写了部分内容,添加了源码分析和其他细节。

本文分成两个部分,第一部分从整体上分析AQS的实现机制和原理,第二部分深入源码探究。好了,那就开始吧

整体介绍

从AQS类的注释中,我们可以了解到:该类是一个用于构建锁或其他同步器的基础框架,使用一个int的成员变量表示同步状态。另外,还有一个内置的先进先出的队列可储存竞争同步状态时排队的线程

将上面描述的翻译成通俗的语言就是:有一个共享资源state(int类型的变量),各个线程去竞争这个资源,竞争到的拥有资源,去处理自己的逻辑;没竞争到去排队(进入先进先出队列),等拥有资源的线程释放共享资源后,队列中线程的再去竞争

如下图表示

在这里插入图片描述

注意:并不是state为0就表示无锁,state大于0就表示有锁,上面只是一个示例,子类完全可以自己实现是否获取到锁的逻辑。

除了提供同步队列,AQS内部还实现了Condition接口用于提供类似waitnotify的等待-通知机制。

具体地,当获取同步变量的线程需要等待某个条件时,可以调用await加入另一个队列——等待队列,同时放弃持有的同步变量,待其他线程调用signal可将其唤醒,被唤醒的线程会再次加入同步队列以竞争同步变量。

在这里插入图片描述
上图是当持有同步变量(也就是锁)的线程调用await后加入等待队列示意图,同理,当有线程调用signal后,等待队列队首元素被唤醒,加入同步队列。如下图

在这里插入图片描述
AQS就是通过维护这两个队列以及使用原子设值CAS,完成了基础的同步操作。子类可根据需要实现自己的逻辑。

需要子类实现的方法如下

方法实现思路
tryAcquire独占式获取同步状态,根据子类的逻辑,返回true或false值表示是否获取到了锁
tryRelease独占式释放同步状态
tryAcquireShared共享式获取同步状态,若返回值大于等于0,表示获取成功,否则表示失败
tryReleaseShared共享式释放同步状态
isHeldExclusively在独占模式下,同步状态是否被占用

其中最重要的,就是实现tryAcquire\tryRelease(对于独占模式),或tryAcquireShared\tryReleaseShared(用于共享模式)。AQS会根据子类的具体实现确定同步变量(锁)是否已经得到,并进行相关逻辑处理。

以获取独占锁为例,AQS实现如下


  public final void acquire(int arg) {
        // tryAcquire方法由子类实现,AQS根据子类是否得到了锁判断该线程是进入同步队列阻塞还是继续执行操作。
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

为帮助子类实现获取锁,释放锁的方法,AQS提供了如下相关同步状态的方法

方法描述
getState获取同步状态
setState(state)设置同步状态
compareAndSetState(except,update)使用CAS设置同步状态,只有当同步状态值为except时,才将其设置update

比如子类这样实现

static class Sync extends AbstractQueuedSynchronizer {

        // 子类实现的tryAcquire
        @Override
        protected boolean tryAcquire(int arg) {

            boolean getLock = false;
            // AQS类的获取同步状态方法
            int status = getState();
            if (status == 0) {
                // AQS类的原子设置同步状态方法
                boolean action = compareAndSetState(0, arg);
                if (action) {
                    setExclusiveOwnerThread(Thread.currentThread());
                    getLock = true;
                }
            } else if (getExclusiveOwnerThread() == Thread.currentThread()) {
                setState(status + arg);
            }
            return getLock;
        }
        
        // 子类实现的tryRelease
        @Override
        protected boolean tryRelease(int arg) {

            boolean released = false;

            int c = getState() - arg;

            // 子类设置同步状态,为什么不用原子设值?因为调用此方法的前提就是在已获得锁的情况下
            setState(c);

            if (c == 0) {
                setExclusiveOwnerThread(null);
                released = true;
            }
            return released;
        }

        @Override
        protected boolean isHeldExclusively() {
            return getExclusiveOwnerThread() == Thread.currentThread();
        } 
        
        public void lock() {
            acquire(1);
        }

        public void unlock() {
            release(1);
        }

        final Condition newCondition() {
            return new AbstractQueuedSynchronizer.ConditionObject();
        }    
    }

如此,子类就有高度的灵活性,可实现不同类型不同情况的的同步类。

  1. ReentrantLock,是排他锁,某个线程获取锁后其他线程就会阻塞直至锁的释放。共享资源state初始值为0,表示资源未被占有。某线程访问并设置state为1,表示该线程占有了锁。当其他线程读取到state不为0后进入队列等待,直到占有锁的线程将其设为0后,队列线程才会得到通知,重新竞争锁。(事实上ReentrantLock作为可重入锁,占有锁的线程再次进入锁会使state加1,退出一次state减1)

  2. CountDownLatch,共享锁。可用于控制线程执行、结束的时机。如我们想要主线程在2个子线程执行完后再结束,这时使用CountDownLatch通过构造函数将共享变量state设为2,将主线程锁住,每个子线程结束后state减一,state为0后表示两子线程执行完毕,此时主线程才得以释放。

此外还有CyclicBarrierSemaphore等都是根据AQS构建的。

源码分析

接下来,就是分析源码的时候了。

一般子类实现AQS后,如获取锁的方法还是委托给AQS的acquire,释放锁则为release,就从这两个方法说起吧。

acquire

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

tryAcquire由子类实现这个不说了,当没有获取锁(tryAcquire返回false)的情况下,随后执行顺序为

addWaiter -> acquireQueued -> selfInterrupt

其中selfInterrupt 是响应中断的,这个与主线关系不大就不分析了,主要看前两个方法。

在开始分析之前,我们先假设一个场景好言之有物,假设线程t执行了t.acquire这个方法,并且在tryAcquire没有获取锁。

  1. addWaiter 主要完成将当前线程(即线程t)包装成一个节点加入同步队列中

  2. acquireQueued 判断当前线程(即线程t)是否要阻塞,符合一定条件后将其阻塞。

先看看addWaiter方法


  private Node addWaiter(Node mode) {
        //将当前线程t封装到新创建的节点中
        // 下文将线程t称为当前线程t,将其封装节点称为当前节点。
        //(Node是AQS的节点类,用于封装同步队列的线程)
        Node node = new Node(mode);
        
        // 下面循环就是将当前线程加入同步队列的尾部
        for (;;) {
            // tail是同步队列尾节点
            Node oldTail = tail;
            // 如果队列不为空
            if (oldTail != null) {
                //将新节点的前一个节点设置为同步队列尾节点
                node.setPrevRelaxed(oldTail);
                // 将同步队列尾节点后移
                if (compareAndSetTail(oldTail, node)) {
                    oldTail.next = node;
                    return node;
                }
            } else {
                // 如果队列为空,初始化队列
                initializeSyncQueue();
            }
        }
    }

  // 初始化同步队列
  private final void initializeSyncQueue() {
        Node h;
        if (HEAD.compareAndSet(this, null, (h = new Node())))
            tail = h;
  }
  
  // Node的构造函数,可以看到该节点将当前线程(线程t)封装了
   Node(Node nextWaiter) {
            this.nextWaiter = nextWaiter;
            THREAD.set(this, Thread.currentThread());
   }

了解了addWaiter方法后,可以先了解下同步队列的一些特点:

AQS的同步队列是一个双向的队列,AQS持有队列的队首(head)和队尾(tail)的引用。队列中每个节点又有前一个节点和后一个节点的引用。

除此之外,tail队尾节点指向队列的最后一个元素,head队首好像是一个“空引用”,从上面代码看出,初始时队列的队首队尾相互指向一个空白节点。随后有节点加入后,只有队尾节点跟着移动。其实,队首的这个空引用其实是有含义的,它代表当前正在持有同步状态的线程。可以把它当做一个标识,这个可以从后面代码看出,这里先了解就行。

下面看acquireQueued方法

  // 这个方法是个无限循环方法,除非获取到锁或发生中断,否则是出不来的
  // 得不到锁就调用park阻塞
  final boolean acquireQueued(final Node node, int arg) {
        boolean interrupted = false;
        try {
            for (;;) {
                // 获取当前线程t的前一个节点
                final Node p = node.predecessor();
                // 如果其前置节点是队首节点,再次尝试获取锁
                // 这也说明,如果该线程在队列后面,是要等到前面的线程走完了才能获取锁的
                if (p == head && tryAcquire(arg)) {
                    // 表明得到锁,将自己设为队首节点
                    setHead(node);
                    p.next = null; // help GC
                    return interrupted;
                }
                // 判断是否阻塞
                if (shouldParkAfterFailedAcquire(p, node))
                    // 如果应该,调用park将其阻塞,
                    // 当从阻塞中唤醒后,判断是否中断过
                    interrupted |= parkAndCheckInterrupt();
            }
        } catch (Throwable t) {
            cancelAcquire(node);
            if (interrupted)
                selfInterrupt();
            throw t;
        }
    }

总结下上面方法,大概有2个分支,

① 前置节点不是队首或是队首但没获取锁,这时进入shouldParkAfterFailedAcquire


    // 是否需要阻塞
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        // 如果前置节点状态为SIGNAL,返回true
        if (ws == Node.SIGNAL)
           
            return true;
        // 前置节点状态大于0表示取消了的节点,如是,则将当前节点t的前置节点不断前置,直到前置节点状态不再大于0
        if (ws > 0) {
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 否则,将前置节点状态设为SIGNAL
            pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
        }
        return false;
    }

这里可能有点晕,关于节点的状态文档是这样说的

属性说明
CANCELLED1表示当前的线程被取消,处于这种状态的Node会被踢出队列,被GC回收
SIGNAL-1表示当前节点的后继节点表示的线程需要解除阻塞并执行
CONDITION-2表示这个节点在条件队列中,因为等待某个条件而被阻塞
PROPAGATE-3使用在共享模式可能处于此状态,表示后续节点能够得以执行
初始状态0表示当前节点在sync队列中,等待着获取锁。

通俗的解释下,

CANCELLED这个好理解,表示该线程已经取消了,直接从队列中去掉即可。

SIGNAL和PROPAGATE表示它后面还有节点呢,当前节点完成任务后记得调用相关方法将他们唤醒。

0就是初始状态,没设置的话就是这个状态。

对应上面代码,这方法(shouldParkAfterFailedAcquire)的执行时机是在阻塞前,所以意思就是:在自己被阻塞前要将它的前置节点设为SIGNAL,好让前置节点执行完成后记得将其唤醒

理解了这层,再看上面代码就清楚许多了。第一次循环时将当前线程t的前置节点状态设为SIGNAL了, 再走一次循环(如果还没得到锁的话)就要执行parkAndCheckInterrupt了


 private final boolean parkAndCheckInterrupt() {
        // 阻塞当前线程
        LockSupport.park(this);
        return Thread.interrupted();
 }
 
 public static void park(Object blocker) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        U.park(false, 0L);
        setBlocker(t, null);
    }

这时,线程t就被阻塞住了,准确说,是进入WAITING状态了。

② 获取到了锁

  // p表示当前节点的前置节点
  if (p == head && tryAcquire(arg)) {
         // 表明得到锁,将自己设为队首节点
         setHead(node);
         p.next = null; // help GC
        return interrupted;
  }

如果进入这个条件,表明自己前面已经没有排队的节点了,因为我们说过,head节点不表示任何排队线程,它只是当前已获取锁线程的标识,是个空节点。

setHead(node) 看看

// 很简单,就是将head指向当前节点,
// 当前节点也卸下封装,和线程t没关系了,重新称为已获取锁的线程的标识符号
 private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
 }

release

经过上面的铺垫,release就好说多了


  public final boolean release(int arg) {
        // 子类实现的方法,释放锁,主要是对同步状态的操作
        if (tryRelease(arg)) {
            Node h = head;
            // 从头节点开始唤醒,这里判断waitStatus状态不为0
            // 还记得上面说的么,每个节点在阻塞前会将前置节点状态置为SIGNAL(-1)
            // 如果为0,表示后面不需要唤醒 
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
  }

如果需要唤醒,执行unparkSuccessor


    // 记得node节点就是队首节点,这是个空节点,需要向下找第一个符合条件的节点并唤醒。
    private void unparkSuccessor(Node node) {
      
        int ws = node.waitStatus;
        // 将队首节点状态重置
        if (ws < 0)
            node.compareAndSetWaitStatus(ws, 0);

        // 这里就是将队首节点的下一个节点唤醒
        // 没有的话或该节点已取消,就接着往下找
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node p = tail; p != node && p != null; p = p.prev)
                if (p.waitStatus <= 0)
                    s = p;
        }
        // 唤醒下一个节点线程
        if (s != null)
            LockSupport.unpark(s.thread);
    }

这里唤醒后会重新在acquireQueued 中执行,重新尝试获取锁,这里就不说了。

再看看这个图,是不是有不同认识了

在这里插入图片描述

Condition

AQS也实现了类似waitnotify语义,实现方式前面说过了,就是再加一个等待队列,额,其实不是一个,AQS的等待队列是可以有多个的,可以实现不同条件下的等待。不同于synchronized,synchronized的等待队列只能有一个。

先看看示例吧

  // 示例用法哈,这不能运行
  public static void testAwait() throws Exception {
        
        Sync lock = new Sync();

        // 关联lock的条件
        Condition condition = lock.newCondition();

        // 可以创建多个
        Condition condition2 = lock.newCondition();
        lock.lock();
        try {
           
            // 等待条件
            condition.await();
            // 唤醒条件
            condition2.signal();
            
        } finally {
            lock.unlock();
        }
    }

关键是await和signal。那就开始吧

假设线程t调用了await方法,称当前线程t,要记得当前线程t目前持有锁,否则也不能调用await方法。


   public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
            // ①封装当前线程t,并加入等待队列
            Node node = addConditionWaiter();
            // ② 释放当前线程t持有的锁
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            // ③判断当前节点是否在同步队列中
            // 第一次走肯定不会,但这是循环方法,当节点被唤醒就会
            while (!isOnSyncQueue(node)) {
                // 若不在,阻塞
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            // ④ 后面都是线程从等待队列唤醒后的事了,一会分析
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

先分析①②③,这是线程进入等待队列并阻塞的过程。后面被唤醒要结合signal分析。

addConditionWaiter方法如下

 private Node addConditionWaiter() {
            if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
            // lastWaiter是等待队列尾节点
            Node t = lastWaiter;
            // 尾节点状态异常,则遍历清除已取消节点
            if (t != null && t.waitStatus != Node.CONDITION) {
                unlinkCancelledWaiters();
                t = lastWaiter;
            }
 
            // 构建等待节点,注意状态是CONDITION,表示是等待节点
            Node node = new Node(Node.CONDITION);
            // 下面不用多说,就是将当前节点加入队尾
            if (t == null)
                firstWaiter = node;
            else
                t.nextWaiter = node;
            lastWaiter = node;
            return node;
        }

这里可以分析下,AQS的等待队列,其节点和同步队列的节点是同一个类表示的,都是内部类Node,


 static final class Node {
     //节点状态
     volatile int waitStatus;

     //前置节点
     volatile Node prev;  
     //后继节点
     volatile Node next;
     
     //该节点保存的线程
     volatile Thread thread;

     //该队列下一个等待者
     Node nextWaiter;
}  

prev和next是给同步队列用的,nextWaiter是给等待队列用的,其他就无所谓共用了。

还可以发现,等待队列的每个节点没有前置节点,但仍有队首和队尾的表示,分别是 firstWaiterlastWaiter

接下来是

  // node节点是当前节点,这里就是调用release唤醒同步队列第一个等待的线程
  // 返回持有的同步值
  final int fullyRelease(Node node) {
        try {
            int savedState = getState();
            if (release(savedState))
                return savedState;
            throw new IllegalMonitorStateException();
        } catch (Throwable t) {
            node.waitStatus = Node.CANCELLED;
            throw t;
        }
    }

接下来看看

   // 判断当前节点是否在同步队列中
   final boolean isOnSyncQueue(Node node) {
        if (node.waitStatus == Node.CONDITION || node.prev == null)
            return false;
        if (node.next != null) // If has successor, it must be on queue
            return true;
       
        return findNodeFromTail(node);
    }

总结下,调用await后,当前线程(持有锁的线程)先进入等待队列,然后释放锁,在使用park进行阻塞。

想想此时的同步队列,无论有没有释放锁,这个线程在同步队列里其实是没有记录的,只有队首有一个表示该持有锁线程的符号标识。

接着看 signal

 //代码很简单,找出队首节点,进入doSignal方法
  public final void signal() {
       if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
       Node first = firstWaiter;
       if (first != null)
       doSignal(first);
 }

  private void doSignal(Node first) {
            
          do {
             // if这句表示将当前队首节点移除,毕竟要唤醒了嘛,如果队列为空了,直接清空队列就好
             if ( (firstWaiter = first.nextWaiter) == null)
                    lastWaiter = null;
                first.nextWaiter = null;
            } while (!transferForSignal(first) &&
                     (first = firstWaiter) != null);
  }

主要是transferForSignal这句

 // 这个方法的意思即是将等待队列的队首节点移到同步队列中
 // 准备说,前面一步已经将该节点从等待队列移除了,这里就是将其放入同步队列
 final boolean transferForSignal(Node node) {
        /*
         *  将节点状态置为初始状态
         *  毕竟CONDITION是等待队列那边的状态
         */
        if (!node.compareAndSetWaitStatus(Node.CONDITION, 0))
            return false;
        // 进入同步队列,返回前置节点
        Node p = enq(node);
        int ws = p.waitStatus;
        // 设置前置节点状态为SIGNAL,这个不陌生
        if (ws > 0 || !p.compareAndSetWaitStatus(ws, Node.SIGNAL))
            LockSupport.unpark(node.thread);
        return true;
    }

这步主要目的是唤醒等待队列,具体为将等待队列中的队首元素移除队列,再将其放入同步队列中。

所以可以发现,signal只是将等待队列的队首节点移到到了同步队列中,并没有类似unpark的唤醒操作。原因自然很好理解,这时当前线程t还在同步块中的await处,需要持有锁后才能执行。

直到在同步队列中的前一个节点将其唤醒,会从await的阻塞处醒来,也就是

 // await部分代码
 
 // 醒来再次判断是否在同步队列,此时已经在了,跳出循环
  while (!isOnSyncQueue(node)) {
      // 被唤醒后从这里醒来
     LockSupport.park(this);
     // 执行是否中断的判断
     if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
   }
   // 又出现了acquireQueued方法,尝试获取锁
   if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
          interruptMode = REINTERRUPT;
   if (node.nextWaiter != null) 
           //清除等待队列中已被取消了的等待者
           unlinkCancelledWaiters();
   if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);

至此,AQS的最重要的同步队列和等待队列就分析完了。

关于中断和共享

前面我们常会看到关于中断的判断和方法,比如 acquireacquireInterruptibly,区别就在于前者在等待锁的过程中是不响应中断的,只是给当前线程设置中断标识,等线程获取到锁后自行处理中断;而后者可以响应,即直接抛出异常

看看代码


  public static void main(String[] args) {

        Sync sync = new Sync();

        Thread t1 = new Thread(() -> {

            sync.lock();
            try {
                Thread.sleep(1000 * 100);
                System.out.println("t1完成");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                sync.unlock();
                System.out.println("t1退出");
            }
        });


        Thread t2 = new Thread(() -> {
            sync.lock();
            // 获取锁后,自己处理中断逻辑
            if (Thread.interrupted()) {
                System.out.println("已中断,放弃任务");
                return;
            }
            try {
                System.out.println("t2完成");
            } finally {
                sync.unlock();
                System.out.println("t2退出");
            }

        });


        try {
            t1.start();
            Thread.sleep(1000);
            t2.start();
            Thread.sleep(1000);
            // 线程2发出中断信号,要等到线程2获取锁后自己在同步块中处理
            t2.interrupt();
            System.out.println("t2已中断");
            System.out.println("main over");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

而acquireInterruptibly则是直接抛出异常


   private void doAcquireInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.EXCLUSIVE);
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    return;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    // 直接抛异常
                    throw new InterruptedException();
            }
        } catch (Throwable t) {
            cancelAcquire(node);
            throw t;
        }
    }

补充个知识点 park是会响应中断的

在这里插入图片描述

前面说的都是独占形式获取锁,还有关于共享方式的,如下


 public final void acquireShared(int arg);
 
 public final boolean releaseShared(int arg);

共享方式特点就是可以有多个线程同时处理同步块,即同时拥有锁。


    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);
        boolean interrupted = false;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        // 关键区别在这里
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node))
                    interrupted |= parkAndCheckInterrupt();
            }
        } catch (Throwable t) {
            cancelAcquire(node);
            throw t;
        } finally {
            if (interrupted)
                selfInterrupt();
        }
    }
  // 获取锁后设置队首节点,如果后续节点也是共享节点,则唤醒
  private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; 
        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();
        }
    }

  private void doReleaseShared() {
       
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {
                    if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
                        continue;  
                    // 这是唤醒队首节点的下一个节点         
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !h.compareAndSetWaitStatus(0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            // 如果队首元素被改变,也就是前面被唤醒的节点修改了队首节点,则继续
            if (h == head)                  
                break;
        }
    }

全文完

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值