Java线程池ThreadPoolExecutor使用与源码解析

一、Executor及其子类

Java中我们可能会通过new一个Thread来执行异步任务,比如网络请求这块。但是Thread的创建与销毁都是很耗系统资源的,尤其是有大量并发任务需要频繁的创建与销毁Thread,会对系统造成很大的负担。因此java提供了Executor线程池类来方便我们对线程进行缓存利用,负责管理Thread的使用与任务调度,减少系统创建销毁Thread的开销。而ThreadPoolExecutor则是java提供的一个标准的线程池Executor实现类,在java的并发库java.util.concurrent中。

1. Executor

Executor是一个接口,提供了一个方法execute(Runnable command),用于执行提交的Runnable任务,将任务提交与任务执行解耦。Executor负责线程使用以及调度。因此推荐使用Executo而不是去直接创建一个Thread来执行任务。
因为Executor只是一个接口,而其任务只是执行Runnable。那么就意味着我们并没有一个固定的标准来实现相关的线程管理与调度,其实我们可以根据需求自己实现Executor,比如下面的几个例子:

在调用者线程执行

class DirectExecutor implements Executor {
	public void execute(Runnable r) {
		r.run();
	}
}

串行执行任务

class SerialExecutor implements Executor {
	final Queue<Runnable> tasks = new ArrayDeque<>();
	final Executor executor;
	Runnable active;
	
	SerialExecutor(Executor executor) {
		this.executor = executor;
	}

	public synchronized void execute(final Runnable r) {
		tasks.add(new Runnable() {
			public void run() {
				try {
					r.run();
				} finally {
					scheduleNext();
				}
			}
		});
		if (activie == null) {
			scheduleNext();
		}
	}

	protected synchronized void scheduleNext() {
		if ((active = tasks.poll()) != null) {
			executor.execute(active);
		}
	}
}

上面的两段代码是从Executor源码的注释里摘下来的,很有意思。先说第一段,很简单,就是一个简单的方法调用,其实根本没有用到多线程,因此可以在调用者线程直接执行,当然这是极端的情况,线程池在这里丝毫没有体现出来。

再看一下后面的这个串行线程池的实现。参数为一个队列 tasks,一个真正执行任务的executor以及一个当前执行的Runnable的引用active。当调用execute时,首先将要执行的Runnable再用Runnable进行一次包装加入tasks队列中,包装的意义在于,当Runnable执行完之后可以在当前执行的线程里自动调度下一个任务,而不需要线程池去执行调度任务。首次执行execute时或者说每当队列中任务执行完再次执行新任务时,active为空则用executor来真正执行任务。当队列中任务执行完之后,线程池则暂停调度,等待下一个任务的到来。之所以在这里提这个例子,是因为在Android的AsynTask里就默认的采用了这种串行执行的方式来实现了线程池,实现基本一样。

2. ExecutorService与AbstractExecutorService

ExecutorService也是一个接口,继承了Executor接口。该类提供了管理线程池终止和生成可追踪的Future任务的方法。

ExecutorService可以被关闭,其中shutDown()方法允许在终止之前将之前提交的任务继续执行,而shutDownNow()方法则是会将之前的任务清空,并且会尝试停止当前在跑的任务。线程池终止后任务不再可以提交,而且没有任务在执行或等待执行。不再使用的线程池应当被关闭以回收所占的资源。

submit()方法则是扩展了Executor中的execute方法,可以创建并返回一个Future实例,用于取消任务或者可以通过它获取执行结果。而invokeAny以及invokeAll方法则通常用于一组任务的执行,并等待至少一个或一组任务执行完成。

而AbstractExecutorService则是ExecutorService类的实现类,但是他是个抽象类,仅实现了ExecutorService类中的部分方法,包括submit以及invokeAny和invokeAll方法。此外,AbstractExecutorService还实现了newTaskFor方法用于创建返回RunnableFuture实例。

当然,如平日所见,这些方法我们在使用线程池的时候其实是很少用到的,所以,这里不针对其源码过多纠缠。

二、ThreadPoolExecutor

终于来到正题了,ThreadPoolExecutor是AbstractExecutorService的实现类,真正实现了利用线程池中的线程来执行提交的任务。

