多執行緒(三)、執行緒池 ThreadPoolExecutor 知識點總結

EvanZch發表於2019-12-18

本篇是多執行緒系列的第三篇,如果對前兩篇感興趣的也可以去看看。

多執行緒(一)、基礎概念及notify()和wait()的使用

多執行緒(二)、內建鎖 synchronized

Android進階系列文章是我在學習的同時對知識點的整理,一是為了加深印象,二是方便後續查閱。

如果文中有錯誤的地方,歡迎批評指出。

前言

如果在Android裡面,直接 new Thread ,阿里巴巴 Android 開發規範會提示你不要顯示建立執行緒,請使用執行緒池,為啥要用執行緒池?你對執行緒池瞭解多少?

一、執行緒池ThreadPoolExecutor 基礎概念

1、什麼是執行緒池

多執行緒(一)、基礎概念及notify()和wait()的使用 講了執行緒的建立,每當有任務來的時候,通過建立一個執行緒來執行任務,當任務執行結束,對執行緒進行銷燬,併發操作的時候,大量任務需要執行,每個任務都要需要重複執行緒的建立、執行、銷燬,造成了CPU的資源銷燬,並降低了響應速度。

        new Thread(new Runnable() {
            @Override
            public void run() {
                // 任務執行
            }
        }).start();
複製程式碼

**執行緒池 **:字面上理解就是將執行緒通過一個池子進行管理,當任務來的時候,從池子中取出一個已經建立好的執行緒進行任務的執行,執行結束後再將執行緒放回池中,待執行緒池銷燬的時候再統一對執行緒進行銷燬。

2、使用執行緒池的好處

通過上面的對比,使用執行緒池基本有以前好處:

1、降低資源消耗。通過重複使用執行緒池中的執行緒,降低了執行緒建立和銷燬帶來的資源消耗。

2、提高響應速度。重複使用池中執行緒,減少了重複建立和銷燬執行緒帶來的時間開銷。

3、提高執行緒的可管理性。執行緒是稀缺資源,我們不可能無節制建立,這樣會大量消耗系統資源,使用執行緒池可以統一分配,管理和監控執行緒。

3、執行緒池引數說明

要使用執行緒池,就必須要用到 ThreadPoolExecutor 類,

    /**
     * Creates a new {@code ThreadPoolExecutor} with the given initial
     * parameters.
     *
     * @param corePoolSize the number of threads to keep in the pool, even
     *        if they are idle, unless {@code allowCoreThreadTimeOut} is set
     * @param maximumPoolSize the maximum number of threads to allow in the
     *        pool
     * @param keepAliveTime when the number of threads is greater than
     *        the core, this is the maximum time that excess idle threads
     *        will wait for new tasks before terminating.
     * @param unit the time unit for the {@code keepAliveTime} argument
     * @param workQueue the queue to use for holding tasks before they are
     *        executed.  This queue will hold only the {@code Runnable}
     *        tasks submitted by the {@code execute} method.
     * @param threadFactory the factory to use when the executor
     *        creates a new thread
     * @param handler the handler to use when execution is blocked
     *        because the thread bounds and queue capacities are reached
     * @throws IllegalArgumentException if one of the following holds:<br>
     *         {@code corePoolSize < 0}<br>
     *         {@code keepAliveTime < 0}<br>
     *         {@code maximumPoolSize <= 0}<br>
     *         {@code maximumPoolSize < corePoolSize}
     * @throws NullPointerException if {@code workQueue}
     *         or {@code threadFactory} or {@code handler} is null
     */

    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;
    }
複製程式碼

這裡貼了 ThreadPoolExecutor 最複雜的一個構造方法,我們把引數單獨拎出來講

1、int corePoolSize

核心執行緒數量:每當接收到一個任務的時候,執行緒池會建立一個新的執行緒來執行任務,直到當前執行緒池中的執行緒數目等於 corePoolSize ,當任務大於corePoolSize 時候,會放入阻塞佇列

2、int maximumPoolSize

非核心執行緒數量:執行緒池中允許的最大執行緒數,如果當前阻塞佇列滿了,當接收到新的任務就會再次建立執行緒進行執行,直到執行緒池中的數目等於maximumPoolSize

3、long keepAliveTime

執行緒空閒時存活時間:當執行緒數大於沒有任務執行的時候,繼續存活的時間,預設該引數只有執行緒數大於corePoolSize時才有用

