04.關於執行緒你必須知道的8個問題(下)

王有志發表於2023-01-01

今天我們來學習執行緒中最後4個問題:

  • 執行緒的同步與互斥
  • 執行緒的本質與排程
  • 死鎖的產生與解決
  • 多執行緒的是與非

透過本篇文章,你可以瞭解到計算機中經典的同步機制--管程Java執行緒的本質與排程方式,如何解決死鎖問題,以及為什麼要使用多執行緒。

執行緒的同步與互斥

首先來看執行緒同步執行緒互斥的概念,這裡引用百度百科中的定義:

執行緒同步:

即當有一個執行緒在對記憶體進行操作時,其他執行緒都不可以對這個記憶體地址進行操作,直到該執行緒完成操作, 其他執行緒才能對該記憶體地址進行操作,而其他執行緒又處於等待狀態,實現執行緒同步的方法有很多,臨界區物件就是其中一種。

執行緒互斥:

執行緒互斥是指某一資源同時只允許一個訪問者對其進行訪問,具有唯一性和排它性。但互斥無法限制訪問者對資源的訪問順序,即訪問是無序的。

執行緒同步關注的是執行緒間的執行順序,強調執行緒t2必須線上程t1執行完成後執行,是序列方式

執行緒互斥,關注的是不同執行緒對共享資源的使用方式,同一時間只允許一個執行緒訪問共享資源,在共享資源的訪問上是序列方式,而其它處理過程可以併發執行。

實現同步與互斥的方式有很多,比如:互斥鎖,訊號量和管程。Java 1.5前只提供了基於MESA管程思想實現的synchronized。之後,提供了JUC工具包,包含訊號量,互斥鎖等同步工具。

管程的思想

管程是由Hoare和Hansen提出的,最早用於解決作業系統程式間同步問題。Hansen首次在Pascal上實現了管程,Hoare證明了管程與訊號量是等價的

管程的發展歷史上,先後出現了3種管程模型:

  • Hansen管程,Hansen提出;
  • Hoare管程,Hoare提出;
  • MESA管程,施樂公司在MESA語言中實現。

這裡不過多的涉及管程的內容,只舉一個通俗的例子解釋下管程的實現原理。

最近大家都有好好的做核酸吧?

首先,大家(執行緒)從四面八方趕到核酸亭(併發執行),隨後進入排隊區(入口佇列,序列執行),緊接著是身份識別(檢查條件變數),最後進行核酸監測(操作共享變數),當下一個人看到你完成了核酸監測後,開始進行核酸檢測(喚醒)。

Java中synchronized的底層正是借鑑了MESA管程的實現思想。應用層面,使用synchronizedObject.wait方法,來實現的同步機制也是管程的實現。這些會在synchronized的部分中詳細解釋。

執行緒的本質與排程

關於執行緒你必須知道的8個問題(中)中,我們看到了thread.cpp建立作業系統層面的執行緒,不過礙於篇幅沒有繼續往下追,今天我們來看下os_linux.cpp中是如何建立執行緒的:

bool os::create_thread(Thread* thread, ThreadType thr_type, size_t req_stack_size) {
  int ret = pthread_create(&tid, &attr, (void* (*)(void*)) thread_native_entry, thread);
  return true;
}

可以看到,是透過呼叫pthread_create來建立執行緒的,該方法是Linux的thread.h庫中建立執行緒的方法,用來建立操作Linux的執行緒。

到這裡你可能會有疑問,或者聽到過這樣的問題,Java的執行緒是使用者執行緒還是核心執行緒?

早期Linux並不支援執行緒,但可以透過程式語言模擬實現“執行緒”,本質還是呼叫程式,這時建立的執行緒就是使用者執行緒

2003年RedHat初步完成了NPTL(Native POSIX Thread Library)專案,透過輕量級程式實現了符合POSIX標準的執行緒,這時建立的執行緒就是核心執行緒

因此,如果不是跑在古董伺服器上的專案的話,使用的Java執行緒都會對映到一個核心執行緒上

好了,你已經知道現代Java執行緒的本質是作業系統的核心執行緒,並且也知道了作業系統核心執行緒是透過輕量級程式實現的。所以,我們可以得到:

$Java執行緒\approx作業系統核心執行緒\approx作業系統輕量級程式$

那麼對於Java執行緒的排程方式來說就有:

$Java執行緒的排程方式\approx作業系統程式的排程方式$

恰好,Linux中使用了搶佔式程式排程方式。因此,並不是JVM中實現了搶佔式執行緒排程方式,而是Java使用了Linux的程式排程方式,Linux選擇了搶佔式程式排程方式

死鎖的產生與解決

我們隨便寫個例子:

public static void main(String[] args) {
    String lock_a = "lock-a";
    String lock_b = "lock-b";
	ShareData lock_a_shareData = new ShareData(lock_a, lock_b);
	ShareData lock_b_shareData = new ShareData(lock_b, lock_a);
	new Thread(lock_a_shareData, "lock-a-thread").start();
	new Thread(lock_b_shareData, "lock-b-thread").start();
}

static class ShareData implements Runnable {
    private final String holdLock;
    private final String requestLock;
    public ShareData(String holdLock, String requestLock) {
        this.holdLock = holdLock;
        this.requestLock = requestLock;
    }

