1. 程式人生 > >Java核心技術再理解——深入淺出Java多執行緒

Java核心技術再理解——深入淺出Java多執行緒

,在程式設計語言中,執行緒對程式設計師可以說是一種又愛又恨的矛盾,一方面可以大大的簡化模型,幫助程式設計師編寫出功能強大的程式碼;另一方面又可能因為考慮不周全使得我們的程式出現各種大大小小的很難在開發環境中復現的BUG。

在這裡筆者根據《Java核心技術》一書以及一些博文來和大家分享一下對Java多執行緒的理解和體會

程序與執行緒

在學習之前我們有必要了解什麼是程序和執行緒。

程序

所謂的程序就是程式或者任務執行的過程,程序持有資源(記憶體,檔案)和執行緒。例如我們所用的QQ,Eclipse等等都可以說是程序。在這裡筆者特別提醒大家,特別要注意程序是程式或者任務的執行過程,只有當Eclipse等程式被啟動了才能被稱之為程序。因此程序是一個動態的概念。

執行緒

我們說程序中包括資源和執行緒,所以程序是資源和執行緒的載體,脫離了執行緒討論程序是沒有意義的。那麼什麼是執行緒。一個程式往往可以執行多個任務,通常每個任務被稱為一個執行緒。例如,Eclipse中有原始碼編輯器,可以做語法校驗,可以後臺編譯我們的原始碼。

所以綜上所述,我們可以總結一下幾點:

  • 執行緒是系統中最小的執行單元
  • 同一個執行緒中可以包含多個執行緒
  • 執行緒共享程序中的資源

執行緒的互動

在這裡讀者們可能已經很熟悉現代作業系統中的多工:在同一時刻執行多個程式的能力。多個執行緒通過通訊才能正常的工作。這種通訊我們稱為執行緒的互動。互動的方式可以分為互斥同步

兩種。例如,我們把一個學校比作一個程序,每個學生都可以看作一個執行緒。那麼其中要相互合作完成一些專案或者學習任務的行為可以理解為同步;學校中有圖書館,當一位同學從圖書館把《Java核心技術》借走了,並且圖書館中只有一本《Java核心技術》,那麼其同學就不能借到這本書,只有等待之前的同學把書換回去。這就可以理解為互斥。

執行緒的建立

現在我們說一下執行緒的建立。執行緒的建立常用的有兩種
- 繼承java.lang.Thread
在這個類中有一個run()方法。如果該執行緒是由Runnable物件構造的,則呼叫該Runnable物件中的run()方法;否則Thread子類中應該重寫該方法。對此中執行緒的例項化則直接用new即可。
- 實現java.lang.Runnable介面
同樣,該介面中只有一個run()方法。
使用實現介面Runnable的物件建立一個執行緒時,啟動該執行緒將會呼叫run()方法。例項化這種執行緒則是用Thread的構造方法。如下:

