多線程是開發中較為高階的知識,但是涉及到的知識點非常的多。設計或寫多線程的代碼需要謹慎處理,前提是我們要有比較系統性的認識。

一、從Thread Stack認識線程

在android開發中我們通常會在發生卡頓或者ANR的時候,去拿手機裏的traces.txt做分析,這裡面dump了ANR出現時的線程狀態。我們以traces.txt裡面的堆棧為例,認識下線程。

位置: data/anr/traces.txt

cmd: adb pull data/anr/traces.txt ~/Downloads/anr/

"main" prio=5 tid=1 Native
| group="main" sCount=1 dsCount=0 flags=1 obj=0x73fa0bb0 self=0x7efc4a3a00
| sysTid=21720 nice=0 cgrp=default sched=0/0 handle=0x7f014829b0
| state=S schedstat=( 180625504 192128139 822 ) utm=11 stm=6 core=1 HZ=100
| stack=0x7fd0700000-0x7fd0702000 stackSize=8MB
| held mutexes=
kernel: (couldnt read /proc/self/task/21720/stack)
native: #00 pc 0000000000069068 /system/lib64/libc.so (__epoll_pwait+8)
native: #01 pc 000000000001f640 /system/lib64/libc.so (epoll_pwait+48)
native: #02 pc 0000000000015c80 /system/lib64/libutils.so (_ZN7android6Looper9pollInnerEi+144)
native: #03 pc 0000000000015b68 /system/lib64/libutils.so (_ZN7android6Looper8pollOnceEiPiS1_PPv+108)
native: #04 pc 000000000011a59c /system/lib64/libandroid_runtime.so (???)
native: #05 pc 000000000020081c /system/framework/arm64/boot-framework.oat (Java_android_os_MessageQueue_nativePollOnce__JI+140)
at android.os.MessageQueue.nativePollOnce(Native method)
at android.os.MessageQueue.next(MessageQueue.java:379)
at android.os.Looper.loop(Looper.java:144)
at android.app.ActivityThread.main(ActivityThread.java:7425)
at java.lang.reflect.Method.invoke(Native method)
at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:245)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:921)

Thread 狀態欄位分析:

後面的部分為native和java的堆棧信息

native: #00 pc 0000000000069068 /system/lib64/libc.so (__epoll_pwait+8)

Thread Stack Field Native

解析下來就是,第一個行堆棧產生是來自libc.so庫中偏移量為0x69068的位置(也就是函數名__epoll_pwait+8)發生了調用。

認識了一個線程有哪些狀態(構成),基本上也初步認識了線程,但是線程是怎麼工作的,還要從它的生命週期說起。接下來再瞭解下線程的生命週期和裝換狀態。

二、線程的狀態轉換

圖片已經有很多前輩們總結了,就直接拿過來分析了。

  • 新建(New)線程尚未開啟的時候。
  • 可運行(Runnable)線程對象已經創建,等待執行,改狀態的線程可能在線程池中,等待被調度
  • 運行 (Running)

    線程獲取到了cpu時間片,開始運行。

  • 阻塞 (BLOCKED)線程正在等待獲取鎖(monitor lock)的狀態,比如A線程的Synchronized代碼塊尚未結束,當前線程B會被阻塞執行,一直等到A釋放鎖。
  • 等待 (Watting)線程處於等待狀態,等待的原因是調用了以下方法:
    • Object.wait
    • Thread.join
    • LockSupport.park

  • 定時等待(TIMED_WAITING)線程處於等待狀態,但會設定等待時間,一般是調用了以下方法:
    • Thread.sleep
    • Object.wait with timeout
    • Thread.join with timeout
    • LockSupport.parkNanos
    • LockSupport.parkUntil

  • 死亡(Dead) 線程run結束或退出,生命週期結束

這裡面有幾個概念需要區分清楚:

  1. Blocked 和 WAITING

Blocked狀態線程是會放棄cpu的佔用,一旦鎖被釋放,jvm會自動解除Blocked的狀態,系統自己的行為。Watting的阻塞會一直喫需要有消息通知才會接觸,如調用notify()/notifyAll的方法。

2. WATING vs TIMED_WAITING

TIMED_WAITING狀態的線程阻塞會有時間限制,一旦過了超時時間,線程將重新獲取cpu運行程序,Thread.sleep(timeout)就屬於這種類型。無需像WATING狀態的線程一樣等待notify。

