天天看點

探索 Android 多線程優化方法學習分享

前言

1. 基本介紹

在我學習 Android 多線程優化方法的過程中,發現我對多線程優化的了解太片面。

寫這篇文章的目的是完善我對 Android 多線程優化方法的認識,分享這篇文章的目的是希望大家也能從這些知識從得到一些啟發。

這篇文章分為下面三部分。

  • 第一部分
    第一部分講的是多線程優化的基礎知識,包括線程的介紹和線程排程基本原理的介紹。
               
  • 第二部分
    第二部分講的是多線程優化需要預防的一些問題,包括線程安全問題的介紹和實作線程安全的辦法。
               
  • 第三部分
    第三部分講的是多線程優化可以使用的一些方法,包括線程之間的協作方式與 Android 執行異步任務的常用方式。
               

2. 閱讀技巧

在閱讀本文時,畫圖和思考可以幫助你更好地記憶和了解文中的内容。

  • 畫圖

    畫圖指的是把每一節的重點畫在思維導圖的節點上。

    思維導圖可以讓随意資訊在視覺上建立起一種視覺上的關聯。

    随意資訊指的是不存在邏輯關系的資訊,比如線程的名字和線程的狀态就是一種随意資訊。

    随意資訊的特點就是它們之間不存在邏輯關聯,導緻記憶困難。

    通過建立關聯,我們大腦能更好地記憶随意資訊。

  • 思考

    學習不是為了被現有的知識所束縛,而是以現有的知識為基石,發展出新的思想。

    閱讀本文時,可以帶着下面這些問題邊思考邊閱讀。

    • 這個說法的依據是什麼?
    • 怎麼以自己的方式去解釋這個概念?
    • 怎麼在自己的項目中應用這個技巧?
    • 這個概念的具體代碼實作是怎樣的?
    • 這個實作存在哪些問題?

3. 縮略詞

  • AS

    Android Studio(Android 應用開發工具)

  • GC
    • Garbage Collector(垃圾回收器)
    • Garbage Collection(垃圾回收動作)
  • ART

    Android Runtime(Android 應用運作時環境)

  • JVM

    Java Virtual Machine(Java 虛拟機)

  • JUC

    java.util.concurrent(Java 并發包)

1. 能不能不用多線程?

不管你懂不懂多線程,你也必須要用多線程。

  • GC 線程
    假如我們現在運作的是用 AS 建的一個啥也沒有的 demo 項目,那也不代表我們運作的是一個單線程應用。
    
    因為這個應用是運作在 ART 上的,而 ART 自帶了 GC 線程,再加上主線程,它依舊是一個多線程應用。
               
  • 第三方線程
    在我們開發應用的過程中,即使我們沒有直接建立線程,也間接地建立了線程。
    
    因為我們日常使用的第三方庫,包括 Android 系統本身都用到了多線程。
    
    比如 Glide 就是使用工作線程從網絡上加載圖檔,等圖檔加載完畢後,再切回主線程把圖檔設定到 ImageView 中。
               
  • 硬性要求
    假如我們的應用中隻有一個線程,意味着加載圖檔時 Loading 動畫無法播放,界面是卡死的,使用者會失去耐心。
    
    而且 Android 強制要求開發者在發起網絡請求時,必須在工作線程,不能在主線程,也就是開發 Android 應用必須使用多線程。
               

2. 為什麼要做多線程優化?

既然上面說到了使用多線程是不可避免的,那使用多線程又會遇到哪些問題呢?

做多線程優化是為了解決多線程的安全性和活躍性問題。

這兩個問題會導緻多線程程式輸出錯誤的結果以及任務無法執行,下面我們就來看看這兩個問題的表現。

  • 安全性問題
    假如現在有兩個廚師小張和老王,他們兩個人分别做兩道菜,大家都知道自己的菜放了多少鹽,多少糖,在這種情況下出問題的機率比較低。
    
    但是如果兩個人做一個菜呢?
    
    小張在做一個菜,做着做着鍋被老王搶走了,老王不知道小張有沒有放鹽,就又放了一次鹽,結果炒出來的菜太鹹了,沒法吃,然後他們就決定要出去皇城 PK。
    
    這裡的“菜”對應着我們程式中的資料。
    
    而這種現象就是導緻線程出現安全性的原因之一:競态(Race Condition)。
    
    之是以會出現競态是由 Java 的記憶體模型和線程排程機制決定的,關于 Java 的線程排程機制,在後面會有更詳細的介紹。
               
  • 活躍性問題
    自從上次出了皇城 PK 的事情後,經理老李出了一條規定,打架扣 100,這條規定一出,小張和老王再也不敢 PK 了,不過沒過幾天,他們就找到了一種新的方式來互怼。
    
    有一天,小張在做菜,小張要先放鹽再放糖,而老王拿着鹽,老王要先放糖再放鹽,結果過了兩個小時兩個人都沒把菜做出來,經理老李再次陷入懵逼的狀态。
    
    這就是線程活躍性問題的現象之一:死鎖(Deadlock)。
               

關于線程安全性的三個問題和線程活躍性的四個問題,在本文後面會做更詳細的介紹。

3. 什麼是線程?

上一節我們講到了多線程程式設計可能會導緻程式出現這樣那樣的問題,那什麼是線程呢?

我們這一節的内容包括下面幾個部分。

  • 線程簡介
  • 線程的四個屬性
  • 線程的六個方法
  • 線程的六種狀态

3.1 線程簡介

線程是程序中可獨立執行的最小機關,也是 CPU 資源配置設定的基本機關。

程序是程式向作業系統申請資源的基本條件,一個程序可以包含多個線程,同一個程序中的線程可以共享程序中的資源,如記憶體空間和檔案句柄。

作業系統會把資源配置設定給程序,但是 CPU 資源比較特殊,它是配置設定給線程的,這裡說的 CPU 資源也就是 CPU 時間片。

程序與線程的關系,就像是飯店與員工的關系,飯店為顧客提供服務,而提供服務的具體方式是通過一個個員工實作的。

線程的作用是執行特定任務,這個任務可以是下載下傳檔案、加載圖檔、繪制界面等。

3.2 線程的四個屬性

線程有編号、名字、類别以及優先級四個屬性,除此之外,線程的部分屬性還具有繼承性,下面我們就來看看線程的四個屬性的作用和線程的繼承性。

3.2.1 編号

  • 作用

    線程的編号(id)用于辨別不同的線程,每條線程擁有不同的編号。

  • 注意事項
    • 不能作為唯一辨別
      某個編号的線程運作結束後,該編号可能被後續建立的線程使用,是以編号不适合用作唯一辨別
                 
    • 隻讀
      編号是隻讀屬性,不能修改
                 

3.2.2 名字

每個線程都有自己的名字(name),名字的預設值是 Thread-線程編号,比如 Thread-0 。

除了預設值,我們也可以給線程設定名字,以我們自己的方式去區分每一條線程。

  • 給線程設定名字可以讓我們在某條線程出現問題時,用該線程的名字快速定位出問題的地方
               

3.2.3 類别

線程的類别(daemon)分為守護線程和使用者線程,我們可以通過 setDaemon(true) 把線程設定為守護線程。

當 JVM 要退出時,它會考慮是否所有的使用者線程都已經執行完畢,是的話則退出。

而對于守護線程,JVM 在退出時不會考慮它是否執行完成。

  • 守護線程通常用于執行不重要的任務,比如監控其他線程的運作情況,GC 線程就是一個守護線程。
               
  • setDaemon() 要線上程啟動前設定,否則 JVM 會抛出非法線程狀态異常(IllegalThreadStateException)。
               