4、TimeUnit unit

keepAliveTime的時間單位

5、BlockingQueue workQueue

阻塞佇列:當執行緒池中執行緒數目超過 corePoolSize 的時候,執行緒會進入阻塞佇列進行阻塞等待,當阻塞佇列滿了的時候,會根據 maximumPoolSize 數量新開執行緒執行。

佇列:

是一種特殊的線性表,特殊之處在於它只允許在表的前端(front)進行刪除操作,而在表的後端(rear)進行插入操作,和棧一樣,佇列是一種操作受限制的線性表。

進行插入操作的端稱為隊尾,進行刪除操作的端稱為隊頭。

佇列中沒有元素時,稱為空佇列。佇列的資料元素又稱為佇列元素。

在佇列中插入一個佇列元素稱為入隊,從佇列中刪除一個佇列元素稱為出隊。

因為佇列只允許在一端插入,在另一端刪除,所以只有最早進入佇列的元素才能最先從佇列中刪除,故佇列又稱為先進先出(FIFO—first in first out)線性表。

阻塞佇列常用於生產者和消費者的場景,生產者是往佇列裡新增元素的執行緒,消費者是從佇列裡拿元素的執行緒。阻塞佇列就是生產者存放元素的快取容器,而消費者也只從容器裡拿元素。

先看看 BlockingQueue ,它是一個介面,繼承 Queue

public interface BlockingQueue<Eextends Queue<E>
複製程式碼

再看看它裡面的方法

針對這幾個方法,簡單的進行介紹:

丟擲異常 返回特殊值 阻塞 超時
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time, unit)
檢查 element() peek()

丟擲異常:是指當阻塞佇列滿時候,再往佇列裡插入元素,會丟擲IllegalStateException("Queue full")異常。當佇列為空時,從佇列裡獲取元素時會丟擲NoSuchElementException異常 。

返回特殊值:插入方法會返回是否成功,成功則返回true。移除方法,則是從佇列裡拿出一個元素,如果沒有則返回null

阻塞:當阻塞佇列滿時,如果生產者執行緒往佇列裡put元素,佇列會一直阻塞生產者執行緒,直到拿到資料,或者響應中斷退出。當佇列空時,消費者執行緒試圖從佇列裡take元素,佇列也會阻塞消費者執行緒,直到佇列可用。

超時:當阻塞佇列滿時,佇列會阻塞生產者執行緒一段時間,如果超過一定的時間,生產者執行緒就會退出。

我們再看JDK為我們提供的一些阻塞佇列,如下圖:

簡單說明:

阻塞佇列 用法
ArrayBlockingQueue 一個由陣列結構組成的有界阻塞佇列。
LinkedBlockingQueue 一個由連結串列結構組成的有界阻塞佇列。
PriorityBlockingQueue 一個支援優先順序排序的無界阻塞佇列。
DelayQueue 一個使用優先順序佇列實現的無界阻塞佇列。
SynchronousQueue 一個不儲存元素的阻塞佇列。
LinkedTransferQueue 一個由連結串列結構組成的無界阻塞佇列。
LinkedBlockingDeque 一個由連結串列結構組成的雙向阻塞佇列。

6、ThreadFactory threadFactory

建立執行緒的工廠,通過自定義的執行緒工廠可以給每個新建的執行緒設定一個具有識別度的執行緒名Executors靜態工廠裡預設的threadFactory,執行緒的命名規則是“pool-數字-thread-數字”。

7、RejectedExecutionHandler handler (飽和策略)

執行緒池的飽和策略,如果任務特別多,佇列也滿了,且沒有空閒執行緒進行處理,執行緒池將必須對新的任務採取飽和策略,即提供一種方式來處理這部分任務。

jdk 給我們提供了四種策略,如圖:

策略 作用
AbortPolicy 直接丟擲異常,該策略也為預設策略
CallerRunsPolicy 在呼叫者執行緒中執行該任務
DiscardOldestPolicy 丟棄阻塞佇列最前面的任務,並執行當前任務
DiscardPolicy 直接丟棄任務

我們可以看到 RejectedExecutionHandler 實際是一個介面,且只有一個 rejectedExecution 所以我們可以根據自己的需求定義自己的飽和策略。

/**
 * A handler for tasks that cannot be executed by a {@link ThreadPoolExecutor}.
 *
 * @since 1.5
 * @author Doug Lea
 */

public interface RejectedExecutionHandler {

