源码解读之(四)AbstractQueuedSynchronizer

一、简介

AbstractQueuedSynchronizer 队列同步器(AQS)是一个抽象类,,它是为实现依赖于先进先出(FIFO)等待队列的阻塞锁和相关同步器(信号量,事件等)提供的一个基础框架。AQS继承了AbstractOwnableSynchronizer类,这个类为创建锁和相关同步器提供了基础。AQS是Concurrent包的核心,lock就是在AQS的基础上实现的,阻塞队列,线程池,信号量等都离不开AQS的支持。

二、AQS 的实现原理

1、AQS 的继承关系

AQS 继承了AbstractOwnableSynchronizer,我们先分析一下这个父类。

public abstract class AbstractOwnableSynchronizer
    implements java.io.Serializable {

    /** 序列化ID*/
    private static final long serialVersionUID = 3737899427754241961L;

    /**
     * 构造函数
     */
    protected AbstractOwnableSynchronizer() { }

    /**
     * 独占模式下的线程
     */
    private transient Thread exclusiveOwnerThread;

    /**
     * 设置线程,只是对线程的 set 方法
     */
    protected final void setExclusiveOwnerThread(Thread thread) {
        exclusiveOwnerThread = thread;
    }

    /**
     * 设置线程,对线程的 get 方法
     */
    protected final Thread getExclusiveOwnerThread() {
        return exclusiveOwnerThread;
    }
}

父类非常简单,持有一个独占模式下的线程,然后就只剩下对这个线程的 get 和 set 方法。

2、AQS 的内部实现

AQS的实现依赖内部的同步队列,也就是FIFO的双向队列,如果当前线程竞争锁失败,那么AQS会把当前线程以及等待状态信息构造成一个Node加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。

AQS具有头节点和尾节点

private transient volatile Node head;
private transient volatile Node tail;

Node是构成同步队列的基础,看一下Node的结构
在这里插入图片描述

同步队列中首节点是获取到锁的节点,它在释放的时候会唤醒后继节点,后继节点获取到锁的时候,会把自己设为首节点。

注意,设置首节点不需要使用CAS,因为在并发环境中只有一个线程都获取到锁,只有获取到锁的线程才能设置首节点。

AQS队列内部维护的是一个FIFO的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。每个Node其实是由线程封装,当线程争抢锁失败后会封装成Node加入到ASQ队列中去。

