java多線程設計模

Edit

一、基本介紹

一個線程的作用是爲了能夠執行一段程序。而多線程的目的是爲了能夠提高程序的吞吐率。

  • 在單核CPU情況下,多線程通過線程的交替執行,能夠避免單個線程情況下由於資源等待而處於“假死”狀態,導致吞吐率低下。
  • 在多核CPU情況下,多線程能夠充分利用多個CPU可以同時計算的優勢,提高程序執行效率。

    這本書主要通過類圖、流程圖的方式介紹線程的設計模式。 
    首先是一些基本概念。 
    然後涉及到一些有代表性的設計模式。

二、幾個重要的關鍵字

synchronize、wait、notify、join

1. synchronize關鍵

不同線程訪問同一個方法或者同一塊區域時,能夠保證一次只有一個線程訪問。 
用法:

1) 臨界方法。

用於方法前,說明這個方法同一時間只能由一個線程訪問。注意,若一個類中有多個synchronize方法,那麼多個 線程同一時間只能訪問一個對象裏面的一個synchronize方法。即方法的synchronize是對象級鎖。 
如下圖,當一個線程調用了bank對象的deposit()方法而未返回時,其他任何調用該bank對象synchronize方法的線程都會等待(不僅僅是deposit方法、withdraw方法也會卡住)。 

2) 臨界區。

    void method (){
        synchronize( obj ) { ...}  
    }

這種方式能夠保證當不同線程進入該方法的同一個代碼塊時是互斥的。

  • 若synchronized 的參數是this,當線程進入該臨界區時,會將整個對象鎖住,而導致任何要調用該對象synchronize方法或者進入臨界區的線程都會等待。
  • 若synchronize的參數是某個對象,

    void method(){
        Object lock;
        synchronized(o){...}
    }
    

    則意味着任何調用進入該臨界區的線程都會獲取對象lock的鎖,任何需要獲取lock對象鎖的線程都會等待。

    然而這個沒什麼卵用,因爲線程A的lock是局部變量,其他線程B若進入該臨界區,會獲取新創建的lock對象的鎖,而並非A的lock變量的鎖,所以起不到控制的作用。正確的做法應該將lock對象作爲一個各個線程共享的對象,這樣,若A線程獲取lock的鎖,則其他要獲取lock對象鎖的線程就只能等待了。 
    例如,將使用靜態變量,這樣,由於所有線程訪問的lock對象都是同一個,所以,能夠保證臨界區的互斥。

    class MyThread implements Runnable {
        int n;
        static final Object lock = new Object();
    
        public MyThread(int n) {
            this.n = n;
        }
    
        public void method(){
            synchronized (lock) {
                ...
            }
        }
    
        @Override
        public void run() {
            ...
        }
    }
    

2. wait /notify/notifyAll 方法

用途:當某線程想要獲取的資源不滿足要求時,線程自發進入等待狀態。

爲什麼要有wait?若沒有wait,那麼,當線程等待的資源不滿足要求時,該線程需要不斷地通過while 循環來查詢資源的狀態,這會浪費大量CPU資源。

通過 wait /notify的方式,當線程發現等待的資源不滿足時,將調用該線程(或者某對象)的wait方法,自發的放棄CPU進行等待隊列,而當某個正在執行的進程發現資源狀態能夠滿足時,便調用線程的(或者某對象的)notify/notifyAll方法來喚醒等待隊列中的線程。

notify vs notifyAll 
notify只會隨機喚醒waitset中的一個線程,而notifyAll會喚醒所有的線程(太多了怕出亂子?只要寫好進入獲取臨界資源的方法將好了,不滿足要求的線程仍然會乖乖進入wait set裏的),由於notify只會喚醒一個線程,若這個線程沒處理好導致資源一直處於不可用狀態,那麼,其他程序可能將會一直處於wait set中而無法執行了,所以使用notifyAll並寫好邊界條件的程序更靠譜些。