    /**
     * Method that may be invoked by a {@link ThreadPoolExecutor} when
     * {@link ThreadPoolExecutor#execute execute} cannot accept a
     * task.  This may occur when no more threads or queue slots are
     * available because their bounds would be exceeded, or upon
     * shutdown of the Executor.
     *
     * <p>In the absence of other alternatives, the method may throw
     * an unchecked {@link RejectedExecutionException}, which will be
     * propagated to the caller of {@code execute}.
     *
     * @param r the runnable task requested to be executed
     * @param executor the executor attempting to execute this task
     * @throws RejectedExecutionException if there is no remedy
     */

    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
複製程式碼

4、執行緒池工作機制

熟悉了上面執行緒池的各個引數含義,對執行緒池的工作原理,我們也可以大致總結如下:

1、執行緒池剛建立的時候,裡面沒有執行緒在執行,當有任務進來,並且執行緒池開始執行的時候,會根據實際情況處理。

2、當前執行緒池執行緒數量少於 corePoolSize 時候,每當有新的任務來時,都會建立一個新的執行緒進行執行。

3、當執行緒池中執行的執行緒數大於等於 corePoolSize ,每當有新的任務來的時候,都會加入阻塞佇列中。

4、當阻塞佇列加滿,無法再加入新的任務的時候,則會再根據 maximumPoolSize數 來建立新的非核心執行緒執行任務。

4、當執行緒池中執行緒數目大於等於 maximumPoolSize 時候,當有新的任務來的時候,拒絕執行該任務,採取飽和策略。

5、當一個執行緒無事可做,超過一定的時間(keepAliveTime)時,執行緒池會判斷,如果當前執行的執行緒數大於 corePoolSize,那麼這個執行緒就被停掉。所以執行緒池的所有任務完成後,它最終會收縮到 corePoolSize 的大小。

5、建立執行緒池

5.1、ThreadPoolExecutor

直接通過 ThreadPoolExecutor 建立:

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                210
                , 1, TimeUnit.SECONDS
                , new LinkedBlockingQueue<Runnable>(50)
                , Executors.defaultThreadFactory()
                , new ThreadPoolExecutor.AbortPolicy());
複製程式碼
5.2、Executors 靜態方法

通過工具類java.util.concurrent.Executors 建立的執行緒池,其實質也是呼叫 ThreadPoolExecutor 進行建立,只是針對不同的需求,對引數進行了設定。

1、FixedThreadPool

可重用固定執行緒數

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
複製程式碼

引數說明:

int corePoolSize: nThreads

int maximumPoolSize: nThreads

long keepAliveTime:0L

TimeUnit unit:TimeUnit.MILLISECONDS

BlockingQueue workQueue:new LinkedBlockingQueue()

可以看到核心執行緒和非核心執行緒一致,及不會建立非核心執行緒,超時時間為0,即就算執行緒處於空閒狀態,也不會對其進行回收,阻塞佇列為LinkedBlockingQueue無界阻塞佇列。

當有任務來的時候,先建立核心執行緒,執行緒數超過 corePoolSize 就進入阻塞佇列,當有空閒執行緒的時候,再在阻塞佇列中去任務執行。

使用場景:執行緒池執行緒數固定,且不會回收,執行緒生命週期與執行緒池生命週期同步,適用任務量比較固定且耗時的長的任務。

2、newSingleThreadExecutor

單執行緒執行

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(11,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
複製程式碼

引數說明

int corePoolSize: 1

int maximumPoolSize: 1

long keepAliveTime:0L

TimeUnit unit:TimeUnit.MILLISECONDS

BlockingQueue workQueue:new LinkedBlockingQueue()

基本和 FixedThreadPool 一致,最明顯的區別就是執行緒池中只存在一個核心執行緒來執行任務。

使用場景:只有一個執行緒,確保所以任務都在一個執行緒中順序執行,不需要處理執行緒同步問題,適用多個任務順序執行。

3、newCachedThreadPool
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
複製程式碼

引數說明

int corePoolSize: 0

int maximumPoolSize: Integer.MAX_VALUE

long keepAliveTime:60L

TimeUnit unit:TimeUnit.SECONDS

BlockingQueue workQueue:new SynchronousQueue()

無核心執行緒,非核心執行緒數量 Integer.MAX_VALUE,可以無限建立,空閒執行緒60秒會被回收,任務佇列採用的是SynchronousQueue,這個佇列是無法插入任務的,一有任務立即執行。

使用場景:由於非核心執行緒無限制,且使用無法插入的SynchronousQueue佇列,所以適合任務量大但耗時少的任務。

4、newScheduledThreadPool

定時延時執行

    public ScheduledThreadPoolExecutor(int corePoolSize,
                                       ThreadFactory threadFactory)
 
{
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue(), threadFactory);
    }
