Java多線程之進階篇(一)

在學習完Java多線程之基礎篇(一)Java多線程之基礎篇(二)後接下來開始學習Java多線程之進階篇的內容。
Java 5 添加了一個新的包到Java平臺,這個包是java.util.concurrent包(簡稱JUC)。這個包包含了有一系列能夠讓Java的併發編程更加輕鬆的類。本文使用的Java 7 版本的JUC,下面讓我們繼續來學習吧!

一、線程池

提到線程線程池我們先來說一下線程池的好處,線程池的有點大概可以概括三點:
(1)重用線程池中的線程,避免因爲線程的創建和銷燬所帶來的性能開銷。
(2)能有效控制線程池的最大併發數,避免大量線程之間因互相搶奪系統資源而導致的阻塞現象。
(3)能夠對線程進行簡單的管理,並提供定時執行以及指向間隔循環執行等功能。

1.1 線程池的創建

  Java SE 5的java.util.concurrent包中的執行器(Executor)將爲你管理Thread對象,從而簡化了併發編程。Executor在客戶端和任務執行之間提供了一個間接層;與客戶端直接執行任務不同,這個中介對象將執行任務。Executor允許你管理異步任務的執行,而無須顯式的管理線程的生命週期。Executor在Java中啓動任務的優選方法。

public class CachedThreadPool {

    /**
     * @param args
     */
    public static void main(String[] args) {

        class MyRunnable implements Runnable{
            private int a = 5;
            @Override
            public void run() {
                synchronized(this){
                    for(int i=0;i<10;i++){
                        if(this.a>0){
                            System.out.println(Thread.currentThread().getName()+" a的值:"+this.a--);
                        }

                    }
                }
            }

        }
        ExecutorService exec = Executors.newCachedThreadPool();
        for(int i=0;i<5;i++)
            exec.execute(new MyRunnable());
        exec.shutdown();

    }

}

運行結果:

pool-1-thread-2 a的值:5
pool-1-thread-1 a的值:5
pool-1-thread-1 a的值:4
pool-1-thread-1 a的值:3
pool-1-thread-3 a的值:5
pool-1-thread-2 a的值:4
pool-1-thread-1 a的值:2
pool-1-thread-1 a的值:1
pool-1-thread-2 a的值:3
pool-1-thread-2 a的值:2
pool-1-thread-2 a的值:1
pool-1-thread-3 a的值:4
pool-1-thread-3 a的值:3
pool-1-thread-3 a的值:2
pool-1-thread-3 a的值:1
pool-1-thread-5 a的值:5
pool-1-thread-5 a的值:4
pool-1-thread-5 a的值:3
pool-1-thread-5 a的值:2
pool-1-thread-5 a的值:1
pool-1-thread-4 a的值:5
pool-1-thread-4 a的值:4
pool-1-thread-4 a的值:3
pool-1-thread-4 a的值:2
pool-1-thread-4 a的值:1

說明:
這個結果可以和Java多線程之基礎篇(一)3.2.1定義任務(Runnable)的例子和結果做對比。發現用Executor來管理時,Runnable中的“資源不在共享”,這個疑問我還沒有解決?知道的可以告訴我一聲。
ExecutorService是一個接口,並繼承了接口Executor。而Executors是一個工具類,下面來看看它們之間的UML圖:
這裏寫圖片描述
其中最爲主要的是ThreadPoolExecutor類和Executors中的四類方法,下面我們來逐個分析。

1.1.1 ThreadPoolExecutor

(1)ThreadPoolExecutor簡介
  ThreadPoolExecutor是線程池類。對於線程池,可以通俗的將它理解爲“存放一定數量的一個線程集合。線程池允許若個線程同時運行,運行同時運行的線程數量就是線程池的容量。當添加到線程池中的線程超過它的容量時,會有一部分線程阻塞等待,線程池會通過相應的調度策略和拒絕策略,對添加到線程池中的線程進行管理。”
(2)ThreadPoolExecutor的數據結構
這裏寫圖片描述
下面是ThreadPoolExecutor類中比較典型的部分代碼:

public class ThreadPoolExecutor extends AbstractExecutorService {
    // 阻塞隊列。
    private final BlockingQueue<Runnable> workQueue;
    // 互斥鎖
    private final ReentrantLock mainLock = new ReentrantLock();
    // 線程集合。一個Worker對應一個線程。
    private final HashSet<Worker> workers = new HashSet<Worker>();
    // “終止條件”,與“mainLock”綁定。
    private final Condition termination = mainLock.newCondition();
    // 線程池中線程數量曾經達到過的最大值。
    private int largestPoolSize;
    // 已完成任務數量
    private long completedTaskCount;
    // ThreadFactory對象,用於創建線程。
    private volatile ThreadFactory threadFactory;
    // 拒絕策略的處理句柄。
    private volatile RejectedExecutionHandler handler;
    // 保持線程存活時間。
    private volatile long keepAliveTime;