每个线程都会被封装成一个Node节点放到同步队列中,每个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;
    // 线程的等待状态 表示线程在Condtion上
    static final int CONDITION = -2;
    
    // 表示下一个acquireShared需要无条件的传播
    static final int PROPAGATE = -3;

    /**
     *   SIGNAL:     当前节点的后继节点处于等待状态时,如果当前节点的同步状态被释放或者取消,
     *               必须唤起它的后继节点
     *         
     *   CANCELLED:  一个节点由于超时或者中断需要在CLH队列中取消等待状态,被取消的节点不会再次等待
     *               
     *   CONDITION:  当前节点在等待队列中,只有当节点的状态设为0的时候该节点才会被转移到同步队列
     *               
     *   PROPAGATE:  下一次的共享模式同步状态的获取将会无条件的传播

     * waitStatus的初始值时0,使用CAS来修改节点的状态
     */
    volatile int waitStatus;

    /**
     * 当前节点的前驱节点,当前线程依赖它来检查waitStatus,在入队的时候才被分配,
     * 并且只在出队的时候才被取消(为了GC),头节点永远不会被取消,一个节点成为头节点
     * 仅仅是成功获取到锁的结果,一个被取消的线程永远也不会获取到锁,线程只取消自身,
     * 而不涉及其他节点
     */
    volatile Node prev;

    /**
     * 当前节点的后继节点,当前线程释放的才被唤起,在入队时分配,在绕过被取消的前驱节点
     * 时调整,在出队列的时候取消(为了GC)
     * 如果一个节点的next为空,我们可以从尾部扫描它的prev,双重检查
     * 被取消节点的next设置为指向节点本身而不是null,为了isOnSyncQueue更容易操作
     */
    volatile Node next;

    /**
     * 当前节点的线程,初始化后使用,在使用后失效 
     */
    volatile Thread thread;

    /**
     * 链接到下一个节点的等待条件,或特殊的值SHARED,因为条件队列只有在独占模式时才能被访问,
     * 所以我们只需要一个简单的连接队列在等待的时候保存节点,然后把它们转移到队列中重新获取
     * 因为条件只能是独占性的,我们通过使用特殊的值来表示共享模式
     */
    Node nextWaiter;

    /**
     * 如果节点处于共享模式下等待直接返回true
     */
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    /**
     * 返回当前节点的前驱节点,如果为空,直接抛出空指针异常
     */
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }

    Node() {    // 用来建立初始化的head 或 SHARED的标记
    }

    Node(Thread thread, Node mode) {     // 指定线程和模式的构造方法
        this.nextWaiter = mode;
        this.thread = thread;
    }

    Node(Thread thread, int waitStatus) { // 指定线程和节点状态的构造方法
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

2.1 添加节点

当出现锁竞争以及释放锁的时候,AQS同步队列中的节点会发生变化,首先看一下添加节点的场景。

在这里插入图片描述

这里会涉及到两个变化

  • 新的线程封装成Node节点追加到同步队列中,设置prev节点以及修改当前节点的前置节点的next节点指向自己
  • 通过CAS将tail重新指向新的尾部节点

2.2 释放锁移除节点

head节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下

在这里插入图片描述

这个过程也是涉及到两个变化

  • 修改head节点指向下一个获得锁的节点
  • 新的获得锁的节点,将prev的指针指向null

这里有一个小的变化,就是设置head节点不需要用CAS,原因是设置head节点是由获得锁的线程来完成的,而同步锁只能由一个线程获得,所以不需要CAS保证,只需要把head节点设置为原首节点的后继节点,并且断开原head节点的next引用即可。

三、AQS 的两种功能

从使用层面来说,AQS的功能分为两种:独占和共享

  • 独占锁,每次只能有一个线程持有锁,比如前面给大家演示的ReentrantLock就是以独占方式实现的互斥锁。
  • 共享锁,允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock

3.1 独占式

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

通过调用acquire的方法获取同步状态,该方法忽略中断,线程获取同步状态失败后,进入同步队列,在对其进行中断操作后,线程不会从同步队列移除。首先调用tryAcquire方法获取同步状态,AQS并没有实现这个方法,具体的实现由它的继承类进行重写,比如ReentrantLock的Sync类。如果获取同步状态成功的直接返回true;如果获取同步状态失败的话,调用addWaiter方法把线程封装成一个Node节点添加到同步队列的尾部,最后调用acquireQueued方法使节点以自旋的方式获取同步状态,如果获取同步状态失败,要挂起线程,最后,线程如果在获取同步状态中和同步队列中被中断过,要进行自我中断。

addWaiter 方法

/**
  * 把Node节点添加到同步队列的尾部
  */
 private Node addWaiter(Node mode) {
     Node node = new Node(Thread.currentThread(), mode);  // 以独占模式把当前线程封装成一个Node节点
     // 尝试快速入队
     Node pred = tail;  // 当前队列的尾节点赋给pred
     if (pred != null) {  // 先觉条件 尾节点不为空
         node.prev = pred;  // 把pred作为node的前继节点
         if (compareAndSetTail(pred, node)) { //利用CAS把node作为尾节点
             pred.next = node;    // 把node作为pred的后继节点
             return node;       // 直接返回node
         }
     }
     enq(node);  // 尾节点为空或者利用CAS把node设为尾节点失败
     return node;
 }
 /**
  * 采用自旋的方式把node插入到队列中
  */
 private Node enq(final Node node) {
     for (;;) {
         Node t = tail;
         if (t == null) { // 如果t为空,说明队列为空,必须初始化
             if (compareAndSetHead(new Node())) // 新建一个节点利用CAS设为头节点,就是这样的形式 head=tail=null
                 tail = head;
         } else {    // 尾节点不为空的情况
             node.prev = t;  // 把t设为node的前驱节点
             if (compareAndSetTail(t, node)) {  // 利用CAS把node节点设为尾节点
                 t.next = node;   // 更改指针  把node作为t的后继节点
                 return t;   // 直接返回t
             }
         }
     }
 }

enq方法中采用了非常经典的自旋操作,只有通过CAS把node设为尾节点后,当前线程才能退出该方法,否则的话,当前线程不断的尝试,直到能把节点添加到队列中为止,这样就把并行添加变成了串行添加。

acquireQueued 方法

/* 
 * 此主要是通过自旋方式获取同步状态
 */
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;  // 默认线程没有被中断过
        for (;;) {
            final Node p = node.predecessor();  // 获取该节点的前驱节点p
            if (p == head && tryAcquire(arg)) {  // 如果p是头节点并且能获取到同步状态
                setHead(node);                   // 把当前节点设为头节点
                p.next = null;                  // 把p的next设为null,便于GC
                failed = false;                 // 标志--表示成功获取同步状态,默认是true,表示失败
                return interrupted;             // 返回该线程在获取到同步状态的过程中有没有被中断过
            }
            if (shouldParkAfterFailedAcquire(p, node) &&   // 用于判断是否挂起当前线程
                parkAndCheckInterrupt())
                interrupted = true;      
        }
    } finally {
        if (failed)   // 如果fail为true,直接移除当前节点
            cancelAcquire(node);
    }
}

