带着问题看源码 — 面试必备:理解Java线程池的运行原理

简单来说,线程池就是一个线程管理器,可以管理一组线程使其可重复利用。
使用线程池的好处在于可以减少创建和销毁线程的开销,同时可避免不受控制的创建新线程可能导致的OOM等问题。

本文将按个人理解,对照源码来解答有关线程池的几个关键问题。

在回答这些问题之前,首先了解一下线程池相关的几个类,和它们的关系

网上找的图,其中红框部分的是比较重要的类。线程池的功能,基本都在ThreadPoolExecutor类里实现,就是图中左下角红框的那个类。
这些类都处于java.util.concurrent包下。

然后看下线程池的基本使用

ExecutorService pool = Executors.newCachedThreadPool();
pool.execute(runnable); //runnable是我们自定义的Runnable对象

上面的代码创建了一个线程池,并提交了一个任务。线程池会创建新线程来处理任务。

线程池的详细处理流程,可参考前人文章:https://www.cnblogs.com/qingquanzi/p/8146638.html

下面我们一个个解答问题:

- 线程池的构造方法的各个参数是什么作用?

看下线程池的构造方法是怎么样的,即ThreadPoolExecutor的构造方法。
(上面我们的代码示例里,Executors.newCachedThreadPool()最终也会调用ThreadPoolExecutor的构造方法来创建线程池。)

ThreadPoolExecutor有好几个构造方法,但最终都调的是这个:

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {

corePoolSize: 核心线程数,即创建后会默认一直运行的线程数量。
maximumPoolSize: 最大线程数,线程池创建的线程数不能超过这个数量。
keepAliveTime: 线程没有任务处理时,还可以存活多久。(和unit参数配合生效,unit是单位,keepAliveTime是数值)
unit: 时间单位,比如秒,分等。
workQueue: 提交的任务如果暂时不处理,会放到这个阻塞队列里。
threadFactory: 线程工厂,用来创建线程,主要是为了给线程起名字,默认工厂的线程名字:pool-1-thread-3。
handler: 线程池达到最大线程数之后,再来任务,由这个handler来执行具体拒绝策略。

- 线程池对于新来的任务,会怎么分配?

看源码,也就是ThreadPoolExecutor的execute方法:

    /**
     * Executes the given task sometime in the future.  The task
     * may execute in a new thread or in an existing pooled thread.
     *
     * If the task cannot be submitted for execution, either because this
     * executor has been shutdown or because its capacity has been reached,
     * the task is handled by the current {@code RejectedExecutionHandler}.
     *
     * @param command the task to execute
     * @throws RejectedExecutionException at discretion of
     *         {@code RejectedExecutionHandler}, if the task
     *         cannot be accepted for execution
     * @throws NullPointerException if {@code command} is null
     */
    public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        /*
         * Proceed in 3 steps:
         *
         * 1. If fewer than corePoolSize threads are running, try to
         * start a new thread with the given command as its first
         * task.  The call to addWorker atomically checks runState and
         * workerCount, and so prevents false alarms that would add
         * threads when it shouldn't, by returning false.
         *
         * 2. If a task can be successfully queued, then we still need
         * to double-check whether we should have added a thread
         * (because existing ones died since last checking) or that
         * the pool shut down since entry into this method. So we
         * recheck state and if necessary roll back the enqueuing if
         * stopped, or start a new thread if there are none.
         *
         * 3. If we cannot queue task, then we try to add a new
         * thread.  If it fails, we know we are shut down or saturated
         * and so reject the task.
         */
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {//1 当前线程数小于核心线程数,则创建新线程处理
            if (addWorker(command, true))	// 创建新线程处理完成。addWorker第二个参数true表示核心线程。
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {//2 如果核心线程都满了,则尝试把新来的任务放到阻塞队列里
            int recheck = ctl.get(); //下面这几行是在任务放到队列后,做二次检查,看任务是否需要处理
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))//3 放入阻塞队列失败(队列满了),就再创建新线程进行处理,这里addWorker第二个参数为false,表示非核心线程
            reject(command); //4 阻塞队列满了,线程数也达到最大了,则执行拒绝策略。
    }

代码里的注释已经说的比较清楚了,对新提交的任务,分四种情况处理:
1. 如果线程池当前线程数小于核心线程数,则创建新线程处理。
2. 如果核心线程都满了,则尝试把新来的任务放到阻塞队列里。
3. 如果阻塞队列也满了,就再创建新线程进行处理。
4. 如果线程数达到最大线程数了,再来新的任务按构造方法里的拒绝策略来处理。

