面試再也不怕問ThreadLocal了

来源:https://www.cnblogs.com/jtea/archive/2023/08/01/17595983.html
-Advertisement-
Play Games

要解決多線程併發問題,常見的手段無非就幾種。加鎖,如使用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,會把key也就是ThreadLocal對象設置為弱引用。那為什麼要這麼做呢?
上面的例子我們剛提到,當你忘記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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • ## 嵌入式伺服器 Spring Boot 的嵌入式伺服器功能是一項方便而強大的功能,它允許你在應用程式中直接運行 Web 伺服器,無需將其部署到單獨的獨立 Web 伺服器中。這使得開發、測試和部署 Web 應用程式變得容易,而且它還是輕量級的、易於啟動和停止的,易於配置。 ## Hibernate ...
  • **原文鏈接:** [使用 Go 語言實現二叉搜索樹](https://mp.weixin.qq.com/s/2wYRmG_AiiHYjLDEXg94Ag) 二叉樹是一種常見並且非常重要的數據結構,在很多項目中都能看到二叉樹的身影。 它有很多變種,比如紅黑樹,常被用作 `std::map` 和 `s ...
  • ### 你可以按照以下步驟製作自己的Python模塊: 1.創建一個新的.py文件,並定義你自己的函數或類。 2.編寫文檔字元串docstring,說明該函數或類的作用、參數和返回值說明等。 3.給你的函數或類添加恰當的註釋。 4.將該.py文件放在工程目錄的一個新文件夾中,這個文件夾就是你的模塊。 ...
  • ## 概述 Mybatis 的核心組件如下所示: - Configuration:用於描述 MyBatis 的主配置信息,其他組件需要獲取配置信息時,直接通過 Configuration 對象獲取。除此之外,MyBatis 在應用啟動時,將 Mapper 配置信息、類型別名、TypeHandler ...
  • 大家好,我是棧長。 經過 Spring Cloud Alibaba 2022 的第一個候選版本 2022.0.0.0-RC1 發佈 7 個多月後,中間還有一個 2022.0.0.0-RC2 版本,就在前幾天,**Spring Cloud Alibaba 2022.0.0.0 正式版** 終於正式發佈 ...
  • 在 Protocol Buffers (protobuf) 中,可以使用特定的選項來指定生成的 JSON 標簽。通過在消息定義中使用 `[(json_name)]` 選項,可以控制生成的 JSON 欄位名稱。這樣可以確保 Protocol Buffers 和 JSON 之間的互操作性。 下麵是一個示 ...
  • ## 一、問題是怎麼發現的 最近有個新系統開發完成後要上線,由於系統調用量很大,所以先對核心介面進行了一次壓力測試,由於核心介面中基本上只有純記憶體運算,所以預估核心介面的壓測QPS能夠達到上千。 壓測容器配置:4C8G 先從10個併發開始進行發壓,結果cpu一下就飆升到了100%,但是核心介面的qp ...
  • 編程基礎常識 一、註釋 1、對代碼的說明與解釋,它不會被編譯執行,也不會顯示在編譯結果中 2、註釋分為:單行註釋和多行註釋 3、用#號開始,例如:#這是我的第一個python程式 4、註釋可以寫在單獨一行,也可以寫在一句代碼後面 5、不想執行編譯,又不能刪除的代碼,可以先用#註釋掉,代碼批量註釋用C ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...