2018年12月24日 星期一

Java SE 入門教學 - 執行緒(1)

更新時間:12/24/2018

前言

以前電腦的 CPU 只有單一實體核心,而現在電腦的 CPU 都是實體多顆核心,能夠讓作業系統同時處理許多程序(Process)與執行緒(Thread),使得應用程式充分利用 CPU 資源。此篇文章是 Java (多)執行緒的一個入門,其實 Java 裡頭的執行緒完全可以寫成一本書了!這邊就是如何在 Java 中簡單使用(多)執行緒,並介紹(多)執行緒產生的後遺症。

執行緒是作業系統能夠進行運算排程的最小單位。它被包含在程序之中,是程序中的實際運作單位。一條執行緒指的是程序中一個單一順序的控制流,一個程序中可以並行多個執行緒,每條執行緒並列執行不同的任務。

為什麼使用執行緒?

在 Java 程序中使用執行緒有許多原因。如果您使用 Swing、servlet、RMI 或 Enterprise JavaBeans(EJB)技術,您也許沒有意識到您已經在使用執行緒了。使用執行緒的一些原因是它們可以幫助:

使 UI 響應更快

事件驅動的 UI 工具箱(如 AWT 和 Swing)有一個事件執行緒,它處理 UI 事件,如按下鍵盤或點擊滑鼠。 AWT 和 Swing 程序把事件偵聽器與 UI 物件連接。當特定事件(如單擊了某個按鈕)發生時,這些偵聽器會得到通知。事件偵聽器是在 AWT 事件執行緒中調用的。 如果事件偵聽器要執行持續很久的任務,如檢查一個大文檔中的拼寫,事件執行緒將忙於運行拼寫檢查器,所以在完成事件偵聽器之前,就不能處理額外的 UI 事件。這就會使程序看來似乎停滯了,讓用戶不知所措。 要避免使UI 延遲響應,事件偵聽器應該把較長的任務放到另一個執行緒中,這樣 AWT 執行緒在任務的執行過程中就可以繼續處理 UI 事件(包括取消正在執行的長時間運行任務的請求)。

利用多處理器(MultiProcessor, MP)系統

多處理器(MP)系統比過去更普及了。以前只能在大型數據中心和科學計算設施中才能找到它們。現在許多低端服務器系統,甚至是一些個人主機或行動裝置系統都有多個處理器。 現代操作系統,包括 Linux、Solaris 和 Windows Server/7/10,都可以利用多個處理器並調度執行緒在任何可用的處理器上執行。 調度的基本單位通常是執行緒;如果某個程序只有一個活動的執行緒,它一次只能在一個處理器上運行。如果某個程序有多個活動執行緒,那麼可以同時調度多個執行緒。在精心設計的程序中,使用多個執行緒可以提高程序吞吐量和性能。

簡化建模

在某些情況下,使用執行緒可以使程序編寫和維護起來更簡單。考慮一個仿真應用程序,您要在其中模擬多個實體之間的交互作用。給每個實體一個自己的執行緒可以使許多仿真和對應用程序的建模大大簡化。 另一個適合使用單獨執行緒來簡化程序的示例是在一個應用程序有多個獨立的事件驅動組件的時候。例如,一個應用程序可能有這樣一個組件,該組件在某個事件之後用秒數倒數計時,並更新螢幕顯示。與其讓一個主循環定期檢查時間並更新顯示,不如讓一個執行緒什麼也不做,一直休眠,直到某一段時間後,更新螢幕上的計數器,這樣更簡單,而且不容易出錯。這樣,主執行緒就根本無需擔心計時器。

執行異步(Asynchronous)或後台處理

服務器應用程序從遠程來源,如網路插座(Network socket, 網路埠)獲取輸入。當讀取 Network socket 時,如果當前沒有可用數據,那麼對 SocketInputStream.read() 的調用將會阻塞,直到有可用數據為止。 如果單執行緒程序要讀取 Network socket,而 Network socket 另一端的實體並未發送任何數據,那麼該程序只會永遠等待,而不執行其它處理。相反,程序可以輪詢 Network socket,查看是否有可用數據,但通常不會使用這種做法,因為會影響性能。 但是,如果您創建了一個執行緒來讀取 Network socket,那麼當這個執行緒等待 Network socket 中的輸入時,主執行緒就可以執行其它任務。您甚至可以創建多個執行緒,這樣就可以同時讀取多個 Network socket。這樣,當有可用數據時,您會迅速得到通知(因為正在等待的執行緒被喚醒),而不必經常輪詢以檢查是否有可用數據。使用執行緒等待 Network socket 的代碼也比輪詢更簡單、更不易出錯。


