线程池的运行逻辑与你想象的不一样,它是池族中的异类

本文主要讲解关于线程池的运行逻辑与你想象的不一样,它是池族中的异类相关内容,让我们来一起学习下吧!

只要是 web 项目,程序都会直接或间接使用到线程池,它的使用是如此频繁,以至于像空气一样,大多数时候被我们无视了。但有时候,我们会相当然地认为线程池与其它对象池(如:数据库连接池)一样,要用的时候向池子索取,用完后归还给它即可。然后事实上,线程池独树一帜、鹤立鸡群,它与普通的对象池就是不同。本文本将先阐述这种差异,接着用最简单的代码实现一个线程池,最后再对 JDK 中与线程池相关的 Executor 体系做一个全面介绍。

线程池与普通资源池的差异

提到 pool 这个设计思想,第一反映是这样的:从一个资源容器中获取空闲的资源对象。如果容器中有空闲的,就直接从空闲资源中取出一个返回,如果容器中没有空闲资源,且容器空间未用尽,就新创建一个资源对象,然后再返回给调用方。这个容器就是资源池,它看起来就像这样:

线程池的运行逻辑与你想象的不一样,它是池族中的异类

图中的工人队伍里,有3人是空闲的,工头(资源池的管理者)可以任选两人来提供劳务服务。同时,队队伍尚未饱和,还可以容纳一名工人。如果雇主要求一次性提供4名劳工服务,则工头需要再招纳一名工人加入队伍,然后再向雇主提供服务。此时,这个团队(资源池)已达到饱和,不能再对外提供劳务服务了,除非某些工人完成了工作。

以上是一个典型资源池的基本特点,那么线程池是否也同样如此呢。至少第一感觉是没问题的,大概应该也是这样吧,毕竟拿从池中取出一个线程,再让它执行对应的代码,这听上去很科学嘛。等等,总感觉哪里不对呢,线程这东西能像普通方法调用那样,让我们在主程序里随意支配吗?没错,问题就在这里,线程一旦运行起来,就完全闭关锁国了,除了按照运行前约定好的方式进行数据通信外,再也不能去打扰它老人家了。因此,线程池有点像发动机,池中的各个线程就对应发动机的各个汽缸。整个发动机一旦启动(线程池激活),各个汽缸中的活塞便按照预定的设计,不停地来回运动,永远也不停止,直到燃油耗尽,或人为地关闭油门。在此期间,我们是不能控制单个汽缸的活动方向的。就如同我们不能控制正在运行的线程,让其停止正在执行的代码,转而去执行其它代码一样(利用 Thread.interrpt() 方法也达不到此目的,而 Thread.stop() 更是直接终止了线程)①。

线程池的运行逻辑与你想象的不一样,它是池族中的异类

既然不能直接给线程池里的单个线程明确指派任务,那线程池的意义何在呢?意义就在于,虽然不能一对一精确指派任务,但可以给整个线程池提交任务,至于这些任务由池中的哪个线程来执行,则是不可控的。此时,可以把线程池看作是生产流水线上的单个工序。这里以给「老干妈香辣酱」的玻璃瓶加盖子为例,给瓶子加盖就是要执行的任务,最初该工序上只设置了一个机械臂,加盖子也顺序操作的。但单个机械臂忙不过来,后来又加了一个机械臂,这样效率就提高了。瓶子被加盖的顺序也是不确定的,但最终所有瓶子都会被加盖。

手动编写一个简易的线程池

如上小节所述,线程池与其它池类组件不一样,调用方不可能直接从池中取出一个线程,然后让它执行一段任务代码。因为线程一旦启动起来,就会在自己的频轨道内独立运行,不受外部控制。要让这些线程执行外部提交的任务,需要提供一个数据通道,将任务打包成一个数据结构传递过去。而这些运行起来的线程,他们都执行一个相同的循环操作:读取任务 → 执行任务 → 读取任务 → ...... ②

      ┌──────────┐    ┌──────────────┐
  ┌─→ │Take Task │ -→ │ Execute Task │ ─┐
  │   └──────────┘    └──────────────┘  │
  └─────────────────────────────────────┘