    private volatile boolean allowCoreThreadTimeOut;
    // 核心池大小
    private volatile int corePoolSize;
    // 最大池大小
    private volatile int maximumPoolSize;

    //構造方法
   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;
    }
}

對一些關鍵的變量進行介紹:

  • workers
    workers是HashSet類型,它是一個Worker集合。而一個Worker對應一個線程,也就是說線程池通過workers包含了“一個線程集合”。當Worker對應的線程池啓動時,它會執行線程池中的任務;當執行完一個任務後,它會從線程池的阻塞隊列中取出一個阻塞的任務來繼續運行。workers的作用是:線程池通過它來實現了“允許多個線程同時運行”。
  • workQueue
    workQueue是BlockingQueue類型,它是一個阻塞隊列。當線程池中的線程超過它的容量的時候,線程會進入阻塞隊列進行阻塞等待。workQueue的作用是:讓線程池實現 了阻塞功能。
  • mainLock
    mainLock是互斥鎖,通過mainLock實現了對線程池的互斥訪問。
  • corePoolSize和maximumPoolSize
    corePoolSize是“核心池大小”,maximumPoolSize是“最大池大小”。它們的作用是:調整“線程池中實際運行的線程的數量”。
    例如,當新任務提交給線程池時(通過execute方法)。
    ——如果此時,線程池中運行的線程數量 小於 corePoolSize;則僅當阻塞隊列滿時才創建新線程。
    ——如果此時,線程池中運行的線程數量 大於 corePoolSize,但卻是 小於 maximumPoolSize;則僅當阻塞隊列慢時才創建新線程。
    ——如果此時,corePoolSize和maximumPoolSize相同,則創建了固定大小的線程池。如果maximumPoolSize設置爲基本的無界值(如,Integer.MAX_VALUE),則允許線程池適應任意數量的併發任務。在大多數情況下,核心池大小和最大池大小的值在創建線程池設置的。但是,也可以使用setCorePoolSize(int)和setMaximumPoolSize(int)進行動態更改。
  • poolSize
    poolSize是當前線程池的實際大小,即線程池中任務的數量。
  • allowCoreThreadTimeOut和keepAliveTime
    allowCoreThreadTimeOut表示是否允許“線程在空閒狀態時,仍然能夠存活”。
    keepAliveTime表示線程池處於空閒狀態的時候,超過keepAliveTime時間之後,空閒的線程會被終止。
  • threadFactory
    threadFactory是ThreadFactory對象,它是一個線程工廠類,即“線程池通ThreadFactory創建線程”
  • handler
    handler是RejectedExecutionHandler類型。它是“線程池拒絕策略”的句柄,也就是說“當某任務添加到線程池中,而線程池拒絕任務是,線程池會通過handler進行相應的處理”

綜上所述,線程池通過workers來管理“線程集合”,每個線程在啓動後,會執行線程池中的任務;當一個任務執行完後,它會從線程池的阻塞隊列中取出任務來繼續運行。阻塞隊列時管理線程池任務的隊列,當添加到線程池中的任務超過線程池的容量時,該任務就會進入阻塞隊列進行等候。

1.1.2 線程池的分類

  ExecutorService是Executor直接的擴展接口,也是最常用的線程池接口,我們通常見到的線程池定時任務線程池都是它的實現類。上面的Executors.newCachedThreadPool();中的Executors還有其他靜態方法可以調用,每個方法都有不同特性,它們都是直接或間接的通過配置ThreadPoolExecutor來實現自己的功能特性,這四類線程池分別是FixedThreadPool、CachedThreadPool、ScheduledThreadPool以及SingleThreadExecutor。

