深入理解Java线程池 ThreadPoolExecutor

1、什么是线程池?

线程池是一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。 例如,线程数一般取cpu数量+2比较合适,线程数过多会导致额外的线程切换开销。

2、为什么需要线程池?

在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在Java中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁。如何利用已有对象来服务就是一个需要解决的关键问题,其实这就是一些"池化资源"技术产生的原因。比如大家所熟悉的数据库连接池正是遵循这一思想而产生的,本文将介绍的线程池技术同样符合这一思想。

3、应用范围

  • 需要大量的线程来完成任务,且完成任务的时间比较短。
  • 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
  • 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,并出现"OutOfMemory"的错误。

4、java中的线程池

jdk中提供了线程池的具体实现,实现类是:java.util.concurrent.ThreadPoolExecutor,主要构造方法:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
    if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

参数说明如下:

corePoolSize

保留在池中的线​​程数,即使它们是空闲的,除非设置allowCoreThreadTimeOut = true

allowCoreThreadTimeOut 默认为 false,意思为核心线程即使在空闲时也保持活动状态。如果为真,核心线程使用 keepAliveTime 超时等待工作。如果需要更改使用allowCoreThreadTimeOut(boolean value)更改,但是我们一般情况不需要更改此配置。

maximumPoolSize

池中允许的最大线程数。如果阻塞队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。

keepAliveTime

线程池的工作线程空闲后,保持存活的时间。

如果没有任务处理了,有些线程会空闲,空闲的时间超过了这个值,会被回收掉,如果没有设置allowCoreThreadTimeOut = true,则会回收多余空闲的线程,直到线程池中的线程数为 corePoolSize。如果任务很多,并且每个任务的执行时间比较短,避免线程重复创建和回收,可以调大这个时间,提高线程的利用率。

unit

keepAliveTime的时间单位,可以选择的单位有天、小时、分钟、毫秒、微妙、千分之一毫秒和纳秒。类型是一个枚举java.util.concurrent.TimeUnit,这个枚举也经常使用,源码如下:

public enum TimeUnit {
    /** 纳秒 */
    NANOSECONDS(TimeUnit.NANO_SCALE),
    /** 千分之一微秒 */
    MICROSECONDS(TimeUnit.MICRO_SCALE),
    /** 微秒 */
    MILLISECONDS(TimeUnit.MILLI_SCALE),
    /** 毫秒 */
    SECONDS(TimeUnit.SECOND_SCALE),
    /** 分钟 */
    MINUTES(TimeUnit.MINUTE_SCALE),
    /** 小时 */
    HOURS(TimeUnit.HOUR_SCALE),
    /** 天 */
    DAYS(TimeUnit.DAY_SCALE);
}

workQueue

工作队列,用于缓存待处理任务的阻塞队列,有7种,后面介绍。
在这里插入图片描述我们可以看到 BlockingQueue 的所有实现类,其中 DelayedWorkQueue是 ScheduledThreadPoolExecutor 的静态内部类,那么还剩下7种,我们后面再来讨论。

threadFactory

线程工厂,它的作用是生产线程以便执行任务。可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程,当然也可以选择自己定制线程工厂,以方便给线程自定义命名(方便排错),不同的线程池内的线程通常会根据具体业务来定制不同的线程名。

handler

饱和策略,当线程池无法处理新来的任务了,那么需要提供一种策略处理提交的新任务,默认有4种策略,文章后面会提到。
饱和策略有什么用?什么时候能使用到呢?大家思考一下,我们后面再来细说。

5、线程池工作流程

在这里插入图片描述通过线程池运行的流程图可以看到,当提交任务后,线程池首先会检查当前线程数,如果此时线程数小于核心线程数,比如最开始线程数量为 0,则新建线程并执行任务,随着任务的不断增加,线程数会逐渐增加并达到核心线程数,此时如果仍有任务被不断提交,就会被放入 workQueue 任务队列中,等待核心线程执行完当前任务后重新从 workQueue 中提取正在等待被执行的任务。

此时,假设我们的任务特别的多,已经达到了 workQueue 的容量上限,这时线程池就会启动后备力量,也就是 maximumPoolSize 最大线程数,线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务,假设任务被不断提交,线程池会持续创建线程直到线程数达到 maximumPoolSize 最大线程数,如果依然有任务被提交,这就超过了线程池的最大处理能力,这个时候线程池就会按照饱和策略处理这些任务。

