今天我們來學習執行緒中最後4個問題:
通過本篇文章,你可以瞭解到計算機中經典的同步機制--管程,Java執行緒的本質與排程方式,如何解決死鎖問題,以及為什麼要使用多執行緒。
首先來看執行緒同步與執行緒互斥的概念,這裡參照百度百科中的定義:
執行緒同步:
即當有一個執行緒在對記憶體進行操作時,其他執行緒都不可以對這個記憶體地址進行操作,直到該執行緒完成操作, 其他執行緒才能對該記憶體地址進行操作,而其他執行緒又處於等待狀態,實現執行緒同步的方法有很多,臨界區物件就是其中一種。
執行緒互斥:
執行緒互斥是指某一資源同時只允許一個存取者對其進行存取,具有唯一性和排它性。但互斥無法限制存取者對資源的存取順序,即存取是無序的。
執行緒同步關注的是執行緒間的執行順序,強調執行緒t2必須線上程t1執行完成後執行,是序列方式。
執行緒互斥,關注的是不同執行緒對共用資源的使用方式,同一時間只允許一個執行緒存取共用資源,在共用資源的存取上是序列方式,而其它處理過程可以並行執行。
實現同步與互斥的方式有很多,比如:互斥鎖,號誌和管程。Java 1.5前只提供了基於MESA管程思想實現的synchronized
。之後,提供了JUC工具包,包含號誌,互斥鎖等同步工具。
管程是由Hoare和Hansen提出的,最早用於解決作業系統程序間同步問題。Hansen首次在Pascal上實現了管程,Hoare證明了管程與號誌是等價的。
管程的發展歷史上,先後出現了3種管程模型:
這裡不過多的涉及管程的內容,只舉一個通俗的例子解釋下管程的實現原理。
最近大家都有好好的做核酸吧?
首先,大家(執行緒)從四面八方趕到核酸亭(並行執行),隨後進入排隊區(入口佇列,序列執行),緊接著是身份識別(檢查條件變數),最後進行核酸監測(操作共用變數),當下一個人看到你完成了核酸監測後,開始進行核酸檢測(喚醒)。
Java中synchronized
的底層正是借鑑了MESA管程的實現思想。應用層面,使用synchronized
和Object.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個條件:
synchronized
,保證只有持有對應鎖的執行緒可以進入,這是互斥條件,鎖只能被一個執行緒持有;lock-a-thread
和執行緒lock-b-thread
只是在那裡不斷請求,並沒有誰要求其它執行緒放棄,這是不剝奪條件,不搶奪其它執行緒已獲取的鎖,只能由其主動釋放;lock-a-thread
和執行緒lock-b-thread
的持有與互相請求鎖形成了一個環路,這是迴圈等待條件,多個執行緒間的資源請求形成了環路。知道了死鎖產生的條件,那麼解決的辦法也就顯而易見了。首先互斥條件是無法被打破的,因為本身的目的就是在此處形成互斥,避免並行造成的「意外」。
那麼我們可以嘗試打破剩餘的3個條件:
涉及到多執行緒的問題,往往具有難排查的特點,不過好在我們可以藉助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篇文章能夠給你帶來幫助。接下來我們從synchronized
,volatile
和final
開始。
好了,今天就到這裡了,Bye~~