在執行緒並行的應用程式中,如何做資料共享一直是個重要且棘手的議題。Java 平台長期以來使用 ThreadLocal
來實現執行緒內的資料共享,但是這種方式存在著一些缺陷。為了解決這些問題,Java 引入了一個新的功能:JEP 481 Scoped Values(範圍值)。
本文將探討 Scoped Values 範圍值的核心概念、設計動機、實現方式,以及與 ThreadLocal
相比的優勢。我們將通過實際的程式碼範例,展示如何實際運用這個新功能,以及它如何能夠改變我們的程式設計思維。
目錄
前言
ThreadLocal
(執行緒局部變數)在 Java 1.2 時引入,提供了一種在執行緒內不同方法之間共享資料的便捷方式。然而,隨著時間的推移,其設計缺陷慢慢地顯現出來,例如:記憶體洩漏、記憶體開銷、變數值可以被修改等等。這些缺點導致雖然它在某些情況下很有用,但是執行時會造成異常拋出或未預期的錯誤。開發者需要仔細考慮這些缺點,並根據具體情況選擇更合適的資料共享機制。
ThreadLocal
的缺點
首先,ThreadLocal
變數預設具有不受限制的可變性,這代表著任何可以訪問它的程式碼都可以在任何時候修改它的值。允許這樣做的目的是為了支援完全通用的通信模型,其中資料可以在方法之間任意方向的流動。這可能導致先後資料不一致、難以追蹤的錯誤、難以除錯的資料流動,尤其是在多執行緒環境中增加了程式的複雜性和錯誤風險,讓程式難以辨別哪個方法更新了共享狀態以及以什麼順序更新。
其次,ThreadLocal
的生命週期和該執行緒綁定。一旦通過 set
方法設置後,它的值會在執行緒的整個生命週期內維持著,如果沒有手動移除(remove
)的話,就會一直存在於執行緒的記憶體中,直到該執行緒結束。不幸的是,開發人員經常忘記移除,因此每個執行緒中的資料通常保留了更長的不必要時間。這容易導致記憶體洩漏,特別是在使用執行緒池的情況下;當執行緒被重複使用時,某個任務中設置的值可能會意外地洩漏到不相關的任務中,也造成潛在的數據錯誤或安全漏洞。
另外,每個執行緒都會持有 ThreadLocal
的獨立副本。如果使用了大量執行緒,並且每個執行緒都持有大量的 ThreadLocal
變數時,可能會導致顯著的記憶體開銷。如果使用 InheritableThreadLocal
繼承機制的話,那麼在子執行緒創建時會複製父執行緒中先前寫入的每個 ThreadLocal
變數並持有獨立副本,造成額外的記憶體和時間開銷。子執行緒無法共享父執行緒使用的儲存空間,而且在實際情況下子執行緒有可能不會使用到所有繼承而來的變數,或是很少呼叫 set
方法去修改它的值,但卻不得不繼承而使得整體的效率低下。
最後,隨著虛擬執行緒(Virtual Threads)的出現,ThreadLocal
變數的效能問題變得更加突顯。虛擬執行緒是由 JDK 實現的輕量級執行緒,允許多個虛擬執行緒去共享同一個作業系統執行緒。除了數量眾多之外,虛擬執行緒還足夠便宜,可以實現任何並行的行為單元。當我們建立大量的虛擬執行緒時,每個虛擬執行緒都需要為每個 ThreadLocal
變數分配儲存空間,這可能會非常顯著地大幅增加記憶體的使用量。
基於這些問題,Java 平台需要一種新的機制來實現更安全、更高效的資料共享,而這就是 Scoped Values 範圍值被提出來的原因。
JEP 481 概觀
JEP 481 範圍值是一種容器物件。它提供的機制讓我們能安全高效地在執行緒以及其子執行緒內共享不可變的資料,而無需增加方法的參數數量去傳遞資料。比起 ThreadLocal
變數來說,ScopedValue
更容易理解,並且具有更低的空間和時間成本,尤其是和虛擬執行緒(JEP 444 Virtual Threads)和結構化並行(JEP 480 Structured Concurrency)一起使用時。
它主要用於單向資料傳輸,不適用於需要雙向溝通的場合。因此,在遷移現有程式碼時,需要仔細評估是否適合使用 ScopedValue
。另外,在使用 ScopedValue
時,需要注意生命週期以確保在正確的範圍內使用。
- 不可變性:一旦綁定,範圍值的綁定值在其作用域內不能被修改
- 有邊界的生命週期:範圍值的綁定值只在
runWhere
方法執行期間有效 - 高效的執行緒間繼承:子執行緒可以高效地繼承父執行緒的範圍值
優點
- 提供了比
ThreadLocal
更簡單、更直觀的方式來共享資料,並且其生命週期能夠明確地經由程式碼結構中理解 - 提高了程式碼的可讀性和可維護性,並確保了資料共享的安全性,也使資料流更加清晰
- 提供了更好的效能,特別是在與虛擬執行緒和結構化並行結合使用時,可節省記憶體空間和執行時間的成本
- 自動清理資源,避免記憶體洩漏
缺點
- 僅適用於不可變資料的共享,對於需要共享可變資料的場景,仍然需要使用
ThreadLocal
變數 - 可能需要重構現有的使用
ThreadLocal
的程式碼 - 對於不熟悉此概念的開發者可能有一定的學習曲線
介紹
使用方式
如前所述,JEP 481 範圍值是一種容器物件,允許方法安全高效地與同一執行緒內的直接和間接被呼叫者,以及子執行緒共享不可變資料。如同 ThreadLocal
一樣,範圍值通常被宣告為 static final
欄位,其可訪問性被設置為 private
,使得其他類別中的程式碼無法直接存取它。
範圍值的使用方法如下:
- 宣告
ScopedValue
變數,它通常是private static final
- 綁定參數到範圍值中並執行。
ScopedValue.runWhere
有三個參數:- 範圍值
key
,本例中為NAME
- 欲綁定的物件
value
,本例中為"Hello, World!"
- 欲執行的作用域
op
,本例中為呼叫invokeGet()
的 Lambda 表示式
- 範圍值
- 在需要的時候呼叫
get()
取值
程式碼中呼叫 runWhere
方法後,會綁定範圍值與特定物件到當前執行緒的副本中,然後執行作為參數傳遞的作用域 Lambda 表示式。在 runWhere
方法的生命週期內,Lambda 表示式或是從該表示式直接或間接呼叫的任何方法,都可以透過範圍值的 get
方法讀取範圍值。一旦 runWhere
方法結束後,綁定將被銷毀。
private static final ScopedValue<String> NAME = ScopedValue.newInstance();
void invokeGet() {
// 取值
System.out.println(NAME.get());
}
void main() {
// 綁定參數並執行,並且 invokeGet() 範圍中才能呼叫 get()
ScopedValue.runWhere(NAME, "Hello, World!", () -> invokeGet());
}
從程式碼的結構中,我們可以輕易地了解該執行緒能夠讀取其範圍值副本的時間區段。這種有邊界的生命週期極大地簡化了對執行緒行為的推理,因為從呼叫者到被呼叫者(直接和間接)的單向資料傳輸過程一目了然,並且沒有 set
方法能隨時更改範圍值的綁定物件。這有助於提高性能:無論呼叫者和被呼叫者之間的堆疊距離如何,使用 get
讀取範圍值通常與讀取局部變數一樣快,並且也不需要額外記憶體空間去儲存綁定物件的副本。
「範圍」的含義
事物的範圍指的是它所存在的空間,亦即可以使用它的區域。例如,在 Java 程式語言中,變數宣告的範圍就是在程式碼中可以合法地引用該變數的區域。更準確地來說,我們稱此區域為詞法作用域或靜態作用域,而這個作用域可以透過在程式文本中使用 {}
字元來規範。
另一種範圍稱為動態範圍,它是指執行時期中可以使用該事物的程式碼部分。如果方法 a 呼叫方法 b,而方法 b 又呼叫方法 c,那麼 c 的執行生命週期包含在 b 的執行中,而 b 的執行又包含在 a 的執行中,即使這三個方法是不同的程式碼單元:
|
| +–– a
| | +–– b
TIME | | +–– c
| | | |__
| | |__
| |__
|
v
這就是「範圍值」的概念,在 runWhere
方法中所綁定的範圍值可以由 runWhere
直接或間接呼叫的方法去存取。這些方法的展開執行定義了一個動態範圍的作用域;意即範圍值的綁定物件在這些方法的執行期間內可以存取,而在其他任何地方都無效。
一般使用範例
下例範例以範圍值的基本要素(建立、綁定執行、取值)為基礎,再加上動態範圍的延伸,寫出更複雜一點的程式碼(本範例使用了新的預覽功能,細節請參考隱式宣告類別的介紹):
// ScopedValue1.java
private static final ScopedValue<String> NAME = ScopedValue.newInstance();
void main() { // 程式進入點
// println("main before: " + NAME.get()); // Error: java.util.NoSuchElementException
ScopedValue.runWhere(NAME, "Hello, World!", () -> invokeA());
println("main after: " + NAME.get()); // Error: java.util.NoSuchElementException
}
void invokeA() {
println("invokeA(): " + NAME.get());
invokeB();
}
void invokeB() {
println("invokeB(): " + NAME.get());
}
我們讓 invokeA
方法去呼叫 invokeB
方法,這邊可以看到 invokeB
能夠讀取範圍值。另外,我們也試圖在 main
方法中嘗試去讀取範圍值中的綁定物件。但因為實際上的範圍作用域是在 invokeA
和 invokeB
中,所以若在範圍外的 main
方法中讀取的話會拋出 NoSuchElementException
異常。
invokeA(): Hello, World!
invokeB(): Hello, World!
Exception in thread "main" java.util.NoSuchElementException
at java.base/java.lang.ScopedValue.slowGet(ScopedValue.java:645)
at java.base/java.lang.ScopedValue.get(ScopedValue.java:638)
at ScopedValue1.main(ScopedValue1.java:6)
重新綁定範圍值
如果我們在 invokeA
方法中重新綁定範圍值和作用域的話,不會影響原本的作用域:
// ScopedValue2.java
private static final ScopedValue<String> NAME = ScopedValue.newInstance();
void main() {
ScopedValue.runWhere(NAME, "Hello, World!", () -> invokeA()); // (1)
}
void invokeA() {
println("invokeA()1: " + NAME.get()); // Hello, World! (2)
ScopedValue.runWhere(NAME, "Hi!", () -> invokeC()); // (3) 重新綁定
println("invokeA()2: " + NAME.get()); // Hello, World! (4)
invokeB();
invokeC(); // 直接呼叫
}
void invokeB() {
println("invokeB(): " + NAME.get()); // Hello, World!
}
void invokeC() {
println("invokeC(): " + NAME.get()); // Hi! or Hello, World! (5)
}
可以看到原本 invokeA
和 invokeB
方法中的範圍值不會改變,只有重新綁定作用域 Lambda 裡 invokeC
中的值才會修改,甚至是第 13 行中 invokeA
直接呼叫 invokeC
方法時拿到的範圍值也不會改變。
invokeA()1: Hello, World!
invokeC(): Hi!
invokeA()2: Hello, World!
invokeB(): Hello, World!
invokeC(): Hello, World!
原理分析
上述程式碼中,每一次執行 runWhere
方法時都會產生新的 Snapshot
物件。它會去記錄所綁定的值,以及 prev
欄位指向上一次所產生的 Snapshot
物件。另外,在 Thread
類別中則新增了 scopedValueBindings
欄位去記錄當前執行緒的 Snapshot
物件。整個過程如下:
- 執行
main
方法中的 (1) 時,因為呼叫到runWhere
方法,所以建立Snapshot
物件一號:- 綁定的值設為
"Hello, World!"
prev
欄位設為null
Snapshot
物件一號被指定給當前執行緒的scopedValueBindings
欄位- 完成
Snapshot
物件一號的創建後,執行invokeA
方法
- 綁定的值設為
- 在
invokeA
方法中的 (2) 對NAME
的操作會使用當前執行緒的scopedValueBindings
欄位,也就是Snapshot
物件一號的"Hello, World!"
- 執行
invokeA
方法中的 (3) ,因為呼叫到runWhere
方法,所以建立Snapshot
物件二號:- 綁定的值設為
"Hi!"
prev
欄位設為Snapshot
物件一號Snapshot
物件二號被指定給當前執行緒的scopedValueBindings
欄位- 完成
Snapshot
物件二號的創建後,執行invokeC
方法
- 綁定的值設為
- 在本次的
invokeC
方法中 (5),對NAME
的操作會使用當前執行緒的scopedValueBindings
欄位,也就是Snapshot
物件二號的"Hi!"
- 離開
invokeC
方法時,runWhere
方法將當前執行緒的scopedValueBindings
欄位(Snapshot
物件二號)指定回其prev
欄位(Snapshot
物件一號) - 接著執行
invokeA
方法中的 (4) ,後續對NAME
直接或間接的操作都會使用當前執行緒的scopedValueBindings
欄位,也就是Snapshot
物件一號的"Hello, World!"
- 因為已經沒有物件引用
Snapshot
物件二號,所以它會被垃圾收集器回收 Snapshot
物件一號在invokeA
方法結束執行後,因為沒有被引用而被垃圾收集器回收
也就是說,在 invokeA
方法中,除了 (2) runWhere
重新綁定後所呼叫的 invokeC
方法外,其他對 NAME
直接或間接呼叫的操作都會使用 Snapshot
物件一號所綁定的值 "Hello, World!"
。並且我們不需要手動移除綁定值,範圍值的機制使得它們會自動被垃圾收集器回收。
Web 框架範例
讓我們看一個更複雜的例子,展示範圍值如何在 Web 框架中使用:
class Framework {
private final static ScopedValue<FrameworkContext> CONTEXT
= ScopedValue.newInstance();
void serve(Request request, Response response) {
var context = createContext(request);
ScopedValue.runWhere(CONTEXT, context,
() -> Application.handle(request, response)); // (1)
}
public PersistedObject readKey(String key) {
var context = CONTEXT.get(); // (2)
var db = getDBConnection(context);
return db.readKey(key);
}
}
在這個例子中,CONTEXT
被安全地從 serve 方法傳遞到 readKey 方法,而不需要顯式地作為參數傳遞。這大大簡化了程式碼結構,同時保證了資料的安全性。
runWhere
方法提供從 serve
方法到 readKey
方法的單向資料共享。傳遞給 runWhere
的範圍值在其 Lambda 作用域期間被綁定到相應的物件,因此 Lambda 作用域中的所有方法都能夠用 CONTEXT.get()
去讀取該值。因此,當 Framework.serve
中綁定 handle
方法,並且 handle
方法裡直接或間接呼叫 Framework.readKey
時,從範圍值 (2) 讀取的值是前面 Framework.serve
在執行緒中寫入的值 (1)。
繼承範圍值
上述範例中的每個請求都由一個專門的執行緒處理,因此同一個執行緒:
- 首先會執行一些框架代碼
- 接著是用戶的程式碼
- 然後再執行更多框架代碼來訪問資料庫
為了提升效能,用戶程式碼可以創建大量的輕量級虛擬執行緒,而這些虛擬執行緒將成為請求處理執行緒的子執行緒。在請求處理執行緒中所共享的數據需要對子執行緒中的用戶程式碼可用。否則,當子執行緒中的用戶程式碼呼叫框架方法時,它將無法存取在請求處理執行緒中所創建的 FrameworkContext
。為了實現跨執行緒共享,範圍值可以由子執行緒繼承。
創建虛擬執行緒的首選機制是結構化並行 API,特別是 StructuredTaskScope
類別。父執行緒中的範圍值會自動被使用 StructuredTaskScope
創建的子執行緒繼承。子執行緒可以使用父執行緒中建立的範圍值綁定,並且與執行緒局部變數不同,父執行緒的範圍值綁定不會複製到子執行緒。
下面是一個範圍值繼承在用戶程式碼中的範例。上述例子中的 serve
方法綁定 CONTEXT
並呼叫 Application.handle
,但是 handle
中使用 StructuredTaskScope.fork
並行跑 readUserInfo
和 fetchOffers
方法。每個方法都在自己的虛擬執行緒中執行(1,2),並且每個方法都可以使用 Framework.readKey
並讀取範圍值 CONTEXT
。
@Override
public Response handle(Request request, Response response) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<UserInfo> user = scope.fork(() -> readUserInfo()); // (1)
Supplier<List<Offer>> offers = scope.fork(() -> fetchOffers()); // (2)
scope.join().throwIfFailed(); // Wait for both forks
return new Response(user.get(), order.get());
} catch (Exception ex) {
reportError(response, ex);
}
}
總結
JEP 481 Scoped Values 範圍值的引入無疑是 Java 平台的一大進步。它解決了 ThreadLocal
的諸多問題,並為開發者提供了一種更安全、更高效的不可變資料共享機制。特別是在當前虛擬執行緒和結構化並行程式設計日益重要的背景下,範圍值的價值更加凸顯。
然而,正如所有新技術一樣,範圍值並非萬能的解決方案。開發者需要根據具體的應用場景,權衡使用範圍值的優缺點。隨著時間的推移和更多實際應用經驗的積累,相信範圍值將成為 Java 開發者工具箱中的重要工具,可以建立更加健壯、高效的 Java 應用程式並提供有力支援。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!