Java基础 : BlockingQueue浅析

一、前言

本文仅仅是对 BlockingQueue 的种类和方法进行简单介绍,对于部分实现进行了简单的代码分析。

1. 简介

BlockingQueue 即阻塞队列,关于阻塞队列的介绍已经有很多文章,因此这里直接借用 Java阻塞队列 一文中的介绍。如下:
阻塞队列,顾名思义,首先它是一个队列,而一个阻塞队列在数据结构中所起的作用大致如下图所示:
在这里插入图片描述

  • 当阻塞队列是空时,从队列中获取元素的操作将会被阻塞。
  • 当阻塞队列是满时,往队列里添加元素的操作将会被阻塞。

  • 试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他的线程往空的队列插入新的元素。
  • 试图往已满的阻塞队列中添加新元素的线程同样也会被阻塞,直到其他的线程从列中移除一个或者多个元素或者完全清空队列后使队列重新变得空闲起来并后续新增。

2. 分类

BlockingQueue 的类结构如下:
在这里插入图片描述

BlockingQueue 只是一个接口,Jdk 提供了多种实现类,如下:

实现类特性
ArrayBlockingQueue由数组结构组成的有界阻塞队列
LinkedBlockingQueue由链表结构组成的有界(但大小默认值为Integer.MAX_VALUE)阻塞队列
SynchronousQueue不存储元素的阻塞队列,也即单个元素的队列
PriorityBlockingQueue支持优先级排序的无界阻塞队列
DelayQueue使用优先级队列实现的延迟无界阻塞队列
LinkedTransferQueue由链表结构组成的无界阻塞队列
LikedBlockingDeque由链表结构组成的双向阻塞队列

3. 关键方法

关键方法如下:

方法类型方法名释义
入队操作add(e)入队成功返回 true,若队列已满则抛出异常
入队操作offer(e)入队成功返回 true ,否则false
入队操作put(e)队列满时入队则会一直阻塞线程,直至入队成功
入队操作offer(e, time, unit)将指定元素插入此队列,如果队列已满,则等待指定的等待时间。
出队操作remove()出队操作,返回出队元素,如果队列为空则抛出异常
出队操作poll()出队操作,如果队列为空则返回 null,否则返回出队元素
出队操作take()出队操作,如果队列为空则阻塞线程,直至出队成功
出队操作poll(time, unit)检索并删除此队列的头部,如果有必要等待指定的等待时间以使元素可用。
检查操作element()检索但不删除此队列的头部。此方法与peek的不同之处仅在于如果此队列为空,它将引发异常。
检查操作peek()检索但不删除此队列的头部,如果此队列为空,则返回null 。

二、源码分析

BlockingQueue 的使用我们这里就不再赘述,这里来简单看看其中部分代码实现。我们调其中几个实现来简单看一下:

1. SynchronousQueue

1.1 介绍

SynchronousQueue 是不存储元素的阻塞队列,也即单个元素的队列。

简单来说 :当我们调用 入队方法(put、add、 offer) 时并不会立刻返回,而是阻塞等待,直到有其他操作(一般是其他线程)调用了该队列的出队方法 (remove、poll、take) 后,入队方法才会返回结果。同理当我们调用出队方法时如果之前没有其他操作调用了入队方法则会挂起等待,直至其他操作调用入队方法。

关于SynchronousQueue 的源码,本文就不做分析了,如果需要详参 老猿说说-SynchronousQueue


1.2 使用场景举例

在Executors#newCachedThreadPool 中使用了 SynchronousQueue 作为默认的任务队列,如下:

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

这里可以看到,新建的线程池的最大线程数量是 Integer.MAX_VALUE,当任务数量一瞬间大于 Integer.MAX_VALUE 时(想想也不太可能,如果能到达应该做其他优化了)会将多余的任务放置到任务队列 SynchronousQueue中,此时会挂起线程,直至有其他线程消化了任务开始从队列中获取任务时,入队任务才会有返回。

实际上, SynchronousQueue 的使用场景在其官方注释上已经说明 :它们非常适合切换设计,其中在一个线程中运行的对象必须与在另一个线程中运行的对象同步,以便将一些信息、事件或任务交给它。


