多執行緒(Java Thread)

一般單執行緒的程式,一次只能處理一件事情,如果同時有許多工作需要處理,那就得一個個排隊處理,當然當處理的速度跟不上新增工作的速度時,排隊工作就愈一直越排越長,在加上可能有部份比較緊急的工作可是禁不起這般長時間的等待。而多執行緒程式主要是利用切割cpu執行時間的方式,讓單一CPU可以一次處理多項的工作,每個工作都會分配到一小段的CPU執行時間,時間一到就換下一個工作執行,由於切割的時間非常的小,在快速的切換執行下每個工作看起來就像是在同時進行一般,但必須特別聲明一點,多執行緒對單一CPU的電腦而言並不會真的讓執行的速度變快,只是能讓每個工作都能公平的輪流被執行,而不用等待一段很長的時間,下圖為多執行緒的基本運作原理。

多執行緒運作方式

每一個執行緒就如同一個新的應用程式執行在JavaVM之中,一個執行緒從產生到結束,中間可能會經過許多的狀態變化,如待命、休息、暫停、等待、死亡,而這過程就稱為Thread的生命週期,下圖為執行緒於JavaVM中的運作狀態圖:

圖 410 執行緒生面週期圖

  • Initial一個執行緒被創造出來後首先會進入到Initial狀態,對於執行緒對初始設定,直到被呼叫Start()函數後才會進入到Ready狀態。
  • Ready所有的執行緒在要進入Running狀態前都必須先在Ready狀態中等待,只有被排程演算法所挑中的執行緒才可以進入Running狀態,並取得CPU時間執行程式。
  • Running透過一些排程演算法所挑選出來的執行緒,在進入此狀態後便可取得CPU的資源並執行程式。一直到能夠使用的CPU時間到了,或是呼叫了yield()函式,該緒行緒就會回到Read的狀態,等待下次排程程式在次選到。
  • Sleeping在Running中的執行緒一但被呼叫sleep()函式後,便會進入到Sleeping狀態,一但執行緒進入睡眠狀態,便會釋放CPU資源給其他的執行緒使用,一直到定義的睡眠的時間結束後並不會立即的回複到Runnig狀能,而是會回到Ready的狀態,等待下次排程選到它時才會在繼續執行sleep前未完成的工作。
  • Suspended當執行使用suspend()函數時,此執行緒便會進入Suspended狀態,一直到呼叫resume()函數時才會回到Ready狀態。
  • Blocked當執行緒中有需要使用到一些IO裝置的存取時(如開檔案讀檔),此時執行緒就必須等待這些裝置的回應,此時執行緒便會進入Bloaded狀態。
  • Dead當執行緒完成,或是呼叫stop()函數,則此執行緒便進入Dead狀態,一但執行緒進入這個狀態,便無法在回到其他的狀態,只能等待垃圾收集器(Garbage Collection)來將它收集走。

Thread類別和Runnable介面

在Java語言中要設計多執行緒程式的方法有以下兩種,第一種為透過繼承Thread物件,另一種方法則是透過實作Runnable介面的方式透過Thread物件來為我們執行。之所以會有Runnable介面的方式,主要是由於Java並不支援多重繼承,所以當物件己無法在繼承Thread物件時,這時就可以採用Runnable介面的方式,同樣可以擁有多執行緒的能力。下面的範例將分別透過兩種不同的方式實作,同樣都能擁有多執行緒的功能。

  • 繼承Thread

藉由繼承Thread類別,可讓我們的程式具有多執行緒的能力,在加入執行緒前程式碼只能循序式的進行,若程式中有使用到一些IO操作(如檔案讀寫、串流的讀寫),將會造成長時間的等待,而無法繼續執行排在IO操作後面的程式碼。例如System.in.read()函式,執行後程式會等待讀取使用者至鍵盤所輸入資料,若此時鍵盤都無輸入任何資料,則程式便會一直在等待的狀態,排在IO等待後的程式碼也就無法被執行,這就有點像一條單行道的馬路,只要途中一台車開的比較慢,或是停在路中間,後面所有的車隊便會同時受到影響,可能會行進變慢,也有可能會完全動彈不得。Thread類別直接繼承自Object類別,並位於java.lang套件中,因此在程式中可以直接建立Thread物件,而不需要import任何套件。

以下是Thread類別比較常用的的建構式與函式:

  • Thread()

建立一個Thread物件。

  • Thread(String name)

建立一個Thread物件,並指定Thread名稱。

  • Thread(Runnable target)

使用Runnable介面建立一個Thread物件。

  • Thread(Runnable target, String name)

使用Runnable介面建立一個Thread物件,並指定Thread名稱。

  • static int activeCount()

傳回現在正在運行的Thread數量。

  • void checkAccess()

確認現在的Thread是否允許被存取。

  • static Thread currentThread()

傳回現在正在執行的Thread物件。

  • String getName()

取得Thread的名稱。

  • int getPriority()

取得Thread的優先權。

  • boolean isAlive()

測試此Thread是否還存活著。

  • boolean isDaemon()

測試此Thread是否為背景執行緒。

  • void join()

