話不多說,先上圖。
1、基本概念
欲說線程,必先說進程。
- 進程:進程是代碼在數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位。
- 線程:線程是進程的一個執行路徑,一個進程中至少有一個線程,進程中的多個線程共享進程的資源。
操作系統在分配資源時是把資源分配給進程的, 但是 CPU 資源比較特殊,它是被分配到線程的,因為真正要占用CPU運行的是線程,所以也說線程是 CPU分配的基本單位。
在Java中,當我們啟動 main 函數其實就啟動了一個JVM進程,而 main 函數在的線程就是這個進程中的一個線程,也稱主線程。
示意圖如下:
一個進程中有多個線程,多個線程共用進程的堆和方法區資源,但是每個線程有自己的程序計數器和棧。
2、線程創建和運行
Java中創建線程有三種方式,分別為繼承Thread類、實現Runnable接口、實現Callable接口。
- 繼承Thread類,重寫run()方法,調用start()方法啟動線程
public class ThreadTest {
/**
* 繼承Thread類
*/
public static class MyThread extends Thread {
@Override
public void run() {
System.out.println("This is child thread");
}
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
- 實現 Runnable 接口run()方法
public class RunnableTask implements Runnable {
public void run() {
System.out.println("Runnable!");
}
public static void main(String[] args) {
RunnableTask task = new RunnableTask();
new Thread(task).start();
}
}
上面兩種都沒有返回值。
- 實現Callable接口call()方法,這種方式可以通過FutureTask獲取任務執行的返回值
public class CallerTask implements Callable<String> {
public String call() throws Exception {
return "Hello,i am running!";
}
public static void main(String[] args) {
//創建異步任務
FutureTask<String> task=new FutureTask<String>(new CallerTask());
//啟動線程
new Thread(task).start();
try {
//等待執行完成,並獲取返回結果
String result=task.get();
System.out.println(result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
3、常用方法
3.1、線程等待與通知
在Object類中有一些函數可以用於線程的等待與通知。
-
wait():當一個線程調用一個共享變量的 wait()方法時, 該調用線程會被阻塞掛起, 到發生下面幾件事情之一才返回 :(1) 線程調用了該共享對象 notify()或者 notifyAll()方法;(2)其他線程調用了該線程 interrupt() 方法,該線程拋出InterruptedException異常返回。
-
wait(long timeout) :該方法相 wait() 方法多了一個超時參數,它的不同之處在於,如果一個線程調用共享對象的該方法掛起后,沒有在指定的 timeout ms時間內被其它線程調用該共享變量的notify()或者 notifyAll() 方法喚醒,那么該函數還是會因為超時而返回。
-
wait(long timeout, int nanos),其內部調用的是 wait(long timout)函數。
上面是線程等待的方法,而喚醒線程主要是下面兩個方法:
-
notify() : 一個線程調用共享對象的 notify() 方法后,會喚醒一個在該共享變量上調用 wait 系列方法后被掛起的線程。 一個共享變量上可能會有多個線程在等待,具體喚醒哪個等待的線程是隨機的。
-
notifyAll() :不同於在共享變量上調用 notify() 函數會喚醒被阻塞到該共享變量上的一個線程,notifyAll()方法則會喚醒所有在該共享變量上由於調用 wait 系列方法而被掛起的線程。
如果有這樣的場景,需要等待某幾件事情完成后才能繼續往下執行,比如多個線程加載資源,需要等待多個線程全部加載完畢再匯總處理。Thread類中有一個join方法可實現。
3.2、線程休眠
Thread類中有一個靜態態的 sleep 方法,當一個個執行中的線程調用了Thread 的sleep方法后,調用線程會暫時讓出指定時間的執行權,也就是在這期間不參與 CPU 的調度,但是該線程所擁有的監視器資源,比如鎖還是持有不讓出的。指定的睡眠時間到了后該函數會正常返回,線程就處於就緒狀態,然后參與 CPU 的調度,獲取到 CPU 資源后就可以繼續運行。
3.3、讓出優先權
Thread 有一個靜態 yield 方法,當一個線程調用 yield 方法時,實際就是在暗示線程調度器當前線程請求讓出自己的CPU 使用,但是線程調度器可以無條件忽略這個暗示。
當一個線程調用 yield 方法時, 當前線程會讓出 CPU 使用權,然后處於就緒狀態,線程調度器會從線程就緒隊列里面獲取一個線程優先級最高的線程,當然也有可能會調度到剛剛讓出 CPU 的那個線程來獲取 CPU 行權。
3.4、線程中斷
Java 中的線程中斷是一種線程間的協作模式,通過設置線程的中斷標志並不能直接終止該線程的執行,而是被中斷的線程根據中斷狀態自行處理。
-
void interrupt() :中斷線程,例如,當線程A運行時,線程B可以調用錢程interrupt() 方法來設置線程的中斷標志為 true 並立即返回。設置標志僅僅是設置標志, 線程A實際並沒有被中斷, 會繼續往下執行。如果線程A因為調用了wait() 系列函數、 join 方法或者 sleep 方法阻塞掛起,這時候若線程 B調用線程A的interrupt()方法,線程A會在調用這些方法的地方拋出InterruptedException異常而返回。
-
boolean isInterrupted() 方法: 檢測當前線程是否被中斷。
-
boolean interrupted() 方法: 檢測當前線程是否被中斷,與 isInterrupted 不同的是,該方法如果發現當前線程被中斷,則會清除中斷標志。
4、線程狀態
上面整理了線程的創建方式和一些常用方法,可以用線程的生命周期把這些方法串聯起來。
在Java中,線程共有六種狀態:
狀態 | 說明 |
---|---|
NEW | 初始狀態:線程被創建,但還沒有調用start()方法 |
RUNNABLE | 運行狀態:Java線程將操作系統中的就緒和運行兩種狀態籠統的稱作“運行” |
BLOCKED | 阻塞狀態:表示線程阻塞於鎖 |
WAITING | 等待狀態:表示線程進入等待狀態,進入該狀態表示當前線程需要等待其他線程做出一些特定動作(通知或中斷) |
TIME_WAITING | 超時等待狀態:該狀態不同於 WAITIND,它是可以在指定的時間自行返回的 |
TERMINATED | 終止狀態:表示當前線程已經執行完畢 |
線程在自身的生命周期中, 並不是固定地處於某個狀態,而是隨着代碼的執行在不同的狀態之間進行切換,Java線程狀態變化如圖示:
5、線程上下文切換
使用多線程的目的是為了充分利用CPU,但要認識到,每個CPU同一時刻只能被一個線程使用。
為了讓用戶感覺多個線程是在同時執行的, CPU 資源的分配采用了時間片輪轉也就是給每個線程分配一個時間片,線程在時間片內占用 CPU 執行任務。當線程使用完時間片后,就會處於就緒狀態並讓出 CPU 讓其他線程占用,這就是上下文切換。
6、線程死鎖
死鎖是指兩個或兩個以上的線程在執行過程中,因爭奪資源而造成的互相等待的現象,在無外力作用的情況下,這些線程會一直相互等待而無法繼續運行下去。
那么為什么會產生死鎖呢? 死鎖的產生必須具備以下四個條件:
- 互斥條件:指線程對己經獲取到的資源進行它性使用,即該資源同時只由一個線程占用。如果此時還有其它線程請求獲取獲取該資源,則請求者只能等待,直至占有資源的線程釋放該資源。
- 請求並持有條件:指一個 線程己經持有了至少一個資源,但又提出了新的資源請求,而新資源己被其它線程占有,所以當前線程會被阻塞,但阻塞 的同時並不釋放自己已經獲取的資源。
- 不可剝奪條件:指線程獲取到的資源在自己使用完之前不能被其它線程搶占,只有在自己使用完畢后才由自己釋放該資源。
- 環路等待條件:指在發生死鎖時,必然存在一個線程——資源的環形鏈,即線程集合 {T0,T1,T2,…… ,Tn} 中 T0 正在等待一 T1 占用的資源,Tl1正在等待 T2用的資源,…… Tn 在等待己被 T0占用的資源。
該如何避免死鎖呢?答案是至少破壞死鎖發生的一個條件。
其中,互斥這個條件我們沒有辦法破壞,因為用鎖為的就是互斥。不過其他三個條件都是有辦法破壞掉的,到底如何做呢?
-
對於“請求並持有”這個條件,可以一次性請求所有的資源。
-
對於“不可剝奪”這個條件,占用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它占有的資源,這樣不可搶占這個條件就破壞掉了。
-
對於“環路等待”這個條件,可以靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候可以先申請資源序號小的,再申請資源序號大的,這樣線性化后就不存在環路了。
7、線程分類
Java中的線程分為兩類,分別為 daemon 線程(守護線程)和 user 線程(用戶線程)。
在JVM 啟動時會調用 main 函數,main函數所在的錢程就是一個用戶線程。其實在 JVM 內部同時還啟動了很多守護線程, 比如垃圾回收線程。
那么守護線程和用戶線程有什么區別呢?區別之一是當最后一個非守護線程束時, JVM會正常退出,而不管當前是否存在守護線程,也就是說守護線程是否結束並不影響 JVM退出。換而言之,只要有一個用戶線程還沒結束,正常情況下JVM就不會退出。
8、ThreadLocal
ThreadLocal是JDK 包提供的,它提供了線程本地變量,也就是如果你創建了ThreadLocal ,那么訪問這個變量的每個線程都會有這個變量的一個本地副本,當多個線程操作這個變量時,實際操作的是自己本地內存里面的變量,從而避免了線程安全問題。創建 ThreadLocal 變量后,每個線程都會復制 到自己的本地內存。
可以通過set(T)方法來設置一個值,在當前線程下再通過get()方法獲取到原先設置的值。
下面來看一個ThreadLocal的使用實例:
public class ThreadLocalTest {
//創建ThreadLocal變量
static ThreadLocal<String> localVar = new ThreadLocal<String>();
//打印函數
static void print(String str) {
//打印當前線程本地內存中localVar變量值
System.out.println(str + ":" + localVar.get());
//清除前線程本地內存中localVar變量值
//localVar.remove();
}
public static void main(String[] args) {
Thread thread1 = new Thread(new Runnable() {
public void run() {
//設置線程1中本地變量localVal的值
localVar.set("線程1的值");
//調用打印函數
print("線程1");
//打印本地變量的值
System.out.println("線程1打印本地變量后:" + localVar.get());
}
});
Thread thread2 = new Thread(new Runnable() {
public void run() {
//設置線程2中本地變量localVal的值
localVar.set("線程2的值");
//調用打印函數
print("線程2");
//打印本地變量的值
System.out.println("線程2打印本地變量后:" + localVar.get());
}
});
thread1.start();
thread2.start();
}
}
9、Java內存模型
在Java中,所有實例域、靜態域和數組元素都存儲在堆內存中,堆內存在線程之間共享 。
Java線程之間的通信由Java內存模型控制,Java內存模型決定一個線程對共享變量的寫入何時對另一個線程可見。
從抽象的角度來看,Java內存模型定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是Java內存模型的 一個抽象概念,並不真實存在。它涵蓋了緩存、寫緩沖區、寄存器以及其他的硬件和編譯器優化。
Java內存模型的抽象示意如圖:
在實際實現中線程的工作內存如下圖:
10、synchronized
synchronized 塊是 Java 提供的一種原子性內置鎖, Java中的每個對象都可以把它當作同步鎖來使用,這些 Java內置的使用者看不到的鎖被稱為內部鎖,也作監視器鎖。
線程的執行代碼在進入 synchronized 代碼塊前會自動獲取內部鎖,這時候其他線程訪問該同步代碼塊 被阻塞掛起。拿到內部鎖的線程會在正常退出同步代碼塊或者拋出異常后或者在同步塊調用了該內置鎖資源 wait系列方法時釋放該內置鎖。內置鎖是排它鎖,就是當一個線程獲取這個鎖后,其他線程必須等待該線程釋放鎖后才能獲取該鎖。
synchronized 的內存語義:這個內存語義就可以解決共享變量內存可見性問題,進入synchronized 塊的內存語義是把在synchronized 塊內使用到的變量從線程的工作內存中清除,這樣在 synchronized 塊內使用到該變量時就不會從線程的工作內存中獲取,而是直接從主內存中獲取。 退出 synchronized 塊的內存語義是把在 synchronized 塊內對共享變修改刷新到主內存。
11、volatile
上面介紹了使用鎖的方式可以解決共享內存可見性問題,但是使用鎖太笨重,因為它會帶來線程上下文的切換開銷,對於解決內存可見性問題, Java 還提供了volatile種弱形式的同步,也就是使用 volatile 關鍵字, 該關鍵字可以確保對一個變量的更新對其他線程馬上可見。
當一個變量被聲明為volatile時,線程在寫入變量時不會把值緩存在寄存器或者其他地方,而是會把值刷新回主內存,當其它線程讀取該共享變量,會從主內存重新獲取最新值,而不是使用當前線程的工作內存中的值。
volatile雖然提供了可見性保證,但並不保證操作的原子性。
12、Java 中的原子性操作
所謂原子性操作,是指執行一系列操作時,這些操作要么全部執行,要么全部不執行,不存在只執行其中一部分的情況。
例如在設計計數器一般都先讀取當前值,然后+1,再更新。這個過程是讀-改-寫的過程,如果不能保證這個過程是原子性的,那么就會出現線程安問題。
那么如何才能保證多個操作的原子性呢?最簡單的方法就是使用 synchronized 關鍵字進行同步。還可以用CAS操作。從Java 1.5開始,JDK的並發包里也提供了一些類來支持原子操作。
synchronized 是獨占鎖,沒有獲取內部鎖的線程會被阻塞掉,大大降級了並發性。
13、Java 中的 CAS 操作
在Java中, 鎖在並發處理中占據了一席之地,但是使用鎖有有個不好的地方,就是當線程沒有獲取到鎖時會被阻塞掛起,這會導致線程上下文的切換和重新調度開銷。
Java 提供了非阻塞的 volatile 關鍵字來解決共享變量的可見性問題,這在一定程度上彌補了鎖帶來的開銷問題,但是 volatile 只能保 共享變量可見性,不能解決讀-改-寫等的原子性問題。
CAS即 Compre and Swap ,其是 JDK 提供的非阻塞原子性操作,它通過硬件保證了比較-更新
操作的原子性。JDK 里面的 Unsafe 類提供了一系列的compareAndSwap *方法,以 compareAndSwapLong 方法為例,看一下什么是CAS操作。
- boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update ): CAS 有四個操作數,分別為對象內存位置、 對象中 變量的偏移量、變量預期值和新的值 。其操作含義是:只有當對象 obj 中內存偏移量為 valueOffset 的變量預期值為 expect 的時候,才會將ecpect更新為update。 這是處理器提供的一個原子性指令。
CAS有個經典的ABA問題。因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化,則更新,但是如果一個值原來是A,變成了B,又變成了A,那么使用CAS進行檢查時會發現它 的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加1,那么A→B→A就會變成1A→2B→3A。
14、鎖的概述
14.1、樂觀鎖與悲觀鎖
樂觀鎖和悲觀鎖是在數據庫中引入的名詞,但是在並發包鎖里面引入了類似的思想。
悲觀鎖指對數據被外界修改持保守態度,認為數據很容易就會被其他線程修改,所以在數據被處理前先對數據進行加鎖,並在整個數據處理過程中,使數據處於鎖定狀態。悲觀鎖的實現往往依靠數據庫提供的鎖機制,即在數據 ,在對數據記錄操作前給記錄排它鎖。如果獲取鎖失敗, 則說明數據正在被其它線程修改,當前線程則等待或者拋出異常。 如果獲取鎖成功,則對記錄進行操作 ,然后提交事務后釋放排它鎖。
樂觀鎖相對悲觀鎖來說的,它認為數據在一般情況下不會造成沖突,所以在訪問記錄前不會加排它鎖,而在進行數據提交更新時,才會正式對數據沖 與否進行檢測 。具體來說,根據 update 返回的行數讓用戶決定如何去做 。
14.2、公平鎖與非公平鎖
根據線程獲取鎖的搶占機制,鎖可以分為公平鎖和非公平鎖,公平鎖表示線程獲取鎖的順序是按照線程請求鎖的時間早晚來決定的,也就是最早請求鎖的線程將最早獲取到鎖。
而非公平鎖是在運行時闖入,也就是先來不一定先得。
ReentrantLock 提供了公平鎖和非公平鎖的實現:
-
公平鎖: ReentrantLock pairLock =new eentrantLock(true)
-
非公平鎖: ReentrantLock pairLock =new ReentrantLock(false) 。 構造函數不傳數,則默認是非公平鎖。
例如,假設線程A已經持有了鎖,這時候線程B請求該鎖其將被掛起 。當線程A釋放鎖后,假如當前有線程C也需要取該鎖,如果采用非公平鎖式,則根據線程調度策略 ,線程B和線程C兩者之一可能獲取鎖,這時候不需要任何其他干涉,而如果使用公平鎖則需要把C掛起,讓B獲取當前鎖。
在沒有公平性需求的前提下盡量使用非公平鎖,因為公平鎖會帶來性能開銷。
14.3、獨占鎖與共享鎖
根據鎖只能被單個線程持有還是能被多個線程共同持有,鎖可以分為獨占鎖和共享鎖。
獨占鎖保證任何時候都只有一個線程能得到鎖, ReentrantLock 就是以獨占方式實現的。
共享鎖則可以同時由多個線程持有 ,例如 ReadWriteLock讀寫鎖,它允許一個資源可以被多線程同時進行讀操作。
獨占鎖是一種悲觀鎖,共享鎖是一種樂觀鎖。
14.4、可重入鎖
當一個線程要獲取一個被其他線程持有的獨占鎖時,該線程會被阻塞。
那么當 一個線程再次獲取它自己己經獲取的鎖時是否會被阻塞呢?如果不被阻塞,那么我們說該鎖是可重入的,也就是只要該線程獲取了該鎖,那么可以無限次數(嚴格來說是有限次數)地進入被該鎖鎖住的代碼。
14.5、自旋鎖
由於 Java 中的線程是與操作系統中的線程 一一對應的,所以當一個線程在獲取鎖(比如獨占鎖)失敗后,會被切換到內核狀態而被掛起 。當該線程獲取到鎖時又需要將其切換到內核狀態而喚醒該線程。而從用戶狀態切換到內核狀態的開銷是比較大的,在一定程度上會影響並發性能。
自旋鎖則是,當前線程在獲取鎖時,如果發現鎖已經被其他線程占有,它不馬上阻塞自己,在不放棄 CPU 使用權的情況下,多次嘗試獲取(默認次數是 10 ,可以使用 -XX:PreBlockSpinsh 參數設置該值),很有可能在后面幾次嘗試中其他線程己經釋放了鎖,如果嘗試指定的次數后仍沒有獲取到鎖則當前線程才會被阻塞掛起。由此看來自旋鎖是使用 CPU 時間換取線程阻塞與調度的開銷,但是很有可能這些 CPU 時間白白浪費了。
參考:
【1】:瞿陸續,薛賓田 編著 《並發編程之美》
【2】:極客時間 《Java並發編程實踐》
【3】:方騰飛等編著《Java並發編程的藝術》