3. Blocked & WAITING & TIMED_WAITING 狀態都是不會獲取cpu時間片的,也就是不會佔用CPU的資源。

三、鎖以及鎖的優化

為了保證線程安全,我們通常都是藉助鎖(如中synchronize)來完成線程間的協作。

首先,瞭解JVM中鎖是怎麼實現的有助於我們更深次的瞭解鎖以及鎖的優化邏輯,在實踐中正確的使用鎖。

3.1 鎖的實現原理

認識jvm中對象的結構

HotSpot虛擬機中,對象在內存中存儲的佈局可以分為三塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。

java object

看一下對象頭的結構:定義在opp.hpp—>oopDesc

oopDesc定義了兩個變數:

volatile markOop _mark;

Klass* _klass;

Klass的作用是把C++的class對象包裝成java heap 對象。_mark(mark_word)存儲對象自身的運行數據,如Object hash,GC分代年齡,還有就是包含我們後面要介紹的鎖的信息。查看hotspot 代碼(jdk8):

oop.hpp?

hg.openjdk.java.net

markOop.hpp?

hg.openjdk.java.net

32位markword存儲結構:

線程通過拷貝markword記錄來進行鎖的加鎖和釋放。線程棧存儲一個叫Lock Record的空間,主要用於拷貝對象markword的記錄,一旦拷貝成功,就說明當前線程擁有了這個對象的鎖。

1bit(是否偏向鎖)表示對象鎖是否可以偏向,線程在標誌為為1的情況下會去嘗試使用偏向鎖競爭。

最低位2位鎖標誌位記錄了當前幾種鎖的狀態,在線程鎖的競爭和優化過程會更新狀態。目前有狀態有

這裡面涉及到了很多概念,比如偏向鎖自旋鎖重量級鎖等,這些鎖的設計是用於鎖的優化的。很多時候我們理解syncnorize只是粗暴的同步代碼塊,其實不然,虛擬機的設計對synchnorized鎖做了很深層次的優化。

以上關於對象結構和欄位說明建議大家去讀一首的資料,就是相關類的代碼注釋,比較詳細。

下面對其優化過程做個詳細的分析

3.2 鎖以及鎖的優化

承上說的在使用syschnorized其實虛擬機會有個自發的優化過程,這個過程就是鎖的膨脹過程,java包含的鎖類型有偏向鎖,輕量級鎖,自旋鎖以及重量級鎖,鎖的膨脹過程就是這些狀態之前的轉換過程。

3.2.1 為什麼存在鎖的優化問題?

瞭解線程調度的同學都知道,java是通過搶佔式的調度方式來競爭的,系統會控制時間片的分配和線程的切換。線程切換是有開銷的,線程的開銷主要是在於每次切之前需要保存任務的狀態,下次切回這個線程的時候再載入狀態。

並發編程為了保證共享變數的安全又不得不使用鎖,多線程在競爭鎖的時候回引發上線文切換,並且一個線程持有鎖會導致其它所有需要此鎖的線程都會被掛起。如果需要更高的並發性能,就會需要鎖的優化。

3.2.2 悲觀鎖 vs 樂觀鎖

在java中我們所說的悲觀鎖樂觀鎖是一種延伸的意義,其明確定義出自關係型資料庫並發控制:

悲觀並發控制(又名「悲觀鎖」,Pessimistic Concurrency Control,縮寫「PCC」)是一種並發控制的方法。它可以阻止一個事務以影響其他用戶的方式來修改數據。如果一個事務執行的操作讀某行數據應用了鎖,那只有當這個事務把鎖釋放,其他事務纔能夠執行與該鎖衝突的操作。

樂觀並發控制(又名「樂觀鎖」,Optimistic Concurrency Control,縮寫「OCC」)是一種並發控制的方法。它假設多用戶並發的事務在處理時不會彼此互相影響,各事務能夠在不產生鎖的情況下處理各自影響的那部分數據。在提交數據更新之前,每個事務會先檢查在該事務讀取數據後,有沒有其他事務又修改了該數據。如果其他事務有更新的話,正在提交的事務會進行回滾。

我們可以看出來樂觀鎖相對於悲觀鎖來說,具備更高的並發性能。樂觀鎖的實現基於共享狀態的check,可以減少上下文的切換,競爭的線程也不會因為未競爭到資源而被掛起。

3.2.3 內存同步機制-Volatile

