阻塞队列之LinkedBlockingQueue源码分析

阻塞队列之LinkedBlockingQueue

上一篇文章笔主介绍了BlockingQueue,了解了什么是阻塞队列,阻塞队列的支持的操作以及阻塞队列的应用场景。现在,我们来学习下BlockingQueue的实现类之一LinkedBlockingQueue。

LinkedBlockingQueue的定义

还是老套路,我们看下JDK中对LinkedBlockingQueue的定义,如下:

An optionally-bounded {@linkplain BlockingQueue blocking queue} based on linked nodes. This queue orders elements FIFO (first-in-first-out). The head of the queue is that element that has been on the queue the longest time. The tail of the queue is that element that has been on the queue the shortest time. New elements are inserted at the tail of the queue, and the queue retrieval operations obtain elements at the head of the queue. Linked queues typically have higher throughput than array-based queues but less predictable performance in most concurrent applications.
The optional capacity bound constructor argument serves as a way to prevent excessive queue expansion. The capacity, if unspecified, is equal to {@link Integer#MAX_VALUE}. Linked nodes are dynamically created upon each insertion unless this would bring the queue above capacity.

LinkedBlockingQueue是基于链表实现可以设置大小的阻塞队列。这个队列要求元素“先进先出”,队列头部的元素在队列中存活时间最长,队列尾部的元素在队列中存活时间最短。新元素会被插入队列的尾部,而且移除元素时从队列的头部进行遍历操作。链接队列通常比基于数组的队列具有更高的吞吐量,但在大多数并发应用中其性能难以预测。
可选容量的构造方法用来防止队列过度扩容。如果不指定容量,那么队列的容量等于Integer能表示的最大值。链接节点在每次插入时会动态创建,除非已经超过了队列指定的容量。

LinkedBlockingQueue的特点

简单总结下,LinkedBlockingQueue有以下特点:

  • 内部基于链表实现,使用两个锁分别控制插入和移除操作,插入元素时需要获取插入锁,移除元素时需要获取移除锁,从而提高吞吐量。
  • 按照FIFO的顺序访问队列中的元素
  • 可以在初始化时指定队列的容量,如果不指定队列的容量就等于Integer.MAX_VALUE。

接下来,我们从源码的角度来认识下LinkedBlockingQueue。

LinkedBlockingQueue类的成员属性
LinkedBlockingQueue主要定义了以下成员属性,源码如下:
   /**
    * 使用静态内部类定义链表节点 
    */
   static class Node<E> {
        E item;  //节点中封装的数据,使用泛型
        
        Node<E> next; //当前节点的下一个节点

        Node(E x) { item = x; }  //节点的构造方法
    }

    //队列的容量,如果初始化时不指定,默认容量为Integer.MAX_VALUE
    private final int capacity;

    //使用AtomicInteger类来创建一个原子计数器,表示当前队列中的元素个数
    private final AtomicInteger count = new AtomicInteger();

    //队列的头部节点,头部节点中的数据永远为null
    transient Node<E> head;

    //队列的尾部节点,尾部节点的下一个节点永远为null
    private transient Node<E> last;

    //使用take()、poll()方式移除元素时需要获得此锁
    private final ReentrantLock takeLock = new ReentrantLock();

    //非空条件对象,队列为空时移除元素的线程会被阻塞
    private final Condition notEmpty = takeLock.newCondition();

    //使用put()、offer()方式插入元素时需要获取此锁
    private final ReentrantLock putLock = new ReentrantLock();

    //非满条件对象,队列为满时插入元素的线程会被阻塞
    private final Condition notFull = putLock.newCondition();
LinkedBlockingQueue类的构造方法
LinkedBlockingQueue提供了三种方式来构造阻塞队列,分别是:
  1. 不指定队列容量的构造方法
  2. 指定队列容量的构造方法
  3. 将给定集合的元素初始化至队列的构造方法

构造方法的源码如下:

    //初始化时不指定队列的容量,默认容量为Integer.MAX_VALUE
    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

    //初始化时指定队列的容量
    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        last = head = new Node<E>(null);  //初始化队列头部节点和尾部节点
    }

     //将给定集合的元素初始化至队列
    public LinkedBlockingQueue(Collection<? extends E> c) {
        //队列容量为Integer.MAX_VALUE
        this(Integer.MAX_VALUE); 
        //往队列插入元素时需要加锁
        final ReentrantLock putLock = this.putLock; 
        putLock.lock(); // Never contended, but necessary for visibility
        try {
            int n = 0;
            for (E e : c) {
                if (e == null)
                    throw new NullPointerException();
                if (n == capacity)
                    throw new IllegalStateException("Queue full");
                //循环将元素插入队列
                enqueue(new Node<E>(e));
                ++n; //计数器
            }
            //设置队列中的元素个数
            count.set(n); 
        } finally {
            putLock.unlock(); //释放锁
        }
    }