1. 构造方法及重要属性

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时,超时的话,核心线程也会被销毁;
maximumPoolSize : 线程池中允许存在的最大的线程数;
keepAliveTime : 当池中线程数量大于核心线程数时,此参数是多余线程(如果核心线程allowCoreThreadTimeOut,核心线程也算在内)在等到新任务之前可以存在的最长的时间,超时则被回收。从源码上看其实这个参数起作用的地方就在当任务调度从队列中获取Runnable时,如果队列为空且在一定时间内都没有新任务入队,就是超时;
unit : 超时等待时间的单位;
workQueue : 任务队列。用于在任务被执行前保存任务,且仅持有由execute提交的Runnable(这句话也不是绝对的,因为ThreadPoolExecutor中提供了getQueue()方法可以获取到这个队列,想怎么玩还在于自己,当然源码中也强调了强烈不建议用于其他用途,因为这个接口主要是获取队列用来监控和调试用的);下面是几种常用的队列:

  • SynchronousQueue,直接交给线程处理,而不入队列,适用于有相互依赖关系的任务执行;
  • LinkedBlockingQueue,无限队列,因为容量无限制,所以线程池中不会出现超过corePoolSize数量的线程,适用于相互没有依赖关系的任务;
  • ArrayBlockingQueue,有限队列,有限制的队列可以防止资源过分消耗,但是难以调控。

threadFactory : 当线程池需要时用于创建新线程;ThreadFactory是一个接口类,只提供一个newThread(Runnable r)方法来创建新线程。如果我们使用不指定threadFactory的构造方法,将会默认使用Executors.defaultThreadFactory()生成的默认threadFactory,该默认factory生成的线程都属于同一个线程组,拥有NORM_PRIORITY优先级以及非守护状态。我们可以通过实现ThreadFactory接口构造自己定制的线程Factory。此外,线程应当拥有modifyThread运行时权限,否则配置更改可能不会及时生效,并且关闭池可能保持可以终止但未完成的状态。注意当线程创建失败时会返回null,而且创建线程并启动有可能会出现虚拟机本地方法栈抛出的OOM异常。
handler : 用于当线程池shutDown时提交任务或者当线程数量达到限制数量,且队列已满的情况下拒绝处理无法执行的任务的策略;RejectedExecutionHandler接口,通过调用该接口的rejectedExecution(Runnable, ThreadPoolExecutor)方法来执行拒绝策略;ThreadPoolExecutor提供了四种拒绝策略(源码不贴了,很简单,可以自己去看一下):

  • AbortPolicy,抛出RejectedExecutionException运行时异常,默认的拒绝策略;
  • CallerRunsPolicy,如果线程池未关闭,由调用execute的线程来运行task,如果已经关闭,就会丢弃任务;
  • DiscardPolicy,直接丢弃task;
  • DiscardOldestPolicy,线程池未关闭时,首先丢弃队列中最早的一个未执行的任务,然后重新调用execute执行task,如果关闭了,就丢弃;

其中corePoolSize,keepAliveTime不能小于0,maximumPoolSize要大于0,并且maximumPoolSize不能小于corePoolSize,workQueue,threadFactory以及超出拒绝处理策略handler是必要的。ThreadPoolExecutor会针对corePoolSize与maximumPoolSize两个参数来调整线程池中线程数量,策略为:当新task提交时,如果线程池实际线程数量小于corePoolSize,此时不论其他线程是否空闲,都会通过threadFactory新建线程处理该任务;如果线程数量大于corePoolSize小于maximumPoolSize,如果任务队列workQueue是空的,则添加任务到workQueue中,如果是满的,则会新建线程。最后,如果当前实现线程超过了maximumPoolSize且队列满了,就会使用handler来拒绝处理任务。

当然,除了这些配置参数外,还有一些核心参数