線程安全嚴格來說需要保證線程間對共享變數訪問的有序性、可見性、原子性,但是往往某些場景我們會使用開銷更小的方案來保持平衡,如:使用volatile關鍵字,volatile不是嚴格意義上的鎖,只是利用了內存同步機制來實現。

case:保證獲取單例的有序性:

public class Singleton {
private volatile static Singleton instance; //聲明成 volatile
private Singleton (){}

public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

這樣就可以避免獲取單例方法的雙重加鎖。

volalite不能嚴格保證原子性,只能保證單個操作的原子性,複合操作就會出錯。

原子操作(atomic operation)是指不會被線程調度機制打斷的操作;這種操作一旦開始,就一直運行到結束,中間不會有任何 context switch (切換到另一個線程)。

不能保證原子性的根本原因:內存訪問亂序問題 如下兩個CPU都做如下比較賦值操作等:

CPU 1 CPU 2
=============== ===============
{ A == 1; B == 2 }
A = 3; x = A;
B = 4; y = B;

內存系統能看見的訪問順序可能會是如下的次序:

為了保證多核內存訪問次序的正確性,操作系統引入內存屏障來解決(memory barrier )

插入一個內存屏障,相當於告訴CPU和編譯器先於這個命令的必須先執行,後於這個命令的必須後執行。內存屏障另一個作用是強制更新一次不同CPU的緩存。

內存屏障(memory barrier)和volatile什麼關係?

從Load到store到內存屏障,一共4步,其中最後一步jvm讓這個最新的變數的值在所有線程可見,也就是最後一步讓所有的CPU內核都獲得了最新的值,但中間的幾步(從Load到Store)是不安全的,中間如果其他的CPU修改了值將會丟失。

volatile保證變數對線程的可見性正式利用了內存屏障,但原子性並不能得到保證。

複合操作指的是?

如:int i = 0; i++;

內存中i++的步驟包含load、Increment、store

複合操作有哪些:

根本原因是因為volalite為了保證操作的有序性設定的重排序策略,如,A線程第一個操作如果是volalite多或寫操作,後續A線程第二個操作是什麼(這裡假設是寫變數操作)都不能重排序,B線程的volalite操作如果發生在A線程之前,就沒法獲取B的變數修改值,就不能保證B線程中變數讀取的準確性。voliate的機制大有文章,具體的原理可以去深入瞭解下。

所以,volalite雖然可以提升性能,有一定的適用場景,但是並不能嚴格意義上來保證線程安全。如果需要嚴格意義的線程安全能平衡性能,可以選擇CAS的機制。

3.2.4 樂觀鎖實現:CAS

CAS屬於樂觀鎖,可以在不使用鎖的情況下實現多線程之間的變數同步,也就是在沒有線程被阻塞的情況下實現變數的同(Non-blocking Synchronization)

演算法原理(Compare and Exchange)-> 操作三個變數值

