线程池原理与实战

1 JUC 的线程池架构

JUC中的线程池如图所示:

说明: JUC 就是 java.util .concurrent 工具包的简称,该工具包是从 JDK 1.5 开始加入到 JDK,用于完成高并发、处理多线程的一个工具包。

1.1 Executor

它是 Java 异步目标任务的“执行者”接口,其目标是来执行目标任务。“执行者” Executor提供了 execute()接口来执行已提交的 Runnable 执行目标实例。 Executor 作为执行者的角色,存在的目的是“任务提交者”与“任务执行者”分离开来的机制。它只包含一个函数式方法。

void execute(Runnable command)

1.2 ExecutorService

ExecutorService 继承于 Executor。它是 Java 异步目标任务的“执行者服务“接口,它对外提供异步任务的接收服务, ExecutorService 提供了“接收异步任务、并转交给执行者”的方法,如submit 系列方法、 invoke 系列方法等等。

//向线程池提交单个异步任务
 Future submit(Callable task);
//向线程池提交批量异步任务
 List<Future> invokeAll(Collection<? extends Callable> tasks) throws InterruptedException;

1.3 AbstractExecutorService

AbstractExecutorService 是 一 个 抽 象 类 , 它 实 现 了 ExecutorService 接 口 。AbstractExecutorService 存在的目的是为 ExecutorService 中的接口提供了默认实现。

1.4 ThreadPoolExecutor

ThreadPoolExecutor 就是“线程池”实现类,它继承于 AbstractExecutorService 抽象类。 ThreadPoolExecutor 是 JUC 线程池的核心实现类。线程的创建和终止需要很大的开销,线程池中预先提供了指定数量的可重用线程,所以使用线程池会节省系统资源,并且每个线程池都维护了一些基础的数据统计,方便线程的管理和监控。

1.5 ScheduledExecutorService

ScheduledExecutorService 是一个接口,它继承于于 ExecutorService。它是一个可以完成“延时”“周期性”任务的调度线程池接口,其功能和 Timer/TimerTask 类似。

1.6 ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor 继承于 ThreadPoolExecutor,它提供了 ScheduledExecutorService线程池接口中“延时执行”和“周期执行”等抽象调度方法的具体实现。
ScheduledThreadPoolExecutor 类 似 于 Timer , 但 是 在 高 并 发 程 序 中ScheduledThreadPoolExecutor 的性能要优于 Timer。

1.7 Executors

Executors 是 个 静 态 工 厂 类 , 它 通 过 静 态 工 厂 方 法 返 回 ExecutorService 、
ScheduledExecutorService 等线程池实例对象,这些静态工厂方法可以理解为一些快捷的创建线程池的方法。

2 Executors创建线程池

Java 通过 Executors 工厂类提供四种快捷创建线程池的方法,具体如表所示。

方法名功能简介
newSingleThreadExecutor()创建只有一个线程的线程池
newFixedThreadPool(int nThreads)创建固定大小的线程池
newCachedThreadPool()创建一个不限制线程数量的线程池,任何提交的任务都将立即执行,但是空闲线程会得到及时回收
newScheduledThreadPool()创建一个可定期或者延时执行任务的线程池

2.1 newSingleThreadExecutor

该方法用于创建一个“单线程化线程池”,也就是只有一条线程的线程池,所创建的线程池用唯一的工作线程来执行任务,使用此方法创建的线程池,能保证所有任务按照指定顺序(如FIFO)执行。

public class ThreadPoolDemo {

    CountDownLatch countDownLatch = new CountDownLatch(MAX_TURN);
    public static final int SLEEP_GAP = 500;
    public static final int MAX_TURN = 5;

    //异步的执行目标类
    public static class TargetTask implements Runnable {
        CountDownLatch countDownLatch;
        static AtomicInteger taskNo = new AtomicInteger(1);
        protected String taskName;