目前临时想到一种发起线程调用的情况,写一下抛砖引玉(这种场景其实有很多种解决方案,适用 SynchronousQueue 也并不是一个合理的解决防范,之所以写出来还是为了写个例子防止自己日后忘记了)

@Slf4j
public class SynchronousQueueDemo {
    @SneakyThrows
    public static void main(String[] args) {
        final SynchronousQueue<String> synchronousQueue = new SynchronousQueue<>();
        // 主线程发起调用
        log.info("主线程发起调用");
        invoke(synchronousQueue);
        log.info("主线程发起调用结束");
        // 处理其他逻辑...
        
        // 挂起,直至调用结束后将结果写入到队列中才会执行。
        final String result = synchronousQueue.take();
        log.info("主线程获取调用结果 result = {}", result);
    }

    /**
     * 发起调用
     *
     * @param synchronousQueue
     */
    private static void invoke(SynchronousQueue<String> synchronousQueue) {
        new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {
                // 模拟三方接口调用耗时结束
                Thread.sleep(5000);
                synchronousQueue.put("大清已经亡了");
            }
        }).start();
    }
}

2. PriorityBlockingQueue

PriorityBlockingQueue 支持优先级排序的无界阻塞队列,相较于 PriorityQueue 队列来说,多了“阻塞”的特性(需要注意 PriorityQueue 队列线程并不安全),即队满会阻塞

2.1 入队

我们这里以PriorityBlockingQueue#put 为例,实现如下,可以看到,因为 PriorityBlockingQueue 队列是无界的,所以这里直接调用的 PriorityBlockingQueue#offer 方法

	 public void put(E e) {
	      offer(e); // never need to block
	 }
	 
    public boolean offer(E e) {
    	// 元素为空抛出
        if (e == null)
            throw new NullPointerException();
        final ReentrantLock lock = this.lock;
        // 加锁
        lock.lock();
        int n, cap;
        Object[] array;
        while ((n = size) >= (cap = (array = queue).length))
        	// 队列初始化长度不足则扩展
            tryGrow(array, cap);
        try {
        	// 获取比较器, 根据比较器结果选择合适的位置入队。
            Comparator<? super E> cmp = comparator;
            if (cmp == null)
                siftUpComparable(n, e, array);
            else
                siftUpUsingComparator(n, e, array, cmp);
            size = n + 1;
            // 唤醒出队操作。
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
        return true;
    }

2.2 出队

这里以PriorityBlockingQueue#take 为例,代码比较简单,如下:

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        E result;
        try {
        	// 出队,如果出队元素为空,则挂起等待
            while ( (result = dequeue()) == null)
                notEmpty.await();
        } finally {
            lock.unlock();
        }
        return result;
    }
	
	private E dequeue() {
        int n = size - 1;
        if (n < 0)
            return null;
        else {
        	// 出队操作
            Object[] array = queue;
            E result = (E) array[0];
            E x = (E) array[n];
            array[n] = null;
            Comparator<? super E> cmp = comparator;
            if (cmp == null)
                siftDownComparable(0, x, array, n);
            else
                siftDownUsingComparator(0, x, array, n, cmp);
            size = n;
            return result;
        }
    }

3. LinkedBlockingDeque

LinkedBlockingDeque 是由链表结构组成的双向阻塞队列 。LinkedBlockingDeque 针对入队和出队方法都有对应的插入队头队尾的方法,

如入队方法:

  • LinkedBlockingDeque#add : 默认插入队尾
  • LinkedBlockingDeque#addFirst :插入队头
  • LinkedBlockingDeque#addLast :插入队尾

如出队方法:

  • LinkedBlockingDeque#take : 默认队头出队
  • LinkedBlockingDeque#takeFirst:队头出队
  • LinkedBlockingDeque#takeLast:队尾出队

下面我们来具体看一看实现:

3.1 入队