    @SneakyThrows
    @Override
    public void run() {
        synchronized (holdLock) { // 1
            System.out.println("執行緒:" + Thread.currentThread().getName() + ",持有:" + this.holdLock + ",嘗試獲取:" + this.requestLock);
            TimeUnit.SECONDS.sleep(3);
            synchronized (requestLock) { // 2
                System.out.println("成功獲取!");
            }
        }
    }
}

lock_a_shareData持有lock_a,嘗試請求lock_b,相反的lock_b_shareData持有lock_b,嘗試請求lock_a,在它們互相都不放手的情況下,誰也無法請求成功,因此雙雙阻塞在那裡。

透過上面的例子我們可以總結出死鎖產生的4個條件:

  1. 程式碼1和程式碼2處新增了synchronized,保證只有持有對應鎖的執行緒可以進入,這是互斥條件,鎖只能被一個執行緒持有
  2. 程式碼1處持有鎖不釋放,並且在程式碼2處請求鎖,這是保持和請求條件,保持自己的鎖,並請求其它的鎖
  3. 執行緒lock-a-thread和執行緒lock-b-thread只是在那裡不斷請求,並沒有誰要求其它執行緒放棄,這是不剝奪條件,不搶奪其它執行緒已獲取的鎖,只能由其主動釋放
  4. 執行緒lock-a-thread和執行緒lock-b-thread的持有與互相請求鎖形成了一個環路,這是迴圈等待條件,多個執行緒間的資源請求形成了環路

知道了死鎖產生的條件,那麼解決的辦法也就顯而易見了。首先互斥條件是無法被打破的,因為本身的目的就是在此處形成互斥,避免併發造成的“意外”。

那麼我們可以嘗試打破剩餘的3個條件:

  • 透過一次性申請所有資源來打破保持和請求條件,增加Admin角色去統一管理資源的申請和釋放;
  • 透過主動釋放資源來打破不剝奪條件,既然不能主動搶,那主動釋放總歸是可以的吧?
  • 透過按照資源順序申請來打破迴圈等待條件,每個資源由小到大依次編號,只有申請到編號較小的資源後才可以申請編號較大的資源。

Java中定位死鎖

涉及到多執行緒的問題,往往具有難排查的特點,不過好在我們可以藉助Java提供的工具。

首先是透過jps,ps或者它工具確定Java程式的程式ID:

# Linux平臺
jps -1

# window平臺
.\jps

然後透過jstack檢視執行緒的堆疊資訊,確定“事故”:

# Linux平臺
jstack <程式ID>

# window平臺
.\stack <程式ID>

得到大致如下的資訊(省略了非常多):

這個輸出資訊就非常明顯了吧?雖然實際工作中,情況可能會更加複雜,但是大致思路是一樣的:

程式阻塞 -> 檢視執行緒狀態 -> 檢視持有與等待情況 -> 檢視問題程式碼

預防死鎖

通常快速定位解決死鎖問題,會在程式設計師中獲得“技術大牛”的稱讚,但質量效能部門會記一個大大的事故。為了避免這種情況,我們還是要多做預防工作。

首先是儘量避免使用多個鎖,避免這種持有與請求的情況發生,如果必須要用多個鎖,請保證多個鎖的使用至少滿足以下一種:

  • 執行緒按照特定順序獲取鎖
  • 為每把鎖新增超時時間,當然synchronized是沒辦法做到的。

另外也可以藉助工具在上線前發現死鎖問題,比如:FindBugs™

多執行緒的是與非

使用多執行緒的目的是什麼?

無論是說多核處理器時代不用多執行緒就是浪費資源,還是說程式既要處理資料,又有IO操作,多執行緒可以在IO期間處理資料保證CPU的利用率,歸根結底就是要提速

通常意義上,多執行緒確實會快於單執行緒。

PS:《Java併發程式設計的藝術》中在章節“1.1.1 多執行緒一定快嗎”給出了一個反例。我提供了這本書的電子版,有興趣的可以去閱讀。

我經常會和小夥伴聊到,引入一種技術,有利就會有弊,無論是技術選型還是架構設計,都是一門權衡的藝術。

那麼引入多執行緒會帶來什麼問題?

顯而易見的是程式設計難度的提升,人的思維是線性的,因此程式設計過程中也總是傾向於線性處理流程,在程式中編寫程式碼的難度可想而知。

另外,《Java併發程式設計的藝術》中提到了上下文切換,死鎖,以及資源限制的問題,這些大家都耳熟能詳了,就不過多贅述了。

以上的問題我們都有解決辦法或者可以忽略,併發程式設計中最大的挑戰其實是執行緒安全問題帶來的資料錯誤,比如,前公司的同事曾經使用了有狀態的Spring單例Bean。

最後是額外的一點,如無必要,勿增實體,在可預見的未來(大約3年),如果業務發展並沒有使用多執行緒的必要,那就遵循奧卡姆剃刀原理,選擇最簡單的解決方案。

結語

今天的內容其實都可以在作業系統的發展史中找到它們的影子,與其說是執行緒的問題不如說是多工處理的問題。

文章中涉及到了一些作業系統的內容,尤其是在執行緒的同步與互斥執行緒的本質與排程中,最早寫了3種管程模型,但寫完發現文章奔著上萬字去了,於是就刪掉了這部分內容,儘量做到簡短準確的表達。

關於執行緒的問題到這裡就告一段落了,希望這3篇文章能夠給你帶來幫助。接下來我們從synchronizedvolatilefinal開始。


好了,今天就到這裡了,Bye~~

相關文章