        public TargetTask() {
            taskName = "task-" + taskNo.get();
            taskNo.incrementAndGet();
        }

        public TargetTask(CountDownLatch countDownLatch) {
            this();
            this.countDownLatch = countDownLatch;
        }

        public void run() {
            System.out.println("任务:" + taskName + " doing");

            try {
                Thread.sleep(SLEEP_GAP);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(taskName + " 运行结束.");
            countDownLatch.countDown();
        }

        @Override
        public String toString() {
            return "TargetTask{" + taskName + '}';
        }
    }

    //测试用例:只有一条线程的线程池
    @Test
    public void testSingleThreadExecutor() {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        for (int i = 0; i < MAX_TURN; i++) {
            pool.execute(new TargetTask(countDownLatch));
        }

        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

//        //关闭线程池
        pool.shutdown();
    }
}

newSingleThreadExecutor线程池特点:

  • 单线程化的线程池中的任务,是按照提交的次序,顺序执行的。
  • 池中的唯一线程的存活时间是无限的。
  • 当池中的唯一线程正繁忙时,新提交的任务实例会进入内部的阻塞队列 (无界的阻塞队列)

2.2 newFixedThreadPool

该方法用于创建一个“固定数量的线程池”,其唯一的参数用于设置池中线程的“固定数量”。

public class ThreadPoolDemo {

    CountDownLatch countDownLatch = new CountDownLatch(MAX_TURN);
    public static final int SLEEP_GAP = 500;
    public static final int MAX_TURN = 5;

    //异步的执行目标类
    public static class TargetTask implements Runnable {
        CountDownLatch countDownLatch;
        static AtomicInteger taskNo = new AtomicInteger(1);
        protected String taskName;

        public TargetTask() {
            taskName = "task-" + taskNo.get();
            taskNo.incrementAndGet();
        }

        public TargetTask(CountDownLatch countDownLatch) {
            this();
            this.countDownLatch = countDownLatch;
        }

        public void run() {
            System.out.println("任务:" + taskName + " doing");

            try {
                Thread.sleep(SLEEP_GAP);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(taskName + " 运行结束.");
            countDownLatch.countDown();
        }

        @Override
        public String toString() {
            return "TargetTask{" + taskName + '}';
        }
    }
	@Test
    public void testNewFixedThreadPool() throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(3);
        for (int i = 0; i < MAX_TURN; i++) {
            pool.execute(new TargetTask(countDownLatch));
        }

        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        //关闭线程池
        pool.shutdown();
    }
}

newFixedThreadPool线程池的特点:

  • 如果线程数没有达到“固定数量”,则每次提交一个任务池内就创建一个新线程,直到线程达到线程池的固定的数量。
  • 线程池的大小一旦达到“固定数量”就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
  • 在接收异步任务的执行目标实例时,如果池中的所有线程均在繁忙状态,对于新任务会进入阻塞队列中 (无界的阻塞队列)

2.3 newCachedThreadPool

该方法用于创建一个“可缓存线程池”,如果线程池内的某些线程无事可干成为空闲线程,“可缓存线程池”可灵活回收这些空闲线程。

public class ThreadPoolDemo {

    CountDownLatch countDownLatch = new CountDownLatch(MAX_TURN);
    public static final int SLEEP_GAP = 500;
    public static final int MAX_TURN = 5;

    //异步的执行目标类
    public static class TargetTask implements Runnable {
        CountDownLatch countDownLatch;
        static AtomicInteger taskNo = new AtomicInteger(1);
        protected String taskName;

        public TargetTask() {
            taskName = "task-" + taskNo.get();
            taskNo.incrementAndGet();
        }

        public TargetTask(CountDownLatch countDownLatch) {
            this();
            this.countDownLatch = countDownLatch;
        }