複製程式碼

引數說明

int corePoolSize: corePoolSize (設定)

int maximumPoolSize: Integer.MAX_VALUE

long keepAliveTime:0

TimeUnit unit:NANOSECONDS

BlockingQueue workQueue:new DelayedWorkQueue()

核心執行緒數固定(設定),非核心執行緒數建立無限制,但是空閒時間為0,即非核心執行緒一旦空閒就回收, DelayedWorkQueue() 無界佇列會將任務進行排序,延時執行佇列任務。

使用場景:newScheduledThreadPool是唯一一個具有定時定期執行任務功能的執行緒池。它適合執行一些週期性任務或者延時任務,可以通過schedule(Runnable command, long delay, TimeUnit unit) 方法實現。

6、執行緒池的執行

執行緒池提供了 executesubmit 兩個方法來執行

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();
        }
        //判斷執行緒池是否為RUNNING狀態,並且將任務新增至佇列中.
        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(nullfalse);
        }
        //如果不滿足上述條件,嘗試建立一個非核心執行緒來執行任務,如果建立失敗,呼叫reject()方法.
        else if (!addWorker(command, false))
            reject(command);
    }
複製程式碼

submit():

原始碼:

  public <T> Future<T> submit(Runnable task, T result) {
        if (task == nullthrow new NullPointerException();
          // 將runnable封裝成 Future 物件
        RunnableFuture<T> ftask = newTaskFor(task, result);
          // 執行 execute 方法
        execute(ftask);
          // 返回包裝好的Runable
        return ftask;
    }



  // newTaskFor : 通過 FutureTask 
  protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
        return new FutureTask<T>(runnable, value);
    }
複製程式碼

其中 newTaskFor 返回的 RunnableFuture<T> 方法繼承了 Runnable 介面,所以可以直接通過 execute 方法執行。

public interface RunnableFuture<Vextends RunnableFuture<V{
    /**
     * Sets this Future to the result of its computation
     * unless it has been cancelled.
     */

    void run();
}
複製程式碼

可以看到,submit 中實際也是呼叫了 execute() 方法,只不過在呼叫方法之前,先將Runnable物件封裝成FutureTask物件,然後再返回 Future<T>,我們可以通過Futureget 方法,拿到任務執行結束後的返回值。

我們在 多執行緒(一)、基礎概念及notify()和wait()的使用 中也講了 FutureTask 它提供了 cancelisCancelledisDoneget幾個方法,來對任務進行相應的操作。

總結:

通常情況下,我們不需要對執行緒或者獲取執行結果,可以直接使用 execute 方法。

如果我們要獲取任務執行的結果,或者想對任務進行取消等操作,就使用 submit 方法。

7、執行緒池的關閉

關於執行緒的中斷在 多執行緒(一)、基礎概念及notify()和wait()的使用 裡面有介紹。

  • shutdown():不會立即終止執行緒池,而是要等所有任務快取佇列中的任務都執行完後才終止,但再也不會接受新的任務
  • shutdownNow():立即終止執行緒池,並嘗試打斷正在執行的任務,並且清空任務快取佇列,返回尚未執行的任務

8、執行緒池的合理配置

執行緒池的引數比較靈活,我們可以自由設定,但是具體每個引數該設定成多少比較合理呢?這個要根據我們處理的任務來決定,對任務一般從以下幾個點分析:

8.1、任務的性質

CPU 密集型、IO 密集型、混合型

CPU密集型應配置儘可能小的執行緒,如Ncpu+1個執行緒的執行緒池

IO密集型,IO操作有關,如磁碟,記憶體,網路等等,對CPU的要求不高則應配置儘可能多的執行緒,如2*Ncpu個執行緒的執行緒池

混合型需要拆成CPU 密集型和IO 密集型分別分析,根據任務數量和執行時間,來決定執行緒的數量

8.2、任務的優先順序

高中低優先順序

8.3、任務執行時間

長中短

8.4、任務的依耐性