3.2.4 優先級

  • 線程的優先級(Priority)用于表示應用希望優先運作哪個線程,線程排程器會根據這個值來決定優先運作哪個線程。
  • 取值範圍

    Java 中線程優先級的取值範圍為 1~10,預設值是 5,Thread 中定義了下面三個優先級常量。

    • 最低優先級:MIN_PRIORITY = 1
    • 預設優先級:NORM_PRIORITY = 5
    • 最高優先級:MAX_PRIORITY = 10
    • 不保證
      線程排程器把線程的優先級當作一個參考值,不一定會按我們設定的優先級順序執行線程
                 
    • 線程饑餓
      優先級使用不當會導緻某些線程永遠無法執行,也就是線程饑餓的情況,關于線程饑餓,在第 7 大節會有更多的介紹
                 

3.2.5 繼承性

線程的繼承性指的是線程的類别和優先級屬性是會被繼承的,線程的這兩個屬性的初始值由開啟該線程的線程決定。

假如優先級為 5 的守護線程 A 開啟了線程 B,那麼線程 B 也是一個守護線程,而且優先級也是 5 。

這時我們就把線程 A 叫做線程 B 的父線程,把線程 B 叫做線程 A 的子線程。

3.3 線程的六個方法

線程的常用方法有六個,它們分别是三個非靜态方法 start()、run()、join() 和三個靜态方法 currentThread()、yield()、sleep() 。

下面我們就來看下這六個方法都有哪些作用和注意事項。

3.3.1 start()

  • start() 方法的作用是啟動線程。
               
  • 該方法隻能調用一次,再次調用不僅無法讓線程再次執行,還會抛出非法線程狀态異常。
               

3.3.2 run()

  • run() 方法中放的是任務的具體邏輯,該方法由 JVM 調用,一般情況下開發者不需要直接調用該方法。
               
  • 如果你調用了 run() 方法,加上 JVM 也調用了一次,那這個方法就會執行兩次
               

3.3.3 join()

  • join() 方法用于等待其他線程執行結束。
    
    如果線程 A 調用了線程 B 的 join() 方法,那線程 A 會進入等待狀态,直到線程 B 運作結束。
               
  • join() 方法導緻的等待狀态是可以被中斷的,是以調用這個方法需要捕獲中斷異常
               

3.3.4 Thread.currentThread()

  • currentThread() 方法是一個靜态方法,用于擷取執行目前方法的線程。
    
    我們可以在任意方法中調用 Thread.currentThread() 擷取目前線程,并設定它的名字和優先級等屬性。
               

3.3.5 Thread.yield()

  • yield() 方法是一個靜态方法,用于使目前線程放棄對處理器的占用,相當于是降低線程優先級。
    
    調用該方法就像是是對線程排程器說:“如果其他線程要處理器資源,那就給它們,否則我繼續用”。
               
  • 該方法不一定會讓線程進入暫停狀态。
               

3.3.6 Thread.sleep(ms)

  • sleep(ms) 方法是一個靜态方法,用于使目前線程在指定時間内休眠(暫停)。
               

線程不止提供了上面的 6 個方法給我們使用,而其他方法的使用在文章的後面會有一個更詳細的介紹。

3.4 線程的六種狀态

3.4.1 線程的生命周期

和 Activity 一樣,線程也有自己的生命周期,而且生命周期事件也是由使用者(開發者)觸發的。

從 Activity 的角度來看,使用者點選按鈕後打開一個 Activity,就相當于是觸發了 Activity 的 onCreate() 方法。

從線程的角度來看,開發者調用了 start() 方法,就相當于是觸發了 Thread 的 run() 方法。

如果我們在上一個 Activity 的 onPause() 方法中進行了耗時操作,那麼下一個 Activity 的顯示也會因為這個耗時操作而慢一點顯示,這就相當于是 Thread 的等待狀态。

線程的生命周期不僅可以由開發者觸發,還會受到其他線程的影響,下面是線程各個狀态之間的轉換示意圖。

我們可以通過 Thread.getState() 擷取線程的狀态,該方法傳回的是一個枚舉類 Thread.State。

線程的狀态有建立、可運作、阻塞、等待、限時等待和終止 6 種,下面我們就來看看這 6 種狀态之間的轉換過程。

3.4.2 建立狀态

當一個線程建立後未啟動時,它就處于建立(NEW)狀态。

3.4.3 可運作狀态

當我們調用線程的 start() 方法後,線程就進入了可運作(RUNNABLE)狀态。

可運作狀态又分為預備(READY)和運作(RUNNING)狀态。

  • 預備狀态
    處于預備狀态的線程可被線程排程器排程,排程後線程的狀态會從預備轉換為運作狀态,處于預備狀态的線程也叫活躍線程。
               
  • 運作狀态
    運作狀态表示線程正在運作,也就是處理器正在執行線程的 run() 方法。
    
    當線程的 yield() 方法被調用後,線程的狀态可能由運作狀态變為預備狀态。
               

3.4.4 阻塞狀态

當下面幾種情況發生時,線程就處于阻塞(BLOCKED)狀态。

  • 發起阻塞式 I/O 操作
  • 申請其他線程持有的鎖
  • 進入一個 synchronized 方法或代碼塊失敗

3.4.5 等待狀态

一個線程執行特定方法後,會等待其他線程執行執行完畢,此時線程進入了等待(WAITING)狀态。

  • 等待狀态

    下面的幾個方法可以讓線程進入等待狀态。

    • Object.wait()
    • LockSupport.park()
    • Thread.join()
  • 可運作狀态

    下面的幾個方法可以讓線程從等待狀态轉變為可運作狀态,而這種轉變又叫喚醒。

    • Object.notify()
    • Object.notifyAll()
    • LockSupport.unpark()

3.4.6 限時等待狀态

限時等待狀态 (TIMED_WAITING)與等待狀态的差別就是,限時等待是等待一段時間,時間到了之後就會轉換為可運作狀态。

下面的幾個方法可以讓線程進入限時等待狀态,下面的方法中的 ms、ns、time 參數分别代表毫秒、納秒以及絕對時間。

  • Thread.sleep(ms)
  • Thread.join(ms)
  • Object.wait(ms)
  • LockSupport.parkNonos(ns)
  • LockSupport.parkUntil(time)

3.4.7 終止狀态

當線程的任務執行完畢或者任務執行遇到異常時,線程就處于終止(TERMINATED)狀态。

4. 線程排程的原理是什麼?

閱讀完上一節的内容後,我們對線程有了基本的了解,知道了什麼是線程,也知道了線程的生命周期是怎麼流轉的。

這一節我們就來看看線程是怎麼被排程的,這一節包括以下内容。

  • Java 記憶體模型簡介
  • 高速緩存
  • Java 線程排程機制

4.1 Java 的記憶體模型簡介

了解 Java 的記憶體模型,能幫助我們更好地了解線程的安全性問題,下面我們就來看看什麼是 Java 的記憶體模型。

Java 記憶體模型(Java Memory Model,JMM)規定了所有變量都存儲在主記憶體中,每條線程都有自己的工作記憶體。

JVM 把記憶體劃分成了好幾塊,其中方法區和堆記憶體區域是線程共享的。

假如現在有三個線程同時對值為 5 的變量 a 進行自增操作,那最終的結果應該是 8 。