注意

  • wait、notify,必須在synchronized中調用。
  • 調用wait、notify前,必須已經獲得對象的鎖。

3. notice

  • long 與double 不是原子的,所以是線程不安全的,所以,需要中synchronized中操作,或者聲明爲volatile。

三、一些主要的模式

1. Guarded Suspension Pattern

該模式中,有一個具有狀態的對象,這個對象只有中自己狀態合適的時候,才讓線程進行目的處理。通過警戒條件來控制線程對臨界資源的使用。 
其實這個就是多線程最普遍的寫法

    synchronized(this){
        while(!obj.isready()){
            wait();
        }
        //do something
    }

通過while循環檢測資源是否能夠使用,若可用,則跳出while循環,若不能,則wait,直到其他線程調用了notify後,再次判斷該資源是否可用,重複以上步驟。

爲什麼要使用while而不是if? 
若其他線程調用的時notifyAll,則會喚醒所有的線程,若使用if,則所有的線程都會被允許使用該資源,然而這個資源可能已經被第一個喚醒的資源使用過而變成不可用了(比如,從只有一個元素的隊列中poll一個元素,然後隊列就空了),這樣就導致後續線程對資源的使用失去了控制。

理解這個模式,關鍵要清楚實例的警戒狀態 ,線程之所以需要wait,是因爲資源的狀態不符合,即警戒狀態不成立,所以,程序要能夠往下走,一定是因爲警戒條件成立(而不僅僅是因爲notify的調用),這也更好的說明了爲什麼使用while而不是if。

2.Producer-Consumer Pattern

現在是一個經典模式,生產者-消費者模式。 
需要的對象:

  • 一個用於共享的對象,例如可放若干水果的餐盤、一個放消息的隊列等(稱爲共享區),可用list、數組或map等實現。
  • 生產商品的線程,該線程持續不斷的將物品放入共享區,例如不斷地往餐盤中放水果、往消息隊列中放消息,直到共享區滿了。
  • 消費商品的線程,該線程不斷從共享區中獲取物品,例如不斷地從餐盤中拿水果、從消息隊列中取消息,直接共享區沒有物品。 
    爲了防止當共享區沒有物品時,消費者仍從共享區中獲取物品、或者共享區滿了生產者還往裏放物品,共享區需要進行“自我保護”,防止在不合適的狀態下消費者或者生產者對他的操作。

共享區

共享區會維護一個固定大小的空間,並提供add 和take的方法,簡單實現如下:

package thread;

public class ShareAre {
    final int CAPITAL = 10;
    Object[] arr = new Object[CAPITAL];
    int head = 0, count = 0;

    public synchronized void put(Object obj) {
        while (count >= CAPITAL) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        arr[(head + count) % CAPITAL] = obj;
        count++;
        notifyAll();
    }

    public synchronized Object take() {
        Object obj = null;
        while (count <= 0) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        obj = arr[head];
        head = (head + 1) % CAPITAL;
        count--;
        notifyAll();
        return obj;
    }
}

生產者和消費者的示例代碼如下:

package thread;

public class Producer_Custom1 {
    final int MAX_SIZE = 10;
    volatile int count = 0;

    public static void main(String[] args) {
        new Producer_Custom1().start();
    }

    private void start() {
        ShareAre box = new ShareAre();
        new Thread(new Producer(box)).start();
        new Thread(new Producer(box)).start();
        new Thread(new Customer(box)).start();
        new Thread(new Customer(box)).start();
    }

    class Producer implements Runnable {
        ShareAre box;

        public Producer(ShareAre box) {
            this.box = box;
        }

        @Override
        public void run() {
            // 此處未對count進行保護,所以不能保證count遞增,即便count爲volatile
            while (count < 100) {
                count++;
                System.out.println("produce:" + count);
                box.put(new Integer(count));
            }

        }
    }

    class Customer implements Runnable {
        ShareAre box;

        public Customer(ShareAre box) {
            this.box = box;
        }

        @Override
        public void run() {
            while (true) {
                System.out.println("consume: " + box.take());
            }
        }
    }
}