由于 LinkedBlockingDeque#put 和 LinkedBlockingDeque#offer 和 LinkedBlockingDeque#add 的实现基本相同如下,所以下面我们以LinkedBlockingDeque#add 为例

  1. LinkedBlockingDeque#add

    	// 默认入队队尾
        public boolean add(E e) {
        	// 添加到队列尾部
            addLast(e);
            return true;
        }
        // 入队队头
        public void addFirst(E e) {
            if (!offerFirst(e))
                throw new IllegalStateException("Deque full");
        }
        // 入队队尾
        public void addLast(E e) {
        	// 如果队列已满则抛出异常
            if (!offerLast(e))
                throw new IllegalStateException("Deque full");
        }
        
        // 队头入队
    	public boolean offerFirst(E e) {
            if (e == null) throw new NullPointerException();
            // 初始化当前元素节点
            Node<E> node = new Node<E>(e);
            // 加锁
            final ReentrantLock lock = this.lock;
            lock.lock();
            try {
            	// 入队
                return linkFirst(node);
            } finally {
                lock.unlock();
            }
        }
        
        public boolean offerLast(E e) {
            if (e == null) throw new NullPointerException();
            // 构建节点
            Node<E> node = new Node<E>(e);
            // 加锁
            final ReentrantLock lock = this.lock;
            lock.lock();
            try {
                return linkLast(node);
            } finally {
                lock.unlock();
            }
        }
    

    上面可以看到,关键的代码在于 LinkedBlockingDeque#linkFirst 和LinkedBlockingDeque#linkLast 中,具体实现如下:

        /**
         * 将节点链接为最后一个元素,如果已满则返回 false。
         */
        private boolean linkLast(Node<E> node) {
            // assert lock.isHeldByCurrentThread();
            // 队列已满返回fasle
            if (count >= capacity)
                return false;
            // 将node 作为链表尾结点
            Node<E> l = last;
            node.prev = l;
            last = node;
            if (first == null)
                first = node;
            else
                l.next = node;
            // 当前链表元素数量加1
            ++count;
            // 唤醒其他因为队列空而阻塞等待的线程(如果有)
            notEmpty.signal();
            // 入队成功返回true
            return true;
        }
    
    
    	// 队头入队
    	 private boolean linkFirst(Node<E> node) {
            // assert lock.isHeldByCurrentThread();
            if (count >= capacity)
                return false;
            // 队头入队
            Node<E> f = first;
            node.next = f;
            first = node;
            if (last == null)
                last = node;
            else
                f.prev = node;
            ++count;
            // 唤醒等待线程
            notEmpty.signal();
            return true;
        }
        
    

3.2 出队

同样,我们这里以 LinkedBlockingDeque#take 为例来看具体实现:

	// 默认队头元素出队
    public E take() throws InterruptedException {
        return takeFirst();
    }
	// 队头元素出队
    public E takeFirst() throws InterruptedException {
    	// 加锁
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            E x;
            // 尝试出队,如果是空,则线程挂起等待
            while ( (x = unlinkFirst()) == null)
                notEmpty.await();
            // 出队成功返回出队元素
            return x;
        } finally {
            lock.unlock();
        }
    }

    public E takeLast() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            E x;
            while ( (x = unlinkLast()) == null)
                notEmpty.await();
            return x;
        } finally {
            lock.unlock();
        }
    }

关键代码在 LinkedBlockingDeque#unlinkFirst 和 LinkedBlockingDeque#unlinkLast 中,其实现如下:


    /**
     * 删除并返回第一个元素,如果为空,则返回 null。
     */
    private E unlinkFirst() {
        // assert lock.isHeldByCurrentThread();
        Node<E> f = first;
        if (f == null)
            return null;
        // 队头元素出队
        Node<E> n = f.next;
        E item = f.item;
        f.item = null;
        f.next = f; // help GC
        first = n;
        if (n == null)
            last = null;
        else
            n.prev = null;
        // 队列元素减一
        --count;
        // 唤醒因为队满而等待入队的线程
        notFull.signal();
        return item;
    }


    /**
     * 删除并返回最后一个元素,如果为空,则返回 null。
     */
    private E unlinkLast() {
        // assert lock.isHeldByCurrentThread();
        Node<E> l = last;
        if (l == null)
            return null;
		// 队尾元素出队
        Node<E> p = l.prev;
        E item = l.item;
        l.item = null;
        l.prev = l; // help GC
        last = p;
        if (p == null)
            first = null;
        else
            p.next = null;
        --count;
        // 唤醒因为队满而等待入队的线程
        notFull.signal();
        return item;
    }

