基本介紹
在我學習 Android 多線程優化方法的過程中,發現我對多線程優化的了解太片面。
寫這篇文章的目的是完善我對 Android 多線程優化方法的認識,分享這篇文章的目的是希望大家也能從這些知識從得到一些啟發。
在閱讀本文時,畫圖和思考可以幫助你更好地記憶和理解文中的內容。
1. 畫圖
思維導圖可以讓隨意信息在視覺上建立起一種視覺上的關聯。隨意信息指的是不存在邏輯關係的信息,比如線程的名字和線程的狀態就是一種隨意信息。隨意信息的特點就是它們之間不存在邏輯關聯,導致記憶困難。2. 思考
學習不是為了被現有的知識所束縛,而是以現有的知識為基石,發展出新的思想。
這個說法的依據是什麼?
Garbage Collector (垃圾回收器)Garbage Collection (垃圾回收動作)Android Runtime (Android 應用運行時環境)
Java Virtual Machine (Java 虛擬機)
java.util.concurrent (Java 並發包)
不管你懂不懂多線程,你也必須要用多線程。
假如我們現在運行的是用 AS 建的一個啥也沒有的 demo 項目,那也不代表我們運行的是一個單線程應用。
因為這個應用是運行在 ART 上的,而 ART 自帶了 GC 線程,再加上主線程,它依舊是一個多線程應用。
第三方線程
在我們開發應用的過程中,即使我們沒有直接創建線程,也間接地創建了線程。因為我們日常使用的第三方庫,包括 Android 系統本身都用到了多線程。比如 Glide 就是使用工作線程從網絡上加載圖片,等圖片加載完畢後,再切回主線程把圖片設置到 ImageView 中。假如我們的應用中只有一個線程,意味著加載圖片時 Loading 動畫無法播放,界面是卡死的,用戶會失去耐心。
而且 Android 強制要求開發者在發起網絡請求時,必須在工作線程,不能在主線程,也就是開發 Android 應用必須使用多線程。
既然上面說到了使用多線程是不可避免的,那使用多線程又會遇到哪些問題呢?
做多線程優化是為了解決多線程的安全性和活躍性問題。
這兩個問題會導致多線程程序輸出錯誤的結果以及任務無法執行,下面我們就來看看這兩個問題的表現:
假如現在有兩個廚師小張和老王,他們兩個人分別做兩道菜,大家都知道自己的菜放了多少鹽,多少糖,在這種情況下出問題的概率比較低。但是如果兩個人做一個菜呢?
小張在做一個菜,做著做著鍋被老王搶走了,老王不知道小張有沒有放鹽,就又放了一次鹽,結果炒出來的菜太鹹了,沒法吃,然後他們就決定要出去皇城 PK。
這裡的 "菜" 對應著我們程序中的數據。
而這種現象就是導致線程出現安全性的原因之一: 競態 (Race Condition)。
之所以會出現競態是由 Java 的內存模型和線程調度機制決定的,關於 Java 的線程調度機制,在後面會有更詳細的介紹。活躍性問題
自從上次出了皇城 PK 的事情後,經理老李出了一條規定,打架扣 100,這條規定一出,小張和老王再也不敢 PK 了,不過沒過幾天,他們就找到了一種新的方式來互懟。
有一天,小張在做菜,小張要先放鹽再放糖,而老王拿著鹽,老王要先放糖再放鹽,結果過了兩個小時兩個人都沒把菜做出來,經理老李再次陷入懵逼的狀態。
這就是線程活躍性問題的現象之一: 死鎖 (Deadlock)。
關於線程安全性的三個問題和線程活躍性的四個問題,在本文後面會做更詳細的介紹。
上一節我們講到了多線程編程可能會導致程序出現這樣那樣的問題,那什麼是線程呢?
我們這一節的內容包括下面幾個部分:
1. 線程簡介
線程是進程中可獨立執行的最小單位,也是 CPU 資源分配的基本單位。
2. 線程的四個屬性
線程有編號、名字、類別以及優先級四個屬性,除此之外,線程的部分屬性還具有繼承性,下面我們就來看看線程的四個屬性的作用和線程的繼承性:編號
每個線程都有自己的名字 (name),名字的默認值是 Thread-線程編號,比如 Thread-0。除了默認值,我們也可以給線程設置名字,以我們自己的方式去區分每一條線程。
類別
線程的類別 (daemon) 分為守護線程和用戶線程,我們可以通過 setDaemon (true) 把線程設置為守護線程。當 JVM 要退出時,它會考慮是否所有的用戶線程都已經執行完畢,是的話則退出。而對於守護線程,JVM 在退出時不會考慮它是否執行完成。
作用: 守護線程通常用於執行不重要的任務,比如監控其他線程的運行情況,GC 線程就是一個守護線程。注意事項: setDaemon() 要在線程啟動前設置,否則 JVM 會拋出非法線程狀態異常 (IllegalThreadStateException)。注意事項
不保證: 線程調度器把線程的優先級當作一個參考值,不一定會按我們設定的優先級順序執行線程。線程飢餓: 優先級使用不當會導致某些線程永遠無法執行,也就是線程飢餓的情況,關於線程飢餓,在第 7 大節會有更多的介紹。繼承性
線程的繼承性指的是線程的類別和優先級屬性是會被繼承的,線程的這兩個屬性的初始值由開啟該線程的線程決定。假如優先級為 5 的守護線程 A 開啟了線程 B,那麼線程 B 也是一個守護線程,而且優先級也是 5 。這時我們就把線程 A 叫做線程 B 的父線程,把線程 B 叫做線程 A 的子線程。線程的常用方法有六個,它們分別是三個非靜態方法 start()、run()、join() 和三個靜態方法 currentThread()、yield()、sleep() 。
下面我們就來看下這六個方法都有哪些作用和注意事項:
start()
run()
join()
Thread.sleep(ms)
4. 線程的六種狀態
線程的生命周期
和 Activity 一樣,線程也有自己的生命周期,而且生命周期事件也是由用戶 (開發者) 觸發的。從 Activity 的角度來看,用戶點擊按鈕後打開一個 Activity,就相當於是觸發了 Activity 的 onCreate() 方法。從線程的角度來看,開發者調用了 start() 方法,就相當於是觸發了 Thread 的 run() 方法。如果我們在上一個 Activity 的 onPause() 方法中進行了耗時操作,那麼下一個 Activity 的顯示也會因為這個耗時操作而慢一點顯示,這就相當於是 Thread 的等待狀態。
線程的生命周期不僅可以由開發者觸發,還會受到其他線程的影響,下面是線程各個狀態之間的轉換示意圖。
我們可以通過 Thread.getState() 獲取線程的狀態,該方法返回的是一個枚舉類 Thread.State。線程的狀態有新建、可運行、阻塞、等待、限時等待和終止 6 種,下面我們就來看看這 6 種狀態之間的轉換過程:新建狀態
當一個線程創建後未啟動時,它就處於新建 (NEW) 狀態。當我們調用線程的 start() 方法後,線程就進入了可運行 (RUNNABLE) 狀態。
可運行狀態又分為預備 (READY) 和運行 (RUNNING) 狀態。
預備狀態: 處於預備狀態的線程可被線程調度器調度,調度後線程的狀態會從預備轉換為運行狀態,處於預備狀態的線程也叫活躍線程。
運行狀態: 運行狀態表示線程正在運行,也就是處理器正在執行線程的 run() 方法。當線程的 yield() 方法被調用後,線程的狀態可能由運行狀態變為預備狀態。
當下面幾種情況發生時,線程就處於阻塞 (BLOCKED) 狀態:
進入一個 synchronized 方法或代碼塊失敗一個線程執行特定方法後,會等待其他線程執行執行完畢,此時線程進入了等待 (WAITING) 狀態。
等待狀態: 下面的幾個方法可以讓線程進入等待狀態:Object.wait()
LockSupport.park()
Thread.join()
可運行狀態: 下面的幾個方法可以讓線程從等待狀態轉變為可運行狀態,而這種轉變又叫喚醒:Object.notify()
Object.notifyAll()
LockSupport.unpark()
限時等待狀態
限時等待狀態 (TIMED_WAITING) 與等待狀態的區別就是,限時等待是等待一段時間,時間到了之後就會轉換為可運行狀態。下面的幾個方法可以讓線程進入限時等待狀態,下面的方法中的 ms、ns、time 參數分別代表毫秒、納秒以及絕對時間:
終止狀態
當線程的任務執行完畢或者任務執行遇到異常時,線程就處於終止 (TERMINATED) 狀態。閱讀完上一節的內容後,我們對線程有了基本的了解,知道了什麼是線程,也知道了線程的生命周期是怎麼流轉的。這一節我們就來看看線程是怎麼被調度的,這一節包括以下內容:
Java 內存模型簡介
高速緩存
Java 線程調度機制
1. Java 的內存模型簡介
了解 Java 的內存模型,能幫助我們更好地理解線程的安全性問題,下面我們就來看看什麼是 Java 的內存模型。Java 內存模型 (Java Memory Model,JMM) 規定了所有變量都存儲在主內存中,每條線程都有自己的工作內存。JVM 把內存劃分成了好幾塊,其中方法區和堆內存區域是線程共享的。假如現在有三個線程同時對值為 5 的變量 a 進行自增操作,那最終的結果應該是 8。但是自增的真正實現是分為下面三步的,而不是一個不可分割的 (原子的) 操作:
將 temp 的值加 1
將 temp 的值重新賦給變量 a
假如線程 1 在進行到第二步的時候,其他兩條線程讀取了變量 a ,那麼最終的結果就是 7,而不是預期的 8。這種現象就是線程安全的其中一個問題: 原子性。現代處理器的處理能力要遠勝於主內存 (DRAM) 的訪問速率,主內存執行一次內存讀/寫操作需要的時間,如果給處理器使用,處理器可以執行上百條指令。為了彌補處理器與主內存之間的差距,硬體設計者在主內存與處理器之間加入了高速緩存 (Cache) 。處理器執行內存讀寫操作時,不是直接與主內存打交道,而是通過高速緩存進行的。高速緩存相當於是一個由硬體實現的容量極小的散列表,這個散列表的 key 是一個對象的內存地址,value 可以是內存數據的副本,也可以是準備寫入內存的數據。從內部結構來看,高速緩存相當於是一個鏈式散列表 (Chained Hash Table) ,它包含若干個桶,每個桶包含若干個緩存條目 (Cache Entry)。緩存條目可進一步劃分為 Tag、Data Block 和 Flag 三個部分。
Tag: 包含了與緩存行中數據對應的內存地址的部分信息 (內存地址的高位部分比特)。Data Block: 也叫緩存行 (Cache Line),是高速緩存與主內存之間數據交換的最小單元,可以存儲從內存中讀取的數據,也可以存儲準備寫進內存的數據。在任意時刻,CPU 只能執行一條機器指令,每個線程只有獲取到 CPU 的使用權後,才可以執行指令。也就是在任意時刻,只有一個線程佔用 CPU,處於運行的狀態。多線程並發運行實際上是指多個線程輪流獲取 CPU 使用權,分別執行各自的任務。線程的調度由 JVM 負責,線程的調度是按照特定的機制為多個線程分配 CPU 的使用權。線程調度模型分為兩類: 分時調度模型和搶佔式調度模型:分時調度模型: 分時調度模型是讓所有線程輪流獲取 CPU 使用權,並且平均分配每個線程佔用 CPU 的時間片。
搶佔式調度模型: JVM 採用的是搶佔式調度模型,也就是先讓優先級高的線程佔用 CPU,如果線程的優先級都一樣,那就隨機選擇一個線程,並讓該線程佔用 CPU。也就是如果我們同時啟動多個線程,並不能保證它們能輪流獲取到均等的時間片。如果我們的程序想幹預線程的調度過程,最簡單的辦法就是給每個線程設定一個優先級。
閱讀完上一節的內容後,我們對 Java 的線程調度機制有了基本的了解。這一節我們就來看看線程調度機制導致的線程安全問題,這一節的內容包括以下幾個部分:1. 競態
線程安全問題不是說線程不安全,也不是說線程弄不好把手機都搞爆炸了。線程安全問題指的是多個線程之間對一個或多個共享可變對象交錯操作時,有可能導致數據異常。多線程編程中經常遇到的問題就是一樣的輸入在不同的時間有不一樣的輸出,這種一個計算結果的正確性與時間有關的現象就是競態,也就是計算的正確性依賴於相對時間順序或線程的交錯。競態不一定導致計算結果的不正確,而是不排除計算結果有時正確有時錯誤的可能。競態往往伴隨著髒數據和丟失更新的問題,髒數據就是線程讀到一個過時的數據,丟失更新就是一個線程對數據做的更新,沒有體現在後續其他線程對該數據的讀取上。對於共享變量,競態可以看成訪問 (讀/寫) 同一組共享變量的多個線程鎖執行的操作相互交錯,比如一個線程讀取共享變量,並以該共享變量為基礎進行計算的期間,另一個線程更新了該共享變量的值,導致髒數據或丟失更新。對於局部變量,由於不同的線程各自訪問的是自己的局部變量,所以局部變量的使用不會導致競態。2. 原子性
原子 (Atomic) 的字面意識是不可分割的,對於涉及共享變量訪問的操作,若該操作從其執行線程以外的任意線程看來是不可分割的,那麼該操作就是原子操作,相應地稱該操作具有原子性 (Atomicity)。所謂不可分割,就是訪問 (讀/寫) 某個共享變量的操作,從執行線程以外的其他線程看來,該操作只有未開始和結束兩種狀態,不會知道該操作的中間部分。拿炒菜舉例,炒菜可分為幾個步驟: 放油、放菜、放鹽、放糖等。但是從客人的角度來看,一個菜只有兩種狀態: 沒做好和做好了。訪問同一組共享變量的原子操作是不能被交錯的,這就排除了一個線程執行一個操作的期間,另一個線程讀取或更新該操作鎖訪問的共享變量,導致髒數據和丟失更新。在多線程環境下,一個線程對某個共享變量進行更新後,後續訪問該變量的線程可能無法立刻讀取到這個更新的結果,甚至永遠也無法讀取到這個更新的結果,這就是線程安全問題的另一種表現形式: 可見性。可見性是指一個線程對共享變量的更新,對於其他讀取該變量的線程是否可見。可見性問題與計算機的存儲系統有關,程序中的變量可能會被分配到寄存器而不是主內存中,每個處理器都有自己的寄存器,一個處理器無法讀取另一個處理器的寄存器上的內容。即使共享變量是分配到主內存中存儲的,也不能保證可見性,因為處理器不是直接訪問主內存,而是通過高速緩存進行的。一個處理器上運行的線程對變量的更新,可能只是更新到該處理器的寫緩衝器 (Store Buffer) 中,還沒有到高速緩存中,更別說處理器了。可見性描述的是一個線程對共享變量的更新,對於另一個線程是否可見,保證可見性意味著一個線程可以讀取到對應共享變量的新值。從保證線程安全的角度來看,光保證原子性還不夠,還要保證可見性,同時保證可見性和原子性才能確保一個線程能正確地看到其他線程對共享變量做的更新。4. 有序性
有序性是指一個處理器在為一個線程執行的內存訪問操作,對於另一個處理器上運行的線程來看是亂序的。順序結構是結構化編程中的一種基本結構,它表示我們希望某個操作先於另外一個操作執行。但是在多核處理器的環境下,代碼的執行順序是沒保障的,編譯器可能改變兩個操作的先後順序,處理器也可能不是按照程序代碼的順序執行指令。重排序 (Reordering) 處理器和編譯器是對代碼做的一種優化,它可以在不影響單線程程序正確性的情況下提升程序的性能,但是它會對多線程程序的正確性產生影響,導致線程安全問題。現代處理器為了提高指令的執行效率,往往不是按程序順序注意執行指令的,而是哪條指令就緒就先執行哪條指令,這就是處理器的亂序執行。要實現線程安全就要保證上面說到的原子性、可見性和有序性。常見的實現線程安全的辦法是使用鎖和原子類型,而鎖可分為內部鎖、顯式鎖、讀寫鎖、輕量級鎖 (volatile) 四種。下面我們就來看看這四種鎖和原子類型的用法和特點:文章的開頭提到的 「打架扣 100」 就是一種現實生活中的鎖,可以讓小張和老王乖乖幹活,別再炒出不能吃的菜。這也就是鎖 (Lock) 的作用,讓多個線程更好地協作,避免多個線程的操作交錯導致數據異常的問題。鎖的五個特點
臨界區: 持有鎖的線程獲得鎖後和釋放鎖前執行的代碼叫做臨界區 (Critical Section)。
排他性: 鎖具有排他性,能夠保障一個共享變量在任一時刻只能被一個線程訪問,這就保證了臨界區代碼一次只能夠被一個線程執行,臨界區的操作具有不可分割性,也就保證了原子性。
串行: 鎖相當於是把多個線程對共享變量的操作從並發改為串行。
三種保障: 鎖能夠保護共享變量實現線程安全,它的作用包括保障原子性、可見性和有序性。
調度策略: 鎖的調度策略分為公平策略和非公平策略,對應的鎖就叫公平鎖和非公平鎖。公平鎖會在加鎖前查看是否有排隊等待的線程,有的話會優先處理排在前面的線程。公平鎖以增加上下文切換為代價,保障了鎖調度的公平性,增加了線程暫停和喚醒的可能性。
鎖的兩個問題
Java 為我們提供了 synchronized 關鍵字來實現內部鎖,被 synchronized 關鍵字修飾的方法和代碼塊就叫同步方法和同步代碼塊。1. 監視器鎖: 因為使用 synchronized 實現的線程同步是通過監視器 (monitor) 來實現的,所以內部鎖也叫監視器鎖。
2. 自動獲取/釋放: 線程對同步代碼塊的鎖的申請和釋放由 JVM 內部實施,線程在進入同步代碼塊前會自動獲取鎖,並在退出同步代碼塊時自動釋放鎖,這也是同步代碼塊被稱為內部鎖的原因。
3. 鎖定方法/類/對象: synchronized 關鍵字可以用來修飾方法,鎖住特定類和特定對象。
4. 臨界區: 同步代碼塊就是內部鎖的臨界區,線程在執行臨界區代碼前必須持有該臨界區的內部鎖。
5. 鎖句柄: 內部鎖鎖的對象就叫鎖句柄,鎖句柄通常會用 private 和 final 關鍵字進行修飾。因為鎖句柄變量一旦改變,會導致執行同一個同步代碼塊的多個線程實際上用的是不同的鎖。6. 不會洩漏: 洩漏指的是鎖洩漏,內部鎖不會導致鎖洩漏,因為 javac 編譯器把同步代碼塊編譯為字節碼時,對臨界區中可能拋出的異常做了特殊處理,這樣臨界區的代碼出了異常也不會妨礙鎖的釋放。7. 非公平鎖: 內部鎖是使用的是非公平策略,是非公平鎖,也就是不會增加上下文切換開銷。
內部鎖基本用法
// 鎖句柄private final String hello = "hello";
private void getLock1() { synchronized (hello) { System.out.println("ThreadA 拿到了內部鎖"); ThreadUtils.sleep(2 * 1000); } System.out.println("ThreadA 釋放了內部鎖");}private void getLock2() { System.out.println("ThreadB 嘗試獲取內部鎖"); synchronized (hello) { System.out.println("ThreadB 拿到了內部鎖"); } System.out.println("ThreadB 繼續執行");}當我們在兩個線程中分別運行上面兩個函數後,我們可以得到下面的輸出:ThreadA 拿到了內部鎖ThreadB 嘗試獲取內部鎖ThreadA 釋放了內部鎖ThreadB 拿到了內部鎖ThreadB 繼續執行顯式鎖 (Explict Lock) 是 Lock 接口的實例,Lock 接口對顯式鎖進行了抽象,ReentrantLock 是它的實現類。下面是顯式鎖的四個特點:
可重入: 顯式鎖是可重入鎖,也就是一個線程持有了鎖後,能再次成功申請這個鎖。
手動獲取/釋放: 顯式鎖與內部鎖區別在於,使用顯式鎖,我們要自己釋放和獲取鎖,為了避免鎖洩漏,我們要在 finally 塊中釋放鎖。
臨界區: lock() 與 unlock() 方法之間的代碼就是顯式鎖的臨界區。
公平/非公平鎖: 顯式鎖允許我們自己選擇鎖調度策略。ReentrantLock 有一個構造函數,允許我們傳入一個 fair 值,當這個值為 true 時,說明現在創建的這個鎖是一個公平鎖。由於公平鎖的開銷比非公平鎖大,所以 ReentrantLock 的默認調度策略是非公平策略。
private final Lock lock = new ReentrantLock();
private void lock1() { lock.lock(); System.out.println("線程 1 獲取了顯式鎖"); try { System.out.println("線程 1 開始執行操作"); ThreadUtils.sleep(2 * 1000); } finally { lock.unlock(); System.out.println("線程 1 釋放了顯式鎖"); }}private void lock2() { lock.lock(); System.out.println("線程 2 獲取了顯式鎖"); try { System.out.println("線程 2 開始執行操作"); } finally { System.out.println("線程 2 釋放了顯式鎖"); lock.unlock(); }}當我們分別在兩個線程中分別執行了上面的兩個函數後,我們可以得到下面的輸出:線程 1 獲取了顯式鎖線程 1 開始執行操作線程 1 釋放了顯式鎖線程 2 獲取了顯式鎖線程 2 開始執行操作線程 2 釋放了顯式鎖
顯示鎖獲取鎖的四個方法
lock(): 獲取鎖,獲取失敗時線程會處於阻塞狀態。
tryLock(): 獲取鎖,獲取成功時返回 true,獲取失敗時會返回 false,不會處於阻塞狀態。
tryLock(long time, TimeUnit unit): 獲取鎖,獲取到了會返回 true,如果在指定時間內未獲取到,則返回 false。在指定時間內處於阻塞狀態,可中斷。
lockInterruptibly(): 獲取鎖,可中斷。
內部鎖與顯式鎖的區別
看完了內部鎖和顯式鎖的介紹,下面我們來看下內部鎖和顯式鎖的五個區別:1. 靈活性: 內部鎖是基於代碼的鎖,鎖的申請和釋放只能在一個方法內執行,缺乏靈活性。顯式鎖是基於對象的鎖,鎖的申請和釋放可以在不同的方法中執行,這樣可以充分發揮面向對象編程的靈活性。
2. 鎖調度策略: 內部鎖只能是非公平鎖。顯式鎖可以自己選擇鎖調度策略。
3. 便利性: 內部鎖簡單易用,不會出現鎖洩漏的情況。顯式鎖需要自己手動獲取/釋放鎖,使用不當的話會導致鎖洩漏。
4. 阻塞: 如果持有內部鎖鎖的線程一直不釋放這個鎖,那其他申請這個鎖的線程只能一直等待。顯式鎖 Lock 接口有一個 tryLock() 方法,當其他線程持有鎖時,這個方法會返回直接返回 false。這樣就不會導致線程處於阻塞狀態,我們就可以在獲取鎖失敗時做別的事情。
5. 適用場景: 在多個線程持有鎖的平均時間不長的情況下我們可以使用內部鎖。
在多個線程持有鎖的平均較長的情況下我們可以使用顯式鎖 (公平鎖)。讀寫鎖
鎖的排他性使得多個線程無法以線程安全的方式在同一時刻讀取共享變量,這樣不利於提高系統的並發性,這也是讀寫鎖出現的原因。讀寫鎖 ReadWriteLock 接口的實現類是 ReentrantReadWriteLock。只讀取共享變量的線程叫讀線程,只更新共享變量的線程叫寫線程。讀寫鎖是一種改進的排他鎖,也叫共享/排他 (Shared/Exclusive) 鎖。
讀寫鎖有下面六個特點:
1. 讀鎖共享: 讀寫鎖允許多個線程同時讀取共享變量,讀線程訪問共享變量時,必須持有對應的讀鎖,讀鎖可以被多個線程持有。2. 寫鎖排他: 讀寫鎖一次只允許一個線程更新共享變量,寫線程訪問共享變量時,必須持有對應的寫鎖,寫鎖在任一時刻只能被一個線程持有。3. 可以降級: 讀寫鎖是一個支持降級的可重入鎖,也就是一個線程在持有寫鎖的情況下,可以繼續獲取對應的讀鎖。這樣我們可以在修改變量後,在其他地方讀取該變量,並執行其他操作。
4. 不能升級: 讀寫鎖不支持升級,讀線程只有釋放了讀鎖才能申請寫鎖。5. 三種保障: 讀寫鎖雖然允許多個線程讀取共享變量,但是由於寫鎖的特性,它同樣能保障原子性、可見性和有序性。6. 適用場景: 讀寫鎖會帶來額外的開銷,只有滿足下面兩個條件,讀寫鎖才是合適的選擇。讀操作比寫操作頻繁很多
讀取共享變量的線程持有鎖的時間較長
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();private final Lock readLock = readWriteLock.readLock();private final Lock writeLock = readWriteLock.writeLock();
private void write1() { writeLock.lock(); System.out.println("寫線程1獲取了寫鎖"); try { System.out.println("寫線程1開始執行操作"); ThreadUtils.sleep(3 * 1000); } finally { writeLock.unlock(); System.out.println("寫線程1釋放了寫鎖"); }}
private void write2() { writeLock.lock(); System.out.println("寫線程2獲取了寫鎖"); try { System.out.println("寫線程2開始執行操作"); } finally { writeLock.unlock(); System.out.println("寫線程2釋放了寫鎖"); }}private void read1() { readLock.lock(); System.out.println("讀線程1獲取了讀鎖"); try { System.out.println("讀線程1開始執行操作"); ThreadUtils.sleep(3 * 1000); } finally { readLock.unlock(); System.out.println("讀線程1釋放了讀鎖"); }}
private void read2() { readLock.lock(); System.out.println("讀線程2獲取了讀鎖"); try { System.out.println("讀線程2開始執行操作"); ThreadUtils.sleep(3 * 1000); } finally { readLock.unlock(); System.out.println("讀線程2釋放了讀鎖"); }}當在四個線程中分別執行上面的四個函數時,我們可以得到下面的輸出:寫線程1獲取了寫鎖寫線程1開始執行操作寫線程1釋放了寫鎖寫線程2獲取了寫鎖寫線程2開始執行操作寫線程2釋放了寫鎖讀線程1獲取了讀鎖讀線程1開始執行操作讀線程2獲取了讀鎖讀線程2開始執行操作讀線程1釋放了讀鎖讀線程2釋放了讀鎖
volatile 關鍵字
volatile 關鍵字可用於修飾共享變量,對應的變量就叫 volatile 變量,volatile 變量有下面幾個特點:
1. 易變化: volatile 的字面意思是 "不穩定的" ,也就是 volatile 用於修飾容易發生變化的變量,不穩定指的是對這種變量的讀寫操作要從高速緩存或主內存中讀取,而不會分配到寄存器中。比鎖低: volatile 的開銷比鎖低,volatile 變量的讀寫操作不會導致上下文切換,所以 volatile 關鍵字也叫輕量級鎖 。比普通變量高: volatile 變量讀操作的開銷比普通變量要高,這是因為 volatile 變量的值每次都要從高速緩存或主內存中讀取,無法被暫存到寄存器中。3. 釋放/存儲屏障: 對於 volatile 變量的寫操作,JVM 會在該操作前插入一個釋放屏障,並在該操作後插入一個存儲屏障。存儲屏障具有衝刷處理器緩存的作用,所以在 volatile 變量寫操作後插入一個存儲屏障,能讓該存儲屏障前的所有操作結果對其他處理器來說是同步的。4. 加載/獲取屏障: 對於 volatile 變量的讀操作,JVM 會在該操作前插入一個加載屏障,並在操作後插入一個獲取屏障。加載屏障通過衝刷處理器緩存,使線程所在的處理器將其他處理器對該共享變量做的更新同步到該處理器的高速緩存中。5. 保證有序性: volatile 能禁止指令重排序,也就是使用 volatile 能保證操作的有序性。6. 保證可見性: 讀線程執行的加載屏障和寫線程執行的存儲屏障配合在一起,能讓寫線程對 volatile 變量的寫操作對讀線程可見,從而保證了可見性。7. 原子性: 在原子性方面,對於 long/double 型變量,volatile 能保證讀寫操作的原子型。對於非 long/double 型變量,volatile 只能保證寫操作的原子性。如果 volatile 變量寫操作前涉及共享變量,競態仍然可能發生,因為共享變量賦值給 volatile 變量時,其他線程可能已經更新了該共享變量的值。7. 原子類型
在 JUC 下有一個 atomic 包,這個包裡面有一組原子類,使用原子類的方法,不需要加鎖也能保證線程安全,而原子類是通過 Unsafe 類中的 CAS 指令從硬體層面來實現線程安全的。這個包裡面有如 AtomicInteger、AtomicBoolean、AtomicReference、AtomicReferenceFIeldUpdater 等。我們先來看一個使用原子整型 AtomicInteger 自增的例子// 初始值為 1AtomicInteger integer = new AtomicInteger(1);
// 自增int result = integer.incrementAndGet();
// 結果為 2System.out.println(result);AtomicReference 和 AtomicReferenceFIeldUpdater 可以讓我們自己的類具有原子性,它們的原理都是通過 Unsafe 的 CAS 操作實現的。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。8. 鎖的使用技巧
使用鎖會帶來一定的開銷,而掌握鎖的使用技巧可以在一定程度上減少鎖帶來的開銷和潛在的問題,下面就是一些鎖的使用技巧:
長鎖不如短鎖: 儘量只對必要的部分加鎖
大鎖不如小鎖: 進可能對加鎖的對象拆分
公鎖不如私鎖: 進可能把鎖的邏輯放到私有代碼中,如果讓外部調用者加鎖,可能會導致鎖不正當使用導致死鎖
嵌套鎖不如扁平鎖: 在寫代碼時要避免鎖嵌套
分離讀寫鎖: 儘可能將讀鎖和寫鎖分離
粗化高頻鎖: 合併處理頻繁而且過短的鎖,因為每一把鎖都會帶來一定的開銷
消除無用鎖: 儘可能不加鎖,或者用 volatile 代替
上一大節介紹了鎖的作用和基本用法,鎖能讓線程進入阻塞狀態,而這種阻塞就會導致任務無法正常執行,也就是線程出現活躍性問題,這也就是我們這一節要講的內容。活躍性問題不是說線程過於活躍,而是線程不夠活躍,導致任務無法取得進展。我們這一節就來看一下常見的四個線程活躍性問題: 死鎖、鎖死、活鎖和飢餓。死鎖是線程的一種常見多線程活躍性問題,如果兩個或更多的線程,因為相互等待對方而被永遠暫停,那麼這就叫死鎖現象。下面我們就來看看死鎖產生的四個條件和避免死鎖的三個方法:
死鎖產生的四個條件
當多個線程發生了死鎖後,這些線程和相關共享變量就會滿足下面四個條件:
資源互斥: 涉及的資源必須是獨佔的,也就是資源每次只能被一個線程使用
資源不可搶奪: 涉及的資源只能被持有該資源的線程主動釋放,無法被其他線程搶奪 (被動釋放)
佔用並等待資源: 涉及的線程至少持有一個資源,還申請了其他資源,而其他資源剛好被其他線程持有,並且線程不釋放已持有資源
循環等待資源: 涉及的線程必須等待別的線程持有的資源,而別的線程又反過來等待該線程持有的資源
只要產生了死鎖,上面的條件就一定成立,但是上面的條件都成立也不一定會產生死鎖避免死鎖的三個方法
要想消除死鎖,只要破壞掉上面的其中一個條件即可。由於鎖具有排他性,且無法被動釋放,所以我們只能破壞掉第三個和第四個條件。1. 粗鎖法
使用粗粒度的鎖代替多個鎖,鎖的範圍變大了,訪問共享資源的多個線程都只需要申請一個鎖,因為每個線程只需要申請一個鎖就可以執行自己的任務,這樣 "佔用並等待資源" 和 "循環等待資源" 這兩個條件就不成立了。
粗鎖法的缺點是會降低並發性,而且可能導致資源浪費,因為採用粗鎖法時,一次只能有一個線程訪問資源,這樣其他線程就只能擱置任務了。2. 鎖排序法
鎖排序法指的是相關線程使用全局統一的順序申請鎖。
假如有多個線程需要申請鎖,我們只需要讓這些線程按照一個全局統一的順序去申請鎖,這樣就能破壞 "循環等待資源" 這個條件。3. tryLock
顯式鎖 ReentrantLock.tryLock(long timeUnit) 這個方法允許我們為申請鎖的操作設置超時時間,這樣就能破壞 "佔用並等待資源" 這個條件。4. 開放調用
開放調用 (Open Call) 就是一個方法在調用外部方法時不持有鎖,開放調用能破壞 "佔用並等待資源" 這個條件。
2. 鎖死
等待線程由於喚醒的條件永遠無法成立,導致任務一直無法繼續執行,那麼這個線程是被鎖死 (Lockout) 了。鎖死和死鎖的區別在於,即使產生死鎖的條件全部都不成立,還是有可能發生鎖死。鎖死可分為信號丟失鎖死和嵌套監視器鎖死。信號丟失鎖死
信號丟失鎖死是由於沒有對應的通知線程喚醒等待線程,導致等待線程一直處於等待狀態的一種活躍性問題。信號丟失鎖死的一個典型例子就是等待線程執行 Object.wait()/Condition.await() 前沒有判斷保護條件,而保護條件已經成立,但是後續沒有其他線程更新保護條件並通知等待線程,這也就是為什麼要強調 Object.wait()/Condition.await() 要放在循環語句中執行。嵌套監視器丟失鎖死
嵌套監視器鎖死指的是嵌套地使用鎖導致線程永遠無法被喚醒,在代碼上的表現就是兩個嵌套的同步代碼塊。避免嵌套監視器鎖死的辦法只需要避免嵌套使用內部鎖。3. 活鎖
活鎖 (Livelock) 是指線程一直處於運行狀態,但是任務卻一直無法繼續執行的一種現象。
4. 飢餓
線程飢餓 (Starvation) 是指線程一直無法獲得所需資源,導致任務一直無法執行。線程間的常見協作方式有兩種: 等待和中斷。中斷型協作放在第 8 大節講,我們這一節主要講等待型協作。當一個線程中的操作需要等待另一個線程中的操作結束時,就涉及到等待型線程協作方式。常用的等待型線程協作方式有 join、wait/notify、await/signal、await/countDown 和 CyclicBarrier 五種,下面我們就來看看這五種線程協作方式的用法和區別。1. join
使用 Thread.join() 方法,我們可以讓一個線程等待另一個線程執行結束後再繼續執行。join() 方法實現等待是通過 wait() 方法實現的,在 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(); } }}線程 B 開始等待線程 A 執行結束線程 A 執行結束線程 B 結束等待,開始做自己想做的事情
2. wait/notify
在 Java 中,使用 Object.wait()/Object.wait(long) 和 Object.notify()/Object.notifyAll() 可以用於實現等待和通知。一個線程因為執行操作 (目標動作) 所需的保護條件未滿足而被暫停的過程就叫等待 (wait)。
一個線程更新了共享變量,使得其他線程需要的保護條件成立,喚醒了被暫停的線程的過程就叫通知 (notify)。wait() 方法的執行線程叫等待線程,notify() 方法執行的線程叫通知線程。wait/notify 協作方式有下面九個特點:
1. 暫停/喚醒: Object.wait() 的作用是讓線程暫停 (狀態改為 WAITING),而 Object.notify() 的作用是喚醒一個被暫停的線程。
2. 所有對象: 由於 Object 是所有對象的父類,所以所有對象都可以實現等待和通知。
3. 獲取監視器鎖
使用 wait()/notify() 方法要先獲取共享對象的監視器鎖,獲取共享對象的監視器鎖有兩種方式,一是在同步代碼塊中執行,二是在同步方法 (synchronized 修飾的方法) 中執行 wait()/notify()。如果沒有事先獲取監視器鎖,那線程就會報出非法監視器狀態異常 IllegalMonitorStateException 異常。4. 捕獲中斷異常: 使用 wait() 方法必須要捕獲中斷異常 InterruptedException,因為通過 wait() 進入的等待狀態是可以被打斷的。5. 喚醒任一線程: notify() 方法喚醒的只是對應對象上的一個任意等待線程,被喚醒的線程不一定是我們想喚醒的線程。6. 喚醒特定線程: 如果我們想對應對象上的特定線程,我們可以使用 notifyAll(),把該對象上的所有等待線程都喚醒。7. final 修飾: 之所以 lock 對象要使用 final 修飾,是因為如果沒有用 final 修飾,那麼這個對象的值可能被修改,導致等待線程和通知線程同步在不同的內部鎖上,從而造成競態,違背了使用鎖的初衷。8. 循環判斷: 對保護條件的判斷和 wait() 方法的調用要放在循環語句中,以確保目標動作只有在保護條件成立時才能執行。9. 僅釋放對應內部鎖: 使用 wait() 方法暫停當前線程時,釋放的鎖是與該 wait() 方法所屬對象的內部鎖,當前線程持有的其他內部鎖和顯式鎖不會因此被釋放。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(); }}當我們在兩個線程中分別執行上面兩個函數後,會得到下面的輸出:
等待線程獲取了鎖保護條件不成立,等待線程進入等待狀態通知線程獲取了鎖通知線程即將喚醒等待線程等待線程被喚醒,開始執行目標動作
wait/notify 原理
JVM 會給每個對象維護一個入口集 (Entry Set) 和等待集 (Wait Set)。入口集用於存儲申請該對象內部鎖的線程,等待集用於存儲對象上的等待線程。wait() 方法會將當前線程暫停,在釋放內部鎖時,會將當前線程存入該方法所屬的對象等待集中。調用對象的 notify() 方法,會讓該對象的等待集中的任意一個線程喚醒,被喚醒的線程會繼續留在對象的等待集中,直到該線程再次持有對應的內部鎖時,wait() 方法就會把當前線程從對象的等待集中移除。添加當前線程到等待集、暫停當前線程、釋放鎖以及把喚醒後的線程從對象的等待集中移除,都是在 wait() 方法中實現的。在 wait() 方法的 native 代碼中,會判斷線程是否持有當前對象的內部鎖,如果沒有的話,就會報非法監視器狀態異常,這也就是為什麼要在同步代碼塊中執行 wait() 方法。1. 過早喚醒: 等待線程在保護條件未成立時被喚醒的現象就叫過早喚醒。過早喚醒使得無須被喚醒的等待線程也被喚醒了,導致資源浪費。
2. 信號丟失: 導致信號丟失的情況有兩種,一種是在循環體外判斷保護條件,另一種是 notify() 方法使用不當。循環體外判斷條件: 如果等待線程在執行 wait() 方法前沒有判斷保護條件是否成立,那麼有可能導致通知線程在等待線程進入臨界區前就更新了共享變量,使得保護條件成立,並進行了通知,但是等待線程並沒有暫停,所以也沒有被喚醒。這種現象相當於等待線程錯過了一個發送給它的「信號」,所以叫信號丟失。只要對保護條件的判斷和 wait() 方法的調用放在循環語句中,就可以避免這種情況導致的信號丟失。
notify() 使用不當: 信號丟失的另一個表現是在應該調用 notifyAll() 的情況下調用了 notify(),在這種情況下,避免信號丟失的辦法是使用 notifyAll() 進行通知
3. 欺騙性喚醒: 等待線程可能在沒有其他線程執行 notify()/notifyAll() 的情況下被喚醒,這種現象叫欺騙性喚醒。
雖然欺騙性喚醒出現的概率比較低,但是 Java 允許這種現象存在,這是 Java 平臺對作業系統妥協的一種結果。
等待線程執行 wait() 方法至少會導致該線程對內部鎖的兩次申請與釋放。通知線程在執行 notify()/notifyAll() 時需要持有對應對象的內部鎖,所以這裡會導致一次鎖的申請,而鎖的申請與釋放可能導致上下文切換。其次,等待線程從被暫停到喚醒的過程本身就會導致上下文切換。再次,被喚醒的等待線程在繼續運行時,需要再次申請內部鎖,此時等待線程可能需要和對應對象的入口集中的其他線程,以及其他新來的活躍線程爭用內部鎖,這又可能導致上下文切換。最後,過早喚醒也會導致額外的上下文切換,因為被過早喚醒的線程需要繼續等待,要再次經歷被暫停和喚醒的過程。減少 wait/notify 上下文切換的常用方法有下面兩種:
使用 notify() 代替 notifyAll(): 在保證程序正確性的情況下,使用 notify() 代替 notifyAll(),notify() 不會導致過早喚醒,從而減少上下文切換開銷
儘快釋放對應內部鎖: 通知線程執行完 notify()/notifyAll() 後儘快釋放對應的內部鎖,這樣可以避免被喚醒的線程在 wait() 調用返回前,再次申請對應內部鎖時,由於該鎖未被通知線程釋放,導致該線程被暫停
notify()/notifyAll() 的選用
notify() 可能導致信號丟失,而 notifyAll() 雖然會把不需要喚醒的等待線程也喚醒,但是在正確性方面有保障。所以一般情況下優先使用 notifyAll() 保障正確性。一般情況下,只有在下面兩個條件都實現時,才會選擇使用 notify() 實現通知。1. 只需喚醒一個線程
當一次通知只需要喚醒最多一個線程時,我們可以考慮使用 notify() 實現通知,但是光滿足這個條件還不夠。在不同的等待線程使用不同的保護條件時,notify() 喚醒的一個任意線程可能不是我們需要喚醒的那個線程,所以需要條件 2 來排除。2. 對象的等待集中只包含同質等待線程
同質等待線程指的是線程使用同一個保護條件並且 wait() 調用返回後的邏輯一致。最典型的同質線程是使用同一個 Runnable 創建的不同線程,或者同一個 Thread 子類 new 出來的多個實例。3. await/signal
await/signal 簡介
wait()/notify() 過於底層,而且還存在兩個問題,一是過早喚醒、二是無法區分 Object.wait(ms) 返回是由於等待超時還是被通知線程喚醒。使用 await/signal 協作方式有下面五個要點:
Condition 接口: 在 JDK 5 中引入了 Condition (條件變量) 接口,使用 Condition 也可以實現等待/通知,而且不存在上面提到的兩個問題。Condition 接口提供的 await()/signal()/signalAll() 相當於是 Object 提供的 wait()/notify()/notifyAll()。通過 Lock.newCondition() 可以獲得一個 Condition 實例。持有鎖: 與 wait/notify 類似,wait/notify 需要線程持有所屬對象的內部鎖,而 await/signal 要求線程持有 Condition 實例的顯式鎖。等待隊列: Condition 實例也叫條件變量或條件隊列,每個 Condition 實例內部都維護了一個用於存儲等待線程的等待隊列,相當於是 Object 中的等待集。循環語句: 對於保護條件的判斷和 await() 方法的調用,要放在循環語句中。引導區內: 循環語句和執行目標動作要放在同一個顯式鎖引導的臨界區中,這麼做是為了避免欺騙性喚醒和信號丟失的問題。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(); }}當我們在兩個線程中分別執行了上面的兩個函數後,能得到下面的輸出:等待線程獲取了鎖保護條件不成立,等待線程進入等待狀態通知線程獲取了鎖通知線程即將喚醒等待線程等待線程被喚醒,開始執行目標動作
awaitUntil() 用法
上面我們說到 Condition 接口可以解決 Object.wait(ms) 無法判斷等待的結束是由於超時還是喚醒,而解決辦法就是使用 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();}等待線程獲取了鎖保護條件不滿足,並且等待時間未到,等待進入等待狀態已超時,結束等待任務通知線程獲取了鎖通知線程即將喚醒等待線程
4. await/countDown
await/countDown 簡介
使用 join() 實現的是一個線程等待另一個線程執行結束,但是有的時候我們只是想要一個特定的操作執行結束,不需要等待整個線程執行結束,這時候就可以使用 CountDownLatch 來實現。await/countDown 協作方式有下面六個特點:
先決操作: CountDownLatch 可以實現一個或多個線程等待其他線程完成一組特定的操作後才繼續運行,這組線程就叫先決操作。先決操作數: CountDownLatch 內部維護了一個用於計算未完成先決操作數的 count 值,每當 CountDownLatch.countDown() 方法執行一次,這個值就會減 1。未完成先決操作數 count 是在 CountDownLatch 的構造函數中設置的。要注意的是,這個值不能小於 0,否則會報非法參數異常。一次性: 當計數器的值為 0 時,後續再調用 await() 方法不會再讓執行線程進入等待狀態,所以說 CountDownLatch 是一次性協作。不用加鎖: CountDownLatch 內部封裝了對 count 值的等待和通知邏輯,所以在使用 CountDownLatch 實現等待/通知不需要加鎖await(): CountDownLatch.await() 可以讓線程進入等待狀態,當 CountDownLatch 中的 count 值為 0 時,表示需要等待的先決操作已經完成。countDown(): 調用 CountDownLatch.countDown() 方法後,count 值就會減 1,並且在 count 值為 0 時,會喚醒對應的等待線程。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(); }}當我們在兩個線程中分別執行 startWait() 和 startCountDown() 方法後,我們會得到下面的輸出:
等待線程進入等待狀態執行先決操作計數值減 1執行先決操作計數值減 1等待線程結束等待
有的時候多個線程需要互相等待對方代碼中的某個地方 (集合點),這些線程才能繼續執行,這時可以使用 CyclicBarrier (柵欄)。CyclicBarrier 是 JDK 5 引入的一個類,CyclicBarrier 協作方式有下面幾個特點:使用 CyclicBarrier.await() 實現等待的線程叫參與方 (Party),除了最後一個執行 CyclicBarrier.await() 方法的線程外,其他執行該方法的線程都會被暫停。和 CountDownLatch 不同,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(); } }}第一天爬山,老李先來老王到了,小張還沒到小張到了人來齊了,開始爬山第二天爬山,老王先到小張到了,老李還沒到老李到了人來齊了,開始爬山
CyclicBarrier 內部有一個用於實現等待/通知的 Condition (條件變量) 類型的變量 trip。而且 CyclicBarrier 內部還有一個分代 (Generation) 對象,用於表示 CyclicBarrier 實例是可以重複使用的。當前分代的初始狀態是 parties (參與方總數) ,CyclicBarrier.await() 方法每執行一次,parties 的值就會減 1。調用了 CyclicBarrier 方法的參與方相當於是等待線程,而最後一個參與方相當於是通知線程。當最後一個參與方調用了 CyclicBarrier.await() 方法時,在該方法中會先執行 barrierAction.run() ,再執行 trip.signalAll() 喚醒所有等待線程,接著開始下一個分代,也就是 parties 的值會恢復為初始值。Generation 中有一個布爾值 broken,當調用 CyclicBarrier.await() 方法的線程被中斷時,broken 的值就會變為 true。這時會拋出一個 BrokenBarrierExcetpion 異常,這個異常用於表示當前分代已經被破壞了,無法完成該分代應該完成的任務了。也就是使用 CyclicBarrier 的每一個線程,都不能被中斷 (interrupt() 方法被調用)。1. stop() 方法
JDK 中的 stop() 方法很早就被棄用了,之所以會被棄用,我們可以來看下 stop() 方法可能導致的兩種情況:第一種情況,假如現在有線程 A 和 線程 B,線程 A 持有了線程 B 需要的鎖,然後線程 A 被 stop() 強行結束了,導致這個鎖沒有被釋放,那線程 B 就一直拿不到這個鎖了,相當於是線程 B 中的任務永遠無法執行了。第二種情況,假如線程 A 正在修改一個變量,修改到一半,然後被 stop() 強行結束了,這時候線程 B 去讀取這個變量,讀取到的就是一個異常值,這就可能導致線程 B 出現異常。因為上述兩種資源清理的問題,所以現在很多語言都廢棄了線程的 stop() 方法。雖然線程不能被簡單粗暴地終止,但是線程執行的任務是可以停止的,下面我們就來看看怎麼停止任務。2. interrupt() 方法
當我們調用 sleep() 方法時,編譯器會要求我們捕獲中斷異常 InterruptedException,這是因為線程的休眠狀態可能會被中斷。在線程休眠期間,如果其他地方調用了線程的 interrupt() 方法,那麼這個休眠狀態就會被中斷,中斷後就會接收到一個中斷異常。我們可以在捕獲到中斷異常後釋放鎖,比如關閉流或文件。但是調用線程的 interrupt() 方法不是百分百能中斷任務的,假如我們現在有一個線程,它的 run() 方法中有個 while 循環在執行某些操作,那麼在其他地方調用該線程的 interrupt() 方法並不能中斷這個任務。在這種情況下,我們可以通過 interrupted() 或 isInterruped() 方法判斷任務是否被中斷。interrupted() 與 isInterrupted() 方法都可以獲取線程的中斷狀態,但它們有下面一些區別:靜態
interrupted() 是靜態方法,isInterrupted() 是非靜態方法重置
interrupted() 會重置中斷狀態,也就是不管這次獲取到的中斷狀態是 true 還是 false,下次獲取到的中斷狀態都是 falseisInterrupted() 不會重置中斷狀態,也就是調用了線程的 interrupt() 方法後,通過該方法獲取到的中斷狀態會一直為 true不論是使用 interrupted() 還是 isInterrupted() 方法,本質上都是通過 Native 層的布爾標誌位判斷的既然 interrupt() 只是對布爾值的一個修改,那我們可以在 Java 層自己設一個布爾標誌位,讓每個線程共享這個布爾值。
當我們想取消某個任務時,就在外部把這個標誌位改為 true。
注意事項: 直接使用布爾標誌位會有可見性問題,所以要用 volatile 關鍵字修飾這個值。使用場景: 當我們需要用到 sleep() 方法時,我們可以使用 interrupt() 來中斷任務,其他時候可以使用布爾標誌位。1. ConcurrentHashMap 簡介
ConcurrentHashMap 是一個並發容器,並發容器是相對於同步容器的一個概念。我們經常使用的 HashMap 和 ArrayList 等數據容器是線程不安全的,比如使用 HashMap 時需要自己加鎖,這時候就需要線程安全的數據容器:同步容器和異步容器。同步容器指的是 Hashtable 等線程安全的數據容器,同步容器實現線程安全的方式存在性能問題。同步容器之一的 Hashtable 存在如下的問題:
而並發容器比如 ConcurrentHashMap、CopyOnWriteArrayList 等就不存在這個問題,下面我就來看看它們是怎麼實現的。2. ConcurrentHashMap 簡史
ConcurrentHashMap 從 JDK 5~8 ,每一個版本都進行了優化,下面我們就看下各個版本對 ConcurrentHashMap 做的優化:JDK 5
在 JDK 5 中,ConcurrentHashMap 的實現是使用分段鎖,在必要時加鎖。Hashtable 是整個哈希表加鎖,而 JDK 5 引入的 ConcurrentHashMap 使用段 (Segment) 存儲鍵值對,在必要時對段進行加鎖,不同段之間的訪問不受影響。JDK 5 的 ConcurrentHashMap 中的哈希算法對於比較小的整數,比如三萬以下的整數作為 key 時,無法讓元素均勻分布在各個段中,導致它退化成了一個 Hashtable。JDK 6
在 JDK 6 中,ConcurrentHashMap 優化了二次 Hash 算法,用了 single-word Wang/Jenkins 哈希算法,這個算法可以讓元素均勻分布在各個段中。JDK 7
JDK 7 的 ConcurrentHashMap 初始化段的方式跟之前的版本不一樣,以前是 ConcurrentHashMap 構造出來後直接實例化 16 個段,而 JDK 7 開始,是需要哪個就創建哪個。懶加載實例化段會涉及可見性問題,所以在 JDK 7 的 ConcurrentHashMap 中使用了 volatile 和 UNSAFE.getObjectVolatile() 來保證可見性。JDK 8
在 JDK 8 中,ConcurrentHashMap 廢棄了段這個概念,實現改為基於 HashMap 原理進行並發化。對不必加鎖的地方,儘量使用 volatile 進行訪問,對於一定要加鎖的操作,會選擇小的範圍加鎖。小鎖
分段鎖 (JDK 5~7)
桶節點鎖 (JDK 8)
短鎖
弱一致性
在使用線程執行異步任務的過程中,我們要遵守一些使用準則,這樣能在一定程度上避免使用線程的時候帶來的問題。常見的五個線程使用準則是: 嚴謹直接創建線程、使用基礎線程池、選擇合適的異步方式、線程必須命名以及重視優先級設置。嚴禁直接創建線程
直接創建線程除了簡單方便之外,沒有其他優勢,所以在實際項目開發過程中,一定要嚴禁直接創建線程執行異步任務。提供基礎線程池供各個業務線使用
這個準則是為了避免各個業務線各自維護一套線程池,導致線程數過多。假如我們有 10 條業務線,如果每條業務線都維護一個線程池,假如這個線程池的核心數是 8,那麼我們就有 80 條線程,這明顯是不合理的。選擇合適的異步方式
HandlerThread、IntentService 和 RxJava 等方式都可以執行異步任務,但是要根據任務類型來選擇合適的異步方式。假如我們有一個可能會長時間執行,但是優先級較低的任務,我們就可以選擇用 HandlerThread。還有一種情況就是我們需要執行一個定時任務,這種情況下更適合使用線程池來操作。線程必須命名
當我們開發組成員比較多的時候,不論是使用線程還是使用線程池,如果我們不對我們創建的線程命名,如果這個線程發生了異常,我們光靠默認線程名是不知道要找哪個開發人員的。如果我們對每個線程都命名了,就可以快速地定位到線程的創建者,可以把問題交給他來解決。我們可以在運行期通過 Thread.currentThread().setName(name) 修改線程的名字。如果在一段時間內是我們業務線使用,我們可以把線程的名字改成我們業務線的標誌,在任務完成後,再把名字改回來。重視優先級設置
Java 採用的是搶佔式調度模型,高優先級的任務能先佔用 CPU,如果我們想讓某個任務先完成,我們可以給它設置一個較高的優先級。設置的方式就是通過 android.os.Process.setThreadPriority(priority),這個 priority 的值越小,優先級就越高,它的取值範圍在 -20~19。在這一節,我們會介紹 Android 中常用的 7 種異步方式: Thread、HandlerThread、IntentService、AsyncTask、線程池、RxJava 和 Kotlin 協程。1. 異步簡介
異步指的是代碼不是按照我們寫的順序來執行的,除了多線程,像是 OnClickListener 中的代碼也算是異步執行的。在編寫異步代碼時,要注意的是有可能寫出回調地獄,回調地獄代碼可能過兩天後你自己看自己寫的代碼都不會知道是幹什麼用的,比如下面這樣的。btn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { sendRequest(request, new Callback() { public void onSuccess(Response response) { handler.post(new Runnable() { @Override public void run() { updateUI(response); } }) } }) }});2. Thread
直接創建 Thread 是最簡單的異步方式,但是使用這種方式除了方便簡單之外,沒有任何其他優勢。而且使用這種方式有很多缺點,比如說不容易被復用,導致頻繁創建和銷毀線程的開銷大。假如我們要執行一個定時任務,直接創建 Thread 雖然也能實現,但是比較麻煩。3. HandlerThread
HandlerThread 本質上也是一個 Thread,但是它自帶了消息循環。HandlerThread 內部是以串行的方式執行任務,它比較適合需要長時間執行,不斷從隊列中取出任務執行的場景。4. IntentService
IntentService 是 Service 組件的子類,它的內部有一個 HandlerThread,所以它具備了 HandlerThread 的特性。它有兩點優勢,第一點是相對於 Service 來說,IntenService 的執行是在工作線程而不是主線程。第二點是它是一個 Service,如果應用使用了 Service,會提高應用的優先級,這樣就不容易被系統幹掉。5. AsyncTAsk
AsyncTask 是 Android 提供的異步工具類,它的內部實現使用了線程池,使用 AsyncTask 的好處就是不用我們自己處理線程切換。使用 AsyncTask 要注意它在不同版本的實現不一致,但這個不一致是在 API 14 以下的,而我們現在大部分應用的適配都是在 15 及以上,所以這個問題基本上已經沒有了。6. 線程池
線程池簡介
使用線程池執行異步任務有下面兩個優點:
我們可以通過 Executors 創建線程池,當 Executors 不能滿足我們的需要時,我們可以自定義 ThreadPoolExecutor 實現滿足我們需要的線程池。
線程池基本用法
通過下面的 ThreadPoolUtils,各個業務線使用線程時可以通過這個類直接獲取全局線程池。將線程池的線程數固定為 5 個,可以避免直接創建線程導致線程數過多。通過 ThreadFactory,我們可以在創建線程時設置名字,這樣能避免無法定位問題到出問題的線程。private static ExecutorService sService = Executors.newFixedThreadPool(5, new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r); thread.setName("ThreadPoolUtils"); return thread; }});下面這段代碼是在執行任務前把線程的名字改掉,並且在任務執行完畢後把線程的名字改回來,這樣就能達到一個復用的效果。public void executeTask() { ThreadPoolUtils.getService().execute(new Runnable() { @Override public void run() { String oldName = Thread.currentThread().getName(); Thread.currentThread().setName("newName"); System.out.println("執行任務"); System.out.println("任務執行完畢"); Thread.currentThread().setName(oldName); } });}7. RxJava
RxJava 簡介
RxJava 是一個異步框架,在這裡我們主要關注它的基本用法、異常和取消的處理。RxJava 根據任務類型的不同提供了不同的線程池,對於 I/O 密集型任務,比如網絡請求,它提供了 I/O 線程池。對於 CPU 密集型任務,它提供了 CPU 任務專用的線程池,也就是 Schdulers.computation()。
如果我們項目集成了 RxJava,我們可以使用 RxJava 的線程池。
對於 12.1 小節中的代碼,使用 RxJava 寫的話是下面這樣的:btn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { sendRequest(request) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Consumer<Response>() { @Override public void accept(Response response) throws Exception { updateUI(response); } }); }});而使用了 Lambda 表達式後,上面的代碼就變成了下面這樣:
btn.setOnClickListener(v -> sendRequest(request)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(response -> updateUI(response));但是這兩段代碼是有潛在隱患的,這個隱患是因為直接使用 Consumer 而不是 Observer,沒有對異常進行處理。RxJava 異常處理
上面那段代碼,我們可以在 observeOn() 方法後面加上另一個方法: onErrorReturnItem(),比如下面這樣,把異常映射成 Response。
btn.setOnClickListener(v -> sendRequest(request)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .onErrorReturnItem(t -> mapThrowableToResponse(t)) .subscribe(response -> updateUI(response));另一個辦法就是使用全局捕獲異常,捕獲到異常後上報異常。
這裡要注意的是,捕獲到的如果是 OnErrorNotImplmentedException,那我們要上報它的 cause,因為 cause 裡面才是真正的異常信息,比如下面這樣的。
RxJavaPlugins.setErrorHandler { e -> report(e instanceof OnErrorNotImplmentedException ? e.getCause() : e); Exceptions.throwIfFatal(e);}RxJava 取消處理
RxJava 可以執行異步任務,異步任務就有可能出現 Acitvity 關閉後,任務還在繼續執行的情況,這時候 Activity 就會被 Observer 持有,導致內存洩漏。當我們調用了 subscribe() 方法後,我們可以得到一個 Disposable 對象,使用這個對象我們可以在頁面銷毀時取消對應的任務。也就是我們可以在 Activity 中維護一個 Disposable 列表,在 onDestory() 方法中逐個取消任務。還有一個更好的辦法,就是使用滴滴的開源框架 AutoDispose,這個框架的使用很簡單,只需要向下面這樣加上一句 as 就可以了。btn.setOnClickListener(v -> sendRequest(request)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .onErrorReturnItem(t -> mapThrowableToResponse(t)) .as(AutoDispose.autoDisposable(ViewScopeProvider.from(btn))) .subscribe(response -> updateUI(response));AutoDispose 的原理就是監聽傳進來的控制項的生命周期,當發現這個控制項的被銷毀時,往往也就意味著頁面被關閉了,這時候就可以取消這個任務。
8. Kotlin 協程
Kotlin 協程簡介
除了 RxJava,我們還可以使用 Kotlin 協程在 Andorid 中實現異步任務。使用 Kotlin 協程寫出來的異步代碼,看上去跟同步代碼是非常相似的,下面是一個網絡請求的例子。首先我們定義一個 onClick 擴展方法,把上下文、啟動模式和協程體傳入 launch 方法中。fun View.onClick( context: CoroutineContext = Dispatchers.Main, handler: suspend CoroutineScope.(v: View?) -> Unit) { setOnClickListener { v -> GlobalScope.launch(context,CoroutineStart.DEFAULT) { handler(v) } }}然後讓一個按鈕調用這個方法,並且發起網絡請求。
btn.onClick { val request = Request() val response = async { sendRequest(request) }.await() updateUI(response)}上面這段代碼看上去是同步執行的,但是實際上 async {} 中的代碼是異步執行的,並且在返回了 Response 之後 updateUI() 方法才會被執行。
Kotlin 協程的取消處理
使用 Kotlin 協程和 RxJava 的作用一樣,都是執行異步任務,也都需要注意任務的取消,避免內存洩漏,下面我們就來看下怎麼取消 Kotlin 協程執行的異步任務。對於上面這個例子,我們可以借鑑 AutoDispose 的思路,監聽 View 的生命周期,在 View 銷毀時取消異步任務。使用 Kotlin 協程執行任務時我們可以獲得一個 Job 對象,通過這個對象我們可以取消對應的任務。首先我們定義一個監聽 View 聲明周期的類 AutoDisposableJob,再定義一個 Job 類的擴展函數 autoDispose()。class AutoDisposableJob( private val view: View, private val wrapped: Job) : Job by wrapped, View.OnAttachStateChangeListener {
init { if (ViewCompat.isAttachedToWindow(view)) { view.addOnAttachStateChangeListener(this) } else { cancel() } invokeOnCompletion { view.removeOnAttachStateChangeListener(this) } }
override fun onViewDetachedFromWindow(v: View?) { cancel() view.removeOnAttachStateChangeListener(this) }
override fun onViewAttachedToWindow(v: View?) = Unit
}
fun Job.autoDispose(view: View) = AutoDisposableJob(view, this)然後再在 onClick() 方法中調用 autoDispose() 擴展方法。
fun View.onClick( context: CoroutineContext = Dispatchers.Main, handler: suspend CoroutineScope.(v: View?) -> Unit) { setOnClickListener { v -> GlobalScope.launch(context,CoroutineStart.DEFAULT) { handler(v) }.autoDispose(v) }}"開發者說·DTalk" 面向中國開發者們徵集 Google 移動應用 (apps & games) 相關的產品/技術內容。歡迎大家前來分享您對移動應用的行業洞察或見解、移動開發過程中的心得或新發現、以及應用出海的實戰經驗總結和相關產品的使用反饋等。我們由衷地希望可以給這些出眾的中國開發者們提供更好展現自己、充分發揮自己特長的平臺。我們將通過大家的技術內容著重選出優秀案例進行谷歌開發技術專家 (GDE) 的推薦。
點擊屏末 | 閱讀原文 | 了解更多 "開發者說·DTalk" 活動詳情與參與方式