我们可以看到实际上任务进来之后,线程池会逐一判断 corePoolSize、workQueue、maximumPoolSize,如果依然不能满足需求,则会按照饱和策略处理这些任务。

6、参数详解

workQueue 详解

workQueue 是线程池的工作队列,是线程池的一种缓冲机制。任务太多的时候,工作队列用于暂时缓存待处理的任务。由于线程池当中有很多工作线程,而工作队列是所有线程共享,因此一定会有多线程同时从任务队列中获取任务的并发场景,此时就需要工作队列能够满足线程安全的要求,所以线程池中任务队列采用 BlockingQueue ,即阻塞队列来保障线程间的并发安全问题。常用的队列主要有以下几种:

ArrayBlockingQueue

由数组实现的有界阻塞队列。此队列对元素进行 FIFO(先进先出)排序。队列的头部是在队列中时间最长的元素。队列的尾部是在队列中时间最短的元素。新元素被插入到队列的尾部,队列检索操作获取队列头部的元素。

这是一个经典的“有界缓冲区”,其中一个固定大小的数组保存由生产者插入并由消费者提取的元素。一旦创建,容量将无法更改。尝试put元素放入满队列将导致操作阻塞;尝试从空队列中take元素同样会阻塞。

此类支持对等待的生产者和消费者线程进行排序的可选公平策略。默认情况下,不保证此排序。但是,公平性设置为true的队列以 FIFO 顺序授予线程访问权限。公平性通常会降低吞吐量,但会降低可变性并避免饥饿。

SynchronousQueue

一个阻塞队列,其中每个插入操作都必须等待另一个线程的相应删除操作,反之亦然。同步队列没有任何内部容量,甚至没有一个容量。您无法 peek 同步队列,因为一个元素仅在您尝试删除它时才存在;除非另一个线程试图删除它,否则您不能插入元素(使用任何方法);你不能迭代,因为没有什么可以迭代的。队列的头部是第一个排队的插入线程试图添加到队列中的元素;如果没有这样的排队线程,则没有可删除的元素,并且poll()将返回null 。对于其他 Collection 方法(例如contains ), SynchronousQueue 充当空集合。此队列不允许null元素。

同步队列类似于 CSP 和 Ada 中使用的集合通道。它们非常适合切换设计,其中一个线程中运行的对象必须与另一个线程中运行的对象同步,以便将一些信息、事件或任务交给它。

此类支持对等待的生产者和消费者线程进行排序的可选公平策略。默认情况下,不保证此排序。但是,公平性设置为true的队列以 FIFO 顺序授予线程访问权限。

简单来说:SynchronousQueue 没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。

大家看懂了吗?我不知道你看懂了没有,反正我是没有。咱们直接上代码,看看他是什么妖怪!

public class SynchronousQueueTest {

    static ThreadPoolExecutor executor = new ThreadPoolExecutor(0, Integer.MAX_VALUE,
            60L, TimeUnit.SECONDS,
            new SynchronousQueue<Runnable>(),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());

    public static void main(String[] args) {
        try {
            for (int i = 0; i < 10; i++) {
                int j = i;
                String taskName = "  任务" + (j + 1);
                executor.execute(() -> {
                    //模拟任务的执行过程
                    try {
                        TimeUnit.SECONDS.sleep(j);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + taskName + "处理完毕");
                });
            }
        } finally {
            //关闭线程池
            executor.shutdown();
        }
    }

}

在这里插入图片描述

这种队列比较特殊,放入元素必须要有另外一个线程去获取这个元素,否则放入元素会失败或者一直阻塞在那里直到有线程取走,示例中任务处理休眠了指定的时间,导致已创建的工作线程都忙于处理任务,所以新来任务之后,将任务丢入同步队列会失败,丢入队列失败之后,会尝试新建线程处理任务。使用上面的方式创建线程池需要注意,如果需要处理的任务比较耗时,会导致新来的任务都会创建新的线程进行处理,可能会导致创建非常多的线程,最终耗尽系统资源,触发OOM。

LinkedBlockingQueue

