Android性能優化之Java線程機制與線程調度原理詳解
本文轉載自微信公眾號「Android開發編程」,作者Android開發編程。轉載本文請聯系Android開發編程公眾號。
前言小計
在平時工作中如若使用不當會出現數據錯亂、執行效率低(還不如單線程去運行)或者死鎖程序掛掉等等問題,所以掌握了解多線程至關重要;
線程有很多優勢:
1、提高多處理器的利用效率;
2、簡化業務功能設計;
3、實現異步處理;
多線程的風險:
1、共享數據的線程安全性;
2、多線程執行時的活躍性問題;
3、多線程的所帶來的性能損失問題;
多線程相對于其他知識點來講,有一定的學習門檻,并且了解起來比較費勁;
線程的優勢我們很清楚,線程的風險我們也都知道,但是要做好風險控制就沒有那么簡單了;
本文從基礎概念開始到最后的并發模型由淺入深,講解下線程方面的知識;
一、什么是線程?
1、線程簡介
- 線程是進程中可獨立執行的最小單位,也是 CPU 資源分配的基本單位;
- 進程是程序向操作系統申請資源的基本條件,一個進程可以包含多個線程,同一個進程中的線程可以共享進程中的資源,如內存空間和文件句柄;
- 操作系統會把資源分配給進程,但是 CPU 資源比較特殊,它是分配給線程的,這里說的 CPU 資源也就是 CPU 時間片;
- 進程與線程的關系,就像是飯店與員工的關系,飯店為顧客提供服務,而提供服務的具體方式是通過一個個員工實現的;
- 線程的作用是執行特定任務,這個任務可以是下載文件、加載圖片、繪制界面等;
- 下面我們就來看看線程的四個屬性、六個方法以及六種狀態;
2、線程的四個屬性
線程有編號、名字、類別以及優先級四個屬性,除此之外,線程的部分屬性還具有繼承性,下面我們就來看看線程的四個屬性的作用和線程的繼承性;
①編號
線程的編號(id)用于標識不同的線程,每條線程擁有不同的編號;
注意事項:不能作為唯一標識,某個編號的線程運行結束后,該編號可能被后續創建的線程使用,因此編號不適合用作唯一標識,編號是只讀屬性,不能修改;
②名字
- 每個線程都有自己的名字(name),名字的默認值是 Thread-線程編號,比如 Thread-0 ;
- 除了默認值,我們也可以給線程設置名字,以我們自己的方式去區分每一條線程;
- 作用:給線程設置名字可以讓我們在某條線程出現問題時,用該線程的名字快速定位出問題的地方
③類別
- 線程的類別(daemon)分為守護線程和用戶線程,我們可以通過 setDaemon(true) 把線程設置為守護線程;
- 當 JVM 要退出時,它會考慮是否所有的用戶線程都已經執行完畢,是的話則退出;
- 而對于守護線程,JVM 在退出時不會考慮它是否執行完成;
- 作用:守護線程通常用于執行不重要的任務,比如監控其他線程的運行情況,GC 線程就是一個守護線程;
- 注意事項:setDaemon() 要在線程啟動前設置,否則 JVM 會拋出非法線程狀態異常(IllegalThreadStateException);
④優先級
作用:線程的優先級(Priority)用于表示應用希望優先運行哪個線程,線程調度器會根據這個值來決定優先運行哪個線程;
⑤取值范圍
Java 中線程優先級的取值范圍為 1~10,默認值是 5,Thread 中定義了下面三個優先級常量;
- 最低優先級:MIN_PRIORITY = 1;
- 默認優先級:NORM_PRIORITY = 5;
- 最高優先級:MAX_PRIORITY = 10;
注意事項:不保證,線程調度器把線程的優先級當作一個參考值,不一定會按我們設定的優先級順序執行線程;
⑥線程饑餓
優先級使用不當會導致某些線程永遠無法執行,也就是線程饑餓的情況;
⑦繼承性
線程的繼承性指的是線程的類別和優先級屬性是會被繼承的,線程的這兩個屬性的初始值由開啟該線程的線程決定;
假如優先級為 5 的守護線程 A 開啟了線程 B,那么線程 B 也是一個守護線程,而且優先級也是 5 ;
這時我們就把線程 A 叫做線程 B 的父線程,把線程 B 叫做線程 A 的子線程;
3、線程的六個重要方法
線程的常用方法有六個,它們分別是三個非靜態方法 start()、run()、join() 和三個靜態方法 currentThread()、yield()、sleep() ;
下面我們就來看下這六個方法都有哪些作用和注意事項
①start()
- 作用:start() 方法的作用是啟動線程;
- 注意事項:該方法只能調用一次,再次調用不僅無法讓線程再次執行,還會拋出非法線程狀態異常;
② run()
- 作用:run() 方法中放的是任務的具體邏輯,該方法由 JVM 調用,一般情況下開發者不需要直接調用該方法;
- 注意事項:如果你調用了 run() 方法,加上 JVM 也調用了一次,那這個方法就會執行兩次
③ join()
- 作用:join() 方法用于等待其他線程執行結束;如果線程 A 調用了線程 B 的 join() 方法,那線程 A 會進入等待狀態,直到線程 B 運行結束;
- 注意事項:join() 方法導致的等待狀態是可以被中斷的,所以調用這個方法需要捕獲中斷異常
④Thread.currentThread()
- 作用:currentThread() 方法是一個靜態方法,用于獲取執行當前方法的線程;
- 我們可以在任意方法中調用 Thread.currentThread() 獲取當前線程,并設置它的名字和優先級等屬性;
⑤Thread.yield()
- 作用:yield() 方法是一個靜態方法,用于使當前線程放棄對處理器的占用,相當于是降低線程優先級;
- 調用該方法就像是是對線程調度器說:“如果其他線程要處理器資源,那就給它們,否則我繼續用”;
- 注意事項:該方法不一定會讓線程進入暫停狀態;
⑥ Thread.sleep(ms)
作用:sleep(ms) 方法是一個靜態方法,用于使當前線程在指定時間內休眠(暫停)。
4、線程的六種狀態
①線程的生命周期
線程的生命周期不僅可以由開發者觸發,還會受到其他線程的影響,下面是線程各個狀態之間的轉換示意圖;
我們可以通過 Thread.getState() 獲取線程的狀態,該方法返回的是一個枚舉類 Thread.State;
線程的狀態有新建、可運行、阻塞、等待、限時等待和終止 6 種,下面我們就來看看這 6 種狀態之間的轉換過程;
新建狀態:當一個線程創建后未啟動時,它就處于新建(NEW)狀態;
②可運行狀態:當我們調用線程的 start() 方法后,線程就進入了可運行(RUNNABLE)狀態,可運行狀態又分為預備(READY)和運行(RUNNING)狀態;
③預備狀態:處于預備狀態的線程可被線程調度器調度,調度后線程的狀態會從預備轉換為運行狀態,處于預備狀態的線程也叫活躍線程,當線程的 yield() 方法被調用后,線程的狀態可能由運行狀態變為預備狀態
④運行狀態:運行狀態表示線程正在運行,也就是處理器正在執行線程的 run() 方法;
⑤阻塞狀態:當下面幾種情況發生時,線程就處于阻塞(BLOCKED)狀態,發起阻塞式 I/O 操作、申請其他線程持有的鎖、進入一個 synchronized 方法或代碼塊失敗;
⑥等待狀態:一個線程執行特定方法后,會等待其他線程執行執行完畢,此時線程進入了等待(WAITING)狀態;
⑦等待狀態:下面的幾個方法可以讓線程進入等待狀態;
- Object.wait()
- LockSupport.park()
- Thread.join()
可運行狀態:下面的幾個方法可以讓線程從等待狀態轉變為可運行狀態,而這種轉變又叫喚醒;
- Object.notify()
- Object.notifyAll()
- LockSupport.unpark()
⑧限時等待狀態
- 限時等待狀態 (TIMED_WAITING)與等待狀態的區別就是,限時等待是等待一段時間,時間到了之后就會轉換為可運行狀態;
- 下面的幾個方法可以讓線程進入限時等待狀態,下面的方法中的 ms、ns、time 參數分別代表毫秒、納秒以及絕對時間;
- Thread.sleep(ms);
- Thread.join(ms);
- Object.wait(ms);
- LockSupport.parkNonos(ns);
- LockSupport.parkUntil(time);
⑨ 終止狀態
當線程的任務執行完畢或者任務執行遇到異常時,線程就處于終止(TERMINATED)狀態;
二、線程調度的原理
線程調度原理相關的對 Java 內存模型、高速緩存、Java 線程調度機制進行一個簡單介紹;
1、Java 的內存模型
- Java 內存模型規定了所有變量都存儲在主內存中,每條線程都有自己的工作內存;
- JVM 把內存劃分成了好幾塊,其中方法區和堆內存區域是線程共享的;
- java內存模型詳解
2、高速緩存
①高速緩存簡介
現代處理器的處理能力要遠勝于主內存(DRAM)的訪問速率,主內存執行一次內存讀/寫操作需要的時間,如果給處理器使用,處理器可以執行上百條指令;
為了彌補處理器與主內存之間的差距,硬件設計者在主內存與處理器之間加入了高速緩存(Cache);
處理器執行內存讀寫操作時,不是直接與主內存打交道,而是通過高速緩存進行的;
高速緩存相當于是一個由硬件實現的容量極小的散列表,這個散列表的 key 是一個對象的內存地址,value 可以是內存數據的副本,也可以是準備寫入內存的數據;
②高速緩存內部結構
從內部結構來看,高速緩存相當于是一個鏈式散列表(Chained Hash Table),它包含若干個桶,每個桶包含若干個緩存條目(Cache Entry);
③緩存條目結構
緩存條目可進一步劃分為 Tag、Data Block 和 Flag 三個部分;
Tag:包含了與緩存行中數據對應的內存地址的部分信息(內存地址的高位部分比特)
Data: Block 也叫緩存行(Cache Line),是高速緩存與主內存之間數據交換的最小單元,可以存儲從內存中讀取的數據,也可以存儲準備寫進內存的數據;
Flag: 用于表示對應緩存行的狀態信息
3、Java 線程調度機制
在任意時刻,CPU 只能執行一條機器指令,每個線程只有獲取到 CPU 的使用權后,才可以執行指令;
也就是在任意時刻,只有一個線程占用 CPU,處于運行的狀態;
多線程并發運行實際上是指多個線程輪流獲取 CPU 使用權,分別執行各自的任務;
線程的調度由 JVM 負責,線程的調度是按照特定的機制為多個線程分配 CPU 的使用權;
線程調度模型分為兩類:分時調度模型和搶占式調度模型;
①分時調度模型
分時調度模型是讓所有線程輪流獲取 CPU 使用權,并且平均分配每個線程占用 CPU 的時間片;
②搶占式調度模型
- JVM 采用的是搶占式調度模型,也就是先讓優先級高的線程占用 CPU,如果線程的優先級都一樣,那就隨機選擇一個線程,并讓該線程占用 CPU;
- 也就是如果我們同時啟動多個線程,并不能保證它們能輪流獲取到均等的時間片;
- 如果我們的程序想干預線程的調度過程,最簡單的辦法就是給每個線程設定一個優先級;
三、線程的安全性問題詳解
線程安全問題不是說線程不安全,而是多個線程之間交錯操作有可能導致數據異常;
下面我們就來看下與線程安全相關的競態和實現線程安全要保證的三個點:原子性、可見性和有序性;
①原子性
- 原子(Atomic)的字面意識是不可分割的,對于涉及共享變量訪問的操作,若該操作從其執行線程以外的任意線程看來是不可分割的,那么該操作就是原子操作,相應地稱該操作具有原子性(Atomicity);
- 所謂不可分割,就是訪問(讀/寫)某個共享變量的操作,從執行線程以外的其他線程看來,該操作只有未開始和結束兩種狀態,不會知道該操作的中間部分;
- 訪問同一組共享變量的原子操作是不能被交錯的,這就排除了一個線程執行一個操作的期間,另一個線程讀取或更新該操作鎖訪問的共享變量,導致臟數據和丟失更新;
②可見性
- 在多線程環境下,一個線程對某個共享變量進行更新后,后續訪問該變量的線程可能無法立刻讀取到這個更新的結果,甚至永遠也無法讀取到這個更新的結果,這就是線程安全問題的另一種表現形式:可見性;
- 可見性是指一個線程對共享變量的更新,對于其他讀取該變量的線程是否可見;
- 可見性問題與計算機的存儲系統有關,程序中的變量可能會被分配到寄存器而不是主內存中,每個處理器都有自己的寄存器,一個處理器無法讀取另一個處理器的寄存器上的內容;
- 即使共享變量是分配到主內存中存儲的,也不餓能保證可見性,因為處理器不是直接訪問主內存,而是通過高速緩存(Cache)進行的;
- 一個處理器上運行的線程對變量的更新,可能只是更新到該處理器的寫緩沖器(Store Buffer)中,還沒有到高速緩存中,更別說處理器了;
- 可見性描述的是一個線程對共享變量的更新,對于另一個線程是否可見,保證可見性意味著一個線程可以讀取到對應共享變量的新值;
- 從保證線程安全的角度來看,光保證原子性還不夠,還要保證可見性,同時保證可見性和原子性才能確保一個線程能正確地看到其他線程對共享變量做的更新;
③ 有序性
- 有序性是指一個處理器在為一個線程執行的內存訪問操作,對于另一個處理器上運行的線程來看是亂序的;
- 順序結構是結構化編程中的一種基本結構,它表示我們希望某個操作先于另外一個操作執行;
- 但是在多核處理器的環境下,代碼的執行順序是沒保障的,編譯器可能改變兩個操作的先后順序,處理器也可能不是按照程序代碼的順序執行指令;
- 重排序(Reordering)處理器和編譯器是對代碼做的一種優化,它可以在不影響單線程程序正確性的情況下提升程序的性能,但是它會對多線程程序的正確性產生影響,導致線程安全問題;
- 現代處理器為了提高指令的執行效率,往往不是按程序順序注意執行指令的,而是哪條指令就緒就先執行哪條指令,這就是處理器的亂序執行;
四、實現線程安全
要實現線程安全就要保證上面說到的原子性、可見性和有序性;
常見的實現線程安全的辦法是使用鎖和原子類型,而鎖可分為內部鎖、顯式鎖、讀寫鎖、輕量級鎖(volatile)四種;
下面我們就來看看這四種鎖和原子類型的用法和特點;
1、鎖
是鎖(Lock)的作用,讓多個線程更好地協作,避免多個線程的操作交錯導致數據異常的問題;
鎖的五個特點:
- 臨界區:持有鎖的線程獲得鎖后和釋放鎖前執行的代碼叫做臨界區(Critical Section);
- 排他性:鎖具有排他性,能夠保障一個共享變量在任一時刻只能被一個線程訪問,這就保證了臨界區代碼一次只能夠被一個線程執行,臨界區的操作具有不可分割性,也就保證了原子性;
- 串行:鎖相當于是把多個線程對共享變量的操作從并發改為串行;
- 三種保障:鎖能夠保護共享變量實現線程安全,它的作用包括保障原子性、可見性和有序性;
- 調度策略:鎖的調度策略分為公平策略和非公平策略,對應的鎖就叫 公平鎖和非公平鎖;公平鎖會在加鎖前查看是否有排隊等待的線程,有的話會優先處理排在前面的線程;公平鎖以增加上下文切換為代價,保障了鎖調度的公平性,增加了線程暫停和喚醒的可能性;
- 公平鎖的開銷比非公平鎖大,所以 ReentrantLock 的默認調度策略是非公平策略;
2、 volatile 關鍵字
volatile 關鍵字可用于修飾共享變量,對應的變量就叫 volatile 變量,volatile 變量有下面幾個特點;
- 易變化:volatile 的字面意思是“不穩定的”,也就是 volatile 用于修飾容易發生變化的變量,不穩定指的是對這種變量的讀寫操作要從高速緩存或主內存中讀取,而不會分配到寄存器中;
- 比鎖低:volatile 的開銷比鎖低,volatile 變量的讀寫操作不會導致上下文切換,所以 volatile 關鍵字也叫輕量級鎖 ;
- 比普通變量高:volatile 變量讀操作的開銷比普通變量要高,這是因為 volatile 變量的值每次都要從高速緩存或主內存中讀取,無法被暫存到寄存器中;
- 釋放/存儲屏障:對于 volatile 變量的寫操作,JVM 會在該操作前插入一個釋放屏障,并在該操作后插入一個存儲屏障;存儲屏障具有沖刷處理器緩存的作用,所以在 volatile 變量寫操作后插入一個存儲屏障,能讓該存儲屏障前的所有操作結果對其他處理器來說是同步的;
- 加載/獲取屏障:對于 volatile 變量的讀操作,JVM 會在該操作前插入一個加載屏障,并在操作后插入一個獲取屏障;加載屏障通過沖刷處理器緩存,使線程所在的處理器將其他處理器對該共享變量做的更新同步到該處理器的高速緩存中;
- 保證有序性:volatile 能禁止指令重排序,也就是使用 volatile 能保證操作的有序性;
- 保證可見性:讀線程執行的加載屏障和寫線程執行的存儲屏障配合在一起,能讓寫線程對 volatile 變量的寫操作對讀線程可見,從而保證了可見性;
- 原子性:在原子性方面,對于 long/double 型變量,volatile 能保證讀寫操作的原子型;對于非 long/double 型變量,volatile 只能保證寫操作的原子性;如果 volatile 變量寫操作前涉及共享變量,競態仍然可能發生,因為共享變量賦值給 volatile 變量時,其他線程可能已經更新了該共享變量的值;
3、原子類型
原子類型簡介:
在 JUC 下有一個 atomic 包,這個包里面有一組原子類,使用原子類的方法,不需要加鎖也能保證線程安全,而原子類是通過 Unsafe 類中的 CAS 指令從硬件層面來實現線程安全的;
這個包里面有如 AtomicInteger、AtomicBoolean、AtomicReference、AtomicReferenceFIeldUpdater 等;
我們先來看一個使用原子整型 AtomicInteger 自增的例子;
// 初始值為 1
AtomicInteger integer = new AtomicInteger(1);
// 自增
int result = integer.incrementAndGet();
// 結果為 2
System.out.println(result);
AtomicReference 和 AtomicReferenceFIeldUpdater 可以讓我們自己的類具有原子性,它們的原理都是通過 Unsafe 的 CAS 操作實現的;
我們下面看下它們的用法和區別;
①、AtomicReference 基本用法
- class AtomicReferenceValueHolder {
- AtomicReference<String> atomicValue = new AtomicReference<>("HelloAtomic");
- }
- public void getAndUpdateFromReference() {
- AtomicReferenceValueHolder holder = new AtomicReferenceValueHolder();
- // 對比并設值
- // 如果值是 HelloAtomic,就把值換成 World
- holder.atomicValue.compareAndSet("HelloAtomic", "World");
- // World
- System.out.println(holder.atomicValue.get());
- // 修改并獲取修改后的值
- String value = holder.atomicValue.updateAndGet(new UnaryOperator<String>() {
- @Override
- public String apply(String s) {
- return "HelloWorld";
- }
- });
- // Hello World
- System.out.println(value);
- }
② AtomicReferenceFieldUpdater 基本用法
AtomicReferenceFieldUpdater 在用法上和 AtomicReference 有些不同,我們直接把 String 值暴露了出來,并且用 volatile 對這個值進行了修飾;
并且將當前類和值的類傳到 newUpdater ()方法中獲取 Updater,這種用法有點像反射,而且 AtomicReferenceFieldUpdater 通常是作為類的靜態成員使用;
- public class SimpleValueHolder {
- public static AtomicReferenceFieldUpdater<SimpleValueHolder, String> valueUpdater
- = AtomicReferenceFieldUpdater.newUpdater(
- SimpleValueHolder.class, String.class, "value");
- volatile String value = "HelloAtomic";
- }
- public void getAndUpdateFromUpdater() {
- SimpleValueHolder holder = new SimpleValueHolder();
- holder.valueUpdater.compareAndSet(holder, "HelloAtomic", "World");
- // World
- System.out.println(holder.valueUpdater.get(holder));
- String value = holder.valueUpdater.updateAndGet(holder, new UnaryOperator<String>() {
- @Override
- public String apply(String s) {
- return "HelloWorld";
- }
- });
- // HelloWorld
- System.out.println(value);
- }
③AtomicReference 與 AtomicReferenceFieldUpdater 的區別
AtomicReference 和 AtomicReferenceFieldUpdater 的作用是差不多的,在用法上 AtomicReference 比 AtomicReferenceFIeldUpdater 更簡單;
但是在內部實現上,AtomicReference 內部一樣是有一個 volatile 變量;
使用 AtomicReference 和使用 AtomicReferenceFIeldUpdater 比起來,要多創建一個對象;
對于 32 位的機器,這個對象的頭占 12 個字節,它的成員占 4 個字節,也就是多出來 16 個字節;
對于 64 位的機器,如果啟動了指針壓縮,那這個對象占用的也是 16 個字節;
對于 64 位的機器,如果沒啟動指針壓縮,那么這個對象就會占 24 個字節,其中對象頭占 16 個字節,成員占 8 個字節;
當要使用 AtomicReference 創建成千上萬個對象時,這個開銷就會變得很大;
這也就是為什么 BufferedInputStream 、Kotlin 協程 和 Kotlin 的 lazy 的實現會選擇 AtomicReferenceFieldUpdater 作為原子類型;
因為開銷的原因,所以一般只有在原子類型創建的實例確定了較少的情況下,比如說是單例,才會選擇 AtomicReference,否則都是用 AtomicReferenceFieldUpdater;
4、 鎖的使用技巧
- 使用鎖會帶來一定的開銷,而掌握鎖的使用技巧可以在一定程度上減少鎖帶來的開銷和潛在的問題,下面就是一些鎖的使用技巧;
- 長鎖不如短鎖:盡量只對必要的部分加鎖;
- 大鎖不如小鎖:進可能對加鎖的對象拆分;
- 公鎖不如私鎖:進可能把鎖的邏輯放到私有代碼中,如果讓外部調用者加鎖,可能會導致鎖不正當使用導致死鎖;
- 嵌套鎖不如扁平鎖:在寫代碼時要避免鎖嵌套;
- 分離讀寫鎖:盡可能將讀鎖和寫鎖分離;
- 粗化高頻鎖:合并處理頻繁而且過短的鎖,因為每一把鎖都會帶來一定的開銷;
- 消除無用鎖:盡可能不加鎖,或者用 volatile 代替;
五、線程的四個活躍性問題
1、死鎖
死鎖是線程的一種常見多線程活躍性問題,如果兩個或更多的線程,因為相互等待對方而被永遠暫停,那么這就叫死鎖現象;
下面我們就來看看死鎖產生的四個條件和避免死鎖的三個方法;
2、死鎖產生的四個條件
當多個線程發生了死鎖后,這些線程和相關共享變量就會滿足下面四個條件:
- 資源互斥:涉及的資源必須是獨占的,也就是資源每次只能被一個線程使用
- 資源不可搶奪:涉及的資源只能被持有該資源的線程主動釋放,無法被其他線程搶奪(被動釋放)
- 占用并等待資源:涉及的線程至少持有一個資源,還申請了其他資源,而其他資源剛好被其他線程持有,并且線程不釋放已持有資源
- 循環等待資源:涉及的線程必須等待別的線程持有的資源,而別的線程又反過來等待該線程持有的資源
只要產生了死鎖,上面的條件就一定成立,但是上面的條件都成立也不一定會產生死鎖;
3、 避免死鎖的三個方法
要想消除死鎖,只要破壞掉上面的其中一個條件即可;
由于鎖具有排他性,且無法被動釋放,所以我們只能破壞掉第三個和第四個條件;
①、粗鎖法
- 使用粗粒度的鎖代替多個鎖,鎖的范圍變大了,訪問共享資源的多個線程都只需要申請一個鎖,因為每個線程只需要申請一個鎖就可以執行自己的任務,這樣“占用并等待資源”和“循環等待資源”這兩個條件就不成立了;
- 粗鎖法的缺點是會降低并發性,而且可能導致資源浪費,因為采用粗鎖法時,一次只能有一個線程訪問資源,這樣其他線程就只能擱置任務了;
②鎖排序法
鎖排序法指的是相關線程使用全局統一的順序申請鎖;
假如有多個線程需要申請鎖,我們只需要讓這些線程按照一個全局統一的順序去申請鎖,這樣就能破壞“循環等待資源”這個條件;
③tryLock
顯式鎖 ReentrantLock.tryLock(long timeUnit) 這個方法允許我們為申請鎖的操作設置超時時間,這樣就能破壞“占用并等待資源”這個條件;
④開放調用
開放調用(Open Call)就是一個方法在調用外部方法時不持有鎖,開放調用能破壞“占用并等待資源”這個條件;
六、線程之間怎么協作?
線程間的常見協作方式有兩種:等待和中斷;
當一個線程中的操作需要等待另一個線程中的操作結束時,就涉及到等待型線程協作方式;
常用的等待型線程協作方式有 join、wait/notify、await/signal、await/countDown 和 CyclicBarrier 五種,下面我們就來看看這五種線程協作方式的用法和區別;
1、join
- 使用 Thread.join() 方法,我們可以讓一個線程等待另一個線程執行結束后再繼續執行;
- join() 方法實現等待是通過 wait() 方法實現的,在 join() 方法中,會不斷判斷調用了 join() 方法的線程是否還存活,是的話則繼續等待;
下面是 join() 方法的簡單用法;
- public void tryJoin() {
- Thread threadA = new ThreadA();
- Thread threadB = new ThreadB(threadA);
- threadA.start();
- threadB.start();
- }
- public class ThreadA extends Thread {
- @Override
- public void run() {
- System.out.println("線程 A 開始執行");
- ThreadUtils.sleep(1000);
- System.out.println("線程 A 執行結束");
- }
- }
- public class ThreadB extends Thread {
- private final Thread threadA;
- public ThreadB(Thread thread) {
- threadA = thread;
- }
- @Override
- public void run() {
- try {
- System.out.println("線程 B 開始等待線程 A 執行結束");
- threadA.join();
- System.out.println("線程 B 結束等待,開始做自己想做的事情");
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
2、 wait/notify
- 一個線程因為執行操作(目標動作)所需的保護條件未滿足而被暫停的過程就叫等待(wait);
- 一個線程更新了共享變量,使得其他線程需要的保護條件成立,喚醒了被暫停的線程的過程就叫通知(notify);
- wait() 方法的執行線程叫等待線程,notify() 方法執行的線程叫通知線程;
下面是 wait/notify 使用的示例代碼;
- final Object lock = new Object();
- private volatile boolean conditionSatisfied;
- public void startWait() throws InterruptedException {
- synchronized (lock) {
- System.out.println("等待線程獲取了鎖");
- while(!conditionSatisfied) {
- System.out.println("保護條件不成立,等待線程進入等待狀態");
- lock.wait();
- }
- System.out.println("等待線程被喚醒,開始執行目標動作");
- }
- }
- public void startNotify() {
- synchronized (lock) {
- System.out.println("通知線程獲取了鎖");
- System.out.println("通知線程即將喚醒等待線程");
- conditionSatisfied = true;
- lock.notify();
- }
- }
3、 wait/notify 原理
- JVM 會給每個對象維護一個入口集(Entry Set)和等待集(Wait Set);
- 入口集用于存儲申請該對象內部鎖的線程,等待集用于存儲對象上的等待線程;
- wait() 方法會將當前線程暫停,在釋放內部鎖時,會將當前線程存入該方法所屬的對象等待集中;
- 調用對象的 notify() 方法,會讓該對象的等待集中的任意一個線程喚醒,被喚醒的線程會繼續留在對象的等待集中,直到該線程再次持有對應的內部鎖時,wait() 方法就會把當前線程從對象的等待集中移除;
- 添加當前線程到等待集、暫停當前線程、釋放鎖以及把喚醒后的線程從對象的等待集中移除,都是在 wait() 方法中實現的;
- 在 wait() 方法的 native 代碼中,會判斷線程是否持有當前對象的內部鎖,如果沒有的話,就會報非法監視器狀態異常,這也就是為什么要在同步代碼塊中執行 wait() 方法;
4、notify()/notifyAll()
notify() 可能導致信號丟失,而 notifyAll() 雖然會把不需要喚醒的等待線程也喚醒,但是在正確性方面有保障;
所以一般情況下優先使用 notifyAll() 保障正確性;
一般情況下,只有在下面兩個條件都實現時,才會選擇使用 notify() 實現通知;
①只需喚醒一個線程
當一次通知只需要喚醒最多一個線程時,我們可以考慮使用 notify() 實現通知,但是光滿足這個條件還不夠;
在不同的等待線程使用不同的保護條件時,notify() 喚醒的一個任意線程可能不是我們需要喚醒的那個線程,所以需要條件 2 來排除;
②對象的等待集中只包含同質等待線程
同質等待線程指的是線程使用同一個保護條件并且 wait() 調用返回后的邏輯一致;
最典型的同質線程是使用同一個 Runnable 創建的不同線程,或者同一個 Thread 子類 new 出來的多個實例;
5、await/signal
wait()/notify() 過于底層,而且還存在兩個問題,一是過早喚醒、二是無法區分 Object.wait(ms) 返回是由于等待超時還是被通知線程喚醒;
await/signal 基本用法
- private Lock lock = new ReentrantLock();
- private Condition condition = lock.newCondition();
- private volatile boolean conditionSatisfied = false;
- private void startWait() {
- lock.lock();
- System.out.println("等待線程獲取了鎖");
- try {
- while (!conditionSatisfied) {
- System.out.println("保護條件不成立,等待線程進入等待狀態");
- condition.await();
- }
- System.out.println("等待線程被喚醒,開始執行目標動作");
- } catch (InterruptedException e) {
- e.printStackTrace();
- } finally {
- lock.unlock();
- System.out.println("等待線程釋放了鎖");
- }
- }
- public void startNotify() {
- lock.lock();
- System.out.println("通知線程獲取了鎖");
- try {
- conditionSatisfied = true;
- System.out.println("通知線程即將喚醒等待線程");
- condition.signal();
- } finally {
- System.out.println("通知線程釋放了鎖");
- lock.unlock();
- }
- }
- 當我們在兩個線程中分別執行了上面的兩個函數后,能得到下面的輸出;
- 等待線程獲取了鎖
- 保護條件不成立,等待線程進入等待狀態
- 通知線程獲取了鎖
- 通知線程即將喚醒等待線程
- 等待線程被喚醒,開始執行目標動作
6、 awaitUntil() 用法
awaitUntil(timeout, unit) 方法;
如果是由于超時導致等待結束,那么 awaitUntil() 會返回 false,否則會返回 true,表示等待是被喚醒的,下面我們就看看這個方法是怎么用的;
- private void startTimedWait() throws InterruptedException {
- lock.lock();
- System.out.println("等待線程獲取了鎖");
- // 3 秒后超時
- Date date = new Date(System.currentTimeMillis() + 3 * 1000);
- boolean isWakenUp = true;
- try {
- while (!conditionSatisfied) {
- if (!isWakenUp) {
- System.out.println("已超時,結束等待任務");
- return;
- } else {
- System.out.println("保護條件不滿足,并且等待時間未到,等待進入等待狀態");
- isWakenUp = condition.awaitUntil(date);
- }
- }
- System.out.println("等待線程被喚醒,開始執行目標動作");
- } finally {
- lock.unlock();
- }
- }
- public void startDelayedNotify() {
- threadSleep(4 * 1000);
- startNotify();
- }
- 等待線程獲取了鎖
- 保護條件不滿足,并且等待時間未到,等待進入等待狀態
- 已超時,結束等待任務
- 通知線程獲取了鎖
- 通知線程即將喚醒等待線程
7、 await/countDown
使用 join() 實現的是一個線程等待另一個線程執行結束,但是有的時候我們只是想要一個特定的操作執行結束,不需要等待整個線程執行結束,這時候就可以使用 CountDownLatch 來實現;
await/countDown 基本用法
- public void tryAwaitCountDown() {
- startWaitThread();
- startCountDownThread();
- startCountDownThread();
- }
- final int prerequisiteOperationCount = 2;
- final CountDownLatch latch = new CountDownLatch(prerequisiteOperationCount);
- private void startWait() throws InterruptedException {
- System.out.println("等待線程進入等待狀態");
- latch.await();
- System.out.println("等待線程結束等待");
- }
- private void startCountDown() {
- try {
- System.out.println("執行先決操作");
- } finally {
- System.out.println("計數值減 1");
- latch.countDown();
- }
- }
8、 CyclicBarrier
有的時候多個線程需要互相等待對方代碼中的某個地方(集合點),這些線程才能繼續執行,這時可以使用 CyclicBarrier(柵欄);
CyclicBarrier 基本用法
- final int parties = 3;
- final Runnable barrierAction = new Runnable() {
- @Override
- public void run() {
- System.out.println("人來齊了,開始爬山");
- }
- };
- final CyclicBarrier barrier = new CyclicBarrier(parties, barrierAction);
- public void tryCyclicBarrier() {
- firstDayClimb();
- secondDayClimb();
- }
- private void firstDayClimb() {
- new PartyThread("第一天爬山,老李先來").start();
- new PartyThread("老王到了,小張還沒到").start();
- new PartyThread("小張到了").start();
- }
- private void secondDayClimb() {
- new PartyThread("第二天爬山,老王先來").start();
- new PartyThread("小張到了,老李還沒到").start();
- new PartyThread("老李到了").start();
- }
- public class PartyThread extends Thread {
- private final String content;
- public PartyThread(String content) {
- this.content = content;
- }
- @Override
- public void run() {
- System.out.println(content);
- try {
- barrier.await();
- } catch (BrokenBarrierException e) {
- e.printStackTrace();
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
Android 中常用的 7 種異步方式:Thread、HandlerThread、IntentService、AsyncTask、線程池、RxJava 和 Kotlin 協程;
總結:
1、線程有很多優勢:
- 提高多處理器的利用效率;
- 簡化業務功能設計;
- 實現異步處理;
2、多線程的風險:
- 共享數據的線程安全性;
- 多線程執行時的活躍性問題;
- 多線程的所帶來的性能損失問題;
3、下次詳解下Android中常用異步方式,從實際出發。