但是自增的真正實作是分為下面三步的,而不是一個不可分割的(原子的)操作。

  1. 将變量 a 的值指派給臨時變量 temp
  2. 将 temp 的值加 1
  3. 将 temp 的值重新賦給變量 a。

假如線程 1 在進行到第二步的時候,其他兩條線程讀取了變量 a ,那麼最終的結果就是 7,而不是預期的 8 。

這種現象就是線程安全的其中一個問題:原子性。

4.2 高速緩存

4.2.1 高速緩存簡介

[外鍊圖檔轉存失敗,源站可能有防盜鍊機制,建議将圖檔儲存下來直接上傳(img-eCORs9tS-1577536302058)(

https://upload-images.jianshu.io/upload_images/18452536-698f139216429869.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]

現代處理器的處理能力要遠勝于主記憶體(DRAM)的通路速率,主記憶體執行一次記憶體讀/寫操作需要的時間,如果給處理器使用,處理器可以執行上百條指令。

為了彌補處理器與主記憶體之間的差距,硬體設計者在主記憶體與處理器之間加入了高速緩存(Cache)。

處理器執行記憶體讀寫操作時,不是直接與主記憶體打交道,而是通過高速緩存進行的。

高速緩存相當于是一個由硬體實作的容量極小的散清單,這個散清單的 key 是一個對象的記憶體位址,value 可以是記憶體資料的副本,也可以是準備寫入記憶體的資料。

4.2.2 高速緩存内部結構

從内部結構來看,高速緩存相當于是一個鍊式散清單(Chained Hash Table),它包含若幹個桶,每個桶包含若幹個緩存條目(Cache Entry)。

4.2.3 緩存條目結構

緩存條目可進一步劃分為 Tag、Data Block 和 Flag 三個部分。

  • Tag
    Tag 包含了與緩存行中資料對應的記憶體位址的部分資訊(記憶體位址的高位部分比特)
               
  • Data Block
    Data Block 也叫緩存行(Cache Line),是高速緩存與主記憶體之間資料交換的最小單元,可以存儲從記憶體中讀取的資料,也可以存儲準備寫進記憶體的資料。
               
  • Flag
    Flag 用于表示對應緩存行的狀态資訊
               

4.3 Java 線程排程原理

在任意時刻,CPU 隻能執行一條機器指令,每個線程隻有擷取到 CPU 的使用權後,才可以執行指令。

也就是在任意時刻,隻有一個線程占用 CPU,處于運作的狀态。

多線程并發運作實際上是指多個線程輪流擷取 CPU 使用權,分别執行各自的任務。

線程的排程由 JVM 負責,線程的排程是按照特定的機制為多個線程配置設定 CPU 的使用權。

線程排程模型分為兩類:分時排程模型和搶占式排程模型。

  • 分時排程模型
    分時排程模型是讓所有線程輪流擷取 CPU 使用權,并且平均配置設定每個線程占用 CPU 的時間片。
               
  • 搶占式排程模型
    JVM 采用的是搶占式排程模型,也就是先讓優先級高的線程占用 CPU,如果線程的優先級都一樣,那就随機選擇一個線程,并讓該線程占用 CPU。
    
    也就是如果我們同時啟動多個線程,并不能保證它們能輪流擷取到均等的時間片。
    
    如果我們的程式想幹預線程的排程過程,最簡單的辦法就是給每個線程設定一個優先級。
               

5. 什麼是線程的安全性問題?

閱讀完上一節的内容後,我們對 Java 的線程排程機制有了基本的了解。

這一節我們就來看看線程排程機制導緻的線程安全問題,這一節的内容包括以下幾個部分。

  • 競态
  • 原子性
  • 可見性
  • 有序性

5.1 競态

線程安全問題不是說線程不安全,也不是說線程弄不好把手機都搞爆炸了。

線程安全問題指的是多個線程之間對一個或多個共享可變對象交錯操作時,有可能導緻資料異常。

多線程程式設計中經常遇到的問題就是一樣的輸入在不同的時間有不一樣的輸出,這種一個計算結果的正确性與時間有關的現象就是競态,也就是計算的正确性依賴于相對時間順序或線程的交錯。

競态不一定導緻計算結果的不正确,而是不排除計算結果有時正确有時錯誤的可能。

競态往往伴随着髒資料和丢失更新的問題,髒資料就是線程讀到一個過時的資料,丢失更新就是一個線程對資料做的更新,沒有展現在後續其他線程對該資料的讀取上。

對于共享變量,競态可以看成通路(讀/寫)同一組共享變量的多個線程鎖執行的操作互相交錯,比如一個線程讀取共享變量,并以該共享變量為基礎進行計算的期間,另一個線程更新了該共享變量的值,導緻髒資料或丢失更新。

對于局部變量,由于不同的線程各自通路的是自己的局部變量,是以局部變量的使用不會導緻競态。

5.2 原子性

原子(Atomic)的字面意識是不可分割的,對于涉及共享變量通路的操作,若該操作從其執行線程以外的任意線程看來是不可分割的,那麼該操作就是原子操作,相應地稱該操作具有原子性(Atomicity)。

所謂不可分割,就是通路(讀/寫)某個共享變量的操作,從執行線程以外的其他線程看來,該操作隻有未開始和結束兩種狀态,不會知道該操作的中間部分。

拿炒菜舉例,炒菜可分為幾個步驟:放油、放菜、放鹽、放糖等。

但是從客人的角度來看,一個菜隻有兩種狀态:沒做好和做好了。

通路同一組共享變量的原子操作是不能被交錯的,這就排除了一個線程執行一個操作的期間,另一個線程讀取或更新該操作鎖通路的共享變量,導緻髒資料和丢失更新。

5.3 可見性

在多線程環境下,一個線程對某個共享變量進行更新後,後續通路該變量的線程可能無法立刻讀取到這個更新的結果,甚至永遠也無法讀取到這個更新的結果,這就是線程安全問題的另一種表現形式:可見性。

可見性是指一個線程對共享變量的更新,對于其他讀取該變量的線程是否可見。

可見性問題與計算機的存儲系統有關,程式中的變量可能會被配置設定到寄存器而不是主記憶體中,每個處理器都有自己的寄存器,一個處理器無法讀取另一個處理器的寄存器上的内容。

即使共享變量是配置設定到主記憶體中存儲的,也不餓能保證可見性,因為處理器不是直接通路主記憶體,而是通過高速緩存進行的。

一個處理器上運作的線程對變量的更新,可能隻是更新到該處理器的寫緩沖器(Store Buffer)中,還沒有到高速緩存中,更别說處理器了。

可見性描述的是一個線程對共享變量的更新,對于另一個線程是否可見,保證可見性意味着一個線程可以讀取到對應共享變量的新值。

從保證線程安全的角度來看,光保證原子性還不夠,還要保證可見性,同時保證可見性和原子性才能確定一個線程能正确地看到其他線程對共享變量做的更新。

5.4 有序性

有序性是指一個處理器在為一個線程執行的記憶體通路操作,對于另一個處理器上運作的線程來看是亂序的。

順序結構是結構化程式設計中的一種基本結構,它表示我們希望某個操作先于另外一個操作執行。

但是在多核處理器的環境下,代碼的執行順序是沒保障的,編譯器可能改變兩個操作的先後順序,處理器也可能不是按照程式代碼的順序執行指令

重排序(Reordering)處理器和編譯器是對代碼做的一種優化,它可以在不影響單線程程式正确性的情況下提升程式的性能,但是它會對多線程程式的正确性産生影響,導緻線程安全問題。

現代處理器為了提高指令的執行效率,往往不是按程式順序注意執行指令的,而是哪條指令就緒就先執行哪條指令,這就是處理器的亂序執行。

6. 怎麼實作線程安全?

要實作線程安全就要保證上面說到的原子性、可見性和有序性。

常見的實作線程安全的辦法是使用鎖和原子類型,而鎖可分為内部鎖、顯式鎖、讀寫鎖、輕量級鎖(volatile)四種。

下面我們就來看看這四種鎖和原子類型的用法和特點。

6.1 鎖

文章的開頭提到的“打架扣 100”就是一種現實生活中的鎖,可以讓小張和老王乖乖幹活,别再炒出不能吃的菜。

這也就是鎖(Lock)的作用,讓多個線程更好地協作,避免多個線程的操作交錯導緻資料異常的問題。

6.1.1 鎖的五個特點

  • 臨界區
    持有鎖的線程獲得鎖後和釋放鎖前執行的代碼叫做臨界區(Critical Section)。
               
  • 排他性
    鎖具有排他性,能夠保障一個共享變量在任一時刻隻能被一個線程通路,這就保證了臨界區代碼一次隻能夠被一個線程執行,臨界區的操作具有不可分割性,也就保證了原子性。
               
  • 串行
    鎖相當于是把多個線程對共享變量的操作從并發改為串行。
               
  • 三種保障
    鎖能夠保護共享變量實作線程安全,它的作用包括保障原子性、可見性和有序性。
               
  • 排程政策
    鎖的排程政策分為公平政策和非公平政策,對應的鎖就叫公平鎖和非公平鎖。
    
    公平鎖會在加鎖前檢視是否有排隊等待的線程,有的話會優先處理排在前面的線程。
    
    公平鎖以增加上下文切換為代價,保障了鎖排程的公平性,增加了線程暫停和喚醒的可能性。
               

6.1.2 鎖的兩個問題

  • 鎖洩漏
    鎖洩漏是指一個線程獲得鎖後,由于程式的錯誤導緻鎖一直無法被釋放,導緻其他線程一直無法獲得該鎖。
               
  • 鎖洩漏會導緻活躍性問題,這些問題包括死鎖、和鎖死等。
               

6.2 内部鎖

6.2.1 内部鎖簡介

Java 為我們提供了 synchronized 關鍵字來實作内部鎖,被 synchronized 關鍵字修飾的方法和代碼塊就叫同步方法和同步代碼塊。

下面我們來看下内部鎖的七個特點。

  • 螢幕鎖

    因為使用 synchronized 實作的線程同步是通過螢幕(monitor)來實作的,是以内部鎖也叫螢幕鎖。

  • 自動擷取/釋放

    線程對同步代碼塊的鎖的申請和釋放由 JVM 内部實施,線程在進入同步代碼塊前會自動擷取鎖,并在退出同步代碼塊時自動釋放鎖,這也是同步代碼塊被稱為内部鎖的原因。

  • 鎖定方法/類/對象

    synchronized 關鍵字可以用來修飾方法,鎖住特定類和特定對象。

  • 同步代碼塊就是内部鎖的臨界區,線程在執行臨界區代碼前必須持有該臨界區的内部鎖。
  • 鎖句柄

    内部鎖鎖的對象就叫鎖句柄,鎖句柄通常會用 private 和 final 關鍵字進行修飾。

    因為鎖句柄變量一旦改變,會導緻執行同一個同步代碼塊的多個線程實際上用的是不同的鎖。

  • 不會洩漏

    洩漏指的是鎖洩漏,内部鎖不會導緻鎖洩漏,因為 javac 編譯器把同步代碼塊編譯為位元組碼時,對臨界區中可能抛出的異常做了特殊處理,這樣臨界區的代碼出了異常也不會妨礙鎖的釋放。

  • 非公平鎖

    内部鎖是使用的是非公平政策,是非公平鎖,也就是不會增加上下文切換開銷。

6.2.2 内部鎖基本用法

// 鎖句柄
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 繼續執行           

6.3 顯式鎖

6.3.1 顯式鎖簡介

顯式鎖(Explict Lock)是 Lock 接口的執行個體,Lock 接口對顯式鎖進行了抽象,ReentrantLock 是它的實作類。

下面是顯式鎖的四個特點。

  • 可重入
    顯式鎖是可重入鎖,也就是一個線程持有了鎖後,能再次成功申請這個鎖。
               
  • 手動擷取/釋放
    顯式鎖與内部鎖差別在于,使用顯式鎖,我們要自己釋放和擷取鎖,為了避免鎖洩漏,我們要在 finally 塊中釋放鎖
               
  • lock() 與 unlock() 方法之間的代碼就是顯式鎖的臨界區
               
  • 公平/非公平鎖
    顯式鎖允許我們自己選擇鎖排程政策。
    
    ReentrantLock 有一個構造函數,允許我們傳入一個 fair 值,當這個值為 true 時,說明現在建立的這個鎖是一個公平鎖。
    
    由于公平鎖的開銷比非公平鎖大,是以 ReentrantLock 的預設排程政策是非公平政策。
               

6.3.2 顯式鎖基本用法

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 釋放了顯式鎖           

6.3.3 顯示鎖擷取鎖的四個方法

  • lock()
    擷取鎖,擷取失敗時線程會處于阻塞狀态
               
  • tryLock()
    擷取鎖,擷取成功時傳回 true,擷取失敗時會傳回 false,不會處于阻塞狀态
               
  • tryLock(long time, TimeUnit unit)
    擷取鎖,擷取到了會傳回 true,如果在指定時間内未擷取到,則傳回 false。
    
    在指定時間内處于阻塞狀态,可中斷。
               
  • lockInterruptibly()
    擷取鎖,可中斷。
               

6.4 内部鎖與顯式鎖的差別

看完了内部鎖和顯式鎖的介紹,下面我們來看下内部鎖和顯式鎖的五個差別。

  • 靈活性
    内部鎖是基于代碼的鎖,鎖的申請和釋放隻能在一個方法内執行,缺乏靈活性。
    
    顯式鎖是基于對象的鎖,鎖的申請和釋放可以在不同的方法中執行,這樣可以充分發揮面向對象程式設計的靈活性。
               
  • 鎖排程政策
    内部鎖隻能是非公平鎖。
    
    顯式鎖可以自己選擇鎖排程政策。
               
  • 便利性
    内部鎖簡單易用,不會出現鎖洩漏的情況。
    
    顯式鎖需要自己手動擷取/釋放鎖,使用不當的話會導緻鎖洩漏。
               
  • 阻塞
    如果持有内部鎖鎖的線程一直不釋放這個鎖,那其他申請這個鎖的線程隻能一直等待。
    
    顯式鎖 Lock 接口有一個 tryLock() 方法,當其他線程持有鎖時,這個方法會傳回直接傳回 false。
    
    這樣就不會導緻線程處于阻塞狀态,我們就可以在擷取鎖失敗時做别的事情。
               
  • 适用場景
    在多個線程持有鎖的平均時間不長的情況下我們可以使用内部鎖
    
    在多個線程持有鎖的平均較長的情況下我們可以使用顯式鎖(公平鎖)
               

6.5 讀寫鎖

6.5.1 讀寫鎖簡介

鎖的排他性使得多個線程無法以線程安全的方式在同一時刻讀取共享變量,這樣不利于提高系統的并發性,這也是讀寫鎖出現的原因。

讀寫鎖 ReadWriteLock 接口的實作類是 ReentrantReadWriteLock,。

隻讀取共享變量的線程叫讀線程,隻更新共享變量的線程叫寫線程。

讀寫鎖是一種改進的排他鎖,也叫共享/排他(Shared/Exclusive)鎖。

讀寫鎖有下面六個特點。

  • 讀鎖共享

    讀寫鎖允許多個線程同時讀取共享變量,讀線程通路共享變量時,必須持有對應的讀鎖,讀鎖可以被多個線程持有。

  • 寫鎖排他

    讀寫鎖一次隻允許一個線程更新共享變量,寫線程通路共享變量時,必須持有對應的寫鎖,寫鎖在任一時刻隻能被一個線程持有。

  • 可以降級

    讀寫鎖是一個支援降級的可重入鎖,也就是一個線程在持有寫鎖的情況下,可以繼續擷取對應的讀鎖。

    這樣我們可以在修改變量後,在其他地方讀取該變量,并執行其他操作。

  • 不能更新

    讀寫鎖不支援更新,讀線程隻有釋放了讀鎖才能申請寫鎖

  • 讀寫鎖雖然允許多個線程讀取共享變量,但是由于寫鎖的特性,它同樣能保障原子性、可見性和有序性。
  • 讀寫鎖會帶來額外的開銷,隻有滿足下面兩個條件,讀寫鎖才是合适的選擇
    • 讀操作比寫操作頻繁很多
    • 讀取共享變量的線程持有鎖的時間較長

6.5.2 讀寫鎖基本用法

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釋放了讀鎖           

6.6 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 變量時,其他線程可能已經更新了該共享變量的值。

6.7 原子類型

6.7.1 原子類型簡介

在 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 操作實作的。

我們下面看下它們的用法和差別。

6.7.2 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);
}
           