(1)FixedThreadPool
  通過Executor的newFixedThreadPool方法來創建。它是一種線程數量固定的線程池,當線程池處於空閒狀態時,它們並不會被回收,除非線程池被關閉了。當所有的線程都處於活動狀態時,新任務都會處於等待狀態,直到有線程空閒出來。由於FixedThreadPool只有核心線程線程並且這些核心線程不會被回收,這意味着它能過更加快速的相應外界的請求。newFixedThreadPool方法的實現如下,可以發現FixedThreadPool中只有核心線程並且這些核心線程沒有超時機制,另外任務隊列也是沒有大小限制的。

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

  newFixedThreadPool()在調用ThreadPoolExecutor()時,它傳遞一個LinkedBlockingQueue()對象,而LinkedBlockingQueue是單向鏈表實現的阻塞隊列。在線程池中,就是通過該阻塞隊列來實現“當線程池中任務數量超過允許的任務數量時,部分任務會阻塞等待”。關於LinkedBlockingQueue的實現細節,在後續的文章會繼續介紹。
  有了FixedThreadPool,你可以一次性預先執行代價高昂的線程分配,因而也就可以限制線程的數量了。這可以節省時間,因爲你不用爲每個任務都固定的付出創建線程的開銷。在事件驅動的系統中,這種方式較好。

(2)SingleThreadExecutor
  通過Executor的newSingleThreadExecutor方法來創建。這類線程池內部只有一個核心線程,它確保所有的任務都在同一個線程中按順序執行。SingleThreadExecutor的意義在於統一所有的外界任務到一個線程中,這使得這些任務之間不需要處理線程同步的問題。SingleThreadExecutor方法的實現如下所示:

 public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

這對於你希望在另一個線程中連續運行的任何事物(長期存活的任務)來說,這是很有用的,例如監聽進入的套接字連接的任務。它對於希望在線程中運行的短任務也是同樣方便,例如,更新本地或遠程日誌的小任務,或者是事件分發線程。

(3)ScheduledThreadPool
  通過Executors的newScheduledPool方法來創建。它的核心線程數量時固定的,而非核心線程數是沒有限制的,並且當非核心線程閒置是會被立即回收。ScheduledThreadPool這類線程主要用於執行定時任務和具有固定週期的重複任務,newScheduledThreadPool方法的實現如下:

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
            return new ScheduledThreadPoolExecutor(corePoolSize);
        }

    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, TimeUnit.NANOSECONDS,
              new DelayedWorkQueue());
    }

ScheduledThreadPoolExecutor繼承ThreadPoolExecutor,並實現ScheduledExecutorService。

(4)CachedThreadPool
  通過Executors的newCachedThreadPool方法來創建。它是一種線程數量不定的線程池,它只有非核心線程,並且其最大線程數爲Integer.MAX_VALUE。由於Integer.MAX_VALUE是一個很大的數,實際上就相當於最大線程數可以任意大。當線程池中的線程都是處於活動狀態時,線程池會創建新的線程來處理新任務,否則就會利用空閒的線程來處理新任務。線程池中的空閒線程都有超時機制,這個超時長爲60秒,超過60秒閒置線程就會被回收。和FixedThreadPool不同的是,CachedThreadPool的任務隊列其實相當於一個空集合,這將導致任何任務都會立即被執行,因爲在這種場景下SynchronousQueue是無法插入任務的。SynchronousQueue是一個非常特殊的隊列,在很多情況下可以把它簡單理解爲一個無法存儲元素的隊列,由於它在實際中較少使用,這裏就不探討了。從CachedThreadPool的特性來看,這類線程池比較適合執行大量的耗時較少的任務。當整個線程池都處於閒置狀態時,線程池中的線程都會超時而被終止,這個時候CachedThreadPool之中實際上是沒有任何線程的,它幾乎是不佔用任何系統資源的,newCachedThreadPool的實現方法如下:

  public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

1.2 線程池中任務的添加

1.2.1 execute()

execute()定義在ThreadPoolExecutor.java中,源碼如下:

public void execute(Runnable command) {
    // 如果任務爲null,則拋出異常。
    if (command == null)
        throw new NullPointerException();
    // 獲取ctl對應的int值。該int值保存了"線程池中任務的數量"和"線程池狀態"信息
    int c = ctl.get();
    // 當線程池中的任務數量 < "核心池大小"時,即線程池中少於corePoolSize個任務。
    // 則通過addWorker(command, true)新建一個線程,並將任務(command)添加到該線程中;然後,啓動該線程從而執行任務。
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    // 當線程池中的任務數量 >= "核心池大小"時,
    // 而且,"線程池處於允許狀態"時,則嘗試將任務添加到阻塞隊列中。
    if (isRunning(c) && workQueue.offer(command)) {
        // 再次確認“線程池狀態”,若線程池異常終止了,則刪除任務;然後通過reject()執行相應的拒絕策略的內容。
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        // 否則,如果"線程池中任務數量"爲0,則通過addWorker(null, false)嘗試新建一個線程,新建線程對應的任務爲null。
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    // 通過addWorker(command, false)新建一個線程,並將任務(command)添加到該線程中;然後,啓動該線程從而執行任務。
    // 如果addWorker(command, false)執行失敗,則通過reject()執行相應的拒絕策略的內容。
    else if (!addWorker(command, false))
        reject(command);
}

說明:execute()的作用是將任務添加到線程池中執行。它分爲三種情況:
(1)如果“線程池中任務數量” < “核心池大小” 時,即線程池中少於corePoolSize個任務;此時就新建一個線程,並將該任務添加到線程中進行執行。
(2)如果“線程池中任務數量” >= “核心池大小” ,並且“線程池是允許狀態”;此時,則將任務添加到阻塞隊列中阻塞等待。在該情況下,會再次確認“線程狀態”,如果“第2次讀到的線程池狀態”和“第1次讀到的線程次狀態”不同,則從阻塞隊列中刪除該任務。
(3)如果非上述的兩種情況,就會嘗試新建一個線程,並將該任務添加到線程中進行執行。如果執行失敗,則通過reject()拒絕該任務。

1.2.2 addWorker()

addWorker()的源碼如下:

private boolean addWorker(Runnable firstTask, boolean core) {
    retry:
    // 更新"線程池狀態和計數"標記,即更新ctl。
    for (;;) {
        // 獲取ctl對應的int值。該int值保存了"線程池中任務的數量"和"線程池狀態"信息
        int c = ctl.get();
        // 獲取線程池狀態。
        int rs = runStateOf(c);

        // 有效性檢查
        if (rs >= SHUTDOWN &&
            ! (rs == SHUTDOWN &&
               firstTask == null &&
               ! workQueue.isEmpty()))
            return false;

        for (;;) {
            // 獲取線程池中任務的數量。
            int wc = workerCountOf(c);
            // 如果"線程池中任務的數量"超過限制,則返回false。
            if (wc >= CAPACITY ||
                wc >= (core ? corePoolSize : maximumPoolSize))
                return false;
            // 通過CAS函數將c的值+1。操作失敗的話,則退出循環。
            if (compareAndIncrementWorkerCount(c))
                break retry;
            c = ctl.get();  // Re-read ctl
            // 檢查"線程池狀態",如果與之前的狀態不同,則從retry重新開始。
            if (runStateOf(c) != rs)
                continue retry;
            // else CAS failed due to workerCount change; retry inner loop
        }
    }

    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    // 添加任務到線程池,並啓動任務所在的線程。
    try {
        final ReentrantLock mainLock = this.mainLock;
        // 新建Worker,並且指定firstTask爲Worker的第一個任務。
        w = new Worker(firstTask);
        // 獲取Worker對應的線程。
        final Thread t = w.thread;
        if (t != null) {
            // 獲取鎖
            mainLock.lock();
            try {
                int c = ctl.get();
                int rs = runStateOf(c);

                // 再次確認"線程池狀態"
                if (rs < SHUTDOWN ||
                    (rs == SHUTDOWN && firstTask == null)) {
                    if (t.isAlive()) // precheck that t is startable
                        throw new IllegalThreadStateException();
                    // 將Worker對象(w)添加到"線程池的Worker集合(workers)"中
                    workers.add(w);
                    // 更新largestPoolSize
                    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;
}

  addWorker()的作用是將firstTask添加到線程池中,並啓動該任務。當core爲true是,則以corePoolSize爲界限,若“線程池中已有任務數量” >= corePoolSize ,那麼返回false;當core爲false時,則以maximumPoolSize爲界限,若“線程池中已有任務數量” >= maximumPoolSize ,則返回false。addWorker()方法會先通過for循環不斷嘗試更新 ctl狀態,ctl 記錄了“線程池中任務數量和線程池狀態”。更新成功後,在通過try模塊來將任務添加到線程池中,並啓動任務所在的線程。
  從addWorker()方法中,我們可以發現:線程池在添加任務時,會創建任務對應的Worker對象,而一個Worker對象包含了一個Thread對象。通過將Worker對象添加到“線程的workers集合中”,從而實現將任務添加到線程池中。通過啓動Worker對應的Thread線程,則執行該任務。

1.2.3 submit()

submit()實際上也是通過調用execute()實現的,源碼如下:

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

1.3 線程池的關閉

在ThreadPoolExecutor類中的shutdown()方法源碼爲:

public void shutdown() {
    final ReentrantLock mainLock = this.mainLock;
    // 獲取鎖
    mainLock.lock();
    try {
        // 檢查終止線程池的“線程”是否有權限。
        checkShutdownAccess();
        // 設置線程池的狀態爲關閉狀態。
        advanceRunState(SHUTDOWN);
        // 中斷線程池中空閒的線程。
        interruptIdleWorkers();
        // 鉤子函數,在ThreadPoolExecutor中沒有任何動作。
        onShutdown(); // hook for ScheduledThreadPoolExecutor
    } finally {
        // 釋放鎖
        mainLock.unlock();
    }
    // 嘗試終止線程池
    tryTerminate();
}

1.4 使用Callable

  Runnable是執行工作的獨立任務,但是它不返回任何值。如果你希望任務在完成時能夠返回一個值,那麼可以實現Callable接口而不是Runnable接口。在Java SE 5 中引入的Callable是一種具有類型參數的泛型,它的類型參數表示的是從方法call()中返回的值,並且必須使用ExecutorService.submit()方法調用它,下面是簡單示例:

public class CallableDemo {

    /**
     * @param args
     */
    public static void main(String[] args) {


        class TaskWithResult implements Callable<String>{

            private int id;
            public TaskWithResult(int id){
                this.id = id;
            }
            @Override
            public String call() throws Exception {

                return "result of Callable "+id;
            }

        }

        ExecutorService exec = Executors.newCachedThreadPool();
        ArrayList<Future<String>>  results = new ArrayList<Future<String>>();
        for(int i=0;i<5;i++){
            results.add(exec.submit(new TaskWithResult(i)));
        }

        for(Future<String> fs:results){
            try {
                System.out.println(fs.get());
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (ExecutionException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }finally{
                exec.shutdown();
            }
        }
    }

}

輸出的結果:

result of Callable 0
result of Callable 1
result of Callable 2
result of Callable 3
result of Callable 4

submit()方法會產生Future對象,它用Callable返回結果的特定類型進行了參數化。

二、解決共享資源競爭

  在Java SE5 的java.util.concurrent類庫中還包含有定義在java.util.concurrent.locks中的顯式的互斥機制。Lcok對象必須被顯示的創建、鎖定、和釋放。因此,它與內間的鎖形式相比,代碼缺乏優雅性。但是,對於解決某些類型的問題,它更加靈活。下面是用Lock寫以解決共享資源的示例:

public class LockAndUnLock {

    static Lock lock = new ReentrantLock();//新建鎖

    public static void main(String[] args) {

        new Thread("A"){
            public void run() {
                Thread.yield();//當前線程的讓步,加快線程切換
                numPrint();

            };
        }.start();

        new Thread("B"){
            public void run() {
                Thread.yield();//當前線程的讓步,加快線程切換
                numPrint();
            };
        }.start();

    }

    private static void numPrint(){
        lock.lock();
        try{
            for(int i=0;i<10;i++){
                Thread.sleep(100);
                System.out.println("當前線程"+Thread.currentThread().getName()+":"+i);
            }
        }catch(Exception e){

        }finally{
            lock.unlock();
        }
    }

}

輸出結果:

當前線程A0
當前線程A1
當前線程A2
當前線程A3
當前線程A4
當前線程A5
當前線程A6
當前線程A7
當前線程A8
當前線程A9
當前線程B:0
當前線程B:1
當前線程B:2
當前線程B:3
當前線程B:4
當前線程B:5
當前線程B:6
當前線程B:7
當前線程B:8
當前線程B:9

  可以看出一個被互斥調用的鎖,並使用lock()和unlock()方法在numPrint()內創建了臨界資源。當你在使用Lock對象時,將這裏的所示的慣用法內部化是很重要的:緊接着的對lock()的調用,你必須放再finally子句中帶有unlock()的try-finally語句中。儘管try-finally所需的代碼比synchronized關鍵字要多,但是這也代表了顯示的Lock對象的優點之一。如果在使用synchronized關鍵字,某些事務失敗了,那麼就會拋出一個異常。但是你沒有機會去做任何清理工作,以維護系統使其處於良好狀態。有了顯示的Lock對象,你就可以使用finally子句將系統維護在正確的狀態了。
  大體上,當你使用synchronized關鍵字時,需要寫的代碼量更少,並且用戶錯誤出現的可能性也會降低,因此通常只有在解決特殊問題時,才使用顯示的Lock對象。

  以上,先對線程池做了大體的介紹,然後我會逐步介紹JUC中的原子類、線程安全的集合、鎖以及深層次剖析線程池原理。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章