这个方法比较复杂,里面包含了很多其他的方法,我们先看获取当前节点的前驱节点,如果前驱节点是头节点,有两种情况,一种是默认空的头节点,说明此时是同步队列中的第一个线程去尝试获取同步状态,另一种是获取到同步状态的节点,然后再一次调用子类重写的tryAcquire方法去获取同步状态,如果成功获取同步状态,则把当前节点设为头节点。如果当前节点的前驱节点不是头节点或者没有获取到同步状态的话,就要调用shouldParkAfterFailedAcquire方法挂起当前线程。

shouldParkAfterFailedAcquire 方法

/**
 * 如果线程获取同步状态失败就要检查它的节点status,要保证prev = node.prev
 */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;  // 获取当前节点的前驱节点的waitStatus
    if (ws == Node.SIGNAL)     
        /*
         *  如果前驱节点的ws = singal,表示前驱节点释放后会唤起当前线程,    
         *  可以安全的挂起当前线程
         */
        return true;   // 能够挂起当前线程直接返回true
    if (ws > 0) {
        /*
         * 前驱节点的ws > 0,说明ws = Cancelled,表示前驱线程被取消,
         * 从前驱节点继续往前遍历,直到找到第一个前驱节点的ws <= 0 为止
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * 这种情况表示前驱节点的 ws = 0 或者 ws = PROPAGATE,我们需要一个singal,但是
         * 不能挂起当前线程 
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

这段代码用来检测是否挂起当先线程,分三种情况,第一种情况是前驱节点的 ws = singal,表示前驱节点释放同步状态的时候会唤醒当前节点,可以安全挂起当前线程;第二种情况是前驱节点被取消,那就从前驱节点继续往前遍历,直到往前找到第一个ws <= 0 的节点;第三种是前驱节点的 ws = 0,表示前驱节点获取到同步状态,当前线程不能挂起,应该尝试去获取同步状态,前驱节点的同步状态的释放正好可以让当前节点进行获取,所以使用CAS把前驱节点的ws设为singal,另外如果 ws =PROPAGATE,说明正以共享模式进行传播,也需要使用CAS把ws设为singal.

shouldParkAfterFailedAcquire返回true的情况下,继续看parkAndCheckInterrupted方法

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

调用LockSupportpark方法挂起当前线程,返回该线程是否被中断过,如果被中断过,直接设置interrupted = true.

如果获取同步状态失败,采用cancelAcquire方法取消当前节点。

/**
 * 取消当前节点
 */
private void cancelAcquire(Node node) {
    // 当前节点不存在的话直接忽略 
    if (node == null)
        return;

    node.thread = null;  // 把当前节点的线程设为null

    // 获取当前节点的前驱pred
    Node pred = node.prev;
    while (pred.waitStatus > 0)   // 如果prde的ws > 0,直接跳过pred继续往前遍历,直到pred的
        node.prev = pred = pred.prev;  // ws <= 0

    // 获取pred的后继predNext
    Node predNext = pred.next;

    // 把node节点的ws设为CANCELLED
    node.waitStatus = Node.CANCELLED;

    // 如果node是尾节点,利用CAS把pred设为尾节点,predNext为null
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        // pred不是头结点 && pred的线程不为空 && pred.ws = singal
        // 利用CAS把node的next设为pred的next节点
        int ws;
        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是头结点,唤起它的后继节点
            unparkSuccessor(node);
        }

        node.next = node; // node指向自己,便于GC
    }
}