        public void run() {
            System.out.println("任务:" + taskName + " doing");

            try {
                Thread.sleep(SLEEP_GAP);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(taskName + " 运行结束.");
            countDownLatch.countDown();
        }

        @Override
        public String toString() {
            return "TargetTask{" + taskName + '}';
        }
    }
	@Test
    public void testNewCachedThreadPool() throws InterruptedException {
        ExecutorService pool = Executors.newCachedThreadPool();
        for (int i = 0; i < MAX_TURN; i++) {
            pool.execute(new TargetTask(countDownLatch));
        }


        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
//        try {
//            countDownLatch.await();
//        } catch (InterruptedException e) {
//            throw new RuntimeException(e);
//        }

        //关闭线程池
        pool.shutdown();
    }
}

newCachedThreadPool 特点:

  • 在接收新的异步任务 target 执行目标实例时,如果池内所有线程繁忙,此线程池会添加新线程来处理任务。
  • 此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小
  • 如果部分线程空闲,也就是存量线程的数量超过了处理任务数量,那么就会回收空闲(60 秒不执行任务)线程。
  • “可缓存线程池”的弊端:线程池没有最大线程数量限制,如果大量的异步任务执行目标实例同时提交,可能导致创线程过多会而导致资源耗尽。

2.4 newScheduledThreadPool

该方法用于创建一个“可调度线程池”,一个提供“延时”和“周期性”任务的调度功能的ScheduledExecutorService 类型的线程池。 Executors 提供了多个创建“可调度线程池”工厂方法。

//方法一:创建一个可调度线程池,池内仅含有一条线程
public static ScheduledExecutorService newSingleThreadScheduledExecutor();
//方法二:创建一个可调度线程池,池内含有 N 条线程, N 的值为输入参数 corePoolSize
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) ;
public class ThreadPoolDemo {

    CountDownLatch countDownLatch = new CountDownLatch(MAX_TURN);
    public static final int SLEEP_GAP = 500;
    public static final int MAX_TURN = 5;

    //异步的执行目标类
    public static class TargetTask implements Runnable {
        CountDownLatch countDownLatch;
        static AtomicInteger taskNo = new AtomicInteger(1);
        protected String taskName;

        public TargetTask() {
            taskName = "task-" + taskNo.get();
            taskNo.incrementAndGet();
        }

        public TargetTask(CountDownLatch countDownLatch) {
            this();
            this.countDownLatch = countDownLatch;
        }

        public void run() {
            System.out.println("任务:" + taskName + " doing");

            try {
                Thread.sleep(SLEEP_GAP);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(taskName + " 运行结束.");
            countDownLatch.countDown();
        }

        @Override
        public String toString() {
            return "TargetTask{" + taskName + '}';
        }
    }
	@Test
    public void testNewScheduledThreadPool() {
        ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);
        for (int i = 0; i < 2; i++) {
            scheduled.scheduleAtFixedRate(new TargetTask(countDownLatch),
                    0, 5000, TimeUnit.MILLISECONDS);
        }

        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        //关闭线程池
        scheduled.shutdown();
    }
}

2.5 newSingleThreadScheduledExecutor

该方法所创建的仅含有一条线程的可调度线程池,适用于 调 度 串 行 化 任 务 , 也 就 是 一 个 一 个 任 务 的 串 行 化 调 度 执 行 。 使 用Executors.newScheduledThreadPool(int corePoolSize)快捷工厂方法创建一个“可调度线程池”的测试用例。

public class ThreadPoolDemo {

    CountDownLatch countDownLatch = new CountDownLatch(MAX_TURN);
    public static final int SLEEP_GAP = 500;
    public static final int MAX_TURN = 5;

    //异步的执行目标类
    public static class TargetTask implements Runnable {
        CountDownLatch countDownLatch;
        static AtomicInteger taskNo = new AtomicInteger(1);
        protected String taskName;

        public TargetTask() {
            taskName = "task-" + taskNo.get();
            taskNo.incrementAndGet();
        }

        public TargetTask(CountDownLatch countDownLatch) {
            this();
            this.countDownLatch = countDownLatch;
        }