基于链接节点的可选有界阻塞队列。此队列对元素进行 FIFO(先进先出)排序。队列的头部是在队列中时间最长的元素。队列的尾部是在队列中时间最短的元素。新元素被插入到队列的尾部,队列检索操作获取队列头部的元素。链接队列通常比基于数组的队列具有更高的吞吐量,但在大多数并发应用程序中性能更不可预测。

可选的容量绑定构造函数参数用作防止过度队列扩展的一种方式。容量(如果未指定)等于Integer.MAX_VALUE 。链接节点在每次插入时动态创建,除非这会使队列超出容量。

DelayQueue

Delayed 元素的无界阻塞队列,其中一个元素只有在其延迟到期时才能被取出。队列的头部是Delayed 在过去过期最远的元素。如果没有延迟过期,则没有 head 并且 poll 将返回 null 。当元素的getDelay(TimeUnit.NANOSECONDS)方法返回的值小于或等于零时,就会过期。即使无法使用 take 或 poll 删除未过期的元素,它们也会被视为普通元素。例如, size方法返回过期和未过期元素的计数。此队列不允许空元素。

LinkedTransferQueue

基于链表的无界TransferQueue 。该队列相对于任何给定的生产者对元素进行 FIFO(先进先出)排序。队列的头部是某个生产者在队列中停留时间最长的元素。队列的尾部是某个生产者在队列中停留时间最短的元素。

请注意,与大多数集合不同, size方法不是恒定时间操作。由于这些队列的异步特性,确定当前元素的数量需要遍历元素,因此如果在遍历期间修改了此集合,则可能会报告不准确的结果。
添加、删除或检查多个元素的批量操作(例如addAll 、 removeIf或forEach )不能保证以原子方式执行。例如,与addAll操作并发的forEach遍历可能只观察到一些添加的元素。

内存一致性影响:与其他并发集合一样,线程中的操作在将对象放入LinkedTransferQueue之前发生在另一个线程中从LinkedTransferQueue访问或删除该元素之后的操作。

LinkedBlockingDeque

基于链表可选有界阻塞双端队列,可能你会在一些文章中看到说次队列属于无界的,我曾经也一度相信其是无界的,这也很好理解,因为它的底层是一个链表。但是事实并非如此,官方对此有明确的说明其是“optionally-bounded”。什么意思呢?“可选的有界”,意思是当你未指定容量时是无界的,但是我们可以指定队列的大小,那他就是有界的。

有一个可以指定容量的构造函数,用于防止过度扩展的一种方式。容量(如果未指定)等于Integer.MAX_VALUE。如果这次插入不会使双端队列超出容量,那么插入的链表节点在每次插入时动态创建。咱们看看源码证明一下我说的:

/**
 * 无参构造器,创建一个容量为Integer.MAX_VALUE的LinkedBlockingDeque,
 * 它本质上还是调用的下面的有参构造器。
 */
public LinkedBlockingDeque() {
    this(Integer.MAX_VALUE);
}

/**
 * 创建具有给定(固定)容量的LinkedBlockingDeque
 */
public LinkedBlockingDeque(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
}

其实他还是有一个传入一个集合的构造器,其本质还是调用的第二个有参构造器。那么既然他是一个链表,那他是怎么保证大小的呢,咱们从源码中寻找答案。

/**
 * 将节点插入为第一个元素,如果已满则返回 false。
 */
private boolean linkFirst(LinkedBlockingDeque.Node<E> node) {
    if (count >= capacity)	// 我在这,请看这里。。。。。。。。。。。。。
        return false;
    LinkedBlockingDeque.Node<E> f = first;
    node.next = f;
    first = node;
    if (last == null)
        last = node;
    else
        f.prev = node;
    ++count;
    notEmpty.signal();
    return true;
}

/**
 * 将节点插入为最后一个元素,如果已满则返回 false。
 */
private boolean linkLast(LinkedBlockingDeque.Node<E> node) {
    if (count >= capacity)	// 我在这,请看这里。。。。。。。。。。。。。
        return false;
    LinkedBlockingDeque.Node<E> l = last;
    node.prev = l;
    last = node;
    if (first == null)
        first = node;
    else
        l.next = node;
    ++count;
    notEmpty.signal();
    return true;
}

