要解決多線程併發問題,常見的手段無非就幾種。加鎖,如使用synchronized,ReentrantLock,加鎖可以限制資源只能被一個線程訪問;CAS機制,如AtomicInterger,AtomicBoolean等原子類,通過自旋的方式來嘗試修改資源;還有本次我們要介紹的ThreadLocal類 ...
要解決多線程併發問題,常見的手段無非就幾種。加鎖,如使用synchronized,ReentrantLock,加鎖可以限制資源只能被一個線程訪問;CAS機制,如AtomicInterger,AtomicBoolean等原子類,通過自旋的方式來嘗試修改資源;還有本次我們要介紹的ThreadLocal類,通過為每個線程維護一個變數副本,每個線程都有自己的資源了,自然沒有併發問題。ThreadLocal也是一個高頻面試題,看下如下的問題,是否沒想象中那麼簡單呢,看完這篇文章以後面試再問ThreadLocal就毫無鴨梨了。
- ThreadLocal 作用,原理
- 你在哪些場景使用過ThreadLocal,有什麼註意事項
- ThreadLocalMap的key為什麼設計為弱引用,value為什麼不設置為弱引用
- 如何將父線程的ThreadLocal傳遞給子線程
- 如何將線程的ThreadLocal傳遞給線程池中的線程
- ThreadLocal設計上可以做哪些優化
ThreadLocal原理
ThreadLocal設計上為每個線程維護一份線程私有數據,它可以避免多線程之間共用資源競爭問題,同時可以線上程執行的不同階段傳遞變數。
關於原理主要涉及到3個類,ThreadLocal,Thread,ThreadLocalMap。
ThreadLocal本身只是個“殼”,其操作的都是它的一個內部類ThreadLocalMap,一個類似HashMap的結構,但它不實現Map介面,ThreadLocalMap內部維護了一個Entry數組,存放實際的數據,Entry的key就是ThreadLocal對象本身,value是要存放的值,每次讀寫數據,就是通過TheradLocal對象計算hashcode,定位到數組的下標操作。Entry是一個繼承了WeakReference<ThreadLocal<?>>的類,作為key的ThreadLocal對象會被設置為弱引用。
public class ThreadLocal<T> {
static class ThreadLocalMap {
private Entry[] table;
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
}
Thread線程類內部有一個threadLocals屬性,就是該線程對應的ThreadLocalMap,這個欄位是通過ThreadLocal維護,也就是操作入口都是在ThreadLocal。
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
我們看下ThreadLocal.set方法源碼
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
非常好理解,拿到當前線程,拿到當前線程的ThreadLocalMap,把當前ThreadLocal作為key,和value傳遞給ThreadMap保存。
用一張圖來表示一下三者的關係,如下:
TheradLocal的應用
有時候面試官會問你在哪些場景使用過ThreadLocal,看你到底有沒有真正使用過,記住我如下例子就行啦。(以下代碼都是默寫的偽代碼~)
spring動態數據源
有些時候需要在一次方法內操作不同數據源,這個時候就涉及到多數據源的切換。我們會定義一個AbstractRoutingDataSource用來決定選哪個數據源
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceHolder.getDataSource();
}
}
選哪個數據源是通過從當前線程ThreadLocal獲取
public class DynamicDataSourceHolder {
private static final ThreadLocal<String> threadLocal = new ThreadLocal<String>();
public static String getDataSource() {
return threadLocal.get();
}
public static void setDataSource(String dataSource) {
threadLocal.set(dataSource);
}
public static void clearDataSource() {
threadLocal.remove();
}
}
接著可以定義一個註解和切麵,在方法執行前判斷拿到這個註解標記的數據源,將值設置到ThreadLocal,併在DynamicDataSource決定使用哪個數據時獲取到,實現數據源切換,偽代碼如下:
public @interface DS {
String name();
}
@Component
public class DynamicDataSourceAspect {
@Pointcut("@annotation(com.my.DS)")
public void pointcut() {}
@Before("pointcut()")
public void doBefore(JoinPoint joinPoint) {
DynamicDataSourceHolder.set(dataSourceName);
}
@After("pointcut()")
public void after(JoinPoint point) {
DynamicDataSourceHolder.remove(dataSourceName);
}
}
關於動態數據有興趣的可以看下mybatis plus的dynamic-datasource-spring-boot-starter,原理跟我們上面說的是一樣的。
可靠消息的實現
我們知道資料庫和mq要確保兩者都成功,一種做法就是使用本地消息表,也就是數據落庫的時候同時寫一條待發送的消息,並且將消息id記錄到ThreadLocal,在事務提交完成後,我們可以註冊回調,從本地ThreadLocal拿到消息id,再發送出去。當然,實際還要考慮發送失敗的情況,通過定時任務補償。這就是本地消息表的一種實現思路,ThreadLocal存儲了消息id,在事務提交後,再從ThreadLocal取出來發送消息。
首先說明,如下寫法是不可取的,原因有:1.如果事務commit失敗,mq還是發出去了 2.導致事務時間變長,事務內不宜處理其它耗時邏輯,如發送mq,調用介面等。
@Transactional
public void register() {
//插入數據
User user = new User();
userMapper.insert(user);
//發送消息,處理其它事情
mq.send(topic, user.getId());
}
改寫如下:
@Transactional
public void register() {
//插入數據
User user = new User();
userMapper.insert(user);
UserMsg userMsg = new UserMsg();
userMsgMapper.insert(userMsg);
//不使用整個user對象,只存個id占用記憶體較少,user對象可以及時被回收
threadLocal.set(user.getId);
//註冊回調
transCallbackService.afterCommit(() -> {
mqHandleService.handleUserRegister();
});
}
@Service
class TransCallbackService {
public void afterCommit(Runnable runnable) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
runnable.run();
}
});
}
}
}
@Service
class MqHandleService {
public void handleUserRegister() {
//從threadlocal獲取id,再處理各種事情
mq.send(topic, threadLocal.get());
}
}
弱引用問題
java中對象引用有幾種類型:強引用,弱引用,軟引用和虛引用,它們的區別主要跟gc有關。
強引用:通常我們寫的代碼都是強引用,如:User user = new User(); user就是一個強引用,它指向了記憶體一塊區域,只要user還是可達的,那麼gc就不會回收對應的記憶體。如果user的作用域非常長,而且後面它又沒有用到了,可以將它設置為null,這樣gc可以快點回收對應的記憶體,當然現在jvm比較智能,可以自動分析完成這個事情。還有一個註意事項是,如果對象被如一個全局的HaspMap引用著,那麼即使設置為null或者指向它的變數不可達了,它也不會被回收,如:
User user = new User();
HashMap map = new HashMap(); //被map引用著,map可達就不會被回收
map.put(user, 1);
user = null;
弱引用:如果一個對象只被弱引用對象引用著,那麼它會在下一次gc被回收,弱引用使用WeakReference
User user = new User();
WeakReference weakReference = new WeakReference(user);
user = null;
HashMap hashMap = new HashMap();
hashMap.put(weakReference,1);
System.gc();
當執行完user=null後,其對象記憶體區域就沒有強引用指向它了,只有一個弱引用對象weakReference。接著執行gc,user原本指向的記憶體就會被回收。此時我們執行weakReference.get()將拿到一個null。 從這裡可以看到如果使用弱引用,假設我們忘記從HashMap移除不需要的元素,它也會再下一次gc時被回收,防止記憶體泄漏。
軟引用:在記憶體充足的條件下,不會被回收,只要在記憶體不足時才會被回收。
虛引用:隨時可能被同時,主要用於跟蹤gc,在對象被gc後會收到一個通知。
對於ThreadLocal來說,它裡面的Entry繼承了WeakReference
上面的例子我們剛提到,當你忘記remove的時候,使用弱引用可以防止記憶體泄漏,ThreadLocal也是出於這目的。假設key不是弱引用,開發者忘記remove,那麼key就發生記憶體泄漏,只能等到Thread對象銷毀時才回收,在一些使用線程池的場景下,Thread會一直復用,就會導致記憶體一直回收不了。
public void test() {
inner();
System.gc();
//Thread.currentThread.threadLocals
}
private void inner() {
TestClass testClass = new TestClass();
testClass.set(new User());
}
class TestClass {
ThreadLocal threadLocal = new ThreadLocal();
public void set(Object value) {
threadLocal.set(value);
}
}
//更簡單的例子
public void test() {
ThreadLocal threadLocal = new ThreadLocal();
threadLocal.set(new User());
threadLocal = null;
System.gc();
//Thread.currentThread.threadLocals
}
如上代碼,往ThreadLocal放了一個User對象,此時ThreadLocalMap就維護一個key為threadLocal,value為User的Entry,當inner方法執行完,threadLocal已經不可達,但它的記憶體區域還被Entry引用著,並且沒法再訪問到,如果是強引用,就出現記憶體泄漏。如果是弱引用,在gc後,我們觀察Thread.currentThread.threadLocals就可以發現,它的referent變成了null,被回收了。但作為value的User對象是強引用,不會被回收。到這裡有些面試官就會問,為什麼value不也設置為弱引用呢?
如下代碼:
public void test() {
TestClass testClass = inner();
System.gc();
User user = testClass.get();
}
private void inner() {
User user = new User();
TestClass testClass = new TestClass();
testClass.set(user);
return testClass;
}
class TestClass {
ThreadLocal threadLocal = new ThreadLocal();
public void set(User user) {
threadLocal.set(user);
}
public User get() {
reteurn threadLocal.get();
}
}
這裡我們返回了TestClass,threadLocal對象就還被引用著,我們假設value如果是弱引用,那value在inner方法後就沒有強引用了,gc後會被回收,會後再獲取會拿到一個null,這顯然是不合理的。
說到底,key設置為弱引用是為了防止記憶體泄漏,value不能設置為弱引用是因為如果key還被強引用著,value若是弱引用會被gc回收,下次就拿不到了。
從另一個方面說,開發人員處理的是value,key是java自己幫我們生成的,所以它要負責任,確保不會出現記憶體泄漏問題,而value是開發自己設置的,不需要時要手動remove,不然出現問題就是開發的鍋啦。如果我忘記remove value,value泄漏是我的問題,但不能因此還多了一個key的泄漏,這個開發就不認了,為了避免這種糾纏不清問題,所以java作者將key設置為弱引用。
父線程/線程池傳遞ThreadLocal
如果線上程內,創建一個子線程,子線程還能訪問到父線程的ThreadLocal嗎?答案是不能的,但是從父子繼承的角度來說,有時候需要能,所以Thread內部還有一個inheritableThreadLocals,它也是一個ThreadLocalMap。對應的也有一個InheritableThreadLocal,它繼承了ThreadLocal。
/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
*/
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
在new Therad()創建子線程的時候有如下邏輯
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
this是子線程,parent是父線程,也是當前線程,這裡會判斷父線程是否有inheritableThreadLocals,有就傳遞給子線程。
所以在父子線程場景下,傳遞ThreadLocal可以使用InheritableThreadLocal。
使用InheritableThreadLocal只能在第一次創建時把數據傳遞過去,後面主線程再改子線程也不會變化。對於使用線程池的情況,線程是復用的,如果希望子線程每次執行都能獲取到主線程的ThreadLocal值,InheritableThreadLocal也無能為力了。例如日誌跟蹤traceid,每次執行主線程都會生成一個traceid,線程值每次執行,也都應該拿到最新的traceid,這樣才能鏈路才能一致。
實現思路是自定義一個TtlRunnable繼承Runnable,在執行run方法前,拷貝一下當前線程的值,在runnable.run執行前,將父線程的值拷貝到當前線程,這樣每次執行都會做一次拷貝。
public class TtlRunnable implements Runnable {
private Runnable runnable;
private HashMap<ThreadLocal, Object> ttlThreadLocals;
public TtlRunnable(Runnable runnable) {
this.runnable = runnable;
//將當前線程的ThreadLocal拷貝一份
ttlThreadLocals = copyCurrentThreadLocals();
}
@Override
public void run() {
//將父線程ThreadLocal拷貝到當前子線程
copyParentThreadLocal2Current(ttlThreadLocals);
runnable.run();
}
}
上面只是簡單的實現思路,像spring cloud sleuth在處理traceid時思想也是類似的,當然實際還有很多東西要考慮,不過我們不用自己實現,阿裡有一個TransmmittableThreadLocal可以直接使用,參見:transmittable-thread-local。
ThreadLocal可以做哪些優化
能問到這裡證明離offer已經不遠了,基本很多面試官也不會問到這個層面。
回到ThreadLocal原理部分,它實際操作的是ThreadLocalMap,通過當前ThreadLocal的hashcode,計算Entry數組的下標,這個hashcode是new ThreadLocal()時通過一個全局的AtomicInteger累加0x61c88647得到。
跟hashmap的原理類似,通過hashcode計算下標,可能會出現hash衝突,hashmap使用鏈表+紅黑樹的方式解決hash衝突。而ThreadLocal使用線性探測法解決。
線性探測法的做法是,當出現hash衝突時,探測下一個位置,看看是否可以放入,可以就放入,否則繼續往下一個位置探測。問題就出現在這裡,當出現較多hash衝突時,相當於鏈表的遍歷不斷的探測,效率較低,可能ThreadLocal的作者認為ThreadLocal的設計上它不會存放太多數據吧。
那怎麼優化呢?既然出現hash衝突影響效率,那乾脆就不處理了,使用一個遞增為1的AtomicInteger,每個ThreadLocal對應一個下標,這樣就不會有衝突了,O(1)的查詢速度,但是會占用較多空間,是一種空間換時間的思想。
實際這種做法就是netty中FastThreadLocal的實現,netty中提供了FastThreadLocal,FastThreadLocalMap,InternalThreadLocalMap,它們需要搭配使用,否則會退化為jdk的ThreadLocal。
每個FastThreadLocal都有一個遞增唯一的index,放入InternalThreadLocalMap時不會有衝突,查詢效率也高。通過index直接定位到下標,不需要hash,在擴容的時候直接搬到新數組對應下標,也不需要rehash,擴容速度快。同時由於不會出現衝突,所以不需要保持ThreadLocal的引用,也就沒有上面弱引用和記憶體泄漏的問題。
通過netty的FastThreadLocal來回答這個問題,有理有據,有興趣的可以去研究一下它的源碼。
更多分享,歡迎關註我的github:https://github.com/jmilktea/jtea