按照規劃,從本篇開始我們開啟『併發』系列內容的總結,從本篇的執行緒開始,到執行緒池,到幾種併發集合原始碼的分析,我們一點點來,希望你也有耐心,因為併發這塊知識是你職業生涯始終繞不過的坎,任何一個專案都或多或少的要涉及一些併發的處理。
這一系列文章只能算是對併發這塊基本理論知識的一個總結與介紹,想要成為併發高手,必然是需要通過大規模併發訪問的線上場景應用,或許以後我有了相關經驗了,再給你們做一點分享吧。
基本的程式執行緒概念
程式和執行緒算是作業系統內兩個很基本、很重要的概念了,程式是作業系統中進行保護和資源分配的基本單位,作業系統分配資源以程式為基本單位。而執行緒是程式的組成部分,它代表了一條順序的執行流。
系統中的程式執行緒模型是這樣的:
程式從作業系統獲得基本的記憶體空間,所有的執行緒共享著程式的記憶體地址空間。當然,每個執行緒也會擁有自己私有的記憶體地址範圍,其他執行緒不能訪問。
由於所有的執行緒共享程式的記憶體地址空間,所以執行緒間的通訊就容易的多,通過共享程式級全域性變數即可實現。
同時,在沒有引入多執行緒概念之前,所謂的『併發』是發生在程式之間的,每一次的程式上下文切換都將導致系統排程演算法的執行,以及各種 CPU 上下文的資訊儲存,非常耗時。而執行緒級併發沒有系統排程這一步驟,程式分配到 CPU 使用時間,並給其內部的各個執行緒使用。
在分時系統中,程式中的每個執行緒都擁有一個時間片,時間片結束時儲存 CPU 及暫存器中的執行緒上下文並交出 CPU,完成一次執行緒間切換。當然,當程式的 CPU 時間使用結束時,所有的執行緒必然被阻塞。
JAVA 對執行緒概念的抽象
JAVA API 中用 Thread 這個類抽象化描述執行緒,執行緒有幾種狀態:
- NEW:執行緒剛被建立
- RUNNABLE:執行緒處於可執行狀態
- BLOCKED、WAITING:執行緒被阻塞,具體區別後面說
- TERMINATED:執行緒執行結束,被終止
其中 RUNNABLE 表示的是執行緒可執行,但不代表執行緒一定在獲取 CPU 執行中,可能由於時間片使用結束而等待系統的重新排程。BLOCKED、WAITING 都是由於執行緒執行過程中缺少某些條件而暫時阻塞,一旦它們等待的條件滿足時,它們將回到 RUNNABLE 狀態重新競爭 CPU。
此外,Thread 類中還有一些屬性用於描述一個執行緒物件:
- private long tid:執行緒的序號
- private volatile char name[]:執行緒的名稱
- private int priority:執行緒的優先順序
- private boolean daemon = false:是否是守護執行緒
- private Runnable target:該執行緒需要執行的方法
其中,tid 是一個自增的欄位,每建立一個新執行緒,這個 id 都會自增一。優先順序取值範圍,從一到十,數值越大,優先順序越高,預設值為五。
Runnable 是一個介面,它抽象化了一個執行緒的執行流,定義如下:
public interface Runnable {
public abstract void run();
}
複製程式碼
通過重寫 run 方法,你也就指明瞭你的執行緒在得到 CPU 之後執行指令的起點。我們一般會在構造 Thread 例項的時候傳入這個引數。
建立並啟動一個執行緒
建立一個執行緒基本上有兩種方式,一是通過傳入 Runnable 實現類,二是直接重寫 Thread 類的 run 方法。我們詳細看看:
1、自定義 Runnable 實現
public class MyThread implements Runnable{
@Override
public void run(){
System.out.println("hello world");
}
}
複製程式碼
public static void main(String[] args) {
Thread thread = new Thread(new MyThread());
thread.start();
System.out.println("i am main Thread");
}
複製程式碼
執行結果:
i am main Thread
hello world
複製程式碼
其實 Thread 這個類也是繼承 Runnable 介面的,並且提供了預設的 run 方法實現:
@Override
public void run() {
if (target != null) {
target.run();
}
}
複製程式碼
target 我們說過了,是一個 Runnable 型別的欄位,Thread 建構函式會初始化這個 target 欄位。所以當執行緒啟動時,呼叫的 run 方法就會是我們自己實現的實現類的 run 方法。
所以,自然會有第二種建立方式。
2、繼承 Thread 類
既然執行緒啟動時會去呼叫 run 方法,那麼我們只要重寫 Thread 類的 run 方法也是可以定義出我們的執行緒類的。
public class MyThreadT extends Thread{
@Override
public void run(){
System.out.println("hello world");
}
}
複製程式碼
Thread thread = new MyThreadT();
thread.start();
複製程式碼
效果是一樣的。
幾個常用的方法
關於執行緒的操作,Thread 類中也給我們提供了一些方法,有些方法還是比較常用的。
1、sleep
public static native void sleep(long millis)
複製程式碼
這是一個本地方法,用於阻塞當前執行緒指定毫秒時長。
2、start
public synchronized void start()
複製程式碼
這個方法可能很多人會疑惑,為什麼我通過重寫 Runnable 的 run 方法指定了執行緒的工作,但卻是通過 start 方法來啟動執行緒的?
那是因為,啟動一個執行緒不僅僅是給定一個指令開始入口即可,作業系統還需要在程式的共享記憶體空間中劃分一部分作為執行緒的私有資源,建立程式計數器,棧等資源,最終才會去呼叫 run 方法。
3、interrupt
public void interrupt()
複製程式碼
這個方法用於中斷當前執行緒,當然執行緒的不同狀態應對中斷的方式也是不同的,這一點我們後面再說。
4、join
public final synchronized void join(long millis)
複製程式碼
這個方法一般在其他執行緒中進行呼叫,指明當前執行緒需要阻塞在當前位置,等待目標執行緒所有指令全部執行完畢。例如:
Thread thread = new MyThreadT();
thread.start();
thread.join();
System.out.println("i am the main thread");
複製程式碼
正常情況下,主函式的列印語句會在 MyThreadT 執行緒 run 方法執行前執行,而 join 語句則指明 main 執行緒必須阻塞直到 MyThreadT 執行結束。
多執行緒帶來的一些問題
多執行緒的優點我們不說了,現在來看看多執行緒,也就是併發下會有哪些記憶體問題。
1、競態條件
這是一類問題,當多個執行緒同時訪問並修改同一個物件,該物件最終的值往往不如預期。例如:
我們建立了 100 個執行緒,每個執行緒啟動時隨機 sleep 一會,然後為 count 加一,按照一般的順序執行流,count 的值會是 100。
但是我告訴你,無論你執行多少遍,結果都不盡相同,等於 100 的概率非常低。這就是併發,原因也很簡單,count++ 這個操作它不是一條指令可以做的。
它分為三個步驟,讀取 count 的值,自增一,寫回變數 count 中。多執行緒之間互相不知道彼此,都在執行這三個步驟,所以某個執行緒當前讀到的資料值可能早已不是最新的了,結果自然不盡如期望。
但,這就是併發。
2、記憶體可見性
記憶體可見性是指,某些情況下,執行緒對於一些資源變數的修改並不會立馬重新整理到記憶體中,而是暫時存放在快取,暫存器中。
這導致的最直接的問題就是,對共享變數的修改,另一個執行緒看不到。
這段程式碼很簡單,主執行緒和我們的 ThreadTwo 共享一個全域性變數 flag,後者一直監聽這個變數值的變化情況,而我們在主執行緒中修改了這個變數的值,由於記憶體可見性問題,主執行緒中的修改並不會立馬對映到記憶體,暫時存在快取或暫存器中,這就導致 ThreadTwo 無法知曉 flag 值的變化而一直在做迴圈。
總結一下,程式作為系統分配資源的基本單元,而執行緒是程式的一部分,共享著程式中的資源,並且執行緒還是系統排程的最小執行流。在實時系統中,每個執行緒獲得時間片呼叫 CPU,多執行緒併發式使用 CPU,每一次上下文切換都對應著「執行現場」的儲存與恢復,這也是一個相對耗時的操作。
ps:前段時間確實有點忙,拖更好多天,這裡再給大家說聲抱歉了,感謝你們還沒有走,現在正式恢復,開啟併發系列總結~
文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:
歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。