一、執行緒的基本概念

執行緒是一個程序內部的順序控制流。

執行緒和程序的區別:

  • 每個程序都有獨立的代碼和數據空間,程序間的切換會有較大的開銷。
  • 執行緒可以看成是輕量級的程序,同一類執行緒共享代碼和數據空間,每個執行緒有獨立的運行棧和程式計數器(Program counter, PC),執行緒切換的開銷小。
  • 多程序:在操作系統中能同時運行多個任務(程序)。
  • 多執行緒:在同一應用程式中有多個順序流同時執行。

Java 的執行緒是通過 java.lang.Thread 類別來實現的,JVM 啟動時會有一個由主方法(public static void main(String[] args){})所定義的執行緒,之後可以通過創建 Thread 的物件實例來創建新的執行緒。每個執行緒都是通過某個特定 Thread 物件所對應的方法 run() 來完成其操作的,方法 run() 稱為執行緒體,最後通過調用 Thread 類別的 start() 方法來啟動一個執行緒。

執行緒的理解:執行緒是一個程式裡面不同的執行路徑

每一個分支都叫做一個執行緒,main()叫做主分支,也叫主執行緒。程式只是一個靜態的概念,機器上的一個 *.class 文件,機器上的一個 *.exe 文件,這個叫做一個程序。程序的執行過程都是這樣的:首先把程序的代碼放到記憶體的代碼區裡面,代碼放到代碼區後並沒有馬上開始執行,但這時候說明了一個程序準備開始,程序已經產生了,但還沒有開始執行,這就是程序,所以程序其實是一個靜態的概念,它本身就不能動。平常所說的程式的執行指的是程序裡面主執行緒開始執行了,也就是 main() 方法開始執行了。程序是一個靜態的概念,在我們機器裡面實際上運行的都是執行緒。

Windows 操作系統是支持多執行緒的,它可以同時執行很多個執行緒,也支持多程序,因此 Windows 操作系統是支持多執行緒多程序的操作系統。Linux 和 Uinux 也是支持多執行緒和多程序的操作系統。DOS 就不是支持多執行緒和多程序了,它只支持單程序,在同一個時間點只能有一個程序在執行,這就叫單執行緒。

CPU 難道真的很神通廣大,能夠同時執行那麼多程序嗎?不是的,CPU 的執行是這樣的:CPU 的速度很快,一秒鐘可以算好幾億次,因此 CPU 把自己的時間分成一個個小時間片段,我這個時間片段執行你一會兒,下一個時間片段執行他一會兒,再下一個時間片段又執行其他人一會兒,雖然有幾十個執行緒,但一樣可以在很短的時間內把他們通通都執行一遍,但對我們人來說,CPU 的執行速度太快了,因此看起來就像是在同時執行一樣,但實際上在一個時間點上,CPU 只有一個執行緒在運行。

學習執行緒首先要理清楚三個概念:

  • 程序:程序是一個靜態的概念
  • 執行緒:一個程序裡面有一個主執行緒叫 main() 方法,是一個程序裡面的,一個程序裡面不同的執行路徑。
  • 在同一個時間點上,一個 CPU 只能支持一個執行緒在執行。因為 CPU 運行的速度很快,因此我們看起來的感覺就像是多執行緒一樣。

什麼才是真正的多執行緒?如果你的機器是實體雙核 CPU,這確確實實是多執行緒。


二、執行緒的創建和啟動

可以有兩種方式創建新的執行緒:

2.1 第一種:實作 Runnable 介面

  • Runnable 中只有一個方法 public void run() 用以定義執行緒運行體。
  • 使用 Runnable 介面可以為多個執行緒提供共享的數據。
  • class MyJob implements Runnable {
      @Override
      public void run(){...}
    }
  • Thread cpu1 = new Thread(Runnable target),建立執行緒。
  • MyJob work = new MyJob();
    Thread cpu1 = new Thread(work);

2.2 第二種:繼承 Thread 類別

可以定義一個 Thread 的子類別覆寫其 run() 方法,如:

class MyThread extends Thread {
  @Override
  public void run(){...}
}

然後生成該類別的物件,如:

MyThread cpu2 = new MyThread(...);

