浅谈线程池

类关系图

基本属性:COUNT_BITS、CAPACITY

private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

示意图:

线程池状态

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://mp-new.csdn.net/mp_blog/creation/editor/117792529

ctl

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

private static int ctlOf(int rs, int wc) { return rs | wc; }

由此可见,ctl初始值如下:

再来分析下 runStateOf

//获取状态
private static int runStateOf(int c)     { return c & ~CAPACITY; }

再来分析下 workerCountOf

//获取工作线程数
private static int workerCountOf(int c)  { return c & CAPACITY; }

最大值为 2^29 - 1

线程池的其他关键属性

//任务队列
private final BlockingQueue<Runnable> workQueue;
//操作线程池锁
private final ReentrantLock mainLock = new ReentrantLock();
//工作线程
private final HashSet<Worker> workers = new HashSet<Worker>();
//记录整个线程池生命周期中,创建工作线程的最大值
private int largestPoolSize;
//记录整个线程池生命周期中,完成的任务
private long completedTaskCount;
//拒绝策略
private volatile RejectedExecutionHandler handler;
//允许线程存活时间
private volatile long keepAliveTime;
//允许核心线程超时
private volatile boolean allowCoreThreadTimeOut;
//核心线程数
private volatile int corePoolSize;
//允许最大创建的线程数
private volatile int maximumPoolSize;

提交任务方式

1.提交Callable任务

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

2.提交Runnable任务,带返回值

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

3.提交Runnable任务,不带返回值

public Future<?> submit(Runnable task) {
    if (task == null) throw new NullPointerException();
    RunnableFuture<Void> ftask = newTaskFor(task, null);
    execute(ftask);
    return ftask;
}

由此可见,通过submit提交的任务,最终都会包装为FutureTask,有关FutureTask的分析,可以阅读另外一篇文章。

https://mp-new.csdn.net/mp_blog/creation/editor/117748379

核心方法execute(ftask)

假设核心线程数大小为5,允许创建的最大线程数为10,任务队列大小为8,一次提交了30个任务,执行过程大致如下:

先创建5个核心线程执行任务,再将8个任务放到任务队列中,再创建5个非核心线程执行任务,剩下的12个任务,就会根据拒绝策略,做相应处理,如:抛异常。

备注:例举具体数量,只为更直观理解线程池执行任务的原理,真实情况,并非如此,无需较真。

示意图:

java.util.concurrent.ThreadPoolExecutor#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();
    }
    //超过核心线程,则将任务添加到workQueue中
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        //再次校验状态,非RUNNING,则移除节点,并执行拒绝策略
        if (! isRunning(recheck) && remove(command))
            reject(command);
        //线程数量为0,则开启一个非核心线程
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    //workQueue满了,则尝试启动非核心线程(maximumPoolSize),失败则执行拒绝策略
    //若线程池非RUNNING状态,也会进入此逻辑
    else if (!addWorker(command, false))
        reject(command);
}

由此可见,执行任务优先顺序为:1.核心线程>2.任务队列>3.非核心线程>4.拒绝策略

现在分析addWorker方法

java.util.concurrent.ThreadPoolExecutor#addWorker

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;
            //workCount++,成功则跳出整个循环
            if (compareAndIncrementWorkerCount(c))
                break retry;
            c = ctl.get();  // Re-read ctl
            //线程池状态变更而导致失败,重新走第一重for循环
            if (runStateOf(c) != rs)
                continue retry;
            //其他线程修改workCount数量而导致cas失败,则重新走第二重for循环
        }
    }
    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
        w = new Worker(firstTask);//创建worker节点
        final Thread t = w.thread;
        if (t != null) {
            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
                int rs = runStateOf(ctl.get());
                if (rs < SHUTDOWN ||
                    (rs == SHUTDOWN && firstTask == null)) {
                    if (t.isAlive()) // 检查线程状态
                        throw new IllegalThreadStateException();
                    workers.add(w);//添加work节点
                    int s = workers.size();
                    //更新最大线程数
                    if (s > largestPoolSize)
                        largestPoolSize = s;
                    workerAdded = true;
                }
            } finally {
                mainLock.unlock();
            }
            if (workerAdded) {
                //启动worker
                t.start();
                workerStarted = true;
            }
        }
    } finally {
        if (! workerStarted)
            addWorkerFailed(w);
    }
    return workerStarted;
}

代码块分析

根据线程池状态可知,