  • 需要讀寫的內存值 V
  • 進行比較的值 A
  • 擬寫入的新值 B

B新值插入的條件當前僅當V=A的時候成立。即在插入B新值之前先檢查一遍內存值和舊的值是否是一致的(未被修改),確認一致纔去修改變數value為B

jdk中java.util.concurrent.atomic包下面提供了很多的類似的cas工具,其中較為常見的有AtomicInteger,AtomicBoolean。

他們都是藉助系統指令CMPXCHG來完成演算法的,CMPXCHG指令詳情看這裡。

CAS作為樂觀鎖也是存在問提的,即ABA問題,有很多文檔不做詳述。

3.2.5 syschnorized原理與優化

有前面的知識作為鋪墊,回到虛擬機的鎖的優化問題,java虛擬機使用了哪些方案去優化鎖呢,以及鎖的狀態裝換流程是怎麼樣的呢?

偏向鎖(Biased Locking)

是Java6引入的一項多線程優化。它會偏向於第一個訪問鎖的線程,如果在運行過程中,同步鎖只有一個線程訪問,不存在多線程爭用的情況,則線程是不需要觸發同步的,這種情況下,就會給線程加一個偏向鎖。 如果在運行過程中,遇到了其他線程搶佔鎖,則持有偏向鎖的線程會被掛起,JVM會消除它身上的偏向鎖,將鎖恢復到標準的輕量級鎖。

輕量級鎖

輕量級鎖是由偏向所升級來的,偏向鎖運行在一個線程進入同步塊的情況下,當第二個線程加入鎖爭用的時候,偏向鎖就會升級為輕量級鎖。它不是使用操作系統互斥量來實現鎖, 而是通過CAS實現。當線程獲得輕量級鎖後,可以再次進入鎖,即鎖是可重入(Reentrance Lock)的。

重量級鎖

重量級鎖(monitor)就是一個互斥的鎖了,這種同步方式的成本非常高,包括系統調用引起的內核態與用戶態切換、線程阻塞造成的線程切換等。

在"鎖的實現原理"那一節我們已經分析解釋過分析鎖的標誌位的用途了,結合鎖的裝換狀態及對象Mark Word的關係圖我們可以更深入理解mark word的工作原理:

歸納mark word標誌位狀態如下:

通過分析mark word就可以清楚地知道當前的鎖的裝換狀態處於什麼樣一個階段。

線程是怎麼從偏向鎖如何轉升級為輕量級鎖以及輕量級是如何膨脹成重量級鎖的。

1.偏向鎖的流程圖:

如果CAS嘗試競爭成功,就直接執行代碼塊。如果失敗,發現有其他線程在競爭撤銷偏向鎖,升級成為輕量級鎖。

2.升級為輕量級鎖線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

自旋鎖儘可能的減少線程的阻塞,這對於鎖的競爭不激烈,且佔用鎖時間非常短的代碼塊來說性能能大幅度的提升,因為自旋的消耗會小於線程阻塞掛起再喚醒的操作的消耗。

3.輕量級鎖自旋獲取鎖依然失敗,升級為重量級鎖。

四、性能優化與測試

在並發編程中線程安全是我們首要要關注的問題,它是保證程序穩定運行的前提。但是鎖的引入可能也是性能上的殺手。

4.1 多線程性能問題誘因

導致並發性能的主要誘因是"上下文切換"。

上線文切換

在多核的操作系統裏,當一個線程被切換進來時,它所需要的數據可能不在當前處理器的緩存裏,緩存的確實會使得這個線程初次的切換帶來更多的開銷。通常一次上下文的開銷相當於5000~10000個cp4時鐘週期。

引發上下切換的誘因是:阻塞IO,等待獲取競爭鎖等。

當線程發生阻塞或者等待獲取競爭鎖(cas,voliate都屬於非競爭同步,JVM自行調度,無需操作系統介入)時,需要被掛起,這個過程將包含兩次額外的上下文切換,被阻塞的線程在其執行的時間片還沒有用完之前就被交換出去,而在隨後的獲取鎖或者資源可用的時候再切換回來。

調用哪些方法會引起上線文切換(常見):

  • Thread.sleep(long millis)
  • Object,wait(long timeout)
  • Thread.join()/Thread.join(long timeout)

性能的影響因素也包含如內存同步等,這裡只說上下文切換是主要誘因,可能人為的做優化的部分。

4.2 性能優化方案

4.2.1 減少鎖力粒度

如果我們給public類每個方法加synchnorized(this)的同步,線程安全確實沒問題。但是基本也等同於串列操作,違背了並發的本意。

在編碼的時候盡量避免鎖的力度太粗。避免佔用鎖的時間過長。

case:

public class Grocery {
private final ArrayList fruits = new ArrayList();
private final ArrayList vegetables = new ArrayList();
public synchronized void addFruit(int index, String fruit) {
fruits.add(index, fruit);
}
public synchronized void removeFruit(int index) {
fruits.remove(index);
}
public synchronized void addVegetable(int index, String vegetable) {
vegetables.add(index, vegetable);
}
public synchronized void removeVegetable(int index) {
vegetables.remove(index);
}
}

這裡可以看出來在添加和移除水果的時候也會阻塞蔬菜的添加和刪除,對於水果和蔬菜的增刪操作都是方法域的同步。如果拆分下鎖:

public class Grocery {
private final ArrayList fruits = new ArrayList();
private final ArrayList vegetables = new ArrayList();
public void addFruit(int index, String fruit) {
synchronized(fruits) fruits.add(index, fruit);
}
public void removeFruit(int index) {
synchronized(fruits) {fruits.remove(index);}
}
public void addVegetable(int index, String vegetable) {
synchronized(vegetables) vegetables.add(index, vegetable);
}
public void removeVegetable(int index) {
synchronized(vegetables) vegetables.remove(index);
}
}

鎖的範圍就被縮小到了針對每個資源fruits和vegetables的鎖。通常這樣的方式阻塞出現的情況會更加少,性能會有較大的提升。

4.2.2 使用非競爭同步

在cas和voliate關鍵字適用的場景可以盡量去選擇使用此類樂觀鎖。

4.2.3 分段鎖

分解鎖可以帶來一定的伸縮性和性能的提升,但是如果競爭條件還是比較激烈的話,還是會有問題,這時候可以通過分段鎖來優化。jdk 1.7中的線程安全集合類ConcurrentHashMap就是利用了這個技術來實現高並發。

ConcurrentHashMap把容器分為多個 segment(片段) ,每個片段有一把鎖,當多線程訪問容器裏不同數據段的數據時,線程間就不會存在競爭關係;一個線程佔用鎖訪問一個segment的數據時,並不影響另外的線程訪問其他segment中的數據。

@SuppressWarnings("unchecked")
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key.hashCode()); //獲取hash值
int j = (hash >>> segmentShift) & segmentMask; //mask計算出資源散列在哪個片段中
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}