除此之外,还存在一个 LinkedBlockingDeque#unlink 方法,该方法是删除指定的元素,其实现如下:

    void unlink(Node<E> x) {
        // assert lock.isHeldByCurrentThread();
        Node<E> p = x.prev;
        Node<E> n = x.next;
        // p 为空认为 x 是队首元素,队首元素出队
        if (p == null) {
            unlinkFirst();
        } else if (n == null) {
        	// n 为空则认为是对尾元素,队尾出队
            unlinkLast();
        } else {
        	// 将 x 出队
            p.next = n;
            n.prev = p;
            x.item = null;
            // Don't mess with x's links.  They may still be in use by
            // an iterator.
            --count;
            // 唤醒因为队满而等待入队的线程
            notFull.signal();
        }
    }

在 LinkedBlockingDeque#removeFirstOccurrence 和 LinkedBlockingDeque#removeLastOccurrence 中都会调用,如下:

	// 从队列中移除 o 第一次出现的元素节点
    public boolean removeFirstOccurrence(Object o) {
        if (o == null) return false;
        // 加锁
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
        	// 从头开始遍历,直至 找到与o 相等的元素,移除后返回 true
            for (Node<E> p = first; p != null; p = p.next) {
                if (o.equals(p.item)) {
                    unlink(p);
                    return true;
                }
            }
            return false;
        } finally {
            lock.unlock();
        }
    }
	// 从队列中移除 o 最后一次出现的元素节点
    public boolean removeLastOccurrence(Object o) {
        if (o == null) return false;
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
        	// 从尾开始遍历,直至 找到与o 相等的元素,移除后返回 true
            for (Node<E> p = last; p != null; p = p.prev) {
                if (o.equals(p.item)) {
                    unlink(p);
                    return true;
                }
            }
            return false;
        } finally {
            lock.unlock();
        }
    }

4. DelayQueue

DelayQueue 是延迟无界阻塞队列,但是需要注意,线程不安全,我们先来看下简单的使用实例:

@Slf4j
public class DelayQueueMain {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<DelayedItem> delayQueue = new DelayQueue<>();
        delayQueue.add(new DelayedItem("1", 1000L));
        delayQueue.add(new DelayedItem("2", 2000L));
        delayQueue.add(new DelayedItem("3", 3000L));
        delayQueue.add(new DelayedItem("4", 4000L));

        log.info("start");
        while (true) {
            final DelayedItem take = delayQueue.take();
            log.info("take.getMessage() = " + take.getMessage());
        }

    }

    @Getter
    public static class DelayedItem implements Delayed {
        private String message;

        private long delayTime;

        public DelayedItem(String message, long delayTime) {
            this.message = message;
            this.delayTime = delayTime + System.currentTimeMillis();
        }

        /**
         * 获取延迟时间,距离当前时间延迟多久后执行,当返回值小于0 时才会开始执行
         *
         * @param unit
         * @return 剩余的延迟;零或负值表示延迟已经过去
         */
        @Override
        public long getDelay(TimeUnit unit) {
            return unit.convert(delayTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }

        /**
         * 优先级,会根据返回值判断在队列中的先后顺序
         *
         * @param o
         * @return
         */
        @Override
        public int compareTo(Delayed o) {
            return (int) (this.delayTime - ((DelayedItem) o).delayTime);
        }
    }
}

输出如下:
在这里插入图片描述
可以看到,输出是按照延迟时间输出的。不过需要注意的是 Delayed#compareTo 方法决定了队列元素执行的优先级顺序,谁的优先级高谁作为对头元素,Delayed#getDelay 决定延迟执行时间,这两个方法并没有强关联。DelayQueue的实现是队头元素未出队执行前,其他元素即使到达延迟时间也不会执行。如下例子,因为 new DelayedItem("10", 10000L) 的优先级最高,所以其作为表头,即是其他 DelayedItem 延迟时间已经到了,仍需要等待表头的元素执行结束才能执行。