在 Java 裡面,Java 的執行緒是通過 java.lang.Thread 類別來實現的,每一個 Thread 物件代表一個新的執行緒。創建一個新執行緒出來有兩種方法:第一個是從 Thread 類別繼承,另一個是實作介面 runnable。JVM 啟動時會有一個由主方法(public static void main(String[] args))所定義的執行緒,這個執行緒叫主執行緒。可以通過創建 Thread 的物件實例來創建新的執行緒。你只要 new 一個 Thread 物件實例,一個新的執行緒也就出現了。每個執行緒都是通過某個特定的 Thread 物件所對應的方法 run() 來完成其操作的,方法 run() 稱為執行緒體。

範例:取得主執行緒的名字


範例:建立另一個 Job1 類別實作 Runnable 介面

▼ 對著專案中的套件(這邊放在 default package)按右鍵,新增一個 Java 類別(Class)

▼ 取類別名字(這邊取 Job1),然後按【Finish】

覆蓋 run() 方法

回到 MutiThread1 類別中的主程式,增加程式碼


三、執行緒狀態轉換

3.1 新建:

新建立一個執行緒物件實例,Thread t = new Thread();。

3.2 就緒:

呼叫執行緒的 start() 方法,啟動執行緒,該執行緒位於可執行執行緒池中,等待 CPU 資源。

3.3 執行:

就緒的執行緒池中的執行緒,獲取 CPU 時間片段(Timeslice)後,執行緒執行方法 run()。

3.4 阻塞:

執行中執行緒因為某些原因,放棄 CPU 資源,暫時停止執行,直到進入就緒執行緒池中。阻塞的情況分為三種:

(i) 等待阻塞

執行中,執行緒執行了鎖物件的 wait() 方法,JVM 將執行緒放到鎖物件的等待區域(Wait Set)中。

(ii) 同步阻塞

執行中,執行緒在獲取物件的同步鎖時,若該同步鎖被別的執行緒佔用,則 JVM 虛擬機器會把執行緒放入鎖池(Lock Pool)中。

(iii) 其它阻塞

執行緒執行了 sleep() 或 join() 方法,或發出 IO 請求時,JVM 會把執行緒設定為阻塞狀態。當 sleep() 狀態超時、join() 等待執行緒終止、或者 IO 處理完畢時,執行緒則重新轉入就緒執行緒池中,等待 CPU 資源。

3.5 死亡:

執行緒 run()、main() 方法執行結束,則該程序結束生命週期。死亡的執行緒不可再次復生。


▼ 執行緒狀態轉換圖

範例:建立多執行緒

建立 Job2 類別,實作 Runnable 介面。

建立 Job3 類別,繼承 Thread 類別。

修改 MutiThread1 類別


四、執行緒基本控制的方式

在 Thread 類別中有許多常用的方法;有些是取得、設置此執行緒的屬性或狀態;有些是控制執行緒的方法。

取得、設置執行緒的屬性或狀態

方法 功能
getPriority() 獲得執行緒的優先級數值。
setPriority(int newPriority) 設置執行緒的優先級數值。
getName() 獲取執行緒的名稱。
setName(String name) 設置執行緒的名稱。
getId() 獲得執行緒的標識符。
getState() 獲得執行緒的狀態。
getThreadGroup() 獲得執行緒的群組
isDaemon() 判斷執行緒是否在"背景"執行。
setDaemon(boolean on) 將此執行緒標記為"背景"或"前景"執行。
isAlive() 判斷執行緒是否還"活"著,即執行緒是否還未終止。
isInterrupted() 判斷執行緒是否"被中斷"。

控制執行緒的方法

方法 功能
start() 啟動執行緒。
sleep(long millis)
sleep(long millis, int nanos)
將當前的執行緒睡眠指定毫秒數。
interrupt() 將這個執行緒中斷。
join()
join(long millis)
join(long millis, int nanos)
調用某執行緒的該方法,將當前執行緒與該執行緒"合併",即等待該執行緒結束,再恢復當前執行緒的執行。
yield() 讓出 CPU,當前執行緒進入就緒的可執行的執行緒池中等待執行。

範例:sleep

修改 Job1 的程式碼

修改 MutiThread1 的程式碼


範例:join

只修改 MutiThread1 類別中的主程式,Job1 類別維持使用上述程式碼。


範例:yield

修改 Job1 的程式碼

修改 MutiThread1 的程式碼

▼ 使當前執行緒從「執行狀態」變為「可執行(就緒)狀態」。
cpu 會從眾多的可執行狀態裡選擇,也就是說,
當前也就是剛剛的那個執行緒還是有可能會被再次執行到的。

五、競速情況(Race Condition)