6.7.3 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);
}           

6.7.4 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。

6.8 鎖的使用技巧

使用鎖會帶來一定的開銷,而掌握鎖的使用技巧可以在一定程度上減少鎖帶來的開銷和潛在的問題,下面就是一些鎖的使用技巧。

  • 長鎖不如短鎖
    盡量隻對必要的部分加鎖
               
  • 大鎖不如小鎖
    進可能對加鎖的對象拆分
               
  • 公鎖不如私鎖
    進可能把鎖的邏輯放到私有代碼中,如果讓外部調用者加鎖,可能會導緻鎖不正當使用導緻死鎖
               
  • 嵌套鎖不如扁平鎖
    在寫代碼時要避免鎖嵌套
               
  • 分離讀寫鎖
    盡可能将讀鎖和寫鎖分離
               
  • 粗化高頻鎖
    合并處理頻繁而且過短的鎖,因為每一把鎖都會帶來一定的開銷
               
  • 消除無用鎖
    盡可能不加鎖,或者用 volatile 代替
               

7. 什麼是線程的活躍性問題?

上一大節介紹了鎖的作用和基本用法,鎖能讓線程進入阻塞狀态,而這種阻塞就會導緻任務無法正常執行,也就是線程出現活躍性問題,這也就是我們這一節要講的内容。