@Slf4j
public class DelayQueueMain {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<DelayedItem> delayQueue = new DelayQueue<>();
        delayQueue.add(new DelayedItem("1", 1000L));
        delayQueue.add(new DelayedItem("2", 2000L));
        delayQueue.add(new DelayedItem("3", 3000L));
        delayQueue.add(new DelayedItem("4", 4000L));

        delayQueue.add(new DelayedItem("10", 10000L) {
            @Override
            public int compareTo(Delayed o) {
                return -1;
            }
        });

        log.info("start");
        while (true) {
            final DelayedItem take = delayQueue.take();
            log.info("take.getMessage() = " + take.getMessage());
        }

    }

    @Getter
    public static class DelayedItem implements Delayed {
        private String message;

        private long delayTime;

        public DelayedItem(String message, long delayTime) {
            this.message = message;
            this.delayTime = delayTime + System.currentTimeMillis();
        }

        /**
         * 获取延迟时间,距离当前时间延迟多久后执行,当返回值小于0 时才会开始执行
         *
         * @param unit
         * @return 剩余的延迟;零或负值表示延迟已经过去
         */
        @Override
        public long getDelay(TimeUnit unit) {
            return unit.convert(delayTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }

        /**
         * 优先级,会根据返回值判断在队列中的先后顺序
         *
         * @param o
         * @return
         */
        @Override
        public int compareTo(Delayed o) {
            return (int) (this.delayTime - ((DelayedItem) o).delayTime);
        }
    }
}

在这里插入图片描述


简单总结下 DelayQueue 的实现逻辑:在使用 DelayQueue 队列时需要注意,DelayQueue 有两个方法需要实现:

  • DelayQueue#compareTo :返回值决定不同任务的执行顺序。
  • DelayQueue#getDelay :返回值决定延迟多久后执行。

需要注意的是,如果一个队列元素优先级在前,那么即是他后面的队列元素延迟时间已经到了,仍然需要等待前一个队列元素执行结束后才会执行。


下面来简单看下DelayQueue 的具体实现逻辑:

4.1 PriorityQueue

在 DelayQueue 中的有一个属性 q 初始化如下:

	// 未执行排序器,则使用元素自然排序
    private final PriorityQueue<E> q = new PriorityQueue<E>();

这说明 q 的实现是 PriorityQueue ,是一个优先级队列,其队列元素并非按照先进先出的逻辑,而是按照指定的优先级进行排序入队。下面我们来看其入队出队方法,其实现并不复杂,这里不再赘述。

  • 入队方法: PriorityQueue#offer

        public boolean offer(E e) {
            if (e == null)
                throw new NullPointerException();
            // 修改数量,安全失败使用,在迭代时如果发现 modCount 修改了则说明在迭代期间数组被修改,则抛出异常
            modCount++;
            int i = size;
            if (i >= queue.length)
            	// 需要的话扩容数组大小
                grow(i + 1);
            size = i + 1;
            if (i == 0)
            	// 如果当前添加的元素为第一个元素直接赋值
                queue[0] = e;
            else
            	// 按照优先级排序入队
                siftUp(i, e);
            return true;
        }
    
  • 出队方法 : PriorityQueue#poll

        public E poll() {
        	// 没有元素返回 null
            if (size == 0)
                return null;
            int s = --size;
            // 修改次数,安全失败使用,在迭代时如果发现 modCount 修改了则说明在迭代期间数组被修改,则抛出异常
            modCount++;
            // 数组第一个元素出队,因为在入队时已经按照优先级进行了排序
            E result = (E) queue[0];
            E x = (E) queue[s];
            queue[s] = null;
            if (s != 0)
                siftDown(0, x);
            return result;
        }
    