分三种情况进行考虑:

  1. node本身就是尾节点,直接把node的prev设为尾节点

  2. node的prev不是头结点,直接把prev和node的next进行连接

  3. node的prev是头结点,使用unparkSuccessor唤醒后继节点

看一下unparkSuccessor方法

/**
 * 如果node存在唤醒它的后继节点
 */
private void unparkSuccessor(Node node) {
    /*
     * 获取node的ws,如果ws<0,使用CAS把node的ws设为0,表示释放同步状态
     */
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    /*
     * 获取node的后继节点s,根据条件s = null 或者 s.ws > 0,从同步队列的尾部开始遍历,
     * 直到找到距node最近的满足ws <= 0的节点t,把t赋给s,唤醒s节点的线程
     * 如果s不为null && s的ws <= 0,直接唤醒s的线程
     */
    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);
}

这就是整个acquireQueued的流程,如果执行完acquireQueued方法返回线程被中断过,那线程最后要进行自我中断一下。

/**
 * 当前线程的自我中断
 */
private static void selfInterrupt() {
    Thread.currentThread().interrupt();
}

下面看一下同步状态的释放

 /**
 * 以独占模式释放同步状态,当前线程释放同步状态的时候,会唤醒同步队列上的后继节点
 * 释放成功后之后直接返回true
 */
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方法,这个方法在AQS中直接抛出异常,必须由继承它的子类去重写此方法,如果此方法返回成功释放同步状态,如果当前节点不是空并且ws!=0,直接调用unparkSuccessor方法唤醒当前节点的后继节点。

获取同步状态的其他方法:

  • 响应式中断获取同步状态

    /**
     * 当前线程被中断后,直接抛出异常,否则的话,再次调用tryAcquire方法获取同步状态
     */
    public final void acquireInterruptibly(int arg)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        if (!tryAcquire(arg))
            doAcquireInterruptibly(arg);
    }
    

    这个方法和acquire方法很相似,只不过线程在被中断后直接抛出异常。

    /**
     *  以独占模式获取同步状态,线程被中断直接抛出异常
     */
    private void doAcquireInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
    
  • 指定时间内获取同步状态

 /**
 * 以独占模式获取同步状态,线程被中断,直接抛出异常,如果在指定时间内没有获取到同步状态,
 * 直接返回false,表现获取同步状态失败.
 */
public final boolean tryAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return tryAcquire(arg) ||
        doAcquireNanos(arg, nanosTimeout);
}

主要看一下doAcquireNanos方法

private boolean doAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (nanosTimeout <= 0L)
        return false;
    final long deadline = System.nanoTime() + nanosTimeout;
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return true;
            }
            nanosTimeout = deadline - System.nanoTime();
            if (nanosTimeout <= 0L)
                return false;
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > spinForTimeoutThreshold)
                LockSupport.parkNanos(this, nanosTimeout);
            if (Thread.interrupted())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