volatile關鍵字特性

  • 可見性:對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入。
  • 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。
  • 有序性:禁止指令重排序。

以上示例由於對ShareArea的put 和take進行了synchronize的保護,所以,這是一個線程安全的類。若將該類的synchronize關鍵字去除,並刪除wait、notifyAll的相關代碼,使得ShareArea成爲一個線程不安全的類,則需要在生產者和消費者的線程中針對具體情況進行同步。此處將不多言了。 
可見,生產者消費者模式,主要通過提供線程安全的共享區域,來簡化多線程中對於處理數據的添加和刪除的需求。java中也提供了對應的線程安全的數據處理的類,如:Vector、Stack、HashTable、StringBuffer。同時,針對線程不安全的類(LinkedList、HashMap…),可以通過

List list = Collections.synchronizedList(new LinkedList(...)); 
Map map = Collections.synchronizedMap(new HashMap(...)); 

使其變成線程安全的類。原理嘛,將是使用了個委託模式,在方法前加了個synchronize。

3.Worker Thread——工作!

消息委託的模式,將上一節的生產者、消費者模式進行了一定的轉化: 
生產者 –> 命令生產者(委託人) 
共享區域中的對象 –> 可執行的命令(Request) 
消費者 –> 命令執行者(工人) 
那麼,就能夠按照如下方式進行:生產者生成一系列需要執行的命令,將命令放入共享區域中,命令執行者則從共享區中獲取命令執行。

  • 優點:1)這種模式將委託者從創建命令和執行命令中分離,讓命令的執行無需佔用委託者的CPU資源。2)通過增加工人的數量,則可以提高併發處理的效率。3)命令的產生和執行相分離。

    命令的產生invocation 和執行execution分離的意義?

    • 提高任務的相應,委託者中創建完任務後可以立即執行下一個任務,而無需等待命令執行完畢。
    • 分離後,可以根據自身需要,控制任務的執行順序、設立優先級,比如,將小任務先執行。
    • 可取消和可重複執行,就是字面意思了。
    • 分離後,可分發到多臺服務器上執行,提高效率。
  • 缺點:只能在不需要返回值的情況下使用,並且,執行順序難以保證。

  • 適用: 
    • 最經典的用法是UI頁面中,點擊一個後臺需要長時間才能相應的按鈕時,不會導致UI頁面卡死(java中使用ActionListener 方式實現)。
    • 在需要進行IO時,將耗時的IO任務從主線程中剝離開。

4.Read Write Lock——讀寫鎖

針對某些特定情況(讀多寫少),可使用讀寫鎖進行性能的優化。考慮到互斥的操作是比較耗時的,所以在程序中,應該做到只對必要之處進行互斥,而讀寫的情況就是可優化的方案。 
讀操作並不會破壞數據,可以多個線程同時進行,而寫操作必須保證線程間的互斥。所以,可以設計一種鎖,當某線程獲取讀鎖時,其他線程能夠讀但不能寫;獲取寫鎖時,其他線程則不能讀也不能寫。這樣,在大量讀的情況下,能夠有效提高效率。 
一個讀寫鎖,若無其他線程進行寫操作,則可獲得讀鎖;只有無其他線程在讀且無其他線程中寫,纔可以獲得寫鎖。 
可以用如下方式自己實現一個讀寫鎖

package thread.readwritelock;
public class MyReadWriteLocker {
    int readThreads = 0, writeThreads = 0;
    public synchronized void readLock() throws InterruptedException {
        while (writeThreads > 0) {
            wait();
        }
        readThreads++;
    }
    public synchronized void readUnlock() {
        readThreads--;
        notifyAll();
    }
    public synchronized void writeLock() throws InterruptedException {
        while (writeThreads > 0 || readThreads > 0) {
            wait();
        }
        writeThreads++;
    }
    public synchronized void writeUnlock() {
        writeThreads--;
        notifyAll();
    }
}

一個使用讀寫鎖進行併發控制的對象