capacity这个参数是不是就是我们构造器中的那个参数,为什么可以是有界的就不用我解释了吧。你可能还会看到此类还有一些其他的添加元素的方法,但是大家可以去看看源码,他们的本质上都是调用上面两个方法添加元素的。

PriorityBlockingQueue

使用与类PriorityQueue相同的排序规则并提供阻塞检索操作的无界阻塞队列。虽然此队列在逻辑上是无界的,但尝试添加可能会由于资源耗尽而失败(导致OutOfMemoryError)。此类不允许 null 元素。依赖于自然排序的优先级队列也不允许插入不可比较的对象(这样做会导致ClassCastException )。

此类上的操作不保证具有相同优先级的元素的顺序。如果需要强制执行排序,可以定义自定义类或比较器,它们使用辅助键来打破主要优先级值的关系。例如,这里有一个类将先进先出的平局应用于可比较的元素。要使用它,您将插入一个new FIFOEntry(anEntry)而不是一个普通的条目对象。

什么意思呢?就是我们可以指定队列中元素的排序规则:

LinkedBlockingQueue 和 LinkedBlockingDeque 属于有界阻塞队列,与其他的有界阻塞队列一样,当工作队列已满时,如果依然有任务被提交,会按照相应的拒绝策略进行处理。

handler拒绝策略详解

在使用线程池并且使用有界队列的时候,如果队列满了,任务添加到线程池的时候就会有问题,那么这些溢出的任务,ThreadPoolExecutor为我们提供了拒绝任务的处理方式,以便在必要的时候按照我们的策略来拒绝任务,线程池拒绝任务的时机有以下两种:

第一种情况是当我们调用 shutdown 等方法关闭线程池后,即便此时可能线程池内部依然有没执行完的任务正在执行,但是由于线程池已经关闭,此时如果再向线程池内提交任务,就会遭到拒绝。

第二种情况是线程池没有能力继续处理新提交的任务,也就是工作已经非常饱和的时候。

AbortPolicy

直接抛出RejectedExecutionException异常。这是ThreadPoolExecutorScheduledThreadPoolExecutor的默认处理方式。这种拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。

咱们写个案列来看看是怎么回事,也有助于理解:

package com.webjuly;

import java.util.concurrent.*;

/**
 * @author : guyun
 * @date : 2022/4/17 23:11
 */
public class RejectedHandlerTest {