i).若线程池为 RUNNING状态,则case 1就不满足,直接结束if判断,走后续逻辑;

i).若为 STOP 、TIDYING 、TERMINATED状态,则case 2不满足,直接结束if判断,并返回false,addWork失败;

因此,关键分析状态为shutdown的情况。

ii).当线程池处于SHUTDOWN时,若firstTask不为空,case 3不满足,结束if判断,并返回false,addWork失败。由此可见,SHUTDOWN状态不会再接收新任务

ii).当线程池处于SHUTDOWN,且firstTask为null时

 a)若workQueue为空,即没有要执行的任务,没必要再执行addWorker逻辑;

 b)若workQueue不为空,说明还有未执行完的任务,此时可以继续addWorker.可见SHUTDOWN状态时,可以继续执行已添加的任务

现在分析run方法

java.util.concurrent.ThreadPoolExecutor.Worker#run

public void run() {
    runWorker(this);
}

分析java.util.concurrent.ThreadPoolExecutor#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 {
        //从workQueue获取任务并执行
        while (task != null || (task = getTask()) != null) {
            w.lock();
            if ((runStateAtLeast(ctl.get(), STOP) ||
                 (Thread.interrupted() &&
                  runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            try {
                //空方法
                beforeExecute(wt, task);
                Throwable thrown = null;
                try {
                    //手动执行run()方法 非线程start()调用
                    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);
    }
}

现在分析getTask方法

java.util.concurrent.ThreadPoolExecutor#getTask

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

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

        // 线程池状态>=STOP且workQueue中没有要执行的任务,则直接回收当前线程
        if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
            decrementWorkerCount();
            return null;
        }


        int wc = workerCountOf(c);
        //非核心线程 或 核心线程允许超时,没取到任务,就会回收该线程
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

        if ((wc > maximumPoolSize || (timed && timedOut))
            && (wc > 1 || workQueue.isEmpty())) {
            if (compareAndDecrementWorkerCount(c))
                return null;
            continue;
        }


        try {
            //poll方法不会park,或只park指定时间,超时后仍未取到元素,直接返回null
            //take会一直park,直到put或offer操作添加元素后,才会唤醒线程
            Runnable r = timed ?
                workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                workQueue.take();
            if (r != null)
                return r;
            //超时未取到元素
            timedOut = true;
        } catch (InterruptedException retry) {
            timedOut = false;
        }
    }
}

由上述分析可知,线程池执行任务的大致流程为:

1.客户端提交任务。

2.创建核心线程执行任务;若任务量超过核心线程数,则将超出部分添加到workQueue,若workQueue超出容量,则创建非核心线程执行任务。

3.工作线程执行完当前任务后,从workQueue获取任务并执行。

4.当workQueue中的所有任务全部执行完毕后,回收非核心线程。核心线程则因为getTask方法中的take方法,而被park阻塞,等待新任务提交后才会unpark被唤醒。若允许核心线程超时,则核心线程也会被回收。

主体流程基本分析完毕,现在开始分析shutdown等相关方法。

java.util.concurrent.ThreadPoolExecutor#shutdown

public void shutdown() {
	//获取线程池操作锁
	final ReentrantLock mainLock = this.mainLock;
	mainLock.lock();
	try {
		//校验线程权限
		checkShutdownAccess();
		//将状态置为 SHUTDOWN
		advanceRunState(SHUTDOWN);
		//中断 空闲工作线程
		interruptIdleWorkers();
		onShutdown();//空方法
	} finally {
		//是否线程池操作锁
		mainLock.unlock();
	}
	//尝试终止线程池
	tryTerminate();
}

现在分析 interruptIdleWorkers,从方法名可得知,是中断空闲线程

java.util.concurrent.ThreadPoolExecutor#interruptIdleWorkers()

private void interruptIdleWorkers(boolean onlyOne) {
	//获取线程池锁
	final ReentrantLock mainLock = this.mainLock;
	mainLock.lock();
	try {
		for (Worker w : workers) {
			Thread t = w.thread;
			//获取工作线程锁
			if (!t.isInterrupted() && w.tryLock()) {
				try {
					//中断
					t.interrupt();
				} catch (SecurityException ignore) {
				} finally {
					w.unlock();
				}
			}
			if (onlyOne)
				break;
		}
	} finally {
		mainLock.unlock();
	}
}

空闲线程是如何体现出来的呢?