LinkedBlockingQueue类的成员方法
LinkedBlockingQueue类的成员方法可以划分为以下几类:
  1. 公共方法
  2. 插入方法
  3. 移除方法

这里公共方法是指在类内部会被多次调用的方法或者是获取队列属性的方法,例如:唤醒操作(插入或移除)阻塞线程的方法、真正向队列中插入节点(enqueue)的方法、真正从队列中移除节点(dequeue)的方法、整个队列加锁的方法、整个队列释放锁的方法、获取队列元素个数的方法、清空整个队列的方法、将队列转换为数组的方法等等。源码如下:

     
    //唤醒阻塞的获取元素线程,该方法只能在put()、offer()中被调用
    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            //需要注意,await()、signal()方法必须在加锁的环境中使用
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }

    //唤醒阻塞的插入元素线程,该方法只能在take()、poll()中被调用
    private void signalNotFull() {
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        try {
            notFull.signal();
        } finally {
            putLock.unlock();
        }
    }

    //在队列尾部插入元素
    private void enqueue(Node<E> node) {
        /**
         * 以下赋值语句相当于:
         * last.next = node;
         * last = last.next;
         */
        last = last.next = node;
    }

    //从队列的头部移除一个元素
    private E dequeue() {
         //获取头部节点,注意头部节点的数据永远为null
        Node<E> h = head;
        //获取头部节点的下一个节点,也就是第一个数据节点
        Node<E> first = h.next;
        //h.next指向自身,这样h.next与GC Roots没有可达路径,下次GC时会被回收
        h.next = h; // help GC
        //指定新的头部节点
        head = first;
        //获取头部节点的数据
        E x = first.item;
        //使头部节点数据重新变为null
        first.item = null;
        //返回头部节点数据
        return x;
    }

    //整个队列加锁,不能在尾部插入元素也不能从头部移除元素
    void fullyLock() {
        putLock.lock();
        takeLock.lock();
    }

    //整个队列释放锁
    void fullyUnlock() {
        takeLock.unlock();
        putLock.unlock();
    }
   
    //获取队列中的元素个数
    public int size() {
        return count.get();
    }

    //返回队列还可以容纳的元素个数
    public int remainingCapacity() {
        return capacity - count.get();
    }
    
    /**
     * 如果队列为空,直接返回null
     * 否则,从队列头部节点查询元素。注意,此处仅仅是查询元素,并未从队列中移除。
     */
    public E peek() {
        if (count.get() == 0)
            return null;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock(); //获取锁
        try {
            Node<E> first = head.next;
            if (first == null) 
                return null;
            else
                return first.item;
        } finally {
            takeLock.unlock(); //释放锁
        }
    }

   
    //寻找队列中是否存在数据等于o的节点
    public boolean contains(Object o) {
        if (o == null) return false;
        fullyLock(); //同时获取插入锁和移除锁
        try {
            for (Node<E> p = head.next; p != null; p = p.next)
                if (o.equals(p.item)) //找到节点
                    return true;
            return false;
        } finally {
            fullyUnlock(); //同时释放插入锁和移除锁
        }
    }

    //将队列转换为Object数组
    public Object[] toArray() {
        fullyLock(); //同时获取插入锁和移除锁
        try {
            int size = count.get(); //获取队列的元素个数
            //创建数组
            Object[] a = new Object[size]; 
            int k = 0;
            for (Node<E> p = head.next; p != null; p = p.next)
                a[k++] = p.item; //将队列中的元素放入数组
            return a;
        } finally {
            fullyUnlock(); //同时释放插入锁和移除锁
    }

    //将队列转换为T类型数组
    public <T> T[] toArray(T[] a) {
        fullyLock();  //同时获取插入锁和移除锁
        try {
            int size = count.get(); //获取队列中元素个数
            if (a.length < size)
                a = (T[])java.lang.reflect.Array.newInstance
                    (a.getClass().getComponentType(), size);

            int k = 0;
            for (Node<E> p = head.next; p != null; p = p.next)
                a[k++] = (T)p.item;  //将队列中的元素放入数组
            if (a.length > k)
                a[k] = null;
            return a;
        } finally {
            fullyUnlock();  //同时释放插入锁和移除锁
        }
    }
    
    //使用字符串表示队列中的元素
    public String toString() {
        fullyLock(); //同时获取插入锁和移除锁
        try {
            Node<E> p = head.next;
            if (p == null)
                return "[]";

            StringBuilder sb = new StringBuilder();
            sb.append('[');
            for (;;) {
                E e = p.item;
                sb.append(e == this ? "(this Collection)" : e); //将元素追加到StringBuilder对象中
                p = p.next;
                if (p == null)
                    return sb.append(']').toString();
                sb.append(',').append(' ');
            }
        } finally {
            fullyUnlock(); //同时释放插入锁和移除锁
        }
    }

    //清空队列
    public void clear() {
        fullyLock();//同时获取插入锁和移除锁
        try {
            for (Node<E> p, h = head; (p = h.next) != null; h = p) {
                h.next = h; //h.next指向自身,这样h.next与GC Roots没有可达路径,下次GC时会被回收
                p.item = null; //数据置为null
            }
            head = last; //使头结点等于尾节点
            
            if (count.getAndSet(0) == capacity)
                notFull.signal();
        } finally {
            fullyUnlock(); //同时释放插入锁和移除锁
        }
    }

    //批量将队列中的元素移除并放入集合中
    public int drainTo(Collection<? super E> c) {
        return drainTo(c, Integer.MAX_VALUE);
    }

    //批量将队列中的元素移除并放入集合中
    public int drainTo(Collection<? super E> c, int maxElements) {
        if (c == null)
            throw new NullPointerException();
        if (c == this)
            throw new IllegalArgumentException();
        if (maxElements <= 0)
            return 0;
        boolean signalNotFull = false;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock(); //获取锁
        try {
            //获取Integer.MAX_VALUE和队列元素个数中的最小值
            int n = Math.min(maxElements, count.get());
            Node<E> h = head;
            int i = 0;
            try {
                while (i < n) {
                    Node<E> p = h.next;
                    //将队列中的元素放入集合
                    c.add(p.item);
                    p.item = null; //数据置为null
                    h.next = h;  //h.next指向自身,这样h.next与GC Roots没有可达路径,下次GC时会被回收
                    h = p;
                    ++i;
                }
                return n;
            } finally {
                if (i > 0) {
                    head = h;
                    signalNotFull = (count.getAndAdd(-i) == capacity);
                }
            }
        } finally {
            takeLock.unlock(); //释放锁
            if (signalNotFull)
                signalNotFull(); //唤醒阻塞的插入线程
        }
    }

    //返回队列的迭代器对象,通过私有内部类实现
    public Iterator<E> iterator() {
        return new Itr();
    }

LinkedBlockingQueue插入操作提供了put(E e)、offer(E e, long timeout, TimeUnit unit)、offer(E e)这三个方法,这三个方法的特点如下:

  • put(E e):如果队列已满,插入操作会阻塞直至队列有空闲位置。
  • offer(E e, long timeout, TimeUnit unit):如果队列已满,插入操作在给定的时间内会阻塞。如果阻塞结束队列还是已满,返回false。否则,在队列尾部执行插入操作。
  • offer(E e):如果队列已满,返回false。否则,在队列尾部执行插入操作。

这三个方法的源码如下:

    //如果队列已满,插入操作会阻塞直至队列有空闲位置
    public void put(E e) throws InterruptedException {
        //待插入的元素不能为空
        if (e == null) throw new NullPointerException();
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly(); //获取锁
        try {
            //如果队列已满,阻塞当前插入操作
            while (count.get() == capacity) {
                notFull.await();  //阻塞
            }
            //阻塞结束在队列尾部插入元素
            enqueue(node);
            //获取插入元素前队列的元素个数
            c = count.getAndIncrement();
            //队列未满
            if (c + 1 < capacity)
                //唤醒插入阻塞的线程
                notFull.signal();
        } finally {
            putLock.unlock(); //释放锁
        }
        if (c == 0)
            signalNotEmpty(); 
    }

    //如果队列已满,插入操作在给定的时间内会阻塞
    public boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException {

        if (e == null) throw new NullPointerException();
        long nanos = unit.toNanos(timeout);
        int c = -1;
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly(); //获取锁
        try {
            while (count.get() == capacity) {
                if (nanos <= 0) //阻塞结束,队列还是已满状态,返回false
                    return false;
                //插入操作给定的时间内会阻塞
                nanos = notFull.awaitNanos(nanos);
            }
            //阻塞结束在队列尾部插入元素
            enqueue(new Node<E>(e));
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock(); //释放锁
        }
        if (c == 0)
            signalNotEmpty();
        return true;
    }

    /**
     * 如果队列已满,返回false
     * 否则,在队列尾部执行插入操作
     */
    public boolean offer(E e) {
        if (e == null) throw new NullPointerException();
        final AtomicInteger count = this.count;
        if (count.get() == capacity) //队列已满,直接返回false
            return false;
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        putLock.lock(); //获取锁
        try {
            if (count.get() < capacity) { //插入前再判断队列是否已满
                enqueue(node); //在队列尾部插入元素
                c = count.getAndIncrement();
                if (c + 1 < capacity)
                    notFull.signal();
            }
        } finally {
            putLock.unlock(); //释放锁
        }
        if (c == 0)
            signalNotEmpty();
        return c >= 0; //插入成功返回true,否则返回false
    }

LinkedBlockingQueue操作移除提供了 take()、poll(long timeout, TimeUnit unit)、poll()、remove(Object o) 这四个方法,这四个方法的特点如下:

  • take():如果队列为空,移除操作会阻塞直至队列中有元素。
  • poll(long timeout, TimeUnit unit):如果队列为空,移除操作在给定的时间会阻塞。如果阻塞结束队列还是为空,返回false。否则,从队列头部节点移除元素。
  • poll():如果队列为空,直接返回null。否则,从队列头部节点移除元素。
  • remove(Object o) :不同于以上三个方法,remove(Object o)并非是从队列的头部移除元素,而是根据给定的元素o在队列中查找。如果找到元素o对应的节点,就将该节点从队列中删除。

这四个方法的源码如下:

     //如果队列为空,移除操作会阻塞直至队列中有元素
    public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly(); //获取锁
        try {
            while (count.get() == 0) { //如果队列为空,移除操作会一直阻塞
                notEmpty.await();
            }
            x = dequeue(); //阻塞结束,从队列头部节点移除元素
            c = count.getAndDecrement();
            //队列非空
            if (c > 1) 
                notEmpty.signal(); //唤醒移除操作阻塞的线程
        } finally {
            takeLock.unlock(); //释放锁
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }

    //如果队列为空,移除操作在给定的时间会阻塞
    public E poll(long timeout, TimeUnit unit) throws InterruptedException {
        E x = null;
        int c = -1;
        long nanos = unit.toNanos(timeout);
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly(); //获取锁
        try {
            while (count.get() == 0) { 
                if (nanos <= 0) //阻塞结束,队列还是为空直接返回null
                    return null;
                //获取操作在给定的时间会阻塞
                nanos = notEmpty.awaitNanos(nanos);
            }
            //阻塞结束,从队列头部节点移除元素
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock(); //释放锁
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }

    /**
     * 如果队列为空,直接返回null
     * 否则,从队列头部节点移除元素
     */
    public E poll() {
        final AtomicInteger count = this.count;
        if (count.get() == 0) //队列为空直接返回null
            return null;
        E x = null;
        int c = -1;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock(); //获取锁
        try {
            if (count.get() > 0) {
                //队列非空,从队列头部节点移除元素
                x = dequeue();
                c = count.getAndDecrement();
                if (c > 1)
                    notEmpty.signal();
            }
        } finally {
            takeLock.unlock(); //释放锁
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }

    //根据节点p和节点p的前一个节点trail将节点p从队列中删除
    void unlink(Node<E> p, Node<E> trail) {
        //将节点p的数据置为null
        p.item = null;
        //将节点p的前一个节点trail的next指向节点p的next,意味着将节点p从队列中删除
        trail.next = p.next;
        if (last == p) //如果p是最后一个节点,将last指向trail
            last = trail;
        //从队列中删除元素后,唤醒阻塞的插入线程
        if (count.getAndDecrement() == capacity)
            notFull.signal();
    }

    //从队列中删除元素
    public boolean remove(Object o) {
        if (o == null) return false;
        fullyLock(); //同时获取插入锁和移除锁
        try {
            //通过循环寻找数据等于o的节点
            for (Node<E> trail = head, p = trail.next;
                 p != null;
                 trail = p, p = p.next) {
                if (o.equals(p.item)) {
                    unlink(p, trail); //找到待删除的节点p,将节点p从队列中删除
                    return true;
                }
            }
            return false;
        } finally {
            fullyUnlock(); //同时释放插入锁和移除锁
        }
    }

LinkedBlockingQueue类的迭代器

在上文分析LinkedBlockingQueue类的源码时,有讲到iterator(),这个方法的返回值是队列的迭代器Itr类的实例。我们知道使用迭代器可以遍历整个队列,依次访问队列中的每个元素,当然LinkedBlockingQueue类的迭代器也是按照从头到尾的顺序进行遍历。下面我们通过源码来看下Itr类的具体实现,源码如下:

/ **
 * Itr实现了Iterator<E>接口,为遍历队列提供了具体实现
  */
private class Itr implements Iterator<E> {
       
       //当前节点对象
        private Node<E> current;
        //最近访问节点对象
        private Node<E> lastRet;
        //当前节点对象中的数据
        private E currentElement;

        //无参数构造方法
        Itr() {
            fullyLock(); //同时获取插入锁和移除锁
            try {
                current = head.next; //获取头结点的下一个节点,也就是队列中的第一个节点
                if (current != null)
                    //当前节点不为null,就把当前节点的数据赋给currentElement
                    currentElement = current.item; 
            } finally {
                fullyUnlock(); //同时释放插入锁和移除锁
            }
        }

        //判断队列中是否还有下一个节点
        public boolean hasNext() {
            return current != null;
        }

        //获取队列中下一个节点
        private Node<E> nextNode(Node<E> p) {
            for (;;) {
                Node<E> s = p.next; //获取节点p的下一个节点
                if (s == p) //如果p==s,就获取头结点的下一个节点,也就是队列中的第一个节点
                    return head.next;
                if (s == null || s.item != null)
                    // 第一种情况:s == null,说明队列已经遍历完毕,没有节点可以遍历
                    // 第二种情况:s.item != null,说明正在遍历有效的节点
                    return s;
                // 说明出现了第三种情况:s != null,但是s.item == null
                // 说明正在遍历的节点要么是head节点,要么是last节点,就把s赋给p
                p = s; 
            }
        }
        
        //获取队列中下一个节点
        public E next() {
            fullyLock();//同时获取插入锁和移除锁
            try {
                if (current == null)
                    throw new NoSuchElementException();
                E x = currentElement;
                lastRet = current;//将当前节点赋给最近访问节点
                current = nextNode(current); //获取队列的下一个节点
                currentElement = (current == null) ? null : current.item;
                return x;
            } finally {
                fullyUnlock();//同时释放插入锁和移除锁
            }
        }

        //删除队列中的节点,使用remove()方法前一定是先使用了next()方法。否则,lastRet对象为null,会抛出异常
        public void remove() {
            if (lastRet == null)
                throw new IllegalStateException();
            fullyLock();  //同时获取插入锁和移除锁
            try {
                Node<E> node = lastRet; //把最近访问的节点赋给node
                lastRet = null; //使lastRet为null
                for (Node<E> trail = head, p = trail.next;
                     p != null;
                     trail = p, p = p.next) {
                    if (p == node) {
                        unlink(p, trail);  //找到node节点,将节点node从队列中删除
                        break; //跳出循环
                    }
                }
            } finally {
                fullyUnlock(); //同时释放插入锁和移除锁
            }
        }
    }

总体来看,LinkedBlockingQueue的迭代器实现思路还是比较清晰的,current和currentElement分别指向了当前节点对象和当前节点对象中的数据,lastRet指向了最近访问的节点对象。实现思路可以概括如下:

  • current和currentElement会在执行迭代器的构造方法时被初始化,如果队列不为空,则current和currentElement分别指向队列的第一个节点对象和第一个节点对象中的数据。否则,current和currentElement均为null。
  • 判断队列是否有下一个节点的hasNext(),正是判断current是否为nul。l
  • 获取队列中下一个节点的next(),会返回当前节点对象中的数据,同时使current和currentElement分别指向了下一个节点(相对于当前节点)对象和下一个节点对象中的数据。
  • 删除队列中节点的remove(),正是通过lastRet指向的最近访问的节点对象来实现删除最近访问的节点,当然删除过程中调用了LinkedBlockingQueue的unlink(Node p, Node trail)。迭代器的remove()和了LinkedBlockingQueue的remove(Object o)的实现思路是一样的,区别在于LinkedBlockingQueue的remove(Object o)方法是根据传入节点的数据对象去删除节点,寻找的节点在队列中可能不存在。但是,Itr类的remove()方法是根据lastRet去删除节点,lastRet节点在队列中是一定存在的。
总结

至此,我们通过分析源码分方式学习了LinkedBlockingQueue。再次总结下其特点:

  • 内部基于链表方式实现
  • 可以指定队列的大小
  • 使用两个锁分别控制插入和移除操作,还可以控制插入和移除操作线程的阻塞和唤醒,提高队列的吞吐量。

那么,LinkedBlockingQueue与ArrayBlockingQueue相比,有什么不同之处?

  • LinkedBlockingQueue内部基于链表实现,ArrayBlockingQueue基于数组实现。
  • LinkedBlockingQueue如果初始化不指定队列的容量,默认容量就是 Integer.MAX_VALUE。但是,ArrayBlockingQueue初始化时必须指定队列的容量。
  • LinkedBlockingQueue始终按照FIFO的策略访问队列中的元素,而ArrayBlockingQueue只有在选择了公平策略的情况下才会按照FIFO的策略访问队列中的元素。
  • LinkedBlockingQueue使用两个锁分别控制插入和移除操作,ArrayBlockingQueue使用一个锁控制插入和移除操作。因此,LinkedBlockingQueue的插入和移除操作可以并行,队列的吞吐量更好。

由于笔主水平有限,笔误或者不当之处还请批评指正。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值