作者 | 花名有孚
來源 | rrd.me/fukFv
每個人都有自己的喜好,就像我喜歡Java一樣。學習Java的樂趣之一在于它的深度和廣度。在日常工作中,我們常常會遇到一些從未探索過的功能,比如線程。沒錯,就是Thread類。當我們構建高擴展性系統時,常常會遇到各種并發編程問題,本文將介紹一些關于線程的不常用方法和技術,無論你是初學者、高級用戶還是Java專家,希望能從中有所收獲。如果你有關于線程的其他見解,歡迎在評論區分享。
初學者篇
- 線程名稱
每個線程都有一個名稱,創建線程時會分配一個簡單的字符串作為線程名。默認名稱是“Thread-0”、“Thread-1”、“Thread-2”等。Thread類提供了兩種設置線程名稱的方法:
通過線程構造函數:
class SuchThread extends Thread { public void run() { System.out.println("Hi Mom! " + getName()); } } SuchThread wow = new SuchThread("much-name");
通過setName方法:
wow.setName("Just another thread name");
線程名稱是可變的,可以在運行時修改,不必在初始化時就指定。名稱字段是一個簡單的字符串對象,最多可達231-1個字符(Integer.MAX_VALUE)。注意,線程名稱不是唯一標識符,不同線程可以有相同的名稱。使用NULL作為線程名會拋出異常,但“null”字符串是可以的。
使用線程名稱調試問題
設置線程名稱可以幫助調試問題。例如,在處理用戶請求時,將事務ID附加到線程名稱中,可以顯著減少排查問題的時間。
"pool-1-thread-1" #17 prio=5 os_prio=31 tid=0x00007f9d620c9800nid=0x6d03 in Object.wait() [0x000000013ebcc000]
改進后的名稱:
Thread.currentThread().setName(Context + TID + Params + current Time, ...);
使用jstack運行后,情況變得清晰:
"Queue Processing Thread, MessageID: AB5cad, type:AnalyzeGraph, queue: ACTIVE_PROD, Transaction_ID: 5678956,Start Time: 30/12/2014 17:37" #17 prio=5 os_prio=31 tid=0x00007f9d620c9800nid=0x6d03 in Object.wait() [0x000000013ebcc000]
這樣,當線程出現問題時,至少可以獲取事務ID來開始排查。
- 線程優先級
線程還有一個有趣的屬性——優先級。線程優先級在1(MIN_PRIORITY)到10(MAX_PRIORITY)之間,主線程默認是5(NORM_PRIORITY)。新線程默認繼承父線程的優先級,如果沒有設置,所有線程的優先級都是5。這個屬性常被忽略,可以通過getPriority()和setPriority()方法獲取和修改。
優先級的應用場景
并不是所有線程都是平等的,有些需要立即獲得CPU注意力,有些只是后臺任務。優先級就是用來告訴操作系統線程調度器的。在Takipi中,我們開發的錯誤跟蹤工具中,處理用戶異常的線程優先級是MAX_PRIORITY,而上報新部署情況的線程優先級較低。高優先級的線程并不總是能從jvm線程調度器那里獲得更多時間。
在操作系統層面,每個新線程對應一個本地線程,Java線程的優先級會被轉換為本地線程的優先級,不同平臺可能不同。在linux上,可以通過“-XX:+UseThreadPriorities”選項啟用此功能。Java線程的優先級只是一個建議,不能覆蓋所有本地優先級(Linux優先級從1到99,線程優先級在-20到20之間)。設置優先級可以影響每個線程獲得的CPU時間,但不建議完全依賴優先級。
進階篇
- 線程本地存儲
ThreadLocal是一個在Thread類之外實現的功能(java.lang.ThreadLocal),為每個線程存儲一份唯一的數據。就像它的名字一樣,它為線程提供了本地存儲,每個線程實例的變量都是唯一的。可以自定義一些屬性,就像它們存儲在Thread線程內部一樣。不過,需要注意一些潛在的問題。
創建ThreadLocal有兩種推薦方式:靜態變量或單例實例中的屬性,這樣可以是非靜態的。它的作用域是全局的,但對訪問它的線程而言是本地的。在下面的例子中,ThreadLocal存儲了一個數據結構,方便訪問:
public static class CriticalData { public int transactionId; public int username; } public static final ThreadLocal<CriticalData> globalData = new ThreadLocal<CriticalData>();
獲取ThreadLocal對象后,可以通過globalData.set()和globalData.get()方法進行操作。
全局變量?這不是好事
確實如此。ThreadLocal可以存儲事務ID,當代碼中出現未捕獲異常時非常有用。最佳實踐是設置一個UncaughtExceptionHandler,這是Thread類本身支持的,但需要自己實現。一旦執行到UncaughtExceptionHandler,之前導致異常的所有變量都無法訪問,因為那些棧幀已經被彈出。唯一能抓住的最后一根稻草就是ThreadLocal。
嘗試這樣做:
System.err.println("Transaction ID " + globalData.get().transactionId);
ThreadLocal還可以分配一塊特定的內存,讓工作線程作為緩存反復使用。需要注意的是,ThreadLocal會造成內存浪費,只要線程還活著,它就會一直存在,除非主動釋放,否則不會被回收。因此,使用時應盡量保持簡單。
- 用戶線程和守護線程
回到Thread類。每個線程都有狀態,要么是用戶狀態,要么是守護狀態,即前臺線程或后臺線程。主線程默認是用戶線程,每個新線程會從創建它的線程中繼承線程狀態。如果將一個線程設置為守護線程,它創建的所有線程也會被標記為守護線程。如果程序中所有線程都是守護線程,進程就會終止。可以通過setDaemon(true)和isDaemon()方法查看和設置線程狀態。
何時使用守護線程?
如果進程不必等待某個線程結束即可終止,那么這個線程可以設置為守護線程。這可以避免正常關閉線程的麻煩,立即結束線程。如果一個執行操作的線程必須正確關閉以避免不良后果,那么它應該是用戶線程。通常是關鍵事務,如數據庫錄入或更新,這些操作不能中斷。
專家級
- 處理器親和性
處理器親和性讓線程或進程綁定到特定的CPU核上,意味著特定線程只在特定CPU核上執行。通常,操作系統的線程調度器會根據自己的邏輯決定如何綁定,可能考慮線程優先級。
這樣做的好處是提高CPU緩存命中率。如果線程只在一個核上運行,數據在緩存中的概率就大大提高。如果數據在CPU緩存中,就不需要從內存中重新加載。這幾毫秒的節省可以用于執行代碼,更好地利用分配的CPU時間。操作系統和硬件架構可能有優化,但處理器親和性至少能減少線程切換CPU的概率。
處理器親和性對吞吐量的影響需要通過測試來驗證。雖然不總是能顯著提升性能,但至少能使吞吐量更穩定。親和策略可以細化到非常細的粒度,這取決于具體需求。高頻交易行業是這一策略最能發揮作用的場景之一。
處理器親和性的測試
Java沒有原生支持處理器親和性,但在Linux上可以通過taskset命令設置進程的親和性。例如,要將Java進程綁定到特定CPU上:
taskset -c 1 "java AboutToBePinned"
如果是一個已運行的進程:
taskset -c 1 <pid>
要深入到線程級別,需要額外的代碼。幸運的是,有一個開源庫可以完成這項工作:Java-Thread-Affinity。這個庫由OpenHFT的Peter Lawrey開發,是實現這一功能的最簡單方式。快速看一下如何綁定某個線程,關于該庫的更多細節,請參考其github文檔:
AffinityLock al = AffinityLock.acquireLock();
關于獲取鎖的更多高級選項,如根據不同策略選擇CPU,github上有詳細說明。
結論
本文介紹了關于線程的5個知識點:線程名稱、線程本地存儲、優先級、守護線程以及處理器親和性。希望這些內容能為你日常工作中的線程使用打開一扇新窗,期待你的反饋!如果你有關于線程處理的其他方法可以分享,歡迎不吝賜教。