正是通过w.tryLock(),体现的,中断前,先要获取worker锁,若该线程处于运行状态,获取锁是不可能成功的。因此,只有当worker线程处于空闲状态时,才可能被shutdown。

现在再分析下shutdownNow方法

public List<Runnable> shutdownNow() {
	List<Runnable> tasks;
	//获取线程池操作锁
	final ReentrantLock mainLock = this.mainLock;
	mainLock.lock();
	try {
		checkShutdownAccess();
		//将线程池状态设置为 STOP
		advanceRunState(STOP);
		//中断线程
		interruptWorkers();
		//清空workQueue
		tasks = drainQueue();
	} finally {
		mainLock.unlock();
	}
	//尝试终止线程池
	tryTerminate();
	return tasks;
}

现在分析interruptWorkers

java.util.concurrent.ThreadPoolExecutor#interruptWorkers

private void interruptWorkers() {
	//获取线程池操作锁
	final ReentrantLock mainLock = this.mainLock;
	mainLock.lock();
	try {
		//无需获取worker锁,直接中断
		for (Worker w : workers)
			w.interruptIfStarted();
	} finally {
		mainLock.unlock();
	}
}

通过对比,可发现shutdown 于shutdownNow的区别

shutdownshutdownNow
状态SHUTDOWNSTOP
中断worker线程对象空闲状态的线程所有state>0的线程
任务队列workQueue不再接收新任务,存量任务可继续执行清空workQueue

接下来,再分析下worker线程的AQS状态问题

Worker类继承结构如下:

一般AQS的state状态,初始值都为0,可Worker类,初始化时,状态为-1,这肯定有原因的。

Worker线程start后,调用runWorker方法,通过w.unlock(),将状态设置为0,并且注释说明为,允许中断,由此可见,线程在创建到runWorker这段时间内,是不能被中断的。

不能被中断是如何实现的呢?接着往下分析,通过shutdown方法中断线程时,需先获取锁,trylock时,状态必须为0,由此可见,状态未置为0时,是不能中断的。

如下图:shutdownNow也只中断state>0的线程

从这方法,也能分析出,worker锁,是不允许重入的,原因如下:

一旦获取锁成功,state状态就改为1,想再次获取锁时,cas操作必定失败,因此不支持重入。unused这个变量命名,就初露端倪了。而且源码中也确实没有重入的场景。

对比ReentrantLock就更清楚了

ReentrantLock是允许重入的,具体就体现在,更改state时,若当前线程已获取了锁,直接修改获取锁的次数就OK了。

对比也能分析出,Worker获取锁,是非公平模式。至于原因,其实也很简单,从源码分析,可得知,每个Worker都是new出来的,所以worker之间的锁都是相互独立的,worker主要用于执行task,每个worker一次只会执行一个task,由此得知,worker锁,基本是不存在竞争的。何时才会产生竞争呢? 那就是调用shutdown、shutdownNow等方法时,线程Thread1执行shutdown需要获取worker锁,worker线程Thread2执行task任务需要获取worker锁,因此竞争就产生了。从这分析能得出,worker锁基本无竞争,或者竞争很少,因此worker锁中的CLH同步队列基本处于null状态,对于这样的情况,直接尝试获取锁就OK了,没必要用公平锁。

调用链&分析如下:

java.util.concurrent.ThreadPoolExecutor#runWorker
 ->w.lock();
   ->java.util.concurrent.ThreadPoolExecutor.Worker#lock
     ->java.util.concurrent.locks.AbstractQueuedSynchronizer#acquire
       ->java.util.concurrent.ThreadPoolExecutor.Worker#tryAcquire

protected boolean tryAcquire(int unused) {
    //直接尝试修改&占用锁,不会判断是否有其他线程
	if (compareAndSetState(0, 1)) {
		setExclusiveOwnerThread(Thread.currentThread());
		return true;
	}
	return false;
}

最后再分析下线程池中的锁资源。

线程池中,涉及的锁资源主要有如下几种

1.线程池操作全局锁 ReentrantLock mainLock = new ReentrantLock()。

示意图中,用ThreadPool.mainLock表示;

2.线程池任务队列 BlockingQueue<Runnable> workQueue,获取到workQueue的锁资源才能对队列做相关操作,以ArrayBlockingQueue为例,定义了 ReentrantLock lock。

示意图中,用workQueue.lock表示;