Thread(Runnable target) 
    Thread(Runnable target, String name) 
    Thread(ThreadGroup group, Runnable target) 
    Thread(ThreadGroup group, Runnable target, String name) 
    Thread(ThreadGroup group, Runnable target, String name, long stackSize)`

執行緒的啟動

啟動一個執行緒,則只需要呼叫Thread物件中的start()方法即可。其實一個執行緒呼叫了start()方法後,並不是說該執行緒就處於執行狀態了,在這裡筆者對執行緒的狀態不做詳解,在之後的文章中會做詳細的說明

執行緒的建立例項

下面同過一個小例子來說明一下執行緒的建立:

package org.joea.java;

public class ThreadDemo extends Thread {

    public static void main(String[] args) {
        System.out.println(Thread.currentThread().getName()+"是主執行緒!");
        Thread threadDemo = new ThreadDemo();
        threadDemo.setName("Thread");// 為執行緒設定名字
        // 啟動執行緒
        threadDemo.start();

        // 通過Runnable介面物件建立執行緒並設定執行緒名稱
        Thread threadRunnable = new Thread(new ThreadRunnableDemo(), "Runnable");
        // 啟動執行緒
        threadRunnable.start();
    }

    // 重寫run()方法
    public void run() {
        System.out.println(getName() + "是一個Thread的擴充套件執行緒!");
        int count = 0;
        boolean keyRuning = true;
        while (keyRuning) {
            System.out.println(getName() + "運行了" + (++count) + "次");
            if (count == 10) {
                keyRuning = false;
            }

            if (count <= 10) {
                try {// 讓執行緒休眠1秒
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
        System.out.println(getName() + "結束了!");
    }

}

class ThreadRunnableDemo implements Runnable {

    @Override
    public void run() {
        // TODO Auto-generated method stub

        System.out.println(Thread.currentThread().getName()
                + "是一個實現Runnable介面的執行緒!");
        int count = 0;
        boolean keyRuning = true;
        while (keyRuning) {
            System.out.println(Thread.currentThread().getName() + "運行了"
                    + (++count) + "次");
            if (count == 10) {
                keyRuning = false;
            }

            if (count <= 10) {
                try {// 讓執行緒休眠1秒
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
        System.out.println(Thread.currentThread().getName() + "結束了!");
    }

}

以上程式碼演示了Java中建立執行緒的兩種方式,從上面的程式碼中我們可一得到如下幾條:
- 執行緒都可以設定名字,也可以獲取執行緒的名字。執行緒的名字既可以是程式設計師自己決定的,也可以是JVM自己賦予的。
- 以上程式碼中的執行緒只能保證:每個執行緒都能啟動並且執行至結束。但是對於任何一組(兩個或以上)執行緒,都不能保證其執行次序,執行時間也無法保證(這裡涉及搶佔式排程原則,之後的文章會又介紹)。
- 通常當執行緒中的run()方法結束,意味著執行緒的結束

同步

在大多數實際的多執行緒的應用中,兩個或者兩個以上的執行緒需要共享同一資料的存取。試想一下如果兩個程序都呼叫了同一個修改資料庫中資料的方法,可能會導致修改後的資料出現意想之外的錯誤。
例如:模擬有一個銀行。裡面有n個賬戶,每一賬戶有一個執行緒,每一筆交易中,會從執行緒所在的賬戶轉到另一個賬戶中隨機數目的錢。
BankTest.java

 package org.javathread.joea;

public class BankTest {

    public static final int NACCOUNTS=100;
    public static final double INITIAL_BALABCE=100;

    public static void main(String[] args) {
        Bank b=new Bank(NACCOUNTS, INITIAL_BALABCE);
        int i;
        for(i=0;i<NACCOUNTS;i++){
            TransferRunnable r=new TransferRunnable(b, i, INITIAL_BALABCE);
            Thread t=new Thread(r);
            t.start();
        }
    }
}

Bank.java

 package org.javathread.joea;

public class Bank {

    private final double[] accounts;

    /**
     * 
     * @param n
     *            使用者數目
     * @param initialBalance
     *            使用者初始存款
     */
    public Bank(int n, double initialBalance) {
        accounts = new double[n];
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = initialBalance;
        }
    }

    /**
     * 從from賬戶轉錢到to賬戶
     * @param from 轉出賬戶
     * @param to 轉入賬戶
     * @param amount 轉錢數目
     */

    public void transfer(int from, int to, double amount) {
        if (accounts[from] < amount) {
            return;
        }
        System.out.println(Thread.currentThread());
        accounts[from] -= amount;
        System.out.printf("%10.2f from %d to %d", amount, from, to);
        accounts[to] += amount;
        System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
    }

    /**
     * 重新計算銀行中資料的總值
     * @return 錢的總數
     */

    private double getTotalBalance() {
        double sum = 0;
        for (double a : accounts)
            sum += a;
        return sum;
    }

    /**
     * 得到使用者數量
     * @return 使用者數量
     */

    public int size() {
        return accounts.length;
    }
}

TransferRunnable.java

package org.javathread.joea;

/**
 * 模擬使用者轉賬的執行緒
 * 
 * @author Joea
 * 
 */
public class TransferRunnable implements Runnable {

    private Bank bank;
    private int fromAccount;
    private double maxAmount;
    private final int DELAY = 10;

    public TransferRunnable(Bank b, int from, double max) {

        bank = b;
        fromAccount = from;
        maxAmount = max;
    }

    @Override
    public void run() {
        // TODO Auto-generated method stub
        try {
            while (true) {
                int toAccount = (int) (bank.size() * Math.random());
                double amount = maxAmount * Math.random();
                bank.transfer(fromAccount, toAccount, amount);
                Thread.sleep(DELAY);
            }
        } catch (InterruptedException e) {
            // TODO: handle exception
        }

    }

}

上面的程式碼是通過呼叫Bank類中的transfer方法,使使用者從一個賬戶轉移一定數目的錢款到另外一個賬戶。然後通過TransferRunnable類中的run方法不斷的從銀行中從某一個賬戶中隨機取出一定數目的錢,並且選擇一個隨機的目標賬戶進行轉賬。執行的結果如下:

  52.39 from 31 to 90Totle Balance :  10000.00 
Thread[Thread-55,5,main]
     86.58 from 55 to 15Totle Balance :  10000.00 
Thread[Thread-7,5,main]
     44.79 from 7 to 77Totle Balance :  10000.00 
Thread[Thread-30,5,main]
     14.38 from 30 to 22Totle Balance :  10000.00 
Thread[Thread-53,5,main]
.
.
.
Thread[Thread-78,5,main]
     34.40 from 78 to 61Totle Balance :   9912.33 
Thread[Thread-1,5,main]
     42.24 from 1 to 46Totle Balance :   9912.33 
Thread[Thread-70,5,main]
     27.56 from 70 to 69Totle Balance :   9912.33 

這裡我們發現銀行中錢的總數發生的輕微的變動。檢視我們的程式碼,我們會發現,我們雖然對銀行中的賬戶進行了相互的轉賬,但是我們並沒有改變銀行中錢的總數目。但是卻發生了我們意想不到的錯誤。這是為什麼呢。
這裡我們分析一下我們程式中的轉賬流程:

  • 獲取目標賬戶account[to]
  • 增加amount數目的錢
  • 將結果從新寫入account[to]
    現在我們假定某一個執行緒K獲得了account[i],並且剛好執行完步驟2,然後它被剝奪了執行的權利。然後另一個執行緒H被喚醒並且修改了account[i]中的數值,然後K執行緒被喚醒並且完成了第三步。所以導致了總的數目不再正確。

鎖物件和條件物件

鎖物件

Java中存在兩種機制來防止程式碼受併發訪問的影響。這裡我們先介紹鎖物件和條件物件。
java.util.concurrent框架中為我們提供了Lock介面並且引入了一個繼承Lock介面的一個類——ReentrantLock類。
這裡讓我們先使用一個鎖來保護Bank中的transfer方法:

public class Bank {

    private Lock bankLock = new ReentrantLock();
    .
    .
    .
    public void transfer(int from, int to, double amount) {
        if (accounts[from] < amount) {
            return;
        }
        bankLock.lock();
        try {
            System.out.println(Thread.currentThread());
            accounts[from] -= amount;
            System.out.printf("%10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
        } finally {
            bankLock.unlock();
        }
    }
    .
    .
    .

}

這是我們再執行程式碼的話,就會發現銀行中的錢的總數不會發生改變。這是因為一旦一個執行緒封鎖了鎖物件,那麼其他的任何執行緒都無法通過lock語句,都會進入阻塞狀態,直到第一個執行緒釋放鎖物件。所以當一個執行緒呼叫transfer時,即使在執行結束前被剝奪的執行的權利,此時第二個呼叫transfer的執行緒也不能獲得鎖物件。它必須等待第一個執行緒釋放鎖物件。

條件物件

再來看我們的transfer程式碼:

public void transfer(int from, int to, double amount) {
        if (accounts[from] < amount) {
            return;
        }
    ....
    }

當我們的轉出賬戶中的錢少於amount是我們沒有采取任何的操作。這裡我們對程式進行細化。我們避免選擇沒有足夠的資金的賬戶作為轉出賬戶。並且確保沒有其他的執行緒在本執行緒檢測餘額與轉賬操作之間修改餘額。所以通過鎖物件來保護檢測餘額與轉賬操作:

public void transfer(int from, int to, double amount) {
        bankLock.lock();
        try {
            while(accounts[from]<amount){
                ....
            }
            ....
        } finally {
            bankLock.unlock();
        }
    }

當某個執行緒中發現餘額不足的時候,我們想要該執行緒等待直到另一個執行緒為該賬戶注入足夠的資金。但是這一執行緒剛剛獲取了對bankLock的排他訪問,因此別的執行緒沒有對該賬戶進行訪問的許可權。這就是引入條件物件的原因。

一個鎖物件可能會擁有多個條件物件。我們可以用newCondition方法來獲取一個Condition物件。當該物件發現不能滿足執行緒的執行條件的時候,就會呼叫await()方法使當前執行緒進入阻塞狀態,並且放棄了鎖。比如:當transfer方法發現賬戶中餘額不足的時候就會呼叫await()方法。等待另一個執行緒對該賬戶進行增加餘額的操作。一旦一個執行緒呼叫了await方法,它將進入該條件的等待集中。當鎖可用的時候,該執行緒並不能馬上解除阻塞狀態,直到另外一個執行緒呼叫同條件上的signalAll方法為止。此時執行緒會再次檢測條件。
最後我們的Bank.java中的程式碼如下:

package org.javathread.joea;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Bank {

    private Lock bankLock;
    private Condition sufficient;

    private final double[] accounts;

    /**
     * 
     * @param n
     *            使用者數目
     * @param initialBalance
     *            使用者初始存款
     */
    public Bank(int n, double initialBalance) {
        accounts = new double[n];
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = initialBalance;
        }

        bankLock = new ReentrantLock();

        sufficient = bankLock.newCondition();// 獲得一個與該鎖相關的條件F
    }

    /**
     * 從from賬戶轉錢到to賬戶
     * 
     * @param from
     *            轉出賬戶
     * @param to
     *            轉入賬戶
     * @param amount
     *            轉錢數目
     * @throws InterruptedException 
     */

    public void transfer(int from, int to, double amount) throws InterruptedException {
        bankLock.lock();
        try {
            while (accounts[from] < amount) {
                sufficient.await();//餘額不足,進入阻塞狀態
            }
            System.out.println(Thread.currentThread());
            accounts[from] -= amount;
            System.out.printf("%10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
            sufficient.signalAll();//喚醒條件等待下的所有執行緒
        } finally {
            bankLock.unlock();
        }
    }

    /**
     * 重新計算銀行中資料的總值
     * 
     * @return 錢的總數
     */

    private double getTotalBalance() {
        double sum = 0;
        for (double a : accounts)
            sum += a;
        return sum;
    }

    /**
     * 得到使用者數量
     * 
     * @return 使用者數量
     */

    public int size() {
        return accounts.length;
    }
}

下面我們來總結一下鎖和條件的關鍵之處:

  • 鎖物件可以保證任何時刻只能有一個執行緒執行被保護的程式碼
  • 鎖可以擁有一個或者多個相關的條件物件
  • 鎖可以管理那些試圖進入被保護程式碼的執行緒
  • 每個條件物件管理那些已經進入被保護的程式碼但是還不能執行的執行緒

synchronized關鍵字

雖然Lock和Condition介面為程式設計師提供了高度的鎖定控制。但是,事實上從Java 1.0開始每一個物件都有一個內部鎖。如果一個方法用synchronized關鍵字宣告。那麼物件的鎖將保護整個方法。
內部鎖只有一個相關條件。wait方法新增一個執行緒到等待集。notify和notifyAll方法接觸等待執行緒的阻塞狀態。
用synchronized修飾的Bank方法如下:

package org.javathread.joea;

public class Bank {


    private final double[] accounts;

    /**
     * 
     * @param n
     *            使用者數目
     * @param initialBalance
     *            使用者初始存款
     */
    public Bank(int n, double initialBalance) {
        accounts = new double[n];
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = initialBalance;
        }

//      bankLock = new ReentrantLock();
//      
//      sufficient = bankLock.newCondition();// 獲得一個與該鎖相關的條件F
    }

    /**
     * 從from賬戶轉錢到to賬戶
     * 
     * @param from
     *            轉出賬戶
     * @param to
     *            轉入賬戶
     * @param amount
     *            轉錢數目
     * @throws InterruptedException 
     */

    public synchronized void transfer(int from, int to, double amount) throws InterruptedException {

            while (accounts[from] < amount) {
                wait();//餘額不足,進入阻塞狀態
            }
            System.out.println(Thread.currentThread());
            accounts[from] -= amount;
            System.out.printf("%10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf("Totle Balance :%10.2f %n", getTotalBalance());
            //sufficient.signalAll();//喚醒條件等待下的所有執行緒
            notifyAll();//喚醒條件等待下的所有執行緒
    }

    /**
     * 重新計算銀行中資料的總值
     * 
     * @return 錢的總數
     */

    private synchronized double getTotalBalance() {
        double sum = 0;
        for (double a : accounts)
            sum += a;
        return sum;
    }

    /**
     * 得到使用者數量
     * 
     * @return 使用者數量
     */

    public int size() {
        return accounts.length;
    }
}

可以看出用synchronized關鍵字來編寫程式碼要簡潔的多。當然要理解這種程式碼,你就必須要了解Lock物件和Condition物件。但是這種內部鎖還是存在一些侷限性的。如:當一個執行緒試圖獲得鎖的時候不能設定超時條件;每個鎖的條件單一,可能是不夠的;不能中斷一個正在試圖獲得鎖的執行緒。

關於執行緒還有很多的知識,深知自己只學習了執行緒的一些皮毛。之後的文章中會繼續的學習和分享對執行緒和同步學習的心得。
以上。