,在程式設計語言中,線程對程式員可以說是一種又愛又恨的矛盾,一方面可以大大的簡化模型,協助程式員編寫出功能強大的代碼;另一方面又可能因為考慮不周全使得我們的程式出現各種大大小小的很難在開發環境中複現的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對象。但是這種內部鎖還是存在一些局限性的。如:當一個線程試圖獲得鎖的時候不能設定逾時條件;每個鎖的條件單一,可能是不夠的;不能中斷一個正在試圖獲得鎖的線程。
關於線程還有很多的知識,深知自己只學習了線程的一些皮毛。之後的文章中會繼續的學習和分享對線程和同步學習的心得。
以上。