如果指定时间nanosTimeOut <= 0,表示已经超时,直接返回false;如果当前节点可以挂起,重新计算nanosTimeOut的时间,在nanosTimeOut挂起当前线程,在nanosTimeOut没有获取到同步状态,直接返回false,在nanosTimeOut < spinForTimeoutThreshold(1000纳秒),线程不会被挂起,而是进入快速的自旋过程,因为非常短的时间挂起线程等待无法做到十分精确。

3.2 共享式

独占式和共享式的最大不同就是在同一时刻能否有多个线程获取同步状态,通过调用acquireShared方法获取同步状态。

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
/**
 * 以共享非中断获取同步状态
 */
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);
                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);
    }
}

tryAcquireShared是留给子类去重写的,如果tryAcquireShared方法返回值<0,说明获取同步状态失败,执行doAcquireShared方法,在doAcquireShared再次调用tryAcquireShared方法,判断其返回值,若返回值<0,获取同步状态失败,需要进入同步队列进行等待,若返回值 >= 0,如果返回值=0,说明当前线程获取同步状态成功,其他线程无法获取,也就不需要唤醒它的后继节点进行传播.如果返回值>0,此时当前线程获取同步状态后要唤醒它的后继节点,让其他线程也尝试去获取同步状态.

独占式获取同步状态之后,直接返回中断状态,结束流程,共享式则调用setHeadAndPropagate方法传播唤醒的动作.

private void setHeadAndPropagate(Node node, int propagate) {
   Node h = head; // 保存当前的头节点
    setHead(node); // 把当前节点设为头节点
    /*
     * 这里有三种情况执行唤醒操作:1.propagate > 0,代表后继节点需要被唤醒
     *                          2. h节点的ws < 0或者 h=null
     *                          3. 新的头结点为空 或者 新的头结点的ws < 0
     */
    if (propagate > 0 || h == null || h.waitStatus < 0 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;    // 找到当前节点的后继节点s
        if (s == null || s.isShared())   // s=null 或者 s是共享模式,调用doReleaseShared方法唤醒后继线程
            doReleaseShared();
    }
}

接着看一下doReleaseShared方法,这个方法比较复杂。

private void doReleaseShared() {
    /*
     * 注意,这里的头结点已经是上面新设定的头结点了,从这里可以看出,如果propagate=0,
     * 不会进入doReleaseShared方法里面,那就有共享式变成了独占式.
     */
    for (;;) {  // 这里一个死循环直到满足条件h=head才能跳出
        Node h = head;
        if (h != null && h != tail) {  // 前提条件-当前的头结点不为null && h不是尾节点
            int ws = h.waitStatus;   
            if (ws == Node.SIGNAL) {  // 如果当前头结点的ws=signal,利用CAS把h的ws设为0
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            
                unparkSuccessor(h);  // 唤醒头结点的后继节点
            }   // 如果h的ws=0,就把h的ws设为PROPAGATE,表示可以向后传播唤醒
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                
        }
        if (h == head)  // 如果头结点没有发生改变,表示设置完成,可以退出循环
            break;      // 如果头结点发生了变化,可能被唤醒的其他节点重新设置了头结点
    }                   // 这样头结点发生了改变,要进行重试,保证可以传播唤醒信号
}

最后看一下同步状态的释放

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

通过tryReleaseShared方法获取返回值,如果返回值>=0,还是调用doReleaseShared方法去释放,释放成功直接返回true,释放后的同步状态独占式和共享式都能被唤醒尝试获取。

AQS主要实现了独占式和共享式,独占式无非就是同步状态在0与1之间切换,同一时刻只有一个线程获取锁进行操作,其他线程挂起,ReentrantLock就是个经典的独占式锁.共享式的PROPAGATE的数值>0,可以使同一时刻有多个线程获取锁,如果PROPAGATE<0,则就变成了共享模式,可以参照Semphore。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

老周聊架构

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

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

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

打赏作者

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

抵扣说明:

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

余额充值