是否需要依賴其他系統資源,如資料庫連線

Runtime.getRuntime().availableProcessors() : 當前裝置的CPU個數

9、執行緒池實戰

又巴拉巴拉說了一大推,我覺得唯有程式碼執行,通過結果分析最能打動人心,下面就通過程式碼執行結果來分析。

先看這麼一段程式碼:

    public static void main(String[] args) {
        // 1、通過 ThreadPoolExecutor 建立基本執行緒池
        final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                3,
                5,
                1,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(50));
        for (int i = 0; i < 30; i++) {
            final int num = i;
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    try {
                        // 睡兩秒後執行
                        Thread.sleep(2000);
                        System.out.println("run : " + num + "  當前執行緒:" + Thread.currentThread().getName());
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
            // 執行
            threadPoolExecutor.execute(runnable);
        }
    }
複製程式碼

我們通過 ThreadPoolExecutor 建立了一個執行緒池,然後執行30個任務。

引數說明

int corePoolSize: 3

int maximumPoolSize: 5

long keepAliveTime:1

TimeUnit unit:TimeUnit.SECONDS

BlockingQueue workQueue:new LinkedBlockingQueue(50)

執行緒池核心執行緒數為3,非核心執行緒數為5,非核心執行緒空閒1秒被回收,阻塞佇列使用了 new LinkedBlockingQueue 並指定了佇列容量為50。

結果:

我們看到每兩秒後,有三個任務被執行。這是因為核心我們設定的核心執行緒數為3,當多餘的任務到來後,會先放入到阻塞佇列中,又由於我們設定的阻塞佇列容量為50,所以,阻塞佇列永遠不會滿,就不會啟動非核心執行緒。

我們改一下我們的執行緒池如下:

final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                2,
                5,
                1,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(25));
複製程式碼

引數就不分析了,我們直接看結果:

我們看到這次每隔兩秒有五個任務在執行,為什麼?這裡要根據我們前面執行緒池的工作原理來分析,我們有三十個任務需要執行,核心執行緒數為2,其餘的任務放入阻塞佇列中,阻塞佇列容量為25,剩餘任務不超過非核心執行緒數,當阻塞佇列滿的時候,就啟動了非核心執行緒來執行。

我們再簡單改一下我們的執行緒池,程式碼如下:

        final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                2,
                5,
                1,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(24));
複製程式碼

相比上面的,我們就將阻塞佇列容量改成了24,如果上面你對執行緒池的工作原理清楚了,你應該能知道我這裡改成 24 的良苦用心了,我們先看結果。

最直接的就是拋異常了,但是執行緒池仍然再執行任務,首先為啥拋異常?首先,我們需要執行三十個任務,但是我們的阻塞佇列容量為 24,佇列滿後啟動了非核心執行緒,但是非核心執行緒數量為5,當剩下的這個任務來的時候,執行緒池將採取飽和策略,我們沒有設定,預設為 AbortPolicy,即直接拋異常,如果我們手動設定飽和策略如下:

        final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                2,
                5,
                1,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(24),new ThreadPoolExecutor.DiscardPolicy());
複製程式碼

我們這裡採用的飽和策略為 DiscardPolicy ,即丟棄多餘任務。最終可以看到結果沒有拋異常,最終只執行了29個任務,最後一個任務被拋棄了。

最後再看一下通過 Executors 靜態方法建立的執行緒池執行上面的任務結果如何,Executors 建立的執行緒池本質也是通過建立 ThreadPoolExecutor 來執行,可結合上面分析自行總結。

1、FixedThreadPool

ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(3);
複製程式碼

結果:

2、newSingleThreadExecutor

ExecutorService threadPoolExecutor = Executors.newSingleThreadExecutor();
複製程式碼

結果:

3、newCachedThreadPool

ExecutorService threadPoolExecutor = Executors.newCachedThreadPool();
複製程式碼

結果 :

4、newScheduledThreadPool

ScheduledExecutorService threadPoolExecutor = Executors.newScheduledThreadPool(3);
複製程式碼

總結

這是多執行緒的第三篇,這篇文章篇幅有點多, 有點小亂,後續會再整理一下,基本都是跟著自己的思路,在寫的同時,自己也會再操作一遍,原始碼分析過程中,也會盡可能的詳細,一步步的深入,後續查閱的時候也方便,文章中有些不是很詳細的地方,後面可能會再次更新,或者單獨用一篇文章來講。

相關文章