我信任各人都看过很多的关于线程池的文章,根本上也是口试的时间必问的,假如你在看过很多文章以后,照旧一孔之见的,那盼望这篇文章能让你真正的把握好 Java 线程池。
线程池是非常告急的工具,假如你要成为一个好的工程师,照旧得比力好地把握这个知识,很多线上标题都是由于没有效好线程池导致的。纵然你为了营生,也要知道,这根本上是口试必问的标题,而且口试官很轻易从被口试者的答复中捕获到被口试者的技能程度。
本文略长,边看文章边翻源码(Java7 和 Java8 都一样),发起想好悦目的读者抽出至少 30 分钟的整块时间来阅读。固然,假如读者仅为口试预备,可以直接滑到末了的总结部门。
总览
开篇来一些废话。下图是 java 线程池几个相干类的继承结构:
先简单说说这个继承结构,Executor 位于最顶层,也是最简单的,就一个 execute(Runnable runnable) 接口方法界说。
ExecutorService 也是接口,在 Executor 接口的底子上添加了很多的接口方法,所以一样平常来说我们会利用这个接口。
然后再下来一层是 AbstractExecutorService,从名字我们就知道,这是抽象类,这里实现了非常有效的一些方法供子类直接利用,之后我们再细说。
然后才到我们的重点部门 ThreadPoolExecutor 类,这个类提供了关于线程池所需的非常丰富的功能。
别的,我们还涉及到下图中的这些类:
同在并发包中的 Executors 类,类名中带字母 s,我们猜到这个是工具类,内里的方法都是静态方法,如以下我们最常用的用于天生 ThreadPoolExecutor 的实例的一些方法:
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());}public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());}别的,由于线程池支持获取线程执行的结果,所以,引入了 Future 接口,RunnableFuture 继承自此接口,然后我们最必要关心的就是它的实现类 FutureTask。到这里,记着这个概念,在线程池的利用过程中,我们是往线程池提交任务(task),利用过线程池的都知道,我们提交的每个任务是实现了 Runnable 接口的,实在就是先将 Runnable 的任务包装成 FutureTask,然后再提交到线程池。如许,读者才华比力轻易记着 FutureTask 这个类名:它起首是一个任务(Task),然后具有 Future 接口的语义,即可以在将来(Future)得到执行的结果。
固然,线程池中的 BlockingQueue 也是非常告急的概念,假如线程数到达 corePoolSize,我们的每个任务会提交到等待队列中,等待线程池中的线程来取任务并执行。这里的 BlockingQueue 通常我们利用实在现类 LinkedBlockingQueue、ArrayBlockingQueue 和 SynchronousQueue,每个实现类都有差别的特性,利用场景之后会渐渐分析。
想要具体相识各个 BlockingQueue 的读者,可以参考我的前面的一篇对 BlockingQueue 的各个实现类举行具体分析的文章。
https://www.jianshu.com/p/4f66833cc432
以上就是本文要先容的知识,废话不多说,开始进入正文。
Executor 接口
/* * @since 1.5 * @author Doug Lea */public interface Executor { void execute(Runnable command);}我们可以看到 Executor 接口非常简单,就一个 void execute(Runnable command) 方法,代表提交一个任务。为了让各人明确 Java 线程池的整个计划方案,我会按照 Doug Lea 的计划思绪来多说一些相干的东西。
我们经常如许启动一个线程:
new Thread(new Runnable(){ // do something}).start();用了线程池 Executor 后就可以像下面这么利用:
Executor executor = anExecutor;executor.execute(new RunnableTask1());executor.execute(new RunnableTask2());假如我们盼望线程池同步执行每一个任务,我们可以这么实现这个接口:
class DirectExecutor implements Executor { public void execute(Runnable r) { r.run();// 这里不是用的new Thread(r).start(),也就是说没有启动任何一个新的线程。 }}我们盼望每个任务提交进来后,直接启动一个新的线程来执行这个任务,我们可以这么实现:
class ThreadPerTaskExecutor implements Executor { public void execute(Runnable r) { new Thread(r).start(); // 每个任务都用一个新的线程来执行 }}我们再来看下怎么组合两个 Executor 来利用,下面这个实现是将全部的任务都加到一个 queue 中,然后从 queue 中取任务,交给真正的执行器执行,这里接纳 synchronized 举行并发控制:
class SerialExecutor implements Executor { // 任务队列 final Queue<Runnable> tasks = new ArrayDeque<Runnable>(); // 这个才是真正的执行器 final Executor executor; // 当前正在执行的任务 Runnable active; // 初始化的时间,指定执行器 SerialExecutor(Executor executor) { this.executor = executor; } // 添加任务到线程池: 将任务添加到任务队列,scheduleNext 触发执行器去任务队列取任务 public synchronized void execute(final Runnable r) { tasks.offer(new Runnable() { public void run() { try { r.run(); } finally { scheduleNext(); } } }); if (active == null) { scheduleNext(); } } protected synchronized void scheduleNext() { if ((active = tasks.poll()) != null) { // 具体的执行转给真正的执行器 executor executor.execute(active); } }}固然了,Executor 这个接口只有提交任务的功能,太简单了,我们想要更丰富的功能,好比我们想知道执行结果、我们想知道当火线程池有多少个线程在世、已经完成了多少任务等等,这些都是这个接口的不敷的地方。接下来我们要先容的是继承自 Executor 接口的 ExecutorService 接口,这个接口提供了比力丰富的功能,也是我们最常利用到的接口。
ExecutorService
一样平常我们界说一个线程池的时间,通常都是利用这个接口:
ExecutorService executor = Executors.newFixedThreadPool(args...);ExecutorService executor = Executors.newCachedThreadPool(args...);由于这个接口中界说的一系列方法大部门环境下已经可以满意我们的必要了。
那么我们简单初略地来看一下这个接口中都有哪些方法:
public interface ExecutorService extends Executor { // 关闭线程池,已提交的任务继承执行,不继承继承提交新任务 void shutdown(); // 关闭线程池,实验制止正在执行的全部任务,不继承继承提交新任务 // 它和前面的方法相比,加了一个单词“now”,区别在于它会去制止当前正在举行的任务 List<Runnable> shutdownNow(); // 线程池是否已关闭 boolean isShutdown(); // 假如调用了 shutdown() 或 shutdownNow() 方法后,全部任务竣事了,那么返回true // 这个方法必须在调用shutdown或shutdownNow方法之后调用才会返回true boolean isTerminated(); // 等待全部任务完成,并设置超时时间 // 我们这么明确,实际应用中是,先调用 shutdown 或 shutdownNow, // 然后再调这个方法等待全部的线程真正地完成,返回值意味着有没有超时 boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException; // 提交一个 Callable 任务 <T> Future<T> submit(Callable<T> task); // 提交一个 Runnable 任务,第二个参数将会放到 Future 中,作为返回值, // 由于 Runnable 的 run 方法本身并不返回任何东西 <T> Future<T> submit(Runnable task, T result); // 提交一个 Runnable 任务 Future<?> submit(Runnable task); // 执行全部任务,返回 Future 范例的一个 list <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; // 同上一个方法,只有此中的一个任务竣事了,就可以返回,返回执行完的谁人任务的结果, // 不外这个带超时,凌驾指定的时间,抛出 TimeoutException 非常 <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;}这些方法都很好明确,一个简单的线程池告急就是这些功能,能提交任务,能获取结果,能关闭线程池,这也是为什么我们经常用这个接口的缘故原由。
FutureTask
在继承往下层先容 ExecutorService 的实现类之前,我们先来说说相干的类 FutureTask。
Future Runnable \ / \ / RunnableFuture | | FutureTaskFutureTask 通过 RunnableFuture 间接实现了 Runnable 接口,
所以每个 Runnable 通常都先包装成 FutureTask,
然后调用 executor.execute(Runnable command) 将其提交给线程池
我们知道,Runnable 的 void run() 方法是没有返回值的,所以,通常,假如我们必要的话,会在 submit 中指定第二个参数作为返回值:
<T> Future<T> submit(Runnable task, T result);实在到时间会通过这两个参数,将其包装成 Callable。它和 Runnable 的区别在于 run() 没有返回值,而 Callable 的 call() 方法有返回值,同时,假如运行出现非常,call() 方法会抛出非常。
public interface Callable<V> { V call() throws Exception;}在这里,就不睁开说 FutureTask 类了,由于本文篇幅原来就够大了,这里我们必要知道怎么用就行了。
下面,我们来看看 ExecutorService 的抽象实现 AbstractExecutorService 。
AbstractExecutorService
AbstractExecutorService 抽象类派生自 ExecutorService 接口,然后在其底子上实现了几个实用的方法,这些方法提供给子类举行调用。
这个抽象类实现了 invokeAny 方法和 invokeAll 方法,这里的两个 newTaskFor 方法也比力有效,用于将任务包装成 FutureTask。界说于最上层接口 Executor中的 void execute(Runnable command) 由于不必要获取结果,不会举行 FutureTask 的包装。
必要获取结果(FutureTask),用 submit 方法,不必要获取结果,可以用 execute 方法。
下面,我将一行一行源码地来分析这个类,跟着源码来看看实在现吧:
invokeAny 和 invokeAll 方法占了这整个类的绝大多数篇幅,读者可以选择适当跳过,由于它们大概在你的实践中利用的频次比力低,而且它们不带有承前启后的作用,不消担心会遗漏什么导致看不懂反面的代码。
public abstract class AbstractExecutorService implements ExecutorService { // RunnableFuture 是用于获取执行结果的,我们常用它的子类 FutureTask // 下面两个 newTaskFor 方法用于将我们的任务包装成 FutureTask 提交到线程池中执行 protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) { return new FutureTask<T>(runnable, value); } protected <T> RunnableFuture<T> newTaskFor(Callable<T> callable) { return new FutureTask<T>(callable); } // 提交任务 public Future<?> submit(Runnable task) { if (task == null) throw new NullPointerException(); // 1\. 将任务包装成 FutureTask RunnableFuture<Void> ftask = newTaskFor(task, null); // 2\. 交给执行器执行,execute 方法由具体的子类来实现 // 前面也说了,FutureTask 间接实现了Runnable 接口。 execute(ftask); return ftask; } public <T> Future<T> submit(Runnable task, T result) { if (task == null) throw new NullPointerException(); // 1\. 将任务包装成 FutureTask RunnableFuture<T> ftask = newTaskFor(task, result); // 2\. 交给执行器执行 execute(ftask); return ftask; } public <T> Future<T> submit(Callable<T> task) { if (task == null) throw new NullPointerException(); // 1\. 将任务包装成 FutureTask RunnableFuture<T> ftask = newTaskFor(task); // 2\. 交给执行器执行 execute(ftask); return ftask; } // 此方法目的:将 tasks 集会合的任务提交到线程池执行,恣意一个线程执行完后就可以竣事了 // 第二个参数 timed 代表是否设置超机遇制,超时时间为第三个参数, // 假如 timed 为 true,同时超时了还没有一个线程返回结果,那么抛出 TimeoutException 非常 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(); // List<Future<T>> futures= new ArrayList<Future<T>>(ntasks); // ExecutorCompletionService 不是一个真正的执行器,参数 this 才是真正的执行器 // 它对执行器举行了包装,每个任务竣事后,将结果生存到内部的一个 completionQueue 队列中 // 这也是为什么这个类的名字内里有个 Completion 的缘故原由吧。 ExecutorCompletionService<T> ecs = new ExecutorCompletionService<T>(this); try { // 用于生存非常信息,此方法假如没有得到任何有效的结果,那么我们可以抛出末了得到的一个非常 ExecutionException ee = null; long lastTime = timed ? System.nanoTime() : 0; Iterator<? extends Callable<T>> it = tasks.iterator(); // 起首先提交一个任务,反面的任务到下面的 for 循环一个个提交 futures.add(ecs.submit(it.next())); // 提交了一个任务,所以任务数量减 1 --ntasks; // 正在执行的任务数(提交的时间 +1,任务竣事的时间 -1) int active = 1; for (;;) { // ecs 上面说了,其内部有一个 completionQueue 用于生存执行完成的结果 // BlockingQueue 的 poll 方法不壅闭,返回 null 代表队列为空 Future<T> f = ecs.poll(); // 为 null,分析刚刚提交的第一个线程还没有执行完成 // 在前面先提交一个任务,加上这里做一次查抄,也是为了进步性能 if (f == null) { if (ntasks > 0) { --ntasks; futures.add(ecs.submit(it.next())); ++active; } // 这里是 else if,不是 if。这里分析,没有任务了,同时 active 为 0 分析 // 任务都执行完成了。实在我也没明确为什么这里做一次 break? // 由于我以为 active 为 0 的环境,肯定从下面的 f.get() 返回了 // 2018-02-23 感谢读者 newmicro 的 comment, // 这里的 active == 0,分析全部的任务都执行失败,那么这里是 for 循环出口 else if (active == 0) break; // 这里也是 else if。这里说的是,没有任务了,但是设置了超时时间,这里检测是否超时 else if (timed) { // 带等待的 poll 方法 f = ecs.poll(nanos, TimeUnit.NANOSECONDS); // 假如已经超时,抛出 TimeoutException 非常,这整个方法就竣事了 if (f == null) throw new TimeoutException(); long now = System.nanoTime(); nanos -= now - lastTime; lastTime = now; } // 这里是 else。分析,没有任务必要提交,但是池中的任务没有完成,还没有超时(假如设置了超时) // take() 方法会壅闭,直到有元素返回,分析有任务竣事了 else f = ecs.take(); } /* * 我感觉上面这一段并不是很好明确,这里简单说下。 * 1\. 起首,这在一个 for 循环中,我们假想每一个任务都没那么快竣事, * 那么,每一次都会进到第一个分支,举行提交任务,直到将全部的任务都提交了 * 2\. 任务都提交完成后,假如设置了超时,那么 for 循环实在进入了“不绝检测是否超时” 这件事变上 * 3\. 假如没有设置超机遇制,那么不须要检测超时,那就会壅闭在 ecs.take() 方法上, 等待获取第一个执行结果 * 4\. 假如全部的任务都执行失败,也就是说 future 都返回了, 但是 f.get() 抛出非常,那么从 active == 0 分付出去(感谢 newmicro 提出) // 固然,这个必要看下面的 if 分支。 */ // 有任务竣事了 if (f != null) { --active; try { // 返回执行结果,假如有非常,都包装成 ExecutionException return f.get(); } catch (ExecutionException eex) { ee = eex; } catch (RuntimeException rex) { ee = new ExecutionException(rex); } } }// 注意看 for 循环的范围,不绝到这里 if (ee == null) ee = new ExecutionException(); throw ee; } finally { // 方法退出之前,取消其他的任务 for (Future<T> f : futures) f.cancel(true); } } 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; } } public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { return doInvokeAny(tasks, true, unit.toNanos(timeout)); } // 执行全部的任务,返回任务结果。 // 先不要看这个方法,我们先想想,实在我们本身提交任务到线程池,也是想要线程池执行全部的任务 // 只不外,我们是每次 submit 一个任务,这里以一个集互助为参数提交 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException { if (tasks == null) throw new NullPointerException(); List<Future<T>> futures = new ArrayList<Future<T>>(tasks.size()); boolean done = false; try { // 这个很简单 for (Callable<T> t : tasks) { // 包装成 FutureTask RunnableFuture<T> f = newTaskFor(t); futures.add(f); // 提交任务 execute(f); } for (Future<T> f : futures) { if (!f.isDone()) { try { // 这是一个壅闭方法,直到获取到值,或抛出了非常 // 这里有个小细节,实在 get 方法署名上是会抛出 InterruptedException 的 // 但是这里没有举行处理处罚,而是抛给外层去了。此非常发生于还没执行完的任务被取消了 f.get(); } catch (CancellationException ignore) { } catch (ExecutionException ignore) { } } } done = true; // 这个方法返回,不像其他的场景,返回 List<Future>,实在执行结果还没出来 // 这个方法返回是真正的返回,任务都竣事了 return futures; } finally { // 为什么要这个?就是上面说的有非常的环境 if (!done) for (Future<T> f : futures) f.cancel(true); } } // 带超时的 invokeAll,我们找差别吧 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException { if (tasks == null || unit == null) throw new NullPointerException(); long nanos = unit.toNanos(timeout); List<Future<T>> futures = new ArrayList<Future<T>>(tasks.size()); boolean done = false; try { for (Callable<T> t : tasks) futures.add(newTaskFor(t)); long lastTime = System.nanoTime(); Iterator<Future<T>> it = futures.iterator(); // 每提交一个任务,检测一次是否超时 while (it.hasNext()) { execute((Runnable)(it.next())); long now = System.nanoTime(); nanos -= now - lastTime; lastTime = now; // 超时 if (nanos <= 0) return futures; } for (Future<T> f : futures) { if (!f.isDone()) { if (nanos <= 0) return futures; try { // 调用带超时的 get 方法,这里的参数 nanos 是剩余的时间, // 由于上面实在已经用掉了一些时间了 f.get(nanos, TimeUnit.NANOSECONDS); } catch (CancellationException ignore) { } catch (ExecutionException ignore) { } catch (TimeoutException toe) { return futures; } long now = System.nanoTime(); nanos -= now - lastTime; lastTime = now; } } done = true; return futures; } finally { if (!done) for (Future<T> f : futures) f.cancel(true); } }}到这里,我们发现,这个抽象类包装了一些根本的方法,但是像 submit、invokeAny、invokeAll 等方法,它们都没有真正开启线程来执行任务,它们都只是在方法内部调用了 execute 方法,所以最告急的 execute(Runnable runnable) 方法还没出现,必要等具体执行器来实现这个最告急的部门,这里我们要说的就是 ThreadPoolExecutor 类了。
ThreadPoolExecutor
ThreadPoolExecutor 是 JDK 中的线程池实现,这个类实现了一个线程池必要的各个方法,它实现了任务提交、线程管理、监控等等方法。
我们可以基于它来举行业务上的扩展,以实现我们必要的其他功能,好比实现定时任务的类 ScheduledThreadPoolExecutor 就继承自 ThreadPoolExecutor。固然,这不是本文关注的重点,下面,照旧赶紧举行源码分析吧。
起首,我们来看看线程池实现中的几个概念和处理处罚流程。
我们先回首下提交任务的几个方法:
public Future<?> submit(Runnable task) { if (task == null) throw new NullPointerException(); RunnableFuture<Void> ftask = newTaskFor(task, null); execute(ftask); return ftask;}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;}public <T> Future<T> submit(Callable<T> task) { if (task == null) throw new NullPointerException(); RunnableFuture<T> ftask = newTaskFor(task); execute(ftask); return ftask;}一个最根本的概念是,submit 方法中,参数是 Runnable 范例(也有Callable 范例),这个参数不是用于 new Thread(runnable).start() 中的,此处的这个参数不是用于启动线程的,这里指的是任务,任务要做的事变是 run() 方法内里界说的或 Callable 中的 call() 方法内里界说的。
初学者通常会搞混这个,由于 Runnable 总是在各个地方出现,经常把一个 Runnable 包到另一个 Runnable 中。请把它想象成有个 Task 接口,这个接口内里有一个 run() 方法。
我们回过神来继承往下看,我画了一个简单的体现图来形貌线程池中的一些告急的构件:
固然,上图没有思量队列是否有界,提交任务时队列满了怎么办?什么环境下会创建新的线程?提交任务时线程池满了怎么办?空闲线程怎么关掉?这些标题下面我们会逐一办理。
我们经常会利用 Executors 这个工具类来快速构造一个线程池,对于初学者而言,这种工具类是很有效的,开辟者不必要关注太多的细节,只要知道本身必要一个线程池,仅仅提供必须的参数就可以了,其他参数都接纳作者提供的默认值。
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());}public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());}这里先不说有什么区别,它们最终都会导向这个构造方法:
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
核心线程数,不要抠字眼,反正先记着有这么个属性就可以了。
- maximumPoolSize
最大线程数,线程池答应创建的最大线程数。
- workQueue
任务队列,BlockingQueue 接口的某个实现(常利用 ArrayBlockingQueue 和 LinkedBlockingQueue)。
- keepAliveTime
空闲线程的保活时间,假如某线程的空闲时间凌驾这个值都没有任务给它做,那么可以被关闭了。注意这个值并不会对全部线程起作用,假如线程池中的线程数少于便是核心线程数 corePoolSize,那么这些线程不会由于空闲太长时间而被关闭,固然,也可以通过调用 allowCoreThreadTimeOut(true)使核心线程数内的线程也可以被接纳。
- threadFactory
用于天生线程,一样平常我们可以用默认的就可以了。通常,我们可以通过它将我们的线程的名字设置得比力可读一些,如 Message-Thread-1, Message-Thread-2 雷同如许。
- handler:
当线程池已经满了,但是又有新的任务提交的时间,该接纳什么计谋由这个来指定。有几种方式可供选择,像抛出非常、直接拒绝然后返回等,也可以本身实现相应的接口实现本身的逻辑,这个之后再说。
除了上面几个属性外,我们再看看其他告急的属性。
Doug Lea 接纳一个 32 位的整数来存放线程池的状态和当前池中的线程数,此中高 3 位用于存放线程池状态,低 29 位体现线程数(纵然只有 29 位,也已经不小了,大概 5 亿多,如今还没有哪个呆板能起这么多线程的吧)。我们知道,java 语言在整数编码上是同一的,都是接纳补码的情势,下面是简单的移位利用和布尔利用,都是挺简单的。 |