这个读取任务的数据通道就是队列,池中的所有线程都不断地执行 ② 处的循环逻辑,这便是线程池运行的基本原理。

相对于线程池这个叫法,实际上「执行器 Executor」这个术语在实践中使用得要更多些。因为在 jdk 的 java.util.concurrent 包下,有一个 Executor 接口,它只有一个方法:

public interface Executor {
    void execute(Runnable command);
}

这便是执行器接口,顾名思义,它接受一个 Runnable 对象,并能够执行它。至于如何执行,交由具体的实现类负责,目前至少有以下四种执行方式 ③

  • 在当前线程中同步执行
  • 总是新开线程来异步执行
  • 只使用一个线程来异步串行执行
  • 使用多个线程来并发执行

本小节将以一个简易的线程池方式来实现 Executor。

编写只有一个线程的线程池

这是线程池的最简形式,实现代码也非常简单,如下所示

public class SingleThreadPoolExecutor implements Executor {
    // 任务队列
    private final Queue<Runnable> tasks = new LinkedBlockingDeque<>();

    // 直接将任务添加到队列中
    @Override
    public void execute(Runnable task) {
        tasks.offer(task);
    }

    public SingleThreadPoolExecutor() {
        // 在构造函数中,直接创建一个线程,作为为线程池的唯一任务执行线程
        // 它将在被创建后立即执行,执行逻辑为:
        // 1. 从队列中获取任务
        // 2. 如果获取到任务,则执行它,执行完后,返回第1步
        // 3. 如果未获取到任务,则简短休息,继续第1步
        Thread taskRunner = new Thread(() -> {
            Runnable task;
            while (true) {
                task = tasks.poll();
                if (task != null) {
                    task.run();
                    continue;
                }
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        });
        taskRunner.start();
    }
}

上述的单线程执行器实现中,执行任务的线程是永远不会停止的,获取到任务时,就执行它,没有获取到,就一直不断的获取。下面是这个执行器的测试代码:

public class SingleThreadPoolTest {
    public static void main(String[] args) throws InterruptedException {
        SingleThreadPoolExecutorstp stp= new SingleThreadPoolExecutor();
        // 连续添加 5 个任务
        for (int i = 1; i <= 5; i++) {
            stp.execute(new SpeakNameTask("Coding Change The World " + i));
        }
        System.out.println("主线程已结束");
    }

    // 一个模拟的任务:简单地输出名称
    static class SpeakNameTask implements Runnable {
        private String name;

        public SpeakNameTask(String name) {
            this.name = name;
        }