3.Worker锁,Worker继承至AQS,自带CLH同步阻塞队列,Worker的run操作,interrupt操作,需要tryLock成功,才能进行,每个Worker都是new出来的,因此有Worker之间的锁是相互独立的。示意图中,用Worker.lock表示;

从示意图分析,基本可以得出如下锁资源竞争的场景:

1.多个用户端执行submit操作,此时需要竞争到ThreadPool.mainLock才能操作成功,竞争失败的,在ThreadPool.mainLock对应的同步队列中等待,直到竞争成功的线程释放锁后,再唤醒一个线程继续争抢资源。

2.addWorker成功后,开始执行firstTask,执行任务无需获取ThreadPool.mainLock,只要Worker.lock即可。每个Worker都有各自独立的Worker.lock,因此相安无事。

3.firstTask执行完毕后,需要从workQueue中获取task,因此需要获取workQueue.lock才能取得task,竞争锁失败的,在workQueue.lock对应的同步队列中等待。倘若此时workQueue为空,核心队列就会被park,并在workQueue的条件队列中等待,有新的task提交时,才会被unpark唤醒,并转移到同步队列中。非核心队列,或者允许核心队列超时,该Worker就直接game over了。

4.用户端执行shutdown操作,尝试中断workers中的工作线程,因此需要获取ThreadPool.mainLock,shutdown只会中断空闲线程,而且需要获取Worker.lock才能操作。

5.用户端执行shutdownNow操作,尝试中断workers中的工作线程,因此需要获取ThreadPool.mainLock,shutdownNow会清空workQueue,因此需要获取workQueue.lock。

-------------------------------------------------------华丽的分割线------------------------------------------------------

现在分析下AbstractExecutorService的方法

java.util.concurrent.AbstractExecutorService#invokeAny

public <T> T invokeAny(Collection<? extends Callable<T>> tasks)
	throws InterruptedException, ExecutionException {
	try {
		return doInvokeAny(tasks, false, 0);
	} catch (TimeoutException cannotHappen) {
		assert false;
		return null;
	}
}

java.util.concurrent.AbstractExecutorService#doInvokeAny

private <T> T doInvokeAny(Collection<? extends Callable<T>> tasks,
						  boolean timed, long nanos)
	throws InterruptedException, ExecutionException, TimeoutException {
	//任务不能为空
	if (tasks == null)
		throw new NullPointerException();
	int ntasks = tasks.size();
	//至少要有一个任务
	if (ntasks == 0)
		throw new IllegalArgumentException();
	ArrayList<Future<T>> futures = new ArrayList<Future<T>>(ntasks);
	ExecutorCompletionService<T> ecs =
		new ExecutorCompletionService<T>(this);

	try {
		ExecutionException ee = null;
		//若设置超时,则计算超时时间
		final long deadline = timed ? System.nanoTime() + nanos : 0L;
		Iterator<? extends Callable<T>> it = tasks.iterator();

		//提交执行第一个任务
		futures.add(ecs.submit(it.next()));
		--ntasks;
		int active = 1;

		for (;;) {
			//获取执行完毕的任务
			Future<T> f = ecs.poll();
			if (f == null) {
				//还有未执行的任务,则继续提交执行
				if (ntasks > 0) {
					--ntasks;
					futures.add(ecs.submit(it.next()));
					++active;
				}
				//已执行完所有任务,但全部失败,退出循环
				else if (active == 0)
					break;
				//设置超时,则等待指定时间
				else if (timed) {
					f = ecs.poll(nanos, TimeUnit.NANOSECONDS);
					if (f == null)
						throw new TimeoutException();
					nanos = deadline - System.nanoTime();
				}
				//等待上一个任务执行完毕
				else
					f = ecs.take();
			}
			if (f != null) {
				--active;
				try {
					//尝试获取返回值
					return f.get();
				} catch (ExecutionException eex) {
					ee = eex;
				} catch (RuntimeException rex) {
					ee = new ExecutionException(rex);
				}
			}
		}

		if (ee == null)
			ee = new ExecutionException();
		throw ee;
	} finally {
		//取消其他执行中的任务
		for (int i = 0, size = futures.size(); i < size; i++)
			futures.get(i).cancel(true);
	}
}

看到这,肯定是一头雾水,没关系,先继续往下分析

java.util.concurrent.ExecutorCompletionService#submit

public Future<V> submit(Callable<V> task) {
	if (task == null) throw new NullPointerException();
	RunnableFuture<V> f = newTaskFor(task);
	executor.execute(new QueueingFuture(f));
	return f;
}