其中拒绝策略又分为四种。在ThreadPoolExecutor中有四个静态内部类,实现了RejectedExecutionHandler接口,此接口提供了拒绝任务处理的自定义方法。

  1. CallerRunsPolicy:直接使用调用该execute的线程本身来执行。
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    if (!e.isShutdown()) { r.run(); }
    }
  2. AbortPolicy:直接抛出RejectedExecutionException异常,丢弃任务。 因此调用ThreadPoolExecutor的execute()方法时得按情况考虑是不是得捕获这个异常做处理。
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    throw new RejectedExecutionException();
    }
  3. DiscardPolicy:和AbortPolicy几乎一样,也是丢弃任务,只不过他不抛出异常。
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {}
  4. DiscardOldestPolicy:如果执行程序尚未关闭,则位于工作队列头部的任务将被删除,然后重试执行程序(如果再次失败,则重复此过程)
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    if (!e.isShutdown()) {
    e.getQueue().poll();
    e.execute(r );
    }
    }
- Java默认提供的线程池有哪几种?分别有什么特性,适用场景是什么?

Java默认提供了四种线程池,创建方法都位于Executors类里。
具体四种线程池的应用场景,看前人的总结吧,我懒得写了,我这里只简单列一下。
https://www.cnblogs.com/frankyou/p/9467905.html

1. Executors.newCachedThreadPool()

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

此线程池的核心线程数为0,也就是说,所有的任务一提交就会尝试加入到阻塞队列中。
而阻塞队列是SynchronousQueue,这种队列的特点是放入一个任务到队列必须等待另一个任务取出才能完成,反之亦然。放入失败,就会创建新线程处理,而最大线程数为Int最大值,可以理解为无限大,因此可以创建的线程数量是不受限制的,适合处理时间短的异步任务。

2. Executors.newFixedThreadPool(int n)

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

此线程池最大线程数和核心线程数是一样的。线程数量一旦达到最大后,就会始终保持固定,新来的任务会放入队列。队列使用了LinkedBlockingQueue,是一个无界队列,可放入无限多的任务。

3. Executors.newSingleThreadExecutor()

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

线程数量为1,是单线程的线程池,使用的阻塞队列也是LinkedBlockingQueue,是无界的队列,可以无限存储任务,然后按照指定顺序(FIFO, LIFO, 优先级)执行,所以这个比较适合那些需要按序执行任务的场景。

4. Executors.newScheduledThreadPool(int n)

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }

    // ScheduledThreadPoolExecutor.java
    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }

此线程池主要用于处理延时任务,以及周期性的任务,还可以执行带有返回值的任务。它的最大线程数也是无限大的。

看看处理延时任务的示例:

ScheduledExecutorService ss = Executors.newScheduledThreadPool(5); //传入核心线程数
ss.schedule(r, 3, TimeUnit.SECONDS); // 延时3秒后开始任务。r为Runnable对象

再顺便提下几种线程池的弊端,来自《阿里开发手册》:
在这里插入图片描述

- 线程池是如何做到线程复用的?

非核心线程如果没有任务要处理,会在60s之后销毁。而如果在60s之内从阻塞队列里取到任务了,就会继续执行任务。
默认情况下,核心线程不会销毁,会一直尝试从阻塞队列里取任务去执行。(除非我们把核心线程也设置为超过时间没有任务就销毁。方法是通过ThreadPoolExecutor的allowCoreThreadTimeOut(boolean value)来设置。)

这就是复用的原理。
追代码的话,流程为ThreadPoolExecutor的execute() -> addWorker() -> addWorker() 里的 t.start() -> Worker的run()方法 -> runWorker()。