        @Override
        public void run() {
            Random random = new Random();
            int milliseconds = 500 + random.nextInt(1000);
            try {
                TimeUnit.MILLISECONDS.sleep(milliseconds);
                System.out.println("["+Thread.currentThread().getName()+"]: I believe " + name);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

下面是输出结果:

主线程已结束
[Thread-0]: I believe Coding Change The World 1
[Thread-0]: I believe Coding Change The World 2
[Thread-0]: I believe Coding Change The World 3
[Thread-0]: I believe Coding Change The World 4
[Thread-0]: I believe Coding Change The World 5

可以看到:作为测试程序的主线程,已经先执行结束了,而线程池还在顺序地执行主线程添加的任务。并且线程池在执行完所有任务后,并没有退出,jvm 进程会一直存在。

改进为拥有多个线程的线程池

多线程版本的线程池执任务执行器,只是在单线程版本上,增加了执行线程的数量,其它的变化不是很大。但为了更好的组织代码,需要将任务执行线程的逻辑单独抽取出来。另外,为了模拟得更像一个池,本示例代码还增加了以下特性

  • 支持核心线程数功能
    核心线程数在执行器创建时,一起创建,并永不结束

  • 支持最大线程数功能
    当核心线程执行任务效率变慢时,增加执行线程

  • 支持空闲线程移除功能
    当非核心线程空闲时长超过限定值时,结束该线程,并从池中移除

主要代码如下:

MultiThreadPoolExecutor.java (点击查看代码)

public class MultiThreadPoolExecutor implements Executor {

    // 线程池
    private final Set<TaskRunner> runnerPool = new HashSet<>();

    // 任务队列
    private final Queue<Runnable> tasks = new LinkedBlockingDeque<>();

    // 单个线程最大空闲毫秒数
    private int maxIdleMilliSeconds = 3000;

    // 核心线程数
    private int coreThreadCount = 1;

    // 最大线程数
    private int maxThreadCount = 3;

    public MultiThreadPoolExecutor() {
        // 初始化核心线程
        for (int i = 0; i < coreThreadCount; i++) {
            addRunner(true);
        }
    }

    private void addRunner(boolean isCoreRunner) {
        TaskRunner runner = new TaskRunner(isCoreRunner);
        runnerPool.add(runner);
        runner.start();
    }

    @Override
    public void execute(Runnable task) {
        tasks.add(task);
        addRunnerIfRequired();
    }

    // 视情况增加线程数,这里简化为当任务数超过线程数的两倍时,就增加线程
    private void addRunnerIfRequired() {
        if (tasks.size() <= 2 * runnerPool.size()) {
            return;
        }
        // 未达到最大线程数时,可增加执行线程
        if (runnerPool.size() < maxThreadCount) {
            synchronized (this) {
                if (runnerPool.size() < maxThreadCount) {
                    addRunner(false);
                }
            }
        }
    }

    class TaskRunner extends Thread {
        // 是否为核心线程
        private final boolean coreRunner;

        // 已空闲的毫秒数
        private long idleMilliseconds = 0;

        TaskRunner(boolean coreRunner) {
            this.coreRunner = coreRunner;
        }

        @Override
        public void run() {
            Runnable task;
            while (true) {
                task = tasks.poll();
                if (task != null) {
                    task.run();
                    continue;
                }
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                    idleMilliseconds += 10;
                    if(coreRunner) {
                        continue;
                    }
                    if (idleMilliseconds > maxIdleMilliSeconds) {
                        // 超过最大空间时间,线程结束,并从池中移徐本线程
                        runnerPool.remove(this);
                        break;
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        }
    }
}

完整代码已上传至 thread-pool-sample

其实多线程版本的主要难点,是判定增加新线程来执行任务的算法,即如何确定当前需要添加新线程,而不是保持当前的线程数量来执行任务,以保证最高的效率。以这个粗糙的原始版本为基准,不断丰富细节和增强健壮性,就可以慢慢演进出 Jdk 中的 Executor 体系。

JDK 线程池任务执行器浅析

Executor 体系类结构

Executor 接口是任务执行器的顶级接口,它仅定义了一个方法,但并未限制如何执行传递过来的任务。正如第③处所述,「线程池执行」也只是多种方式中的一种,也是用得最多的一种。由于 Executor 接口定义的功能过于单一,于是在 JDK 的并发包下,又对它进行了扩展,这个扩展就是 ExecutorService,如下所示:

public interface ExecutorService extends Executor {
    Future<?> submit(Runnable task);
    <T> Future<T> submit(Callable<T> task);
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;
    <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit)
            throws InterruptedException;
    <T> T invokeAny(Collection<? extends Callable<T>> tasks)
        throws InterruptedException, ExecutionException;
    <T> T invokeAny(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit)
            throws InterruptedException, ExecutionException, TimeoutException;

    void shutdown();
    List<Runnable> shutdownNow();
    boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;

    boolean isShutdown();
    boolean isTerminated();
}

这些扩展方法共分为三组,分别是:任务提交类、状态控制类、状态检查类。从分类上可以看出,ExecutorService 增加了「提交任务」的概念(相对于 Executor 的「执行任务」)。另外,还有「关闭」操作,以及检测执行器当前的状态,这些都是 Exector 不具备的。下面这个分类列表更加清晰:

  • 任务提交

    方法 异步提交 批量提交 超时等待
    submit(Runnable task)
    submit(Callable task)
    invokeAll(Collection<? extends Callable> tasks)
    invokeAll(Collection<? extends Callable> tasks,long timeout, TimeUnit unit)
    invokeAll(Collection<? extends Callable> tasks)
    invokeAny(Collection<? extends Callable> tasks,long timeout, TimeUnit unit)
  • 状态控制

    • shutdown()
    • shutdownNow()
    • awaitTermination(long timeout, TimeUnit unit)
  • 状态检查

    • isShutdown()
    • isTerminated()

除了增加了新的方法外,还新增加了一种任务类型,即:java.util.concurrent.Callable,而 Executor 接口定义的任务接口是 java.lang.Runnable。二者的区别是,Callable#call() 方法有返回值,而后者没有。一般而言,任务提交给执行器后,通常都会异步执行。提交任务的线程是拿不到这个 call() 方法执行完毕后的返回值的,既然这样,那定义这个有返回值的方法还有什么意义呢?

为了拿到返回值,引入了 java.util.concurrent.Future 接口,它定义了获取单个异步任务执行结果的方法,不仅如此,它还定义了其它一些访问和控制单个任务的方法,见下表:

方法 解释
get() 阻塞调用线程,直到所关联的任务执行结束,拿到返回值,或任务执行结束(取消操作和发生异常均会导致结)
get(long timeout, TimeUnit unit) 同上,但会有一个最大等待时长,若超过该时长后,任务依然未执行结束,则结束等待,并抛出 TimeoutException
cancel(boolean mayInterruptIfRunning) 尝试取消关联的任务,只是尝试,遇到以下情况,均无法取消 · 任务已经取消 · 任务已完成 · 其它原因 通常任务一旦开始执行,就无法取消,除非是极其特定的任务,这类任务的代码本身会与外界通信,判断是否应该取消自己的执行。因此本方法提供了一个 mayInterruptIfRunning 参数,用来做这种信息传达,但也仅仅是一个信息传达,表达了期望已运行的任务能自我终止,但能否真的终止,取决于任务本身的代码逻辑
isCancelled() 检测关联的任务是否已「取消」
isDone() 检测关联的任务是否已「结束」,任务正常执行完毕、遭遇异常和被取消均视为任务已「结束」

? 特别说明

Future#cancel() 方法只是从执行角度上讲,取消了任务的执行。它没有 “回滚” 这种业务上的含义。对于接受 mayInterruptIfRunning 参数的任务,若要实现 “回滚”效果,需要任务自身代码来实现

Future 只是一个接口,要怎么来实现接口的这些功能呢,以 get() 方法为例,大致分为以下3步:

  1. 在 Future 实现类的内部持有它要访问和控制的 Callable 任务实例、执行该任务的线程以及任务执行结果。
  2. Future 实现类自己要实现 Runnable 接口, 并在 Runnable#run() 方法实现中,调用真实任务 Callable 的 run 方法并获取返回值,然后将返回值写入到 Future 实现类的「任务执行结果」字段中。这样一来,Executor 直接要执行的方法就从原始的 Callable 实例,变成了 Future 实例。
  3. 有了上面两步,get() 方法实现就简单了,一直获取「任务执行结果」这个字段的值就可以了。

下而是 get() 方法的简化版(非线程安全)实现样例:

public class MyFutureImpl<T> implements Future<T>, Runnable {
    // 是否运行结束了
    private volatile complete;

    // Callable 任务执行的结果
    private volatile T result;

    // 实际执行 Callable 任务的线程
    private volatile Thread runner;

    private Callable<T> task;

    public MyFutureImpl(Callable<T> task) {
        this.task = task;   // ⑴ 持有真实任务实例
    }

    @Override
    public void run() {
        this.runner = Thread.currentThread();  // ⚠️ ⑴ 持有实际执行此任务的线程
        T result = task.call();                // ⑵ 调用真实任务的 call 方法,并在实际执行线程中获得返回值
        this.complete = true;                  // ⚠️ Future 对象的状态设置为「完成」
        this.result = result;                  // ⚠️ ⑵ 将实际执行线程中获得的返回值,回写到 Future 实例的字段中
    }

    @Override
    public T get(long timeout, TimeUnit unit) {
        long remainsMillis = unit.toMillis(timeout);
        while( !complete ) {                   // ⚠️ ⑶ 检查任务是否执行完毕,未执行完毕则一直检查(更好的办法是阻塞自己)
            TimeUnit.MILLISECONDS.sleep(10);
            remainsMillis -= 10;
            if( remainsMillis <=0 ) {          // 超时检查
                throw new TimeoutException();
            }
        }
        return this.result;     // 任务已执行完毕,直接返回结果
    }
}

以上这个一点也不线程安全的 Future 实现类,由于去除了复杂的同步操作代码,核心逻辑反而更加清晰了。代码中有感叹号 ⚠️ 的地方,都是存在线程同步问题的。感兴趣的码友,在对这个基本的核心逻辑有了认知后,再去看 JDK 的源码就更加容易了(JDK 源码中,使用了 sun.misc.Unsafe 中的相关原子方法来处理并发问题)。

JDK 的并发包下,真实的 Future 实现类是 FutureTask, 它没有直接实现 Future, 因为根据实现步骤的第 2 步,实现类自身还需要实现 Runnable 接口, 因此,又增加了一个中间接口 RunnableFuture,该接口继承了 Runnable,而 FutureTask 直接实现的接口正是 RunnableFuture,如下图所示:线程池的运行逻辑与你想象的不一样,它是池族中的异类

ExecutorService 的直接实现类是 AbstractExecutorService,这是一个抽象类,最终的实现类是 ThreadPoolExecutor。至此终于是回到本文的主题了,即线程池任务执行器,不过 JDK 并发包在此基础上还提供了一个扩展: ScheduledExeuctorService,所谓 Scheduled(可调度的),即可以安排提交的任务在什么时候执行,也就是经常提到的定时任务。OK,至此我们可以看到整个 Executor 体系的类继承结构了,如下图所示:

线程池的运行逻辑与你想象的不一样,它是池族中的异类

Executor 核心实现类

从 Executor 的继承类图中可以看出,最终实现类只有 ThreadPoolExecutor 和 ScheduledThreadPoolExecutor, 但实际上,大部分逻辑都在 AbstractExecutorService 这个抽象类中实现了。这三个类各自实现了整个 Executor 体系中的一部分方法,为更清晰地呈现它们之间的分工,我整理了这个体系下各个接口方法与对应实现类间的关系表,如下所示:

Method AbstractExecutorService ThreadPoolExecutor ScheduledThreadPoolExecutor
Executor#execute(Runnable command)
ExecutorService#submit(Runnable task)
ExecutorService#submit(Callable task)
ExecutorService#invokeAll(Collection<? extends Callable> tasks)
ExecutorService#invokeAll(Collection tasks,long timeout, TimeUnit unit)
ExecutorService#invokeAny(Collection<? extends Callable> tasks)
ExecutorService#invokeAny(Collection tasks,long timeout, TimeUnit unit)
ExecutorService#shutdown()
ExecutorService#shutdownNow()
ExecutorService#awaitTermination(long timeout, TimeUnit unit)
ExecutorService#isShutdown()
ExecutorService#isTerminated()
ScheduledExecutorService#schedule(Runnable command,long delay, TimeUnit unit)
ScheduledExecutorService#schedule(Callable command,long delay, TimeUnit unit)
ScheduledExecutorService#scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnit unit)
ScheduledExecutorService#scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)

程序分层设计的基本准则是:一个层级只负责一件事,无论是 jdk 的基准库还是 spring 这样的框架,它们都遵循这样的理念。上面这个三个类,就是很好的贯彻了这一原则,三者各自负责的内容为:

  • AbstractExecutorService
    只关注如何提交任务,至于提交任务后,如何去执行它,交由子类去处理。之所以要把提交任务的逻辑写在一个抽象类里边,是因为这些提交任务的逻辑具有通用性,不需要有多种实现,子类直接复用就好了。

  • ThreadPoolExecutor
    关注如何执行任务,这也是执行器的核心。同时,由于它直接负责任务的执行,因此,整个执行器的控制和状态检测,也理应由它负责。

  • ScheduledThreadPoolExecutor
    关注如何让任务在指定的时间执行,即所谓的「调度」。它也不关注如何执行任务,所谓在指定的时间执行,其实是在指定的时间提交任务。至于提交后是否会被立刻执行,则取决于真正负责处理执行任务的组件, 这个组件就是ThreadPoolExecutor。

下面是 ThreadPoolExecutor 中最核心的 execute() 方法源码

public class ThreadPoolExecutor {
    public void execute(Runnable command) {
        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);
    }
}

要彻底读懂源码,还需要掌握并发包下的 Lock 体系,这个体系比 Executor 体系更难。不过有了第二小节「手写简易线程池」的经验,即使我们对 Lock 体系没有全面掌握,也能从上述源码中梳理出核心逻辑。比如 ➊ 处,就是向任务队列里添加任务,➋ 处就是在尝试增加执行线程,其它地方都是做各种并发控制与内部状态的控制。

现在可以来看看 AbstractExecutorService 实现的 submit(Callable task) 方法,其底层的逻辑是什么了。所谓提交,其实就是调用父接口 Executor 的 execute(Runnable command) 方法,最简单的实现是将 Callable 对象包装成一个 Runnable,然后直接调用 execute() 方法,将包装出来的 Runnable 对象作为参数传递过去即可。事实上,AbstractExecutorService 的源码就是这么做的,以下是 submit 方法的源码:

public class AbstractExecutorService {
    public <T> Future<T> submit(Callable<T> task) {
        if (task == null) throw new NullPointerException();
        RunnableFuture<T> ftask = newTaskFor(task);    // 将 Callable 包装成 Runnable
                                   ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        execute(ftask);    // 以上一步包装的 Runnable 对象为参数,调用父接口的 execute 方法
         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        return ftask;
    }

    protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
        return new FutureTask<T>(callable);
    }
}

源码中的行为与我们预想的一致,现在问题的是:将 Callable 包装成 Runnable 的核心逻辑是什么?从上述源码看,这个包装过程极其简单,只是简单的用 Callable 作为参数,新创建了一个 FutureTask 实例。这个 FutureTask 正是在「Executor 体系类结构」小节中提到的 FutrueTask,它的核心逻辑,我们已用简易的非线程安全代码演示过了。

有了 ThreadPoolExecutor 和 ScheduledThreadPoolExecutor,我们就可以方便的处理任务了。不过 Jdk 并发包的设计大师为我们考虑得更周到,她还提供一个任务执行器的工厂类 Excutors。Executors 提供的都是静态方法,通过这些静态方法,可以创建拥有不同特性的 ExecutorService 对象。比如 Executors#newFixedThreadPool(int threadCount, ThreadFactory threadFactory) 方法,就可以快速创建一个拥有固定线程数量的 ThreadPoolExecutor 实例。

小结

  • 线程池不是普通的对象池,池中的线程不受外界控制,也不存在 borrow(借出)与 return(归还) 一说。这些线程会不断地从内部的任务队列里提取任务,然后执行它。
  • JDK 并发包构成了一个 Excutor 体系,核心方法的实现有层次地分摊到了 AbstractExecutorService、ThreadPoolExecutor 和 ScheduledThreadPoolExecutor 三个类中。
  • Executor 体系提供了一 Executors 工厂类,使得可以快速创建 ExecutorService 实例。
  • Executor 体系的实现代码,还非常依赖并发包下的 Lock 体系,需要该体系来提供线程安全保障。

以上就是关于线程池的运行逻辑与你想象的不一样,它是池族中的异类相关的全部内容,希望对你有帮助。欢迎持续关注程序员导航网,学习愉快哦!

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...