等待直到此Thread結束(死亡)。

  • void join(long millis)

等待此Thread結束,最多等待millis亳秒。

  • void join(long millis, int nanos)

等待此Thread結束,最多等待millis亳秒、nanos微亳秒的時間。

  • void run()

啟動(start)Thread後所呼叫的函式,如果在建構此執行緒時有指定一個Runnable介面,則Runnable介面的run()函式將會在此函式中被呼叫。

  • void setDaemon(boolean on)

設定此Thread為背景執行緒或使用者執行緒。

  • void setName(String name)

設定此Thread名稱。

  • void setPriority(int newPriority)

設定此Thread優先權。

  • static void sleep(long millis)

使此Thread睡眠millis亳秒的時間。

  • static void sleep(long millis, int nanos)

使此Thread睡眠millis亳秒加上nanos微亳秒的時間。

  • void start()

啟動Thread,JVM將會呼叫Thread中的run()函式,自此後將會產生另一條新的執行緒。

  • String toString()

以字串型態傳回此執行緒的相關資訊,包含名稱、優先等級、所屬群組等。緒的

  • static void yield()

使此執行緒暫時的中斷執行,並允許其他執行緒先執行。

在以上Thread函式中最主要的兩個函式分別是start()與run()函式,當我們呼叫start()函式後,JVM會將此Thread物件丟到執行緒排程器中(scheduler),所有的Thread都會經由排程器,來決定何時可以被執行。當Thread拿到執行權時,run函式將會被呼叫,在run()函式中會檢查建構此Thrad時,有無傳入Runnable介面,若有則Runnable介面中的run()函式會被呼叫。因此執行緒中所要執行的程式碼必須撰寫在run()函式中,並藉由呼叫start()函式來啟動一個執行緒。以下範例為一個簡單的倒數計時器,當該物件的run函式被呼叫後,便會開始倒數計時五秒,並將秒數顯示在螢幕上。為突顯多執行緒與單一執行緒的差別,以下的範例將會分別利用單一執行緒與多執行緒兩種方式撰寫。

【範例程式】單一執行緒-倒數計時器

package com.lattebox.thread;

【執行結果】

2

圖 411 單執行緒倒數計時器範例

【範例程式】多重執行緒-倒數計時器

package com.lattebox.thread;

【執行結果】

2

圖 412 多執行緒倒數計時器

  • Runnable介面

利用Runnable介面撰寫多執行緒的方式與利用繼承Thread的方式並無太大的不同,我們拿前面的倒數計時器範例來做修改,首先在宣告類別名稱的後面必須加上implements Runnable,以實作Runnable介面。在Runnable介面中只有定義一個抽像(abstract)函式run(),所以只需實作run()函式,並在run()函式中撰寫所要執行的程式碼,最後在建立Thread物件,並在建構式中傳入實作Runnable介面的物件(RunnableThread ),並呼叫start()函式,將Thread丟到執行緒排程器中,一個新的執行緒便就此產生。

【範例程式】多緒行緒倒數計時器-使用Runnable介面實作

package com.lattebox.thread;

【執行結果】

2

圖 413 多執行緒倒數計時器-使用Runnable介面實作

  • 執行緒的同步(Synchronized)

若同時有多個Thread想要存取同一個Object的資源,此時必須要有同步機制去避免資料的不一致性。而在Java中提供了synchronized關鍵字可以讓我們來將Object上鎖,每一個要進來存取Object內Method的Thread都必須先取得一把KEY後才能進來執行。因此可以避免資料因Thread多重存取而造成不一致的狀況。

public class Bank {

需要注意的是在Java中每個Object都有其獨 立的Lock Key。如下圖所示,當Object one被lock後,並不會影影Object Two 的運作。

  • Synchronized的語法
  • Synchronized Method

  • Synchronized Static Method

  • Synchronized(this)

  • Synchronized(SomeObject)

  • Thread的協調運作

Java使用wait、notifty來處理Thread之間的流程控制。當Thread之間具有相依性需要交互運作時,可使用wait來將某些Thread進入等待,接著將需要運作的Thread重新喚起(notify)處理,如此可以控制Thread的運作順序與交互的運作流程。。

wait()

notifty()

notifyAll()

  • Java.util.current Package

在JDK1.4之前,要實作Thread相關的應用如ThreadPool、BlockingQueue、Semaphore..,是相當不容易的一件事,你必須對Thread的運作流程相當的熟悉,才能掌握開發Thread相關應用。而在JDK1.5之後Sun提供了一套java.util.current套件,該套件提供了大量的高階Thread操作工具,可以協助開發者撰寫易維護、高效能、安全的多執行緒應用程式。開發者將可以專注在應用程式的開發上,複雜的Thread管理與控制問題就交由current套件來協助解決。以下將與大家介紹幾個常用的多執行緒應用套件。