Worker是实现了Runnable接口,是把线程池要处理的任务包装后的类。它的runWorker()就是任务具体处理的逻辑:

    final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask;
        w.firstTask = null;
        w.unlock(); // allow interrupts
        boolean completedAbruptly = true;
        try {
        	// 重点是While无限循环的条件:会不断调用getgetTask()取任务,如果取不到就终止循环了,线程也就结束了。
            while (task != null || (task = getTask()) != null) {
                w.lock();
                // If pool is stopping, ensure thread is interrupted;
                // if not, ensure thread is not interrupted.  This
                // requires a recheck in second case to deal with
                // shutdownNow race while clearing interrupt
                if ((runStateAtLeast(ctl.get(), STOP) ||
                     (Thread.interrupted() &&
                      runStateAtLeast(ctl.get(), STOP))) &&
                    !wt.isInterrupted())
                    wt.interrupt();
                try {
                    beforeExecute(wt, task);
                    Throwable thrown = null;
                    try {
                        task.run();
                    } catch (RuntimeException x) {
                        thrown = x; throw x;
                    } catch (Error x) {
                        thrown = x; throw x;
                    } catch (Throwable x) {
                        thrown = x; throw new Error(x);
                    } finally {
                        afterExecute(task, thrown);
                    }
                } finally {
                    task = null;
                    w.completedTasks++;
                    w.unlock();
                }
            }
            completedAbruptly = false;
        } finally {
            processWorkerExit(w, completedAbruptly);
        }
    }

从上面代码可见,一个线程创建后,执行任务的具体逻辑就是在一个While无限循环里,不断调用getgetTask()取任务进行处理,如果取不到就终止循环了,线程也就结束了。

getTask()返回null就是取不到任务。那么什么情况下,会返回null呢?看getTask()方法:

    private Runnable getTask() {
        boolean timedOut = false; // Did the last poll() time out?

        for (;;) {
            int c = ctl.get();
            int rs = runStateOf(c);

            // Check if queue empty only if necessary.
            if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
                decrementWorkerCount();
                return null; // 线程池处于终止状态并且阻塞队列为空,返回null
            }

            int wc = workerCountOf(c);

            // Are workers subject to culling?
            boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

            if ((wc > maximumPoolSize || (timed && timedOut))
                && (wc > 1 || workQueue.isEmpty())) {
                if (compareAndDecrementWorkerCount(c))
                    return null; // 超时了,并且阻塞队列为空,返回null
                continue;
            }

            try {
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
    }

从代码可见,线程池终止并且阻塞队列为空,或者已超过了时间,队列仍为空的话,就返回null,线程就会销毁。

那么这个默认60s延时是在哪里呢?相信你已经看到了,就是最后这一段从队列取任务时:

Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();

需要我们先了解一下BlockingQueue的poll()方法及take()方法。
poll()方法:尝试取头部元素,如果没有元素可取,就等待指定时间后再取,如果再取不到,就返回null。延时就是这里实现的。
take()方法:尝试取头部元素,如果没有元素可取,会一直阻塞,不会返回。

- 线程池里的线程不销毁,不会消耗资源吗?

上面也讲到了,线程没有任务时,getTask()这里一直不返回,会阻塞在BlockingQueue的take()方法这里,而不是一直在循环检查。所以不会消耗资源。

具体take()的实现,不同类型的BlockingQueue的实现不同。比如Executors.newScheduledThreadPool(int n)使用的是DelayedWorkQueue,它的take()方法可以点击查看DelayedWorkQueue的take()方法代码

- 用submit()和execute()方法提交任务有什么区别?

看下submit方法的实现,在AbstractExecutorService类里;

submit方法有三个实现,支持传入Runnable或Callable对象,同时可以得到任务处理结果。

public Future<?> submit(Runnable task)
public Future submit(Runnable task, T result)
public Future submit(Callable task)

看下其中一个实现:

    public <T> Future<T> submit(Callable<T> task) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<T> ftask = newTaskFor(task);
        execute(ftask);
        return ftask;
    }

可见submit方法内部也是调用execute方法来处理的。

想使用submit方法来处理任务并得到处理结果的话,可以像如下这样用:
先定义Callable对象:

	static Callable<String> c = new Callable<String> () {
		@Override
		public String call() throws Exception {
			return "hello";
		}
	};

然后使用方法如下:

		Future<String> f = ss.submit(c);
		String result = f.get();// 得到任务结果

而execute()方法只能处理Runnable对象,而且不能获取处理结果。
还有个差异,如果任务发生异常,用execute执行的时候会抛出异常,而submit执行不会抛出异常。具体分析见下面。

顺便提一句,用submit和execute提交任务后,任务的处理流程是一样的:
流程为ThreadPoolExecutor的execute() -> addWorker() -> addWorker() 里的 t.start() -> Worker的run()方法 -> runWorker()。
不同的是最后runWorker()里调用task.run()的时候,submit提交的任务会被包装成FutureTask,因此会调用FutureTask的run()方法。而execute提交的任务会直接运行runnable的run()方法。下面也会分析。