package thread.readwritelock;

public class RWData {
    MyReadWriteLocker myLock = new MyReadWriteLocker();
    String str = "11";

    public void read(String s) {
        try {
            myLock.readLock();
            System.out.println("read:" + s + str);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            myLock.readUnlock();
        }
    }

    public void write(String s) {
        try {
            myLock.writeLock();
            str += "1";
            System.out.println("write:" + s + str);
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            myLock.writeUnlock();
        }
    }

}

多個讀寫線程

package thread.readwritelock;

public class Main {

    private void start() {
        RWData data = new RWData();
        new ReadThread(data, "A").start();
        new ReadThread(data, "B").start();
        new ReadThread(data, "C").start();
        new WriteThread(data, "D").start();
    }

    public static void main(String[] args) {
        new Main().start();
    }

    class ReadThread extends Thread {
        int count = 10;
        RWData data;
        String name;

        public ReadThread(RWData data, String name) {
            this.data = data;
            this.name = name;
        }

        @Override
        public void run() {
            while (count > 0) {
                data.read(name);
                count--;
            }
        }
    }

    class WriteThread extends Thread {
        int count = 10;
        RWData data;
        String name;

        public WriteThread(RWData data, String name) {
            this.data = data;
            this.name = name;
        }

        @Override
        public void run() {
            while (count > 0) {
                data.write(name);
                count--;
            }
        }
    }
}

使用讀寫鎖時,多個讀線程並不會互斥,所以可以任意個線程同時讀,所以,讀的效率很高。 
但以上這種簡單的方式,如果不斷有讀線程進入,那麼,可能會導致寫線程一直等待讀線程釋放鎖而處於“飢餓”狀態。 
java中提供了ReentrantReadWriteLock,可以對讀和寫操作分別進行加鎖,同時避免了由於大量讀而導致寫線程的飢餓狀態。

ReentrantReadWriteLock——可重入讀寫鎖。

  • 可重入: 即同一個線程可對進行多次加鎖(同一線程外層函數獲得鎖之後 ,內層遞歸函數仍然有獲取該鎖的代碼,但不受影響),不支持可重入的鎖在重複加鎖的情況下看能會產生死鎖。重入鎖的方案一般是在線程加鎖的時候進行計數+1,在線程釋放時將計數-1,當計數爲0時,才真正釋放鎖對象。
  • 公平: 
    • 非公平鎖(默認) 這個和獨佔鎖的非公平性一樣,由於讀線程之間沒有鎖競爭,所以讀操作沒有公平性和非公平性,寫操作時,由於寫操作可能立即獲取到鎖,所以會推遲一個或多個讀操作或者寫操作。因此非公平鎖的吞吐量要高於公平鎖。
    • 公平鎖 利用AQS的CLH隊列,釋放當前保持的鎖(讀鎖或者寫鎖)時,優先爲等待時間最長的那個寫線程分配寫入鎖,當前前提是寫線程的等待時間要比所有讀線程的等待時間要長。同樣一個線程持有寫入鎖或者有一個寫線程已經在等待了,那麼試圖獲取公平鎖的(非重入)所有線程(包括讀寫線程)都將被阻塞,直到最先的寫線程釋放鎖。如果讀線程的等待時間比寫線程的等待時間還有長,那麼一旦上一個寫線程釋放鎖,這一組讀線程將獲取鎖。 
      有興趣的朋友可以自行研究,不多說了。

5.Future

Future有點像蛋糕店給你的提貨單,製作完蛋糕後,你可以通過這個提貨單來取蛋糕,這樣,你可以去做其他事情而不必中蛋糕房裏等待。 
寫多線程任務時,你也可以參考以上方式,當你的線程A中需要獲取一個比較耗時的數據,而你又不願意讓線程A循環等待這個數據,那麼,可以在request這個數據時,先返回一個只包含了空變量的Future對象,同時建立一個新線程去獲取數據並裝入Future對象中(setData),當線程A需要Future對象中的數據時,可以通過getData獲取數據(若未準備好,則等待)