活躍性問題不是說線程過于活躍,而是線程不夠活躍,導緻任務無法取得進展。

我們這一節就來看一下常見的四個線程活躍性問題:死鎖、鎖死、活鎖和饑餓。

7.1 線程的四個活躍性問題

7.1 死鎖

死鎖是線程的一種常見多線程活躍性問題,如果兩個或更多的線程,因為互相等待對方而被永遠暫停,那麼這就叫死鎖現象。

下面我們就來看看死鎖産生的四個條件和避免死鎖的三個方法。

7.1.1 死鎖産生的四個條件

當多個線程發生了死鎖後,這些線程和相關共享變量就會滿足下面四個條件。

  1. 資源互斥
    涉及的資源必須是獨占的,也就是資源每次隻能被一個線程使用
               
  2. 資源不可搶奪
    涉及的資源隻能被持有該資源的線程主動釋放,無法被其他線程搶奪(被動釋放)
               
  3. 占用并等待資源
    涉及的線程至少持有一個資源,還申請了其他資源,而其他資源剛好被其他線程持有,并且線程不釋放已持有資源
               
  4. 循環等待資源
    涉及的線程必須等待别的線程持有的資源,而别的線程又反過來等待該線程持有的資源
               

隻要産生了死鎖,上面的條件就一定成立,但是上面的條件都成立也不一定會産生死鎖。

7.1.2 避免死鎖的三個方法

要想消除死鎖,隻要破壞掉上面的其中一個條件即可。

由于鎖具有排他性,且無法被動釋放,是以我們隻能破壞掉第三個和第四個條件。

  1. 粗鎖法
    使用粗粒度的鎖代替多個鎖,鎖的範圍變大了,通路共享資源的多個線程都隻需要申請一個鎖,因為每個線程隻需要申請一個鎖就可以執行自己的任務,這樣“占用并等待資源”和“循環等待資源”這兩個條件就不成立了。
    
    粗鎖法的缺點是會降低并發性,而且可能導緻資源浪費,因為采用粗鎖法時,一次隻能有一個線程通路資源,這樣其他線程就隻能擱置任務了。
               
  2. 鎖排序法
    鎖排序法指的是相關線程使用全局統一的順序申請鎖。
    
    假如有多個線程需要申請鎖,我們隻需要讓這些線程按照一個全局統一的順序去申請鎖,這樣就能破壞“循環等待資源”這個條件。
               
  3. tryLock
    顯式鎖 ReentrantLock.tryLock(long timeUnit) 這個方法允許我們為申請鎖的操作設定逾時時間,這樣就能破壞“占用并等待資源”這個條件。
               
  4. 開放調用
    開放調用(Open Call)就是一個方法在調用外部方法時不持有鎖,開放調用能破壞“占用并等待資源”這個條件。
               

7.2 鎖死

等待線程由于喚醒的條件永遠無法成立,導緻任務一直無法繼續執行,那麼這個線程是被鎖死(Lockout)了。

鎖死和死鎖的差別在于,即使産生死鎖的條件全部都不成立,還是有可能發生鎖死。

鎖死可分為信号丢失鎖死和嵌套螢幕鎖死。

7.2.1 信号丢失鎖死

信号丢失鎖死是由于沒有對應的通知線程喚醒等待線程,導緻等待線程一直處于等待狀态的一種活躍性問題。

信号丢失鎖死的一個典型例子就是等待線程執行 Object.wait()/Condition.await() 前沒有判斷保護條件,而保護條件已經成立,但是後續沒有其他線程更新保護條件并通知等待線程,這也就是為什麼要強調 Object.wait()/Condition.await() 要放在循環語句中執行。

7.2.2 嵌套螢幕丢失鎖死

嵌套螢幕鎖死指的是嵌套地使用鎖導緻線程永遠無法被喚醒,在代碼上的表現就是兩個嵌套的同步代碼塊。

避免嵌套螢幕鎖死的辦法隻需要避免嵌套使用内部鎖。

7.3 活鎖

活鎖(Livelock)是指線程一直處于運作狀态,但是任務卻一直無法繼續執行的一種現象。

7.4 饑餓

線程饑餓(Starvation)是指線程一直無法獲得所需資源,導緻任務一直無法執行。

8. 線程之間怎麼協作?

線程間的常見協作方式有兩種:等待和中斷。

中斷型協作放在第 8 大節講,我們這一節主要講等待型協作。

當一個線程中的操作需要等待另一個線程中的操作結束時,就涉及到等待型線程協作方式。