- 线程池对任务产生的异常会怎么处理?

先给结论:假设任务会发生异常,如果是用submit的方式处理的任务,则任务会终止,程序不会抛异常,但可以调用Future.get()方法捕获到异常。。而如果是用execute的方式处理的任务,则任务终止,程序会抛出异常,需要我们进行异常处理。而无论抛不抛异常,都不会影响线程池里面其他线程的正常执行。线程池会把这个线程移除掉,并创建一个新的线程放到线程池中。

详细解析:
1.首先看下submit方法,如下它会在newTaskFor(task)里把传入的Runnable或者Callable任务包装成FutureTask对象(FutureTask实现了RunnableFuture接口,RunnableFuture是继承了Runnable和Future两个接口的接口)。

    public <T> Future<T> submit(Callable<T> task) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<T> ftask = newTaskFor(task);
        execute(ftask);
        return ftask;
    }

那么最终执行任务时,调用的就是FutureTask的run()方法:

    public void run() {
        if (state != NEW ||
            !UNSAFE.compareAndSwapObject(this, runnerOffset,
                                         null, Thread.currentThread()))
            return;
        try {
            Callable<V> c = callable;
            if (c != null && state == NEW) {
                V result;
                boolean ran;
                try {
                    result = c.call(); //执行任务。如果之前submit传入的是Runnable对象,也早就转成Callable了,在这里处理
                    ran = true;
                } catch (Throwable ex) { //重点在这里!这里直接把异常给捕获了!所以不会抛出
                    result = null;
                    ran = false;
                    setException(ex); // 这里把异常做了传递
                }
                if (ran)
                    set(result);
            }
        } finally {
            // runner must be non-null until state is settled to
            // prevent concurrent calls to run()
            runner = null;
            // state must be re-read after nulling runner to prevent
            // leaked interrupts
            int s = state;
            if (s >= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
    }

如上面代码,如果执行任务时发生了异常,会被直接捕获,不会抛出。所以我们对submit()方法进行try catch是catch不到异常的。
那么如果我们想自己对异常进行处理,怎么办呢?
上面捕获异常后,有setException(ex)这个处理。通过这个处理,使得我们通过Future的get()方法获取任务结果时,可以捕获到异常。
即我们可以对Future的get方法捕获异常,像这样:

		Future<String> f = ss.submit(callable);//ss是创建好的线程池
		try {
			String result = f.get();//获取任务执行结果
		} catch (InterruptedException | ExecutionException e) {
			// 在这里对异常情况做处理
		}

2.再看execute()方法,它处理任务执行的是Worker的runWorker()方法,上面已经贴过代码,这里再次贴出异常相关的部分:

                    try {
                        task.run();
                    } catch (RuntimeException x) {
                        thrown = x; throw x;
                    } catch (Error x) {
                        thrown = x; throw x;
                    } catch (Throwable x) {
                        thrown = x; throw new Error(x);
                    } finally {
                        afterExecute(task, thrown);
                    }

即任务如果发生异常,会进行捕获,并再次抛出。所以我们提交任务时如果是用execute()方法,则对它进行try catch是可以捕获异常的。
同时异常会传入ThreadPoolExecutor的afterExecute(task, thrown),这个是个空方法,我们可以重写此方法对传入的异常进行处理。

还有,这里捕获异常又抛出,抛到哪里去了呢?其实是交给线程的异常处理器去处理了,就不深究了。我们可以给线程设置异常处理器来处理异常,如下示例:

        ExecutorService pool = Executors.newFixedThreadPool(1, new ThreadFactory() {
        	@Override
			public Thread newThread(Runnable r) {
        		Thread t = new Thread(r);
                t.setUncaughtExceptionHandler(new UncaughtExceptionHandler() {
                	@Override
                	public void uncaughtException(Thread t, Throwable e) {
                		// 对异常做处理
                	}
                });
                return t;
        	}
        });

总结:如果是submit方式,可以捕获Future的get方法的异常,进行任务的异常处理。
如果是execute方式,可以捕获execute方法抛出的异常,或者重写ThreadPoolExecutor的afterExecute(task, thrown)方法,或者给线程设置异常处理器进行处理。

如果是shedule的方式提交的任务,最后和submit方式是一样的,这里不再分析。

参考前人文章:https://github.com/aCoder2013/blog/issues/3

- 如何关闭线程池?

ThreadPoolExecutor里有两个方法用于关闭线程池:
shutdownNow():线程池拒接收新提交的任务,同时立马关闭线程池,线程池里的任务不再执行。使用shutdownNow方法,线程会立即释放锁,可能会引起错误。
shutdown():线程池拒接收新提交的任务,同时等待线程池里的任务执行完毕后关闭线程池。

- 线程池有哪几种状态?

线程池有如下五种状态,在ThreadPoolExecutor里定义:

    // 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;

关于这五种状态,还是看别人写的吧:https://blog.csdn.net/L_kanglin/article/details/57411851?locationNum=9&fps=1

- ScheduledExecutorService 延时任务是怎么实现的?

使用ScheduledThreadPool线程池,提交任务不是用submit,也不用execute,而是schedule方法,看看处理延时任务的示例:

ScheduledExecutorService ss = Executors.newScheduledThreadPool(5); //传入核心线程数
ss.schedule(r, 3, TimeUnit.SECONDS); // 延时3秒后开始任务。r为Runnable对象

调用schedule方法后,传入的任务 r 会被包装成一个实现了RunnableScheduledFutur 接口的ScheduledFutureTask(ScheduledThreadPoolExecutor的内部类)对象,放入DelayedWorkQueue阻塞队列里。

// ScheduledThreadPoolExecutor.java

    public ScheduledFuture<?> schedule(Runnable command,
                                       long delay,
                                       TimeUnit unit) {
        if (command == null || unit == null)
            throw new NullPointerException();
        RunnableScheduledFuture<?> t = decorateTask(command,
            new ScheduledFutureTask<Void>(command, null,
                                          triggerTime(delay, unit)));// 这里传入了延时时间
        delayedExecute(t);
        return t;
    }
    /**
     * Main execution method for delayed or periodic tasks.  If pool
     * is shut down, rejects the task. Otherwise adds task to queue
     * and starts a thread, if necessary, to run it.  (We cannot
     * prestart the thread to run the task because the task (probably)
     * shouldn't be run yet.)  If the pool is shut down while the task
     * is being added, cancel and remove it if required by state and
     * run-after-shutdown parameters.
     *
     * @param task the task
     */
    private void delayedExecute(RunnableScheduledFuture<?> task) {
        if (isShutdown())
            reject(task);
        else {
            super.getQueue().add(task);//任务加入队列
            if (isShutdown() &&
                !canRunInCurrentRunState(task.isPeriodic()) &&
                remove(task))
                task.cancel(false);
            else
                ensurePrestart();// 开启线程处理任务
        }
    }

// ThreadPoolExecutor.java

    void ensurePrestart() {
        int wc = workerCountOf(ctl.get());
        if (wc < corePoolSize)
            addWorker(null, true);
        else if (wc == 0)
            addWorker(null, false);
    }

到这里,就是和其它几种线程池一样的流程了。 addWorker() -> addWorker() 里的 t.start() -> Worker的run()方法 -> runWorker()。

runWorker()里会调用getTask()取任务,而getTask()里最终会调用队列的take()或者poll方法取任务。由于此线程池使用的是DelayedWorkQueue阻塞队列,所以我们看下DelayedWorkQueue的take或者poll方法的实现,延时取任务就是这里实现的:

        public RunnableScheduledFuture<?> take() throws InterruptedException {
            final ReentrantLock lock = this.lock;
            lock.lockInterruptibly();
            try {
                for (;;) {
                    RunnableScheduledFuture<?> first = queue[0];
                    if (first == null)
                        available.await();
                    else {
                    	// 这里的getDelay就是得到剩余时间,见ScheduledFutureTask类的getDelay方法
                        long delay = first.getDelay(NANOSECONDS);
                        if (delay <= 0)
                            return finishPoll(first);
                        first = null; // don't retain ref while waiting
                        if (leader != null)
                            available.await();
                        else {
                            Thread thisThread = Thread.currentThread();
                            leader = thisThread;
                            try {
                                available.awaitNanos(delay);
                            } finally {
                                if (leader == thisThread)
                                    leader = null;
                            }
                        }
                    }
                }
            } finally {
                if (leader == null && queue[0] != null)
                    available.signal();
                lock.unlock();
            }
        }

从上面可看出了,take()方法会一直阻塞直至延时时间到,才能取出队列的头部元素(这个延时时间是开始调用schedule方法的时候,传给ScheduledFutureTask的,上面代码注释处也标记了)。
poll方法和这个类似,就不贴了。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值