4.2 关键方法

  1. 入队方法 :DelayQueue#offer

        public boolean offer(E e) {
            final ReentrantLock lock = this.lock;
            // 线程加锁
            lock.lock();
            try {
            	// 这里的 q  是 PriorityQueue,PriorityQueue 入队元素保证优先级
                q.offer(e);
                // 获取队列头元素,如果等于e 则说明e入队后作为队头
                // 则需要将 leader 重置,同时唤醒等待线程重新去竞争
                if (q.peek() == e) {
                	// 重置 leader 
                    leader = null;
                    // 唤醒所有等待线程
                    available.signal();
                }
                return true;
            } finally {
            	// 释放锁
                lock.unlock();
            }
        }
    
  2. 出队方法:DelayQueue#poll

        public E poll() {
            final ReentrantLock lock = this.lock;
            lock.lock();
            try {
            	// 获取队头元素
                E first = q.peek();
                // 队头为空 或者 队头元素延迟时间未到,返回 null
                if (first == null || first.getDelay(NANOSECONDS) > 0)
                    return null;
                else
                    return q.poll();
            } finally {
                lock.unlock();
            }
        }
    
  3. 出队方法 :DelayQueue#take

        public E take() throws InterruptedException {
            final ReentrantLock lock = this.lock;
            lock.lockInterruptibly();
            try {
                for (;;) {
                	// 获取队头元素
                    E first = q.peek();
                    // 队头元素为空则阻塞等待
                    if (first == null)
                        available.await();
                    else {
                    	// 获取队头元素延迟时间
                        long delay = first.getDelay(NANOSECONDS);
                        // 小于0则说明延迟时间已经到了,可以出队了
                        if (delay <= 0)
                            return q.poll();
                        // 到这里说明队头不为空并且延迟时间未到
                        first = null; // don't retain ref while waiting
                        // leader != null 说明有其他线程已经竞争等待该队头元素,则当前线程无限等待
                        // 直至当前队头元素被处理或者有新元素入队并且成为了队头
                        if (leader != null)
                            available.await();
                        else {
                        	// leader  赋值,声明当前线程获取到了队头元素的等待权
                            Thread thisThread = Thread.currentThread();
                            leader = thisThread;
                            try {
                            	// 等待延迟队列剩余的延迟时间
                                available.awaitNanos(delay);
                            } finally {
                            	// 如果 leader = thisThread 则说明等待过程中没有元素入栈,则重置 leader 
                                if (leader == thisThread)
                                    leader = null;
                            }
                        }
                    }
                }
            } finally {
            	// leader 为空说明当前没有线程在等待队头元素 && 队列元素不为空
                if (leader == null && q.peek() != null)
                	// 唤醒其他等待的线程进行新一轮的竞争
                    available.signal();
                // 释放锁
                lock.unlock();
            }
        }
    

三、双端队列和工作密取

本部分参考《Java 并发编程实战》第五章内容。

Java 6新增了两种容器类型 Deque 和 BlockingDeque,分别对应 Queue 和 BlockingQueue 进行了扩展。Deque 时一个双端队列,在队头和队尾都可以高效插入和移除,实现包括 ArrayDeque 和 LinkedBlockingDeque 等。

相较于阻塞队列适用于生产者消费者模式,双端队列更适用于工作密取(Work Stealing)的设计中。
在生产者消费者模式中,所有的消费者都共享同一个工作队列,而在工作密取的设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己的双端队列中的全部工作,那么他就可以从其他消费者双端队列尾部秘密地获取工作。

密取工作模式相较于传统的生产者消费者模式具备更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列中发生竞争。在大多数时候,他们只需要访问自己的双端队列,从而极大减少了竞争。当工作者线程需要访问一个队列时,他会从队尾获取工作,从而进一步降低了队列上的竞争程度。

在 Java Stream 的 parallelStream 方法中就使用了工作密取的思想。(这个如果以后有机会再看

四、参考

《Java 并发编程实战》
https://blog.csdn.net/xiaoguangtouqiang/article/details/124109337
https://blog.csdn.net/zlfing/article/details/109802531

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

猫吻鱼

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

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

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

打赏作者

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

抵扣说明:

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

余额充值