常用的等待型線程協作方式有 join、wait/notify、await/signal、await/countDown 和 CyclicBarrier 五種,下面我們就來看看這五種線程協作方式的用法和差別。

8.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();
    }
  }
}           

當我們執行完上面的代碼後,會得到下面的輸出。

線程 A 開始執行
線程 B 開始等待線程 A 執行結束
線程 A 執行結束
線程 B 結束等待,開始做自己想做的事情           

8.2 wait/notify

8.2.1 wait/notify 簡介

在 Java 中,使用 Object.wait()/Object.wait(long) 和 Object.notify()/Object.notifyAll() 可以用于實作等待和通知。

一個線程因為執行操作(目标動作)所需的保護條件未滿足而被暫停的過程就叫等待(wait)。

一個線程更新了共享變量,使得其他線程需要的保護條件成立,喚醒了被暫停的線程的過程就叫通知(notify)。

wait() 方法的執行線程叫等待線程,notify() 方法執行的線程叫通知線程。

wait/notify 協作方式有下面幾個特點。

  • 暫停/喚醒
    Object.wait() 的作用是讓線程暫停(狀态改為 WAITING),而 Object.notify() 的作用是喚醒一個被暫停的線程。
               
  • 所有對象
    由于 Object 是所有對象的父類,是以所有對象都可以實作等待和通知。
               
  • 擷取螢幕鎖
    使用 wait()/notify() 方法要先擷取共享對象的螢幕鎖,擷取共享對象的螢幕鎖有兩種方式,一是在同步代碼塊中執行,二是在同步方法(synchronized 修飾的方法)中執行 wait()/notify()。
    
    如果沒有事先擷取螢幕鎖,那線程就會報出非法螢幕狀态異常 IllegalMonitorStateException 異常。
               
  • 捕獲中斷異常
    使用 wait() 方法必須要捕獲中斷異常 InterruptedException,因為通過 wait() 進入的等待狀态是可以被打斷的。
               
  • 喚醒任一線程
    notify() 方法喚醒的隻是對應對象上的一個任意等待線程,被喚醒的線程不一定是我們想喚醒的線程。
               
  • 喚醒特定線程
    如果我們想對應對象上的特定線程,我們可以使用 notifyAll(),把該對象上的所有等待線程都喚醒。
               
  • final 修飾
    之是以 lock 對象要使用 final 修飾,是因為如果沒有用 final 修飾,那麼這個對象的值可能被修改,導緻等待線程和通知線程同步在不同的内部鎖上,進而造成競态,違背了使用鎖的初衷。
               
  • 循環判斷
    對保護條件的判斷和 wait() 方法的調用要放在循環語句中,以確定目标動作隻有在保護條件成立時才能執行。
               
  • 僅釋放對應内部鎖
    使用 wait() 方法暫停目前線程時,釋放的鎖是與該 wait() 方法所屬對象的内部鎖,目前線程持有的其他内部鎖和顯式鎖不會是以被釋放
               

8.2.2 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();
  }
}           

當我們在兩個線程中分别執行上面兩個函數後,會得到下面的輸出。

等待線程擷取了鎖
保護條件不成立,等待線程進入等待狀态
通知線程擷取了鎖
通知線程即将喚醒等待線程
等待線程被喚醒,開始執行目标動作           

8.2.3 wait/notify 原理

JVM 會給每個對象維護一個入口集(Entry Set)和等待集(Wait Set)。

入口集用于存儲申請該對象内部鎖的線程,等待集用于存儲對象上的等待線程。

wait() 方法會将目前線程暫停,在釋放内部鎖時,會将目前線程存入該方法所屬的對象等待集中。

調用對象的 notify() 方法,會讓該對象的等待集中的任意一個線程喚醒,被喚醒的線程會繼續留在對象的等待集中,直到該線程再次持有對應的内部鎖時,wait() 方法就會把目前線程從對象的等待集中移除。

添加目前線程到等待集、暫停目前線程、釋放鎖以及把喚醒後的線程從對象的等待集中移除,都是在 wait() 方法中實作的。

在 wait() 方法的 native 代碼中,會判斷線程是否持有目前對象的内部鎖,如果沒有的話,就會報非法螢幕狀态異常,這也就是為什麼要在同步代碼塊中執行 wait() 方法。

8.2.4 wait/notify 存在的問題

  • 過早喚醒

    等待線程在保護條件未成立時被喚醒的現象就叫過早喚醒。

    過早喚醒使得無須被喚醒的等待線程也被喚醒了,導緻資源浪費。

  • 信号丢失

    導緻信号丢失的情況有兩種,一種是在循環體外判斷保護條件,另一種是 notify() 方法使用不當。

    • 循環體外判斷條件
      如果等待線程在執行 wait() 方法前沒有判斷保護條件是否成立,那麼有可能導緻通知線程在等待線程進入臨界區前就更新了共享變量,使得保護條件成立,并進行了通知,但是等待線程并沒有暫停,是以也沒有被喚醒。
      
      這種現象相當于等待線程錯過了一個發送給它的“信号”,是以叫信号丢失。
      
      隻要對保護條件的判斷和 wait() 方法的調用放在循環語句中,就可以避免這種情況導緻的信号丢失。
                 
    • notify() 使用不當
      信号丢失的另一個表現是在應該調用 notifyAll() 的情況下調用了 notify(),在這種情況下,避免信号丢失的辦法是使用 notifyAll() 進行通知
                 
  • 欺騙性喚醒

    等待線程可能在沒有其他線程執行 notify()/notifyAll() 的情況下被喚醒,這種現象叫欺騙性喚醒。

    雖然欺騙性喚醒出現的機率比較低,但是 Java 允許這種現象存在,這是 Java 平台對作業系統妥協的一種結果。

    • 避免欺騙性喚醒
      避免欺騙性喚醒的方法就是在循環中判斷條件是否滿足,不滿足時則繼續等待,也就是再次調用 wait() 方法。
                 
  • 上下文切換

    等待線程執行 wait() 方法至少會導緻該線程對内部鎖的兩次申請與釋放。

    通知線程在執行 notify()/notifyAll() 時需要持有對應對象的内部鎖,是以這裡會導緻一次鎖的申請,而鎖的申請與釋放可能導緻上下文切換。

    其次,等待線程從被暫停到喚醒的過程本身就會導緻上下文切換。

    再次,被喚醒的等待線程在繼續運作時,需要再次申請内部鎖,此時等待線程可能需要和對應對象的入口集中的其他線程,以及其他新來的活躍線程争用内部鎖,這又可能導緻上下文切換。

    最後,過早喚醒也會導緻額外的上下文切換,因為被過早喚醒的線程需要繼續等待,要再次經曆被暫停和喚醒的過程。

減少 wait/notify 上下文切換的常用方法有下面兩種。

  • 使用 notify() 代替 notifyAll()
    在保證程式正确性的情況下,使用 notify() 代替 notifyAll(),notify() 不會導緻過早喚醒,進而減少上下文切換開銷
               
  • 盡快釋放對應内部鎖
    通知線程執行完 notify()/notifyAll() 後盡快釋放對應的内部鎖,這樣可以避免被喚醒的線程在 wait() 調用傳回前,再次申請對應内部鎖時,由于該鎖未被通知線程釋放,導緻該線程被暫停
               

8.2.5 notify()/notifyAll() 的選用

notify() 可能導緻信号丢失,而 notifyAll() 雖然會把不需要喚醒的等待線程也喚醒,但是在正确性方面有保障。