ctl : AtomicInteger类型, 用于将运行状态(runState)与有效线程数量(workerCount)打包,其中,runState存储在高3位中,而workerCount存储在低29位中。当然,源码中也说明了,如果后期需要扩展再改为AtomicLong。
workerCount在这里值得注意一下,因为,其代表的是允许启动但还未允许停止的线程数量,但是池中真正活着的线程数量可能与workerCount不一致,比如,当ThreadFactory创建线程失败或者当线程允许停止,此时workerCount都发生了变化,但对应的线程还未真正创建或停止。
runState提供了主要的声明周期管理,有如下几个取值:

  • RUNNING : 允许接收并处理队列中的任务;线程池默认的状态就是RUNNING;
  • SHUTDOWN : 不能接收新任务,但是可以处理队列中的任务;当调用shutdown()方法时由RUNNING变为SHUTDOWN;当然如果线程池不再被引用并且内部没有线程存在,则会调用finalize()来shutdown线程池,但是前提是要设置keepAliveTime以及allowCoreThreadTimeOut(boolean),不然核心线程始终都会存在。
  • STOP : 不接受新任务,也不处理队列中的任务;当调用shutdownNow()方法时由RUNNING或SHUTDOWN变为STOP;
  • TIDYING : 所有任务都被终止,workerCount为0,线程即将转换为TIDYING状态,并且执行terminated()方法;当队列和线程池都为空的时候,SUTDOWN变为TIDYING;当线程池为空的时候,STOP转换为TIDYING;
  • TERMINATED : terminated()方法执行完成;当terminated()方法执行完时,TIDYING变为TERMINATED方法。

注意,这里面的几个状态的值是从小到大增长的,方便比较。具体的变化逻辑代码就不贴了。
此外,在ThreadPoolExecutor中提供了对ctl变量的操作方法,包括:

  • runStateOf(ctl) 获取状态;
  • workerCountOf(ctl) 获取Worker数量;
  • ctlOf(rs, sc) 将指定runState与workerCount进行包装;
  • runStateLessThan(c, s) 比较c是否小于s状态
  • runStateAtLeast(c, s) 比较c是否不小于s状态;
  • isRunning( c) 判断线程池是否处于SHUTDOWN及以上状态;
  • compareAndIncrementWorkerCount(expect) 比较并增加workercount;
  • compareAndDecrementWorkerCount(expect) 比较并减小workerCount;
  • decrementWorkerCount() 减小workerCount直到减小成功;
  • advanceRunState(targetState)设置线程池状态。

mainLock : 这是一个ReentrantLock可重入锁,用于在访问worker Set集合,以及相关的记录工作(runState、workerCount、largestPoolSize、completedTaskCount)时加锁;
workers : Worker工作集,用于存放线程池中所有工作线程,仅当获取到mainLock锁时可以访问;
largestPoolSize : 线程池达到过的最大池大小,仅当获取到mainLock锁时可以访问;
completedTaskCount : 线程池执行完的task总数量,该值仅在Worker中的线程终止时更新,同样也是获取到mainLock锁时可以访问;
allowCoreThreadTimeOut : 是否允许核心线程超时;
defaultHandler : 默认的拒绝执行策略,AbortPolicy实例;
shutdownPerm : 修改线程的权限;

2. 线程池如何启动线程运行task

这个问题我们从线程池的执行execute开始深入探索,代码如下:

public void execute(Runnable command) {
    if (command == null) {
        throw new NullPointerException();
    }
    int c = ctl.get();
    if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    else if (!addWorker(command, false))
        reject(command);
}

final void reject(Runnable command) {
    handler.rejectedExecution(command, this);
}

代码逻辑很清晰,首先判断command是否为空。接着第一步,如果少于和核心线程数在运行,就启动新线程来执行给定的任务,新增成功则结束;否则第二步,尝试将command添加到workQueue中,如果添加成功,则还要再次检查是否应当添加一个线程(可能上次检查后已经存在的一个线程死亡了),线程池空了就要添加一个新线程,以及检查是否在command进入方法期间线程池被关闭了,因为线程池可能会被多个线程调用,因此存在这种不安全的情况,如果是不再运行,就会回滚这个command并且调用handler拒绝处理;如果无法入队,第三步直接尝试新增一个线程来执行task,如果新增失败,就证明了线程池要么是关闭了,要么就是线程和队列饱和无法添加task,此时会调用handler来执行拒绝处理的逻辑。

这里也验证了,上面说的线程池调整的策略:

  1. 实际线程数小于corePoolSize时,创建新线程;
  2. 不小于核心线程数则加入队列;
  3. 队列满了则新建线程。