1) Future的實現如下:

主函數主要負責調用Client發起請求,並使用返回的數據。

public class Application {
    public static void main(String[] args) throws InterruptedException {
        Client client = new Client();
        //這裏會立即返回,因爲獲取的是FutureData,而非RealData
        Data data = client.request("name");
        //這裏可以用一個sleep代替對其他業務邏輯的處理
        //在處理這些業務邏輯過程中,RealData也正在創建,從而充分了利用等待時間
        Thread.sleep(2000);
        //使用真實數據
        System.out.println("數據="+data.getResult());
    }
}

無論是FutureData還是RealData都實現該接口。

public interface Data {
    String getResult() throws InterruptedException;
}

Client主要完成的功能包括:1. 返回一個FutureData;2.開啓一個線程用於構造RealData。

public class Client {
    public Data request(final String string) {
        final FutureData futureData = new FutureData();

        new Thread(new Runnable() {
            @Override
            public void run() {
                //RealData的構建很慢,所以放在單獨的線程中運行
                RealData realData = new RealData(string);
                futureData.setRealData(realData);
            }
        }).start();

        return futureData; //先直接返回FutureData
    }
}

RealData是最終需要使用的數據,它的構造函數很慢。

public class RealData implements Data {
    protected String data;

    public RealData(String data) {
        //利用sleep方法來表示RealData構造過程是非常緩慢的
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.data = data;
    }

    @Override
    public String getResult() {
        return data;
    }
}

FutureData是Future模式的關鍵,它實際上是真實數據RealData的代理,封裝了獲取RealData的等待過程。

//FutureData是Future模式的關鍵,它實際上是真實數據RealData的代理,封裝了獲取RealData的等待過程
public class FutureData implements Data {
    RealData realData = null; //FutureData是RealData的封裝
    boolean isReady = false;  //是否已經準備好

    public synchronized void setRealData(RealData realData) {
        if(isReady)
            return;
        this.realData = realData;
        isReady = true;
        notifyAll(); //RealData已經被注入到FutureData中了,通知getResult()方法
    }

    @Override
    public synchronized String getResult() throws InterruptedException {
        if(!isReady) {
            wait(); //一直等到RealData注入到FutureData中
        }
        return realData.getResult(); 
    }
}

2)java中對Future的支持

java線程的實現,一般是通過集成Thread類或者實現runnable接口來實現,但這類線程無法獲取線程執行完畢返回的對象(當然,你可以通過全局變量或者傳入一個對象來獲取,但爲了確保獲取到的數據是完整的,你又需要寫對應的控制語句),而java提供了現有的實現方式

  • 建立一個線程池 ExecutorService executor = Executors.newCachedThreadPool();
  • 建立一個實現了Callable接口的對象 Task task = new Task();
  • 通過executor執行線程 Future result = executor.submit(task);
  • 獲取對象 result.get(),若對象未準備完畢,線程會等待。 
    很簡單,代碼如下
package thread.future;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class Test {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        Task task = new Task();
        Future<Integer> result = executor.submit(task);
        executor.shutdown();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }

        System.out.println("主線程在執行任務");

        try {
            System.out.println("task運行結果" + result.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

        System.out.println("所有任務執行完畢");
    }
}

class Task implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        System.out.println("子線程在進行計算");
        Thread.sleep(3000);
        int sum = 0;
        for (int i = 0; i < 100; i++)
            sum += i;
        System.out.println("子線程計算完畢");
        return sum;
    }
}

Future模式應用

  • 異步計算:將分解後的多個計算過程分別交給線程池去執行,然後收集各個計算結果進行整理。
  • 處理一些耗時的操作,如上述示例。

6.Termination——優雅的去結束一個線程

當調用一個線程的interrupt( )方法時,若該線程處於sleep 、wait或者join時,會拋出InterruptedException異常,來終止該線程。但要確保一個線程能夠被終止(尤其是中循環中),還需要設置一個終止條件(每次循環時判斷一下),來避免當線程並未處於上述三種狀態時,interrupt不會拋出異常而導致終止失效的問題。