是以一般情況下優先使用 notifyAll() 保障正确性。

一般情況下,隻有在下面兩個條件都實作時,才會選擇使用 notify() 實作通知。

  1. 隻需喚醒一個線程
    當一次通知隻需要喚醒最多一個線程時,我們可以考慮使用 notify() 實作通知,但是光滿足這個條件還不夠。
    
    在不同的等待線程使用不同的保護條件時,notify() 喚醒的一個任意線程可能不是我們需要喚醒的那個線程,是以需要條件 2 來排除。
               
  2. 對象的等待集中隻包含同質等待線程
    同質等待線程指的是線程使用同一個保護條件并且 wait() 調用傳回後的邏輯一緻。
    
    最典型的同質線程是使用同一個 Runnable 建立的不同線程,或者同一個 Thread 子類 new 出來的多個執行個體。
               

8.3 await/signal

8.3.1 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() 方法的調用,要放在循環語句中
               
  • 引導區内
    循環語句和執行目标動作要放在同一個顯式鎖引導的臨界區中,這麼做是為了避免欺騙性喚醒和信号丢失的問題
               

8.3.2 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();
  }
}           

當我們在兩個線程中分别執行了上面的兩個函數後,能得到下面的輸出。

等待線程擷取了鎖
保護條件不成立,等待線程進入等待狀态
通知線程擷取了鎖
通知線程即将喚醒等待線程
等待線程被喚醒,開始執行目标動作           

8.3.3 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();
}           
等待線程擷取了鎖
保護條件不滿足,并且等待時間未到,等待進入等待狀态
已逾時,結束等待任務
通知線程擷取了鎖
通知線程即将喚醒等待線程           

8.4 await/countDown

8.4.1 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 時,會喚醒對應的等待線程。
               

8.4.2 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
等待線程結束等待           

8.5 CyclicBarrier

8.5.1 CyclicBarrier 簡介

有的時候多個線程需要互相等待對方代碼中的某個地方(集合點),這些線程才能繼續執行,這時可以使用 CyclicBarrier(栅欄)。

CyclicBarrier 是 JDK 5 引入的一個類,CyclicBarrier 協作方式有下面幾個特點。

使用 CyclicBarrier.await() 實作等待的線程叫參與方(Party),除了最後一個執行 CyclicBarrier.await() 方法的線程外,其他執行該方法的線程都會被暫停。

和 CountDownLatch 不同,CyclicBarrier 是可以重複使用的,也就是等待結束後,可以再次進行一輪等待。

8.5.1 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();
    }
  }
}           

運作上面的代碼後,可以得到下面的輸出。

第一天爬山,老李先來
老王到了,小張還沒到
小張到了
人來齊了,開始爬山
第二天爬山,老王先到
小張到了,老李還沒到
老李到了
人來齊了,開始爬山           

8.5.3 CyclicBarrier 原理

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() 方法被調用)。

9. 怎麼讓一個線程停止?

9.1 stop() 方法

JDK 中的 stop() 方法很早就被棄用了,之是以會被棄用,我們可以來看下 stop() 方法可能導緻的兩種情況。

第一種情況,假如現在有線程 A 和 線程 B,線程 A 持有了線程 B 需要的鎖,然後線程 A 被 stop() 強行結束了,導緻這個鎖沒有被釋放,那線程 B 就一直拿不到這個鎖了,相當于是線程 B 中的任務永遠無法執行了。

第二種情況,假如線程 A 正在修改一個變量,修改到一半,然後被 stop() 強行結束了,這時候線程 B 去讀取這個變量,讀取到的就是一個異常值,這就可能導緻線程 B 出現異常。

因為上述兩種資源清理的問題,是以現在很多語言都廢棄了線程的 stop() 方法。

雖然線程不能被簡單粗暴地終止,但是線程執行的任務是可以停止的,下面我們就來看看怎麼停止任務。

9.2 interrupt() 方法

當我們調用 sleep() 方法時,編譯器會要求我們捕獲中斷異常 InterruptedException,這是因為線程的休眠狀态可能會被中斷。

線上程休眠期間,如果其他地方調用了線程的 interrupt() 方法,那麼這個休眠狀态就會被中斷,中斷後就會接收到一個中斷異常。

我們可以在捕獲到中斷異常後釋放鎖,比如關閉流或檔案。

但是調用線程的 interrupt() 方法不是百分百能中斷任務的,假如我們現在有一個線程,它的 run() 方法中有個 while 循環在執行某些操作,那麼在其他地方調用該線程的 interrupt() 方法并不能中斷這個任務。

在這種情況下,我們可以通過 interrupted() 或 isInterruped() 方法判斷任務是否被中斷。

interrupted() 與 isInterrupted() 方法都可以擷取線程的中斷狀态,但它們有下面一些差別。

  • 靜态
    interrupted() 是靜态方法,isInterrupted() 是非靜态方法
               
  • 重置
    interrupted() 會重置中斷狀态,也就是不管這次擷取到的中斷狀态是 true 還是 false,下次擷取到的中斷狀态都是 false
    
    isInterrupted() 不會重置中斷狀态,也就是調用了線程的 interrupt() 方法後,通過該方法擷取到的中斷狀态會一直為 true
               

不論是使用 interrupted() 還是 isInterrupted() 方法,本質上都是通過 Native 層的布爾标志位判斷的。

9.3 布爾标志位

既然 interrupt() 隻是對布爾值的一個修改,那我們可以在 Java 層自己設一個布爾标志位,讓每個線程共享這個布爾值。

當我們想取消某個任務時,就在外部把這個标志位改為 true。

  • 直接使用布爾标志位會有可見性問題,是以要用 volatile 關鍵字修飾這個值。
               
  • 使用場景
    當我們需要用到 sleep() 方法時,我們可以使用 interrupt() 來中斷任務,其他時候可以使用布爾标志位。
               

10. 什麼是 ConcurrentHashMap?

10.1 ConcurrentHashMap 簡介

ConcurrentHashMap 是一個并發容器,并發容器是相對于同步容器的一個概念。

我們經常使用的 HashMap 和 ArrayList 等資料容器是線程不安全的,比如使用 HashMap 時需要自己加鎖,這時候就需要線程安全的資料容器:同步容器和異步容器。

同步容器指的是 Hashtable 等線程安全的資料容器,同步容器實作線程安全的方式存在性能問題。

同步容器之一的 Hashtable 存在如下的問題。

  • 大鎖
    對 Hashtable 對象加鎖
               
  • 長鎖
    直接對方法加鎖
               
  • 讀寫鎖共用
    隻有一把鎖,從頭鎖到尾
               

而并發容器比如 ConcurrentHashMap、CopyOnWriteArrayList 等就不存在這個問題,下面我就來看看它們是怎麼實作的。

10.2 ConcurrentHashMap 簡史