ensureSegment找到segment位置之後,就可以開始執行Segment put操作,Segment.put被使用開銷更小的ReentrantLock.trylock即非公平鎖。

非公平鎖(Nonfair):加鎖時不考慮排隊等待問題,直接嘗試獲取鎖,獲取不到自動到隊尾等待。

4.2.4 ReadWriteLock

如果資源可以被多個讀線程訪問,或者只有一個寫線程訪問,可以使用讀寫鎖。也就是存在多個線程同時存在讀和寫的場景不適用。

資源並發讀寫:

紅色部分表示被阻塞的線程

使用讀寫鎖可以帶來性能上的優化。Java 中的實現有ReentrantReadWriteLock。

實例讀鎖和寫鎖

private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();

獲取鎖和釋放鎖

lock()
unlock()

讀鎖的性能高於獨佔鎖,讀鎖區別於寫鎖使用的是共享鎖,具有更小的開銷

4.3 編寫測試代碼

多線程的正確性通常很難通過肉眼甚至是正常測試回歸發現。對於我們的並發代碼的正確性需要更多的依賴測試代碼的驗證,這一點很重要。

但是編寫多線程的單元測試通常比寫多線程的邏輯代碼更難,需要一些技巧。

4.3.1 覆蓋基本的後驗條件

首先假定在串列的條件下,是否能正確的都到正確的結果。使用單元測試先去覆蓋掉。比如隊列在創建之前應該為空的嚴重,完成之後後驗結果的準確性。確保非並發的環境下程序是爭取的,這樣可以避免把所有錯誤懷疑到多線程上,也可以為多線程的後續結果驗證提供基礎。

4.3.2 使用柵欄模式(CyclicBarrier)設計"同時到達"

CyclicBarrier可以使線程互相等待,任何一個線程達到某個狀態之前,所有的線程都必須等待。等到所有線程都達到某個狀態,線程才能繼續執行。

CyclicBarrier的代碼模型:

//設計50個測試線程
int HREADS = 50 ;

CyclicBarrier barries = new CyclicBarrier(HREADS);

//in thread,wait to start
barries.await()

//do stuff

//in thread,wait to stop
barries.await();

case:

class Party extends Thread {
private int duration;
private CyclicBarrier barrier;

public Party(int duration, CyclicBarrier barrier, String name) {
super(name);
this.duration = duration;
this.barrier = barrier;
}

@Override
public void run() {
try {
Thread.sleep(duration);
System.out.println(Thread.currentThread().getName() + " is calling await()");
barrier.await();
System.out.println(Thread.currentThread().getName() + " has started running again");
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}
}

Test:

public static void main(String args[]) throws InterruptedException, BrokenBarrierException {

CyclicBarrier barrier = new CyclicBarrier(4);
Party first = new Party(1000, barrier, "PARTY-1");
Party second = new Party(2000, barrier, "PARTY-2");
Party third = new Party(3000, barrier, "PARTY-3");
Party fourth = new Party(4000, barrier, "PARTY-4");

first.start();
second.start();
third.start();
fourth.start();

System.out.println(Thread.currentThread().getName() + " has finished");

}

4.3.3 產生更多的交替操作

低概率的事件即使是模擬多線程測試也很難驗證出來,需要讓程序產生更多的交替操作大概率去提高並發隱藏問題出現的概率。

藉助Thread.sleep()/Thread.yield(),通常加到需要驗證的邏輯之前。

public void run() {
try {
Thread.sleep(2);

//do stuff
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}

Thread.sleep()/Thread.yield()都會阻塞使CPU讓出時間片,讓競爭更為激烈。


推薦閱讀:
相關文章