stop方法來終止線程? 
之所以不提倡stop方法,因爲雖然它確實停止了一個正在運行的線程,然而,這種方法是不安全也是不受提倡的,因爲他強迫線程停止,而可能會導致線程任務未執行完畢將意外退出,而導致數據丟失、對象處於不一致的狀態下… 而使用interrupt由於有異常捕獲機制,我們能夠通過異常處理來處理終止的線程,這種方式是可控的。

join 
由於線程之間是獨立的,當線程A中start了線程B(B較耗時),之後,A線程會在B線程之前就結束。於是,當你希望A能夠等待B結束之後再結束,將可以中start線程B之後,再調用B線程的join方法,這樣,將OK了。並且,線程A會在調用join之後就停住,直到線程B結束之後線程A纔會往下走。

package thread.join;

public class Test {

    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {

            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("sub thread end.");
            }
        });
        t.start();
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main thread end.");
    }

}

原理:當調用線程B的join時,實際上調用的是B線程的wait,然後線程A進入等待隊列,直到B執行完畢後,A才繼續往下走。 
所以,你也可以如下實現線程的join。

package thread.join;

public class Test {

    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {

            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("sub thread end.");
            }
        });
        t.start();
        synchronized (t) {
            try {
                t.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("main thread end.");
    }

}

小知識:神奇的ThreadLocal——線程的保險

一個能夠管理多個對象的線程。通過set(obj )方法可以添加一個屬於該線程的對象,通過get( )方法可以獲取調用該方法的線程對應的對象。就像是一個神奇的儲物櫃入口(get方法),每個人(調用get的線程)在用自己鑰匙打開時,都能夠進入自己專屬的儲物櫃,請注意,而入口是一樣的(都是ThreadLocal對象)。

其實,使用任何一個Collection都可以管理多個對象,但爲什麼不用(比如map)?map也能夠幫你實現委託,爲每個對象開闢一個單獨的空間,在需要時獲取相應的操作。但ThreadLocal是一個線程,他有自己的優勢

  • 通過get方法,可以自動找到調用線程對應的單獨的對象(空間),想象下如果通過map,必須爲每個獲取每個線程的ID然後map.get( id )…(其實,ThreadLocal的內部就是維護了一個map,所做的將是把map包裝中一個線程中 )
  • 由於它是單獨的一個線程,在主線程委託某個任務時,不會佔用調用線程的資源(單獨的一個線程執行任務)。我覺得這是與直接使用map的最根本區別。

多線程的同步機制通過精密的控制來保證多個線程在訪問同一個對象時的正確性,而ThreadLocal從另一個角度來解決多線程的併發問題。前者是用時間換空間(資源競爭會耗時),而後者則使用空間換時間(通過爲每個線程提供一個單獨的副本避免衝突)。 
所以,用“線程的保險箱”這個詞來描述他,無比合適。 
下面來看一個hibernate中典型的ThreadLocal的應用:

    private static final ThreadLocal threadSession = new ThreadLocal();

    public static Session getSession() throws InfrastructureException {
        Session s = (Session) threadSession.get();
        try {
            if (s == null) {
                s = getSessionFactory().openSession();
                threadSession.set(s);
            }
        } catch (HibernateException ex) {
            throw new InfrastructureException(ex);
        }
        return s;
    }

通過使用ThreadLocal來維護線程每個線程的Session,這樣,線程能夠方便的獲取屬於自己的session,連參數都不用傳,是不是很方便。

參考: 
《java多線程設計模式》 
Java併發編程:volatile關鍵字解析

http://www.cnblogs.com/dolphin0520/p/3920373.html

轉 Java多線程(十)之ReentrantReadWriteLock深入分析 
http://my.oschina.net/adan1/blog/158107

Java多線程中join方法的理解 
http://uule.iteye.com/blog/1101994



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