到这里我们还是没有看到线程是如何创建并start执行任务的,那继续看addWorker(Runnable, boolean)这个方法,execute方法从头到尾都是跟他有关的.

private boolean addWorker(Runnable firstTask, boolean core) {
    retry:
    for (;;) {
        int c = ctl.get();
        int rs = runStateOf(c);

        // Check if queue empty only if necessary.
        if (rs >= SHUTDOWN &&
            ! (rs == SHUTDOWN &&
               firstTask == null &&
               ! workQueue.isEmpty()))
            return false;

        for (;;) {
            int wc = workerCountOf(c);
            if (wc >= CAPACITY ||
                wc >= (core ? corePoolSize : maximumPoolSize))
                return false;
            if (compareAndIncrementWorkerCount(c))
                break retry;
            c = ctl.get();  // Re-read ctl
            if (runStateOf(c) != rs)
                continue retry;
            // else CAS failed due to workerCount change; retry inner loop
        }
    }

    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
        w = new Worker(firstTask);
        final Thread t = w.thread;
        if (t != null) {
            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
                // Recheck while holding lock.
                // Back out on ThreadFactory failure or if
                // shut down before lock acquired.
                int rs = runStateOf(ctl.get());

                if (rs < SHUTDOWN ||
                    (rs == SHUTDOWN && firstTask == null)) {
                    if (t.isAlive()) // precheck that t is startable
                        throw new IllegalThreadStateException();
                    workers.add(w);
                    int s = workers.size();
                    if (s > largestPoolSize)
                        largestPoolSize = s;
                    workerAdded = true;
                }
            } finally {
                mainLock.unlock();
            }
            if (workerAdded) {
                t.start();
                workerStarted = true;
            }
        }
    } finally {
       if (! workerStarted)
            addWorkerFailed(w);
    }
    return workerStarted;
}

分析一下:

首先进入一个外层循环(校验是否可以新增worker)。先根据状态来判断是否可以新增一个worker,这里的判断看着很绕人,但是我们反过来看:如果是RUNNING状态,或者是SHUTDOWN状态下task为空,但队列不为空时才可能添加新线程。也就是说,要么是正常运行时,要么是已经调用shutdown方法(非shutdownNow方法),但是队列中任务还没跑完,才可以新建线程来运行task。
紧接着,就再次启动一个内层循环(检查记录并修改workerCount),判断实际线程数是否超出线程池容量或者corePoolSize或maximumPoolSize的限制,超出了则不可以创建新线程。然后增加工作线程数量记录workerCount(因为别的线程(包括工作线程)可能也调用了对workerCount的修改方法,因此此处必须要用compareAndIncrementWorkerCount()比较后再修改,以防数据不安全),如果workerCount修改成功,则跳转出循环进行后续步骤。如果没有修改成功,则校验线程池的状态看是继续内层循环修改workerCount,还是跳转到外层循环来重新校验修改。

修改完workerCount后,就是创建Worker的过程。这块代码就很清晰了,首先用参数firstTask创建一个Worker对象,并且获取Worker中持有的线程引用,这个线程怎么来的,我们待会再分析。然后紧接着加锁,判断如果线程池是RUNNING状态,或者SHUTDOWN状态但是task为空时,将工作worker添加到worker set集合中,同时更新相关的largestPoolSize信息。但是如果worker中的线程已经被启动了,那么就抛出IllegalThreadStateException异常。最后调用Thread.start()方法启动线程。到这里我们就分析出了线程池是如何启动线程执行task的问题的答案了。

当然,这里还不算完事,后面还有worker线程如果启动失败,则调用添加worker失败addWorkerFailed(w)方法:

private void addWorkerFailed(Worker w) {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        if (w != null)
            workers.remove(w);
        decrementWorkerCount();
        tryTerminate();
    } finally {
        mainLock.unlock();
    }
}

这里就是简单的回滚刚刚在addWorker中所做的事情,包括从set中移除添加的worker,减少workerCount记录,以及尝试终止线程池,当然要加锁。

3. 线程池如何调度

对于这个问题,我们从上面启动线程执行任务的分析中不难发现,并没有调度用的代码,因此这里就要从Worker这个包装类来分析:

private final class Worker
    extends AbstractQueuedSynchronizer
    implements Runnable
{
    /** worker运行的线程。factory创建失败则为null */
    final Thread thread;
    /** 初始任务,可能为null */
    Runnable firstTask;
    /** 完成的任务数量 */
    volatile long completedTasks;

    Worker(Runnable firstTask) {
        setState(-1); // inhibit interrupts until runWorker
        this.firstTask = firstTask;
        this.thread = getThreadFactory().newThread(this);
    }

    /** 真正运行worker的方法 */
    public void run() {
        runWorker(this);
    }

    ...
}

上面代码显示了一个Worker最主要的部分,首先Worker也是一个Runnable,对我们传递过来的Runnable进行了封装,这与前面的SerialExecutor有相似之处,都对真正执行任务的Runnable进行了封装。此外,在构造函数中,我们可以看到,通过threadFactory.newThread方法创建了一个新线程,这也解释了上面Worker实例中的线程是如何获得的问题。

当然到这里我们还是没有看到task是如何调度的,答案当然就在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 (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);
    }
}

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

        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;
            continue;
        }

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

runWorker()上来先清空了worker中的task,因为后面不再需要它,只要第一次执行完就可以,所以这里也就释放了它的引用,便于回收。然后就是启动了一个循环,不停地从队列中取task执行,实现了由工作线程自己调度task执行的过程。执行的代码也很简单,先判断当前线程池的状态,如果是STOP及以后的状态,则中断线程,根据上面worker的启动过程我们知道runWorker是运行在新的线程中的,所以这里就是中断了工作线程了。当然还有一些其他的处理,如钩子方法beforeExecute()和afterExecute(),以及最后的worker退出方法processWorkerExit()就不细说了。

而getTask()方法处理也很清晰,如果线程池已关闭,且此时是STOP状态或者队列为空,不再处理task,就减少workerCount,并返回空的Runnable。未STOP情况下,如果实际线程数大于最大线程数,或者已经超时(允许核心线程超时或此时不是核心线程情况下超时),也减少workerCount,并返回空Runnable。最后利用workQueue的特性,如果允许超时销毁,则使用poll方法等待获取队列中的task,直到超时或获取到Runnable。如果不允许,则使用take方法一直等待获取到新的Runnable。这样就实现了从队列中获取Runnable。

以上便是关于线程池源码的分析。

三、线程池使用

1. 常用线程池

Executor提供了几个创建常用线程池的方法:

  • newCachedThreadPool(),无边界线程池,可以按需要创建线程并缓存,也会重用空闲的线程,可以自动回收线程(60s),对有大量简短异步任务可以提升性能;
  • newFixedThreadPool(int),固定大小线程池,线程数量固定,当线程数到达上限后,提交的任务会加入无限队列中,如果线程因为异常中断,则有新的线程替代;
  • newSingleThreadExecutor() ,只有一个后台线程的池,后续任务会加入一个无限队列,当这个线程意外中断时,会有新的线程替代;
  • newScheduleThreadPool(),可以调度执行时间的线程池,可以指定任务在一定时间延迟后执行,或者定期执行。

2. 合理配置线程池

这里给出一个AsynTask中配置的线程池作为参考。

private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// We want at least 2 threads and at most 4 threads in the core pool,
// preferring to have 1 less than the CPU count to avoid saturating
// the CPU with background work
private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
private static final int KEEP_ALIVE_SECONDS = 30;

private static final ThreadFactory sThreadFactory = new ThreadFactory() {
    private final AtomicInteger mCount = new AtomicInteger(1);

    public Thread newThread(Runnable r) {
        return new Thread(r, "MyThread #" + mCount.getAndIncrement());
    }
};

private static final BlockingQueue<Runnable> sPoolWorkQueue =
        new LinkedBlockingQueue<Runnable>(128);

/**
 * An {@link Executor} that can be used to execute tasks in parallel.
 */
public static final Executor THREAD_POOL_EXECUTOR;

static {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
            sPoolWorkQueue, sThreadFactory);
    threadPoolExecutor.allowCoreThreadTimeOut(true);
    THREAD_POOL_EXECUTOR = threadPoolExecutor;
}

到这里关于线程池使用与代码分析就算结束了,如有不正之处请提出,会改正。



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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值