  • ThreadPool實作

在Java中建構Thread是相當耗費記憶體與CPU時間,如果你的應用程式需要很頻繁的建構Thread,那麼將會造成一直不斷的有己使用過的Thread物件被產生出來,達到一定數量後會造成Java的資源回收器(GC)起來回收Thread物件,最終會拖累到整個應用程式的運行效能。在上述的應用中建立一個可回收使用的ThreadPool是有必要的,這將可以避免不斷的重新建構與回收Thread。在Java5中ThreadPool的實作方式可利用java.util.current.Executors來協助產生各種不同的ThreadPool。下表為五種常見的ThreadPool應用模式。

方法 說明
newCachedThreadPool 建立一個具有快取功能的ThreadPool,每個使用過的Thread將會保留60秒的時間,若在這60秒內有任務要求執行,則重複使用快取中的Thread。
newFixedThreadPool 包括固定數量的Thread
newSingleThreadExecutor 單一的Thread運行,相等於newFixedThreadPool(1)。一次只運行一個任務。
newScheduledThreadPool 可排程的Thread
newSingleThreadScheduledExecutor 單一可排程的Thread

【範例程式】ThreadPool

package com.ittraining.currentapi;

【運行結果】

  • Callable與Future實作

Callable與Runnable類似,都是可以啟動另一執行緒來運行的介面,但是Callable具有回傳結果物件且可以丟出例外,而Runnable則沒有回傳值,也無法丟出例外。Callable介面的定義如下,繼承的類別必須要實作call函式(相對於Runnalbe中的run).

public interface Callable<V> {

一般使用上Callable會搭配Future介面來使用,Future介面提供了對執行緒查尋與控制能力,例如你可以透過Future取消運行的執行緒、查尋執行緒任務的運行狀態、取得執行緒任務的運作結果。Future的實作類別為FutureTask,可以使用FutureTask來包裝Callable介面。因FutureTask本身實作了Runnable介面,因此可以直接將FutureTask放置到Thread中運行,或是交由Executor來執行。 

舉例來說,當我們的應用程式中有一塊非常耗時的IO操作(例如至網頁上下載圖片),你可以將該IO包裝在FutureTask中,在程式背景中預先處理執行(預載),等待你有需要顯示該圖片時便可以透過FutureTask直接取得結果資料而無需在等待。

【範例程式】FutureTask與Callable

package com.ittraining.currentapi;

【運行結果】

  • BlockingQueue、SynchronousQueue 與DelayQueue

Queue是一個先進先出(FIFO)的資料結構,在多執行序的環境下,BlockingQueue可以來讓非同步的資料處理轉換成同步的呼叫。SynchronousQueue則是具有同步交握的特性,它是單一元素的BlockingQueue,不同之處在於就算元素空間還沒有滿,放置元素到SynchronousQueue中依然有可能造成Block操作。而DelayQueue則是提供依時間優先順序排程的能力,可對Queu中的每個元素指定可取用的delay時間,DelayQueue會自動利用這些時間做優先排程,可利用在快取用途。SynchronousQueue與DelayQueue都算是BlockingQueue的延伸應用,提供開發者可以在不同的應用場合上選擇適當的套件來使用。

BlockingQueue基本操作形式

丟出異常 特殊值 阻塞 逾時
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time, unit)
檢查 element() peek() 不可用 不可用
  • BlockingQueue

在BlockingQueue中若Queue的資料己經滿了則任何要在放入資料進Queue的Thread都會被Block住,直到Queue中有元素被取走了後才會解除Block。反之亦然,若Queue己為空的沒有任何元素資料,則任何要來取得Queue中元素的Thread都會被Block,直到有其它執行緒把資料填進Queu才會解除Block。

一般BlockingQueue可以被利用在Client-Sever的應用程式架構中,Client等待Server的Ack回覆資料,使用BlockingQueue來等待ACK封包可以省去為了查尋封包而不斷的去Polling是否有資料回傳。

【範例程式】BlockingQueue

package com.ittraining.currentapi;

【運行結果】

  • SynchronousQueue

SynchronousQueue主要用的途是用來同步化讀取與寫入請求,在一個SynchronousQueue中一個put的請求必須等待一個talk的請求,相同的一個talk的請求也必須等待put的請求。所以put與get都是在互相等待的狀況,只有在兩者都準備好了資料才會從put端到talk端。

【範例程式】SynchronousQueue

package com.ittraining.currentapi;

【運行結果】

  • DelayQueu

DelayQueue利用了相當巧妙的設計,提供了開發者可對Queue裡的每個元素各別定義Delay時間,當Queue中的所有元素Delay時間都未到期時,則此時呼叫talk()會block住,直到DelayQueue中有某個元素時間到期了,則該元素就會被取出。 這個一巧妙的設計很適合應用在快取與Server的Session越時管理上。舉例來說,你可以利用DelayQueue設計一個具有時間管理的快取,每筆快取資料只暫存一分鐘,若一分鐘之內沒有人取用則就自動將該快取資料移除。在先前介紹的ThreadPool章節中提到Executors.newCachedThreadPool便是DelayQueue的一項應用。

【範例程式】實作Delayed介面的類別

package com.ittraining.currentapi.cache;

【範例程式】快取實作

package com.ittraining.currentapi.cache;

【範例程式】測試程式,主程式

| package com.ittraining.currentapi.cache; | | --- |