執行緒存取同一物件相同資源(如屬性變數)時,會引發「競速情況(Race Condition)」。

例如:工地工頭請丁一與王二合作搬走 50 包水泥

修改 Job1 類別

修改 MutiThread1 類別

▼ 因為作業系統對於執行緒而言,只會執行一小段時間(timeslice)
所以完全不曉得執行到"哪一個語句"就被推回去可執行緒池中。

另一個競速情況的例子

建立一個 Company 類別

建立一個 Driver 類別,繼承 Thread 類別

建立一個 TestThreadProblem 類別

▼ 公司虧大了!

為了解決「競速情況(Race Condition)」的問題,Java 提供修飾字「synchronized」,實現同步化區塊。


六、同步(Synchronization)

多執行緒的程序可能經常遇到多個執行緒試圖訪問相同資源並最終產生錯誤和無法預料的結果的情況。因此需要通過某種同步方法確保只有一個執行緒可以在給定的時間點訪問資源。Java 中的同步塊使用 synchronized 關鍵字標記。Java 中的同步塊在某個物件上同步。在同一物件上同步的所有同步塊一次只能在其中執行一個執行緒。嘗試進入同步塊的所有其他執行緒將被阻塞,直到同步塊內的執行緒退出同步塊。共有三種方式使用同步塊:

6.1 同步自身物件實例(this):不建議使用此種方式

synchronized(this){
  // 程式碼區塊
}

修改 Company 類別


6.2 直接當成方法的修飾字,同步化方法

[modifier] synchronized returnType nameOfMethod(Parameter List){
  // 方法內程式碼
}

修改 Company 類別


6.3 同步特定的物件實例

synchronized(sync_object){
  // 程式碼區塊
}

不同步 Company 類別

修改 Driver 類別


七、重入鎖(ReentrantLock)

ReentrantLock 類別實作 Lock 介面,並在訪問共享資源時為方法提供同步。使用 lock() 和 unlock() 方法操作共享資源的代碼。這會鎖定當前工作執行緒並阻止嘗試鎖定共享資源的所有其它執行緒。

顧名思義,ReentrantLock 允許執行緒不止一次地對資源進行鎖定。當執行緒首次進入鎖定狀態時,保持計數設置為1。在解鎖之前,執行緒可以再次重新進入鎖定狀態,並且每次保持計數增加1。對於每個解鎖請求,保持計數減1,當保持計數為0時,資源解鎖。

ReentrantLock locker = new ReentrantLock();
// ...

locker.lock(); // 阻止直到條件成立
try {
  // 鎖住區塊
} finally {
  lock.unlock();// 解鎖
}

這邊簡單的使用 ReentrantLock 的範例,它有更深入的議題將不在此討論。

不同步 Driver 類別

在 Company 類別中使用 ReentrantLock


八、原子類型、原生型態(Atomic)

除了上述兩種方式可以解決競速情況外,Java 從 JDK 1.5 開始提供 java.util.concurrent.atomic 套件包,裡頭定義了支持單一變數的原子操作(Atomic Operations)。Atomic 套件封裝了一系列的原子類型,並透過這些類別的方法封裝變數的存取過程與一些常見的判斷邏輯,使這些操作過程都變得不可分割的運算。

原子變數的底層使用了處理器提供的原子指令,但是不同的 CPU 架構可能提供的原子指令不一樣,也有可能需要某種形式的內部鎖,所以該方法不能絕對保證執行緒不被阻塞。這邊只操作簡單的範例,至於更深入的問題不再此篇討論。

使用 AtomicInteger 處理搬水泥的競速問題,範例如下:

修改 Job1 類別

修改 MutiThread1 類別,設定執行緒的優先權,但只是建議作業系統,而作業系統採不採用就不知道了!


九、總結

我們瞭解到執行緒的流程、程序的意義。執行緒會在作業系統中有許多的狀態:可執行的執行緒池、執行池、鎖池、等待池或阻塞等狀態。Java 創建執行緒有兩種方式,實作 Runnable 介面或繼承 Thread 類別,這兩種方式都必須覆寫 run() 方法(執行的演算法),且使用 Thread 類別中的 start() 方法開啟另一個執行緒。停止的條件必須在 run() 方法中設定完成

多執行緒如果共享資源時,會產生「競速情況(Race Condition)」,造成資料處理錯誤。Java 中提供 synchronized 關鍵字、Lock 介面或 java.util.concurrent.atomic 原子套件處理競速情況。





沒有留言:

張貼留言