可见,submit方法,主要做两件事,new QueueingFuture(f),executor.execute(xxx)

先分析 new QueueingFuture

//FutureTask的扩展类,在任务执行完毕后,将执行的任务添加到队列中
private class QueueingFuture extends FutureTask<Void> {
	QueueingFuture(RunnableFuture<V> task) {
		super(task, null);
		this.task = task;
	}
    //futureTask执行完毕后,才会往队列里添加元素
	protected void done() { completionQueue.add(task); }
	private final Future<V> task;
}

分析execute前,得先简单分析下ExecutorCompletionService

//任务执行器
private final Executor executor;
//阻塞队列,存放QueueingFuture已执行完毕的task
private final BlockingQueue<Future<V>> completionQueue;

//构造方法
public ExecutorCompletionService(Executor executor) {
	if (executor == null)
		throw new NullPointerException();
	this.executor = executor;
	this.aes = (executor instanceof AbstractExecutorService) ?
		(AbstractExecutorService) executor : null;
    //初始化队列
	this.completionQueue = new LinkedBlockingQueue<Future<V>>();
}

ExecutorCompletionService是如何被创建的呢?往回分析下

在doInvokeAny中,创建了ExecutorCompletionService,而且传入的参数为 this,由此可见,

java.util.concurrent.ExecutorCompletionService#submit方法中,executor.execute(xxx),最终调用的就是AbstractExecutorService的子类(如:ThreadPoolExecutor)的execute(Runnable task)方法。现在我们来梳理下调用链。

分析调用链得出整个执行的关键点,就在于completionQueue,这个队列承上启下,承前启后。现在再回过头来分析doInvokeAny。

从调用链分析可得出executor.execute(xxx)是调用线程池的Worker线程来执行task,因此客户端执行doInvokeAny的线程Thread1和Worker执行FutureTask任务的线程Thread2,是异步的。而且Thread2只有执行完毕后,才会调用done()方法往completionQueue中添加元素,由此可见,虽然已提交并执行了一个任务,但完全有可能因为任务并未执行完毕,而导致ecs.poll()返回null。明白这点,再来分析这些if / else 逻辑,就容易理解了。

假设 tasks.size()=10;

客户端执行 doInvokeAny的线程 Thread1;

Worker执行 FutureTask任务的线程 Thread2;

case A:取到执行完毕的任务为null ;

case B:取到执行完毕的任务不为null;

说明如下:

i)Thread1 从 completionQueue获取执行完毕的task时,Thread2已执行完毕,而且正常返回,那么此时必定走 case B逻辑,通过f.get()获取值,并正常返回。此时

active=0,ntasks=9,futures有1个元素

i)Thread1 从 completionQueue获取执行完毕的task时,Thread2还未执行完毕,此时ecs.poll必定为null,因此走case A逻辑,此时由于tasks>0,因此走case A1逻辑,继续提交并执行任务直到有任务执行完毕。若提交到第3个任务时,Thread2执行完毕了,此时ecs.poll不为空,必定走case B逻辑,并通过f.get()返回。

若Thread2执行task发生了异常,f.get()时,会走异常代码块,因此只是记录异常而不是返回退出。由于第一次ecs.poll操作时,已经取走了元素,因此继续循环判断时,ecs.poll必定为空,此时tasks>0,走case A1逻辑,继续并执行任务。由此可见,若任务执行失败时,会接着继续执行下一个任务,直到成功或者所有任务执行完毕。

i)倘若Thread1将所有任务都提交执行了,Thread2还未执行完毕,此时

active=10,ntasks=0,futures有10个元素

case A1,case A2,case A3都不满足,因此会走case A4逻辑,通过ecs.take,将Thread1阻塞在completionQueue的notEmpty条件队列中,直到有任务执行完毕,往completionQueue中添加元素时,才会将Thread1从条件队列移到同步队列中并唤醒Thread1。

i)若设置了超时,就走case A3逻辑,将Thread1阻塞指定时长,若指定时长后,还未取到元素,只直接抛出异常。

最后再来分析下finally代码块。

finally {
	//取消其他执行中的任务
	for (int i = 0, size = futures.size(); i < size; i++)
		futures.get(i).cancel(true);
}

从上面分析得出,整个过程可能提交了多个任务,当有一个执行完毕时,需要取消futures中的其他任务。具体就是调用FutureTask的cancel方法。

至此,invokeAny方法分析完毕。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值