ConcurrentHashMap 從 JDK 5~8 ,每一個版本都進行了優化,下面我們就看下各個版本對 ConcurrentHashMap 做的優化。

  1. JDK 5
    在 JDK 5 中,ConcurrentHashMap 的實作是使用分段鎖,在必要時加鎖。
    
    Hashtable 是整個哈希表加鎖,而 JDK 5 引入的 ConcurrentHashMap 使用段(Segment)存儲鍵值對,在必要時對段進行加鎖,不同段之間的通路不受影響。
    
    JDK 5 的 ConcurrentHashMap 中的雜湊演算法對于比較小的整數,比如三萬以下的整數作為 key 時,無法讓元素均勻分布在各個段中,導緻它退化成了一個 Hashtable。
               
  2. JDK 6
    在 JDK 6 中,ConcurrentHashMap 優化了二次 Hash 算法,用了 single-word Wang/Jenkins 雜湊演算法,這個算法可以讓元素均勻分布在各個段中。
               
  3. JDK 7
    JDK 7 的 ConcurrentHashMap 初始化段的方式跟之前的版本不一樣,以前是 ConcurrentHashMap 構造出來後直接執行個體化 16 個段,而 JDK 7 開始,是需要哪個就建立哪個。
    
    懶加載執行個體化段會涉及可見性問題,是以在 JDK 7 的 ConcurrentHashMap 中使用了 volatile 和 UNSAFE.getObjectVolatile() 來保證可見性。
               
  4. JDK 8
    在 JDK 8 中,ConcurrentHashMap 廢棄了段這個概念,實作改為基于 HashMap 原理進行并發化。
    
    對不必加鎖的地方,盡量使用 volatile 進行通路,對于一定要加鎖的操作,會選擇小的範圍加鎖。
               

10.3 ConcurrentHashMap 特點

  • 小鎖

    分段鎖(JDK 5~7)

    桶節點鎖(JDK 8)

  • 短鎖

    先嘗試擷取,失敗再加鎖

  • 讀失敗再加鎖(JDK 5~7)

    volatile 讀 CAS 寫(JDK 7~8)

  • 弱一緻性
    • 添加元素後不一定馬上能讀到
    • 清空後可能仍有元素
    • 周遊前的段元素變化能讀到
    • 周遊後的段元素變化讀不到
    • 周遊時元素發生變化不會抛異常

11. 使用線程有哪些準則?

在使用線程執行異步任務的過程中,我們要準收一些使用準則,這樣能在一定程度上避免使用線程的時候帶來的問題。

常見的五個線程使用準則是:嚴謹直接建立線程、使用基礎線程池、選擇合适的異步方式、線程必須命名以及重視優先級設定。

  1. 嚴禁直接建立線程
    直接建立線程除了簡單友善之外,沒有其他優勢,是以在實際項目開發過程中,一定要嚴禁直接建立線程執行異步任務。
               
  2. 提供基礎線程池供各個業務線使用
    這個準則是為了避免各個業務線各自維護一套線程池,導緻線程數過多。
    
    假如我們有 10 條業務線,如果每條業務線都維護一個線程池,假如這個線程池的核心數是 8,那麼我們就有 80 條線程,這明顯是不合理的。
               
  3. 選擇合适的異步方式
    HandlerThread、IntentService 和 RxJava 等方式都可以執行異步任務,但是要根據任務類型來選擇合适的異步方式。
    
    假如我們有一個可能會長時間執行,但是優先級較低的任務,我們就可以選擇用 HandlerThread。
    
    還有一種情況就是我們需要執行一個定時任務,這種情況下更适合使用線程池來操作。
               
  4. 線程必須命名
    當我們開發組成員比較多的時候,不論是使用線程還是使用線程池,如果我們不對我們建立的線程命名,如果這個線程發生了異常,我們光靠預設線程名是不知道要找哪個開發人員的。
    
    如果我們對每個線程都命名了,就可以快速地定位到線程的建立者,可以把問題交給他來解決。
    
    我們可以在運作期通過 Thread.currentThread().setName(name) 修改線程的名字。
    
    如果在一段時間内是我們業務線使用,我們可以把線程的名字改成我們業務線的标志,在任務完成後,再把名字改回來。
               
  5. 重視優先級設定
    Java 采用的是搶占式排程模型,高優先級的任務能先占用 CPU,如果我們想讓某個任務先完成,我們可以給它設定一個較高的優先級。
    
    設定的方式就是通過 android.os.Process.setThreadPriority(priority),這個 priority 的值越小,優先級就越高,它的取值範圍在 -20~19。
               

12 怎麼在 Android 中執行異步任務?

在這一節,我們會介紹 Android 中常用的 7 種異步方式:Thread、HandlerThread、IntentService、AsyncTask、線程池、RxJava 和 Kotlin 協程。

12.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);
          }
        })
      }
    })
  }
});           

12.2 Thread

直接建立 Thread 是最簡單的異步方式,但是使用這種方式除了友善簡單之外,沒有任何其他優勢。

而且使用這種方式有很多缺點,比如說不容易被複用,導緻頻繁建立和銷毀線程的開銷大。

假如我們要執行一個定時任務,直接建立 Thread 雖然也能實作,但是比較麻煩。

12.3 HandlerThread

HandlerThread 本質上也是一個 Thread,但是它自帶了消息循環。

HandlerThread 内部是以串行的方式執行任務,它比較适合需要長時間執行,不斷從隊列中取出任務執行的場景。

12.4 IntentService

IntentService 是 Service 元件的子類,它的内部有一個 HandlerThread,是以它具備了 HandlerThread 的特性。

它有兩點優勢,第一點是相對于 Service 來說,IntenService 的執行是在工作線程而不是主線程。

第二點是它是一個 Service,如果應用使用了 Service,會提高應用的優先級,這樣就不容易被系統幹掉。

12.5 AsyncTAsk

AsyncTask 是 Android 提供的異步工具類,它的内部實作使用了線程池,使用 AsyncTask 的好處就是不用我們自己處理線程切換。

使用 AsyncTask 要注意它在不同版本的實作不一緻,但這個不一緻是在 API 14 以下的,而我們現在大部分應用的适配都是在 15 及以上,是以這個問題基本上已經沒有了。

12.6 線程池

12.6.1 線程池簡介

使用線程池執行異步任務有下面兩個優點。

  • 易于複用
    通過線程池建立的線程容易複用,這樣就避免了線程頻繁建立和銷毀的開銷。
               
  • 功能強大
    線程池提供了幾個強大的功能,比如定時、任務隊列、并發數控制等。
               

我們可以通過 Executors 建立線程池,當 Executors 不能滿足我們的需要時,我們可以自定義 ThreadPoolExecutor 實作滿足我們需要的線程池。

12.6.2 線程池基本用法

通過下面的 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);
    }
  });
}           

12.7 RxJava

12.7.1 RxJava 簡介

RxJava 是一個異步架構,在這裡我們主要關注它的基本用法、異常和取消的處理。

RxJava 根據任務類型的不同提供了不同的線程池,對于 I/O 密集型任務,比如網絡請求,它提供了 I/O 線程池。

對于 CPU 密集型任務,它提供了 CPU 任務專用的線程池,也就是 Schdulers.computation()。

如果我們項目內建了 RxJava,我們可以使用 RxJava 的線程池。

12.7.1 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,沒有對異常進行處理。

12.7.2 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);
}           

12.7.3 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 的原理就是監聽傳進來的控件的生命周期,當發現這個控件的被銷毀時,往往也就意味着頁面被關閉了,這時候就可以取消這個任務。

12.8 Kotlin 協程

12.8.1 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() 方法才會被執行。

12.8.2 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)
  }
}           

學習分享

網際網路領域,最重要的是開源,共同學習,共同進步!技術分享如此,資料共享也是如此!

點選下方連結 附贈Android進階架構進階學習資料

**[15G的Android架構進階、視訊資料

及安卓程式員履歷模闆](

https://shimo.im/docs/PXhwVR6YY9tjwcrY)

**

作者:燈不利多

連結:

https://juejin.im/post/5d45a75de51d4561ee1bdf10

來源:掘金