    public static void main(String[] args) {
        ThreadPoolExecutor executor = null;
        try {
            //核心池和最大线程数都设为 1
            executor = new ThreadPoolExecutor(1, 1,
                    60L, TimeUnit.SECONDS,
                    new LinkedBlockingQueue<>(1),	//工作队列选用 LinkedBlockingQueue
                    Executors.defaultThreadFactory(),
                    new ThreadPoolExecutor.AbortPolicy());

            for (int i = 1; i <= 5; i++) {
                int task = i;
                executor.execute(() -> {
                    try {
                        System.out.println(Thread.currentThread().getName() + " 处理第-" + task + "个任务");
                        //模拟任务耗时操作
                        TimeUnit.SECONDS.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            }
        } finally {
            if (executor != null) {
                executor.shutdown();
            }
        }
    }
}

在这里插入图片描述

哎!!!请看结果怎么样,是不是和我们分析的一致,工作队列容量为1,线程池中只有一个线程,最多也只有一个线程,第一个任务开始执行后,占用了线程池中唯一的一个线程,也就是没有了空闲的线程执行新的任务。当第二个任务到来时,发现线程池的线程数达到了核心池的容量,然后加入到工作队列中,第三个任务来了以后,此时第一个任务还未执行完,然后工作队列也满了,判断线程数是否达到最大线程数,结果达到了,按照拒绝策略AbortPolicy处理,直接抛出异常。

我们的工作队列选用的是LinkedBlockingQueue,上面我们也说了 LinkedBlockingQueue 是可选的有界阻塞队列,在这里进一步证实了,当我们没有指定队列容量的时候,队列大小就为Integer.MAX_VALUE,大家是不是理解了上面所说的可选是什么意思了吧。

DiscardPolicy

啥也不说,直接抛弃任务。不处理,直接丢弃掉,方法内部为空。这种拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。

咱们再来看看 DiscardPolicy 是怎么处理的。。。等等,你废话咋那么多呢?
好吧,那咱啥也不用说了,上代码看看:

package com.webjuly;

import java.util.concurrent.*;

/**
 * @author : guyun
 * @date : 2022/4/17 23:11
 */
public class RejectedHandlerTest {

    public static void main(String[] args) {
        ThreadPoolExecutor executor = null;
        try {
            //核心池和最大线程数都设为1
            executor = new ThreadPoolExecutor(1, 1,
                    60L, TimeUnit.SECONDS,
                    new LinkedBlockingQueue<>(1),//工作队列选用LinkedBlockingQueue
                    Executors.defaultThreadFactory(),
                    new ThreadPoolExecutor.DiscardPolicy()); //注意此处和上面代码用的不是同一个策略

            for (int i = 1; i <= 5; i++) {
                int task = i;
                executor.execute(() -> {
                    try {
                        System.out.println(Thread.currentThread().getName() + " 处理第-" + task + "个任务");
                        //模拟任务耗时操作
                        TimeUnit.SECONDS.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            }
        } finally {
            if (executor != null) {
                executor.shutdown();
            }
        }
    }
}

在这里插入图片描述

看看结果,第一个任务没啥毛病,那第二个任务呢,线程池的大小不是为1吗???咱们不是还有工作队列吗,里面还能塞下一个,另外三个吗咱也没有多余的空间存储,地主家也灭有余粮了,那只好直接默默地抛弃吧。

DiscardOldestPolicy

丢弃队列中最早的未处理请求,即丢弃队列头部的一个任务,然后执行当前传入的任务。如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与CallerRunsPolicy不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。

剩下的两种我就不贴代码了,和上面的一样,把拒绝策略换成 DiscardOldestPolicy,咱们直接看看结果吧。
在这里插入图片描述

我们看到打印的结果中有任务1和任务5,怎么回事呢?我们来分析一下,当任务1(后面我就不写任务的,用编号代替)执行以后,占用了线程池中的唯一的一个线程,2来以后经过判断放到了工作队列中(请看第5点:线程池工作流程),然后3也来了,放到队列中,发现队列满了,也达到了池中最大线程数,那只能按照拒绝策略进行处理。DiscardOldestPolicy 丢弃队列中时间最久的,那不用说了,来一个鸠占鹊巢,直接把 2 一脚踢出去,3成功入住队列中。紧接着4也来了,又把3走的路走了一遍,队列中剩下了4,紧接着5也走了一遍3走的路,最后队列中就剩下了5。1运行完之后,把5取出来运行一遍。(注意这些都是在1还未执行完的基础上进行的,如果1执行完了呢,大家自己想一想会有什么结果?)

CallerRunsPolicy

在当前调用者的线程中运行任务,即谁丢来的任务,由他自己去处理。相对而言它就比较完善了,当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做主要有两点好处:

第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。

第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。
在这里插入图片描述
大家想一想为什么是3先执行完呢,为什么3是main线程处理的呢,认真思考一下???

咱们分析一下,输出的第一行是main线程处理任务3,为什么呢,因为此时1正在执行中,还未执行完,2在队列中。然后3来了,没人接待3,也没有给3站的位置,按照CallerRunsPolicy 进行处理,即谁丢来的任务,由他自己去处理。那谁丢来的任务呢?当然是main线程喽,那就给mian线程去执行呗。main线程执行是不是也要花费5s啊,这时mian处理3,thread-1在处理1,2在队列中,4和5呢?别急我mian线程都还没执行完3,哪轮得到你4和5啊,这时4和5还没创建呢?,mian线程先一步在1的前面处理完了3,创建了4,4又被线程池赶出来了,没办法,烂摊子有到了mian的手里,main接着处理4,4处理的过程中1处理完了,thread-1在处理2,此时队列是不是空闲出来了,main接着创建了任务5,5加入到了工作队列中,交给线程池去处理了。

写在最后:对于ThreadPoolExecutor,大家是不是get到了呢,我是get到了,也希望各位同学能get到啊,建议各位同学可以多看几遍,会理解的深一些。

关于 ThreadPoolExecutor 的一些扩展后面咱有空再写来分享给大家!!!

针对文章可能存在的一些不足之处,请大佬指出,经核实以后会尽快对文章进行修改,感谢您的支持!!!

参考:

[1].百度百科
[2].https://zhuanlan.zhihu.com/p/341935713

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值