        public void run() {
            System.out.println("任务:" + taskName + " doing");

            try {
                Thread.sleep(SLEEP_GAP);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            System.out.println(taskName + " 运行结束.");
            countDownLatch.countDown();
        }

        @Override
        public String toString() {
            return "TargetTask{" + taskName + '}';
        }
    }
    @Test
    public void testThreadPoolExecutor() {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, //corePoolSize
                4, //maximumPoolSize
                100, //keepAliveTime
                TimeUnit.SECONDS, //unit
                new LinkedBlockingDeque<>(2), new ThreadPoolExecutor.AbortPolicy());//workQueue

        for (int i = 0; i < 6; i++) {
            final int taskIndex = i;

            Runnable task = new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() +  "   taskIndex = " + taskIndex );
                    try {
                        Thread.sleep(10000);
//                        Thread.sleep(Long.MAX_VALUE);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };

            executor.execute(task);
        }
        while (true) {
            //每隔 1 秒,输出线程池的工作任务数量、总计的任务数量
            System.out.printf("- activeCount: %d - taskCount: %d\r\n", executor.getActiveCount(), executor.getTaskCount());
            ThreadUtil.sleepSeconds(1);
        }
    }

}

scheduleAtFixedRate 方法的定义如下:

public ScheduledFuture<?> scheduleAtFixedRate(
    Runnable command, //异步任务 target 执行目标实例;
    long initialDelay, //首次执行延时;
    long period, //两次开始执行最小间隔时间;
    TimeUnit unit //所设置的时间的计时单位,如 TimeUnit.SECONDS 常量;
);

scheduleWithFixedDelay方法的定义如下:

public ScheduledFuture<?> scheduleWithFixedDelay(
    Runnable command,//异步任务 target 执行目标实例;
    long initialDelay, //首次执行延时;
    long delay, //前一次执行结束到下一次执行开始的间隔时间(间隔执行延迟时间);
    TimeUnit unit //所设置的时间的计时单位,如 TimeUnit.SECONDS 常量;
);

两个方法区别:

  • scheduleAtFixedRate 是从任务开始时算起
  • scheduleWithFixedDelay 是从任务结束时算起

3 线程池的标准创建方式

大部分企业的开发规范,都会禁止使用快捷线程池,要求通过标准构造器 ThreadPoolExecutor 去构造工作线程池。要求使用ThreadPoolExecutor创建线程池

// 使用标准构造器,构造一个普通的线程池
public ThreadPoolExecutor(
    int corePoolSize, // 核心线程数,即使线程空闲(Idle),也不会回收;
    int maximumPoolSize, // 线程数的上限;
    long keepAliveTime, TimeUnit unit, // 线程最大空闲(Idle)时长
    BlockingQueue workQueue, // 任务的排队队列
    ThreadFactory threadFactory, // 新线程的产生方式
    RejectedExecutionHandler handler) // 拒绝策略

3.1 核心和最大线程数量

corePoolSize 核心线程池数,maximumPoolSize 最大线程数。线程池规则如下:
(1)在线程池接收到的新任务,并且当前工作线程数少于corePoolSize时,即使其他工作线程处于空闲状态,也会创建一个新线程来处理该请求,直到线程数达到 corePoolSize。
(2)当前工作线程数多于 corePoolSize 数量,但小于 maximumPoolSize 数量,则仅当任务排队队列已满时,才会创建新线程。 通过设置 corePoolSize 和 maximumPoolSize 相同,可以创建一个固定大小的线程池。
(3)当 maximumPoolSize 被设置为无界值(如 Integer.MAX_VALUE)时,线程池可以接收任意数量的并发任务。
( 4 ) corePoolSize 和 maximumPoolSize 不 仅 能 在 线 程 池 构 造 时 设 置 , 也 可 以 使 用setCorePoolSize 和 setMaximumPoolSize 两个方法进行动态更改。

3.2 BlockingQueue

BlockingQueue(阻塞队列)的实例用于暂时接收到的异步任务,如果线程池的核心线程都在忙,则所接收到的目标任务,缓存在阻塞队列中。

3.3 keepAliveTime

默认情况下, Idle 超时策略仅适用于存在超过 corePoolSize 线程的情况。 但是如果调用了allowCoreThreadTimeOut(boolean)方法,并且传入了参数 true,则 keepAliveTime 参数所设置的 Idle超时策略也将被应用于核心线程。

4 线程池的任务调度流程

线程池的任务调度流程如下:

(1)当前工作线程数小于核心线程池数量,执行器总是优先创建一个任务线程,而不是从线程队列中取一个空闲线程。
(2)线程池中任务数大于核心线程池数,任务将被加入到阻塞队列中,一直到阻塞队列满。在核心线程池数量已经用完、阻塞队列没有满的场景下,线程池不是为新任务创建一个新线程。
(3)当完成一个任务的执行时,执行器总是优先从阻塞队列中取下一个任务,并开始其执行,一直到阻塞队列为空,其中所有的缓存任务被取光。
(4)在核心线程池数量已经用完、阻塞队列也已经满了的场景下,如果线程池接收到新的任务,将会为新任务创建一个线程(非核心线程),并且立即开始执行新任务。
(5)在核心线程都用完、阻塞队列已满的情况下,一直会创建新线程去执行新任务,直到池内的线程总数超出 maximumPoolSize。如果线程池的线程总数超时 maximumPoolSize,则线程池会拒绝接收任务,当新任务过来时,会为新任务执行拒绝策略。

在创建线程池时,如果线程池的参数如核心线程数量、最大线程数量、 BlockingQueue 等配置不合理,就会出现任务不能被正常调度的问题。
下面是一个错误的线程池配置示例:

    @org.junit.Test
    public void testThreadPoolExecutor() {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, //corePoolSize
                100, //maximumPoolSize
                100, //keepAliveTime
                TimeUnit.SECONDS, //unit
                new LinkedBlockingDeque<>(100));//workQueue

        for (int i = 0; i < 5; i++) {
            final int taskIndex = i;
            executor.execute(() ->
            {
                Print.tco("taskIndex = " + taskIndex);
                try {
                    Thread.sleep(Long.MAX_VALUE);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        //每隔 1 秒,输出线程池的工作任务数量、总计的任务数量
        while (true) {
            Print.tco("- activeCount:" + executor.getActiveCount() +
                    " - taskCount:" + executor.getTaskCount());
            sleepSeconds(1);
        }
    }

5 线程池的拒绝策略

使用有界队列的时候,如果队列满了,提交任务到线程池的时候就会被拒绝。总体来说,任务被拒绝有两种情况:
(1)线程池已经被关闭。
(2)工作队列已满且 maximumPoolSize 已满。
无 论 以 上 哪 种 情 况 任 务 被 拒 , 线 程 池 都 会 调 用 RejectedExecutionHandler 实 例 的rejectedExecution 方法。 RejectedExecutionHandler 是拒绝策略的接口, JUC 为该接口提供了以下几种实现:

  • AbortPolicy:拒绝策略
  • DiscardPolicy:抛弃策略
  • DiscardOldestPolicy:抛弃最老任务策略
  • CallerRunsPolicy:调用者执行策略
  • 自定义策略

JUC 线程池拒绝策略的接口与类之间的关系图:

(1) AbortPolicy
使用该策略时,如果线程池队列满了则新任务被拒绝,并且会抛出 RejectedExecutionException异常。该策略是线程池的默认的拒绝策略。
(2) DiscardPolicy
该策略是 AbortPolicy 的 Silent(安静)版本,如果线程池队列满了,新任务会直接被丢掉,并且不会有任何异常抛出。
(3) DiscardOldestPolicy
抛弃最老任务策略,也就是说如果队列满了,会将最早进入队列的任务抛弃,从队列中腾出空间,再尝试加入队列。因为队列是队尾进队头出,队头元素是最老的,所以每次都是移除对头元素后再尝试入队。
(4) CallerRunsPolicy
调用者执行策略。在新任务被添加到线程池时,如果添加失败,那么提交任务线程会自己去执行该任务,不会使用线程池中的线程去执行新任务。
在以上的四种内置策略中,线程池默认的拒绝策略为 AbortPolicy,如果提交的任务被拒绝,线程池抛出 RejectedExecutionException 异常,该异常是非受检异常(运行时异常),很容易忘记捕获。如果关心任务被拒绝的事件,需要在提交任务时捕获 RejectedExecutionException 异常。
(5)自定义策略
如果以上拒绝策略都不符合需求,则可自定义一个拒绝策略,实现 RejectedExecutionHandler接口的 rejectedExecution 方法即可。

    //自定义拒绝策略
    public static class CustomIgnorePolicy implements RejectedExecutionHandler {
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            // 可做日志记录等
            Print.tco(r + " rejected; " + " - getTaskCount: " + e.getTaskCount());
        }
    }

    @org.junit.Test
    public void testCustomIgnorePolicy() {
        int corePoolSize = 2; //核心线程数
        int maximumPoolSize = 4;  //最大线程数
        long keepAliveTime = 10;
        TimeUnit unit = TimeUnit.SECONDS;
        //最大排队任务数
        BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        //线程工厂
        ThreadFactory threadFactory = new SimpleThreadFactory();
        //拒绝和异常策略
        RejectedExecutionHandler policy = new CustomIgnorePolicy();
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                corePoolSize,
                maximumPoolSize,
                keepAliveTime, unit,
                workQueue,
                threadFactory,
                policy);

        // 预启动所有核心线程
        pool.prestartAllCoreThreads();
        for (int i = 1; i <= 10; i++) {
            pool.execute(new TargetTask());
        }
        //等待10秒
        sleepSeconds(10);
        Print.tco("关闭线程池");
        pool.shutdown();
    }

6 线程池状态

线程池总共存在 5 种状态,定义在 ThreadPoolExecutor 类中,具体代码如下:

package java.util.concurrent;
...省略 import
public class ThreadPoolExecutor extends AbstractExecutorService {
    // runState is stored in the high-order bits
    private static final int RUNNING = -1 << COUNT_BITS;
    private static final int SHUTDOWN = 0 << COUNT_BITS;
    private static final int STOP = 1 << COUNT_BITS;
    private static final int TIDYING = 2 << COUNT_BITS;
    private static final int TERMINATED = 3 << COUNT_BITS;
    …省略其他
}

(1) RUNNING: 线程池创建之后的初始状态,这种状态下可以执行任务。
(2)SHUTDOWN: 该状态下线程池不再接受新任务,但是会将工作队列中的任务执行完毕。
(3) STOP: 该状态下线程池不再接受新任务,也不会处理工作队列中的剩余任务,并且将会中断所有工作线程。
(4) TIDYING: 该状态下所有任务都已终止或者处理完成,将会执行terminated( )钩子方法。
(5) TERMINATED: 执行完terminated( )钩子方法之后的状态。

线程池的状态转换规则:

(1)线程池创建之后状态为 RUNNING。

(2)执行线程池的 shutdown 实例方法,会使线程池状态从 RUNNING 转变为 SHUTDOWN。

(3)执行线程池的 shutdownNow 实例方法,会使线程池状态从 RUNNING 转变为 STOP。

(4)当线程池处于 SHUTDOWN 状态,执行器 shutdownNow 方法,会将其状态转变为 STOP状态。

(5)等待线程池的所有工作线程停止,工作队列清空之后,线程池状态会从 STOP 转变为TIDYING。

(6)执行完 terminated( ) 钩子方法之后,线程池状态从 TIDYING 转变为 TERMINATED 。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值