ThreadPoolExecutor源码分析
来源:互联网 发布:mac 你没有权限文件夹 编辑:程序博客网 时间:2024/05/21 19:25
一、线程和任务的概念
首先区分概念,任务和线程。可以简单理解为任务为Runnable,线程为Thread。ThreadPoolExecutor内部维持的是线程池,因为创建线程比较耗时耗资源。而内部维护任务使用的是BlockingQueue。
二、ThreadPoolExecutor源码
2.1 ctl变量
ThreadPoolExecutor中有一个ctl的变量,声明如下:
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
ctl是AtomicInteger的,所以是线程安全的。ctl维护两个概念上的参数:workCount和runState。workCount表示有效的线程数量,runState表示线程池的运行状态。运行状态只要有五个,分别是RUNNING、SHUTDOWN、STOP、TIDYING和TERMINATED。AtomicInteger是一个32位的整数,为了将状态和数量放在一起,所以高3位用于表示表示状态,低29位表示数量。下面是状态和一些参数定义:
private static final int COUNT_BITS = Integer.SIZE - 3; private static final int CAPACITY = (1 << COUNT_BITS) - 1; // 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;
下面介绍各个线程池各个状态的含义:
RUNNING
接受新任务并且处理已经进入队列的任务
SHUTDOWN
不接受新任务,但是处理已经进入队列的任务
STOP
不接受新任务,不处理已经进入队列的任务,并且中断正在执行的任务
TIDYING
所有任务执行完成,workerCount为0。线程转到了状态TIDYING会执行terminated()钩子方法
TERMINATED
terminated()已经执行完成
状态之间可以相互转换
RUNNING -> SHUTDOWN
调用了shutdown()方法
(RUNNING 或 SHUTDOWN) -> STOP
调用了shutdownNow()
SHUTDOWN -> TIDYING
当队列和线程池为空
STOP -> TIDYING
当线程池为空
TIDYING -> TERMINATED
当terminated()钩子方法执行完成
为了从clt中获取各部分的值,提供了如下方法:
private static int runStateOf(int c) { return c & ~CAPACITY; } private static int workerCountOf(int c) { return c & CAPACITY; } private static int ctlOf(int rs, int wc) { return rs | wc; }
2.2 ThreadPoolExecutor构造方法
ThreadPoolExecutor有几个构造方法,构造方法中有几个参数,分别是corePoolSize、maximunPoolSize、keepAliveTime、unit、workQueue、threadFactory和handler。下面分别介绍这个几个参数:
corePoolSize
核心线程的数量。默认是没有超时的,也就是说就算线程闲置,也不会被处理。但是如果设置了allowCoreTimeOut为true,那么当核心线程闲置时,会被回收。
maximumPoolSize
最大线程池尺寸,被CAPACITY限制(2^29-1)。
keepAliveTime
闲置线程被回收的时间限制
unit
keepAliveTime的单位
workQueue
用于存放任务的队列
threadFactory
创建线程的工厂类
handler
当任务执行失败时,使用handler通知调用者
2.3 ThreadPoolExecutor流程
当创建好一个ThreadPoolExecutor对象后,调用execute(Runnable r)方法执行任务。下面是execute方法的实现:
public void execute(Runnable command) { //检查command不能为null if (command == null) throw new NullPointerException(); int c = ctl.get(); //如果当前线程小于corePoolSize if (workerCountOf(c) < corePoolSize) { //如果添加Worker线程成功,则返回 if (addWorker(command, true)) return; c = ctl.get(); } //如果当前正在运行阶段并且可以将线程入队 if (isRunning(c) && workQueue.offer(command)) { //再次检查ctl状态 int recheck = ctl.get(); //如果不在运行状态了,那么就从队列中移除任务 if (! isRunning(recheck) && remove(command)) reject(command); //如果在运行阶段,但是Worker数量为0,调用addWorker方法 else if (workerCountOf(recheck) == 0) addWorker(null, false); } //如果不能入队,且不能创建Worker,那么reject else if (!addWorker(command, false)) reject(command); }
从注释中可以看到execute方法分为三步:
(1)如果小于corePoolSize的线程正在运行,那么创建一个核心线程,并将任务作为核心线程的第一个任务
(2)如果一个任务可以加入到队列中,然后需要在此检查是否需要新建一个线程(因为可能存在一个线程在上次检查完之后被回收了)或者因为线程池停止了。所以需要再次检查状态,如果不在RUNNING状态并且能够成功移除任务的话,那么调用reject方法,否则就调用addWorker(null,false)方法。
(3)如果任务不能放入队列,会首先尝试添加一个新线程(非核心线程)。如果失败,则调用reject方法。
首先看reject方法,
/** * Invokes the rejected execution handler for the given command. * Package-protected for use by ScheduledThreadPoolExecutor. */ final void reject(Runnable command) { handler.rejectedExecution(command, this); }
reject方法会调用handler的rejectedExecution(command,this)方法。handler是RejectedExecutionHandler接口,默认实现是AbortPolicy,下面是AbortPolicy的实现:
/** * A handler for rejected tasks that throws a * {@code RejectedExecutionException}. */ public static class AbortPolicy implements RejectedExecutionHandler { /** * Creates an {@code AbortPolicy}. */ public AbortPolicy() { } /** * Always throws RejectedExecutionException. * * @param r the runnable task requested to be executed * @param e the executor attempting to execute this task * @throws RejectedExecutionException always */ public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + e.toString()); } }
可以看到rejectedExecution方法就是抛出一个异常。
execute方法中主要使用到addWorker方法,addWorker方法用于创建线程,并且通过core参数表示该线程是否是核心线程,如果返回true则表示创建成功,否则失败。addWorker的代码如下所示:
private boolean addWorker(Runnable firstTask, boolean core) { //外循环死循环 retry: for (;;) { int c = ctl.get(); //得到运行状态 int rs = runStateOf(c); // 检查状态 if (rs >= SHUTDOWN && ! (rs == SHUTDOWN && firstTask == null && ! workQueue.isEmpty())) return false; //状态符合跳球,死循环 for (;;) { int wc = workerCountOf(c); //如果worker数量超过了容量或者超过了corePoolSize或者maximumPoolSize,直接返回false if (wc >= CAPACITY || wc >= (core ? corePoolSize : maximumPoolSize)) return false; //如果成功将worker数+1,那么跳出外循环 if (compareAndIncrementWorkerCount(c)) break retry; //否则,重新读取ctl c = ctl.get(); // Re-read ctl if (runStateOf(c) != rs) continue retry; // else CAS failed due to workerCount change; retry inner loop } } //添加Worker boolean workerStarted = false; boolean workerAdded = false; Worker w = null; try { //以firstTask作为Worker的第一个任务创建Worker w = new Worker(firstTask); final Thread t = w.thread; if (t != null) { //对整个线程池加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //再次检查ctl状态 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; }
首先是两个死循环,外循环主要检查线程池运行状态,内循环检查workerCount之后再检查运行状态。下面简单分析一下哪些情况下才可以进入到内循环,否则就直接返回false了。下面是可以进入到内循环的情况:
(1)rs>=SHUTDOWN为false,即线程池处于RUNNING状态
(2)rs == SHUTDOWN && firstTask == null && ! workQueue.isEmpty()这个条件为true,也就意味着三个条件同时满足,即线程池状态为SHUTDOWN且firstTask为null且队列不为空,这种情况为处理队列中剩余任务。上面提到过当处于SHUTDOWN状态时,不接受新任务,但是会处理完队列里面的任务。如果firstTask不为null,那么就属于添加新任务;如果firstTask为null,并且队列为空,那么就不需要再处理了。
当进入到内循环后,会首先获取当前运行的线程数量。首先判断当前运行线程数量是否大于等于CAPACITYA(2^29-1),其次根据是否是核心线程与corePoolSize或者maximumPoolSize比较。所以线程的数量不会超过CAPACITY和maximumPoolSize的较小值。如果数量符合条件,那么就让ctl加1,然后跳出外部循环。如果线程数量达到了最大,那么回再判断当前状态,如果状态和之前的不一致了,那么继续外循环。下面是可以跳出外循环的情况:
(1)如果是核心线程,当前线程数量小于CAPACITY和corePoolSize中的较小值
(2)如果是非核心线程,当前线程数量小于CAPACITY和maximumPoolSize中的较小值。
一旦跳出外循环,表示可以创建创建线程,这里具体是Worker对象,Worker实现了Runnable接口并且继承AbstractQueueSynchronizer,内部维持一个Runnbale的队列。try块中主要就是创建Worker对象,然后将其保存到workers中,workers是一个HashSet,表示工作线程的集合。然后如果添加成功,则开启Worker所在的线程。如果开启线程失败,则调用addWorkerFailed方法,addWokerFailed用于回滚worker线程的创建。下面是addWorkerFailed的实现:
private void addWorkerFailed(Worker w) { //对整个线程成绩加锁 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { //移除Worker对象 if (w != null) workers.remove(w); //减小worker数量 decrementWorkerCount(); //检查termination状态 tryTerminate(); } finally { mainLock.unlock(); } }
从代码中可以看出,addWorkerFailed首先从workers集合中移除线程,然后将wokerCount减1,最后检查终结。下面是tryTerminate的实现,该方法用于检查是否有必要将线程池状态转移到TERMINATED。
final void tryTerminate() { for (;;) { int c = ctl.get(); if (isRunning(c) || runStateAtLeast(c, TIDYING) || (runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty())) return; if (workerCountOf(c) != 0) { // Eligible to terminate interruptIdleWorkers(ONLY_ONE); return; } final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try { if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) { try { terminated(); } finally { ctl.set(ctlOf(TERMINATED, 0)); termination.signalAll(); } return; } } finally { mainLock.unlock(); } // else retry on failed CAS } }
tryTerminate内部是一个死循环,首先判断状态,下面是跳出循环的情况:
(1)线程池处于RUNNING状态
(2)线程池状态处于TIDYING状态
(3)线程池状态处于SHUTDOWN状态并且队列不为空
如果不满足上述的情况,那么目前状态属于SHUTDOWN切队列为空,或者状态属于STOP,那么调用interruptIdleWorkers方法停止一个Worker线程,然后退出。
接下来如果没有退出循环的话,那么就首先将状态设置成TIDYING,然后调用terminated方法,最后设置状态为TERMINATED。terminated方法是个空实现,用于当线程池终结时处理一些事情。
下面看Worker的实现,
private final class Worker extends AbstractQueuedSynchronizer implements Runnable { private static final long serialVersionUID = 6138294804551838833L; /** Thread this worker is running in. Null if factory fails. */ final Thread thread; /** Initial task to run. Possibly null. */ Runnable firstTask; /** Per-thread task counter */ volatile long completedTasks; /** * Creates with given first task and thread from ThreadFactory. * @param firstTask the first task (null if none) */ Worker(Runnable firstTask) { setState(-1); // inhibit interrupts until runWorker this.firstTask = firstTask; this.thread = getThreadFactory().newThread(this); } /** Delegates main run loop to outer runWorker */ public void run() { runWorker(this); } // Lock methods // // The value 0 represents the unlocked state. // The value 1 represents the locked state. protected boolean isHeldExclusively() { return getState() != 0; } protected boolean tryAcquire(int unused) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } protected boolean tryRelease(int unused) { setExclusiveOwnerThread(null); setState(0); return true; } public void lock() { acquire(1); } public boolean tryLock() { return tryAcquire(1); } public void unlock() { release(1); } public boolean isLocked() { return isHeldExclusively(); } void interruptIfStarted() { Thread t; if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) { try { t.interrupt(); } catch (SecurityException ignore) { } } } }
Worker继承自AbstractQueuedSynchronizer并实现Runnbale接口。AbstractQueuedSynchronizer提供了一个实现阻塞锁和其他同步工具,比如信号量、事件等依赖于等待队列的框架。Woerker的构造方法中会使用threadFactory构造线程变量并持有,run方法调用了runWorker方法,将线程委托给主循环线程。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 { //当任务不为null时 while (task != null || (task = getTask()) != null) { //对Worker加锁 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 { //执行任务 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); } }
runWoker方法主要不断从队列中取得任务并执行。首先获得Worker所在的线程,在addWorker中获得Worker的Thread变量并调用start方法,所以Worker是运行在Worker的Thread中,而thread变量是通过threadFactory创建的。可以看到首先获取Worker的firstTask对象,该对象有可能为空,初始时对于核心线程不为空,但是对于非核心线程就为空,下面是一个循环,跳出循环的条件为task==null&&(task=getTask())==null,也就是说当没有任何任务的时候,就跳出循环了,跳出循环也就意味着Worker的run方法执行结束,也就意味着线程结束;否则会一直尝试着从队列中获取任务来执行,getTask会阻塞,一旦获取到任务,就对Worker加锁,然后判断状态,如果状态处于STOP状态及之上,就不处理任务了;否则处理任务,在处理任务之前,首先会调用beforeExecute,然后调用Runnbale方法的run方法,最后调用afterExecute,其中beforeExecute和afterExecute都是空实现,继承ThreadPoolExecutor时可以实现,在每个任务运行之前和之后做一些处理工作。一旦一个任务执行完毕后,将task置为null,然后继续尝试从队列中取出任务。下面看一下getTask方法的实现:
private Runnable getTask() { boolean timedOut = false; // Did the last poll() time out? for (;;) { int c = ctl.get(); int rs = runStateOf(c); // 必要时检查队列是否为空 if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) { decrementWorkerCount(); return null; } int wc = workerCountOf(c); // 是否允许线程超时 boolean timed = allowCoreThreadTimeOut || wc > corePoolSize; //如果worker数量大于maximumPoolSize或者允许超时 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; } } }
getTask从队列中取出任务;但是在以下几种情况下会返回null,上面说过如果返回null也就标识了runWorker中循环跳出,上面说过,runWorker中的循环跳出意味着Worker线程执行完毕会回收,所以调用了decrementWorkerCount将Worker数量减1。下面返回null的情况
(1)由于调用了setMaximumPoolSize导致Worker的数量超过maximumPoolSize
(2)线程池处于STOP状态,STOP状态不再处理队列中的任务
(3)线程池处于SHUTDOWN并且queue为空。SHUTDOWN状态仍然处理已经在队列中的任务,但是如果queue为空,自然就不再处理了
(4)Worker在等待队列时超时
getTask内部依然是一个死循环,首先依然是判断状态,如果状态是STOP及以上,那么返回null;如果状态是SHUTDOWN且队列为空,那么也返回null。这对应于情况2和3。
接下来是比较Worker的数量,首先获取Worker的数量以及是否需要超时标志,如果设置了allCoreThreadTimeOut为true,那么就意味着所以线程都得检验超时;而如果没有设置为true,那么只需要在Worker数量超过corePoolSize时检查超时。接下来是判断数量是否超过maximumPoolSize,如果超过了,则需要结束多余的Worker;如果超时了并且有时间限制,也需要停止线程。如果没有进入到if语句中,那么将会尝试从队列中获取任务,如果需要有时间限制,那么就调用workQueue的poll方法,如果没有则调用take方法,如果可以从队列中取到任务,那么就返回任务交由runWorker中去执行;但是如果返回失败,那么需要设置timeOut为true,这样在下一次进入循环时,会清除一个Worker。
上面是ThreadPoolExecutor调用execute方法提交任务后的执行流程,下面总结一下:
1. 当Worker数量小于corePoolSize时,新建核心Worker,并将任务作为firstTask参数传入,然后返回;由于runWorker方法中firstTask不为null,所以核心线程在第一次进入循环时会将firstTask执行完成后,再进入循环时getTask时会阻塞,因为此时队列里面任务为空
2. 如果Worker数量超过corePoolSize,那么会首先将任务加入队列;如果可以成功加入队列,那么就再判断是否还在运行状态,如果不在运行状态,那么就从队列中删除任务并且调用reject方法;否则如果因为Worker数量为0,那么就创建一个非核心线程处理队列中的任务。
3. 如果2中由于队列已满不能加入队列,那么就尝试着开启一个非核心线程,如果开启非核心线程失败了,那么就调用reject处理;否则就等待着非核心线程从队列中取数据。
addWorker方法中会两个死循环,外循环检查线程池状态是否还可以接受新任务;内循环根据是否是核心线程与corePoolSize或maximumPoolSize比较,如果数量符合则创建线程,否则添加失败。
三、常见线程池
创建线程池一般都通过Executors的工厂方法创建线程,一般有四种线程,分别是FixedSizeThreadPoolc、SingleThreadPool、CachedThreadPool和ScheduledThreadPool。
3.1 FixedSizeThreadPool
FixedSizeThreadPoolExecutor只有核心线程,没有非核心线程,并且核心线程的数量固定。下面是创建该线程池的方法:
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }
可以看到corePoolSize和maximumPoolSize均为nThreads参数,并且没有超时且队列是没有边界的。所以该线程池一旦开启,最多会有nThreads个线程,且线程一旦创建,就不会销毁。只要有任务提交,就会添加给核心线程或加入队列。
3.2 SingleThreadPool
SingleThreadPool创建一个Worker线程操作一个无边界的队列。如果使用该线程池,那么所有提交的任务将会按照顺序被执行。
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }
3.3 CachedThreadPool
CachedThreadPool只要需要新线程就会创建线程,如果之前创建的线程还可以复用,那么就会复用之前的线程。
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }
从构造方法中可以看出,corePoolSize为0,maximumPoolSize为Integer.MAX_VALUE,超时为60s且队列为SynchronousQueue。从前面的分析我们知道,线程池中线程的最大数量为CAPACITY,所以就算这边设置了Integer.MAX_VALUE,但是最大数量也只能达到2^29-1个线程。SynchronousQueue不会持有任务,一旦拥有任务就会将任务交给线程。所以说会不断创建线程,而如果线程没有销毁的话,就会从调用getTask尝试从队列中获取任务,如果长时间没有新任务,那么之前的线程会由于超时而销毁;而如果在这期间新加了任务,那么getTask就可以获取到任务,那么之前创建的线程也就可以得到复用。
3.4 ScheduledThreadPool
ScheduledThreadPool核心线程数量固定,非核心线程数量为Integer.MAX_VALUE,该线程池主要用于执行周期性的任务或在延时一段时间后执行任务。
public static ScheduledExecutorService newScheduledThreadPool( int corePoolSize, ThreadFactory threadFactory) { return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); }
四、总结
1. ThreadPoolExecutor构造器中各个参数的含义
在addWorker、getTask等方法中都需要使用到构造器中的参数,主要包括四部分,第一部分是线程数量,corePoolSize和maximumPoolSize;第二部分是超时,unit和keepAliveTime;第三部分是创建运行Worker的线程,即threadFactory;第四部分是RejectedExecutionHandler,即handler。
有些线程池会设置maximumPoolSize为Integer.MAX_VALUE,但是由于高三位需要作为线程池的状态,所以线程池中线程的最大数量为CAPACITY
2. ThreadPoolExecutor各个状态的含义以及状态转换
ThreadPoolExecutor的状态保存在ctl变量的高三位,具有五种状态,分别是RUNNING、SHUTDOWN、STOP、TIDYING和TERMITERNED。理解每个状态下线程池对任务和线程的操作,才能清楚在各个方法中为什么那么处理。
3. 执行流程
执行流程分为三步,
1. 如果当前线程数小于corePoolSize,那么创建线程
2. 如果当前线程数大于等于corePoolSize,那么将任务加入队列;如果成功加入,那么就等待线程的getTask获取到任务再去执行;
3. 如果第2步中加入队列失败,那么尝试开启线程。如果当前线程数小于maximumPoolSize,那么创建线程成功,如果大于等于maximumPoolSize,那么创建线程失败。
在上面为了讲解,区分出核心线程和非核心线程的区别,但是其实都一样,只不过是一个有初始的任务,一个firstTask为null,一旦当核心线程执行完初始的任务后,它就变得和非核心线程一样。如果设置了超时,那么并不会因为它是所谓的“核心线程”而不销毁,那么个时候所有线程都一样,一旦哪个线程阻塞在getTask那儿,就可能因为超时而销毁。
在整个执行流程中,各个方法中会有多个死循环,要清楚在哪些状态下会跳出那些死循环。
- ThreadPoolExecutor 源码分析
- ThreadPoolExecutor源码分析
- ThreadPoolExecutor源码分析
- [Java]ThreadPoolExecutor源码分析
- ThreadPoolExecutor源码分析
- JUC - ThreadPoolExecutor 源码分析
- ThreadPoolExecutor源码分析
- 源码分析-ThreadPoolExecutor
- ThreadPoolExecutor源码分析
- ThreadPoolExecutor源码分析
- ThreadPoolExecutor源码分析
- ThreadPoolExecutor的部分源码分析
- java基础--ThreadPoolExecutor源码分析
- Android之ThreadPoolExecutor源码分析
- ThreadPoolExecutor线程池源码分析
- ThreadPoolExecutor线程池源码分析
- JAVA线程池(ThreadPoolExecutor)源码分析
- JAVA线程池(ThreadPoolExecutor)源码分析
- Bellman-Ford算法——解决负权边
- 混淆规则
- eclipese下使用svn
- 常见的系统类+函数
- JSP内置对象详解
- ThreadPoolExecutor源码分析
- 内存泄漏全解析
- js基础知识
- Java实现文件压缩与解压
- Qt+Ogre 双屏显示器,在非主显示器界面上的文件夹中启动程序,程序崩溃
- 00003 不思议迷宫.0009.2.2:自动换装:界面模拟
- 保留两位小数
- 增量式修改检验和(IP, TCP, UDP)算法的研究和实现
- 付费的「小密圈」值不值得我们加入呢?