Java 20 中的作用域值
一、概述
作用域值使開發人員能夠在線程內和線程間存儲和共享不可變數據。這個新的 API 在 Java 20 中作為JEP 439中提出的孵化器預覽功能引入。
在本教程中,我們將首先比較作用域值與線程局部變量,這是一種服務於類似目的的舊 API。然後,我們將研究應用作用域值在線程之間共享數據、重新綁定值以及在子線程中繼承它們。接下來,我們將了解如何在經典 Web 框架中應用範圍值。
最後,我們將了解如何在 Java 20 中啟用此孵化器功能以進行試驗。
2.動機
複雜的 Java 應用程序通常包含多個需要在它們之間共享數據的模塊和組件。當這些組件在多個線程中運行時,開發人員需要一種在它們之間共享不可變數據的方法。
但是,不同的線程可能需要不同的數據,並且不應該能夠訪問或覆蓋其他線程擁有的數據。
2.1.線程本地
從 Java 1.2 開始,我們可以利用線程局部變量在組件之間共享數據,而無需求助於方法參數。線程局部變量只是一個特殊類型ThreadLocal
的變量。
儘管它們看起來像普通變量,但線程局部變量有多個化身,每個線程一個。將使用的特定化身取決於哪個線程調用 getter 或 setter 方法來讀取或寫入其值。
線程局部變量通常被聲明為公共靜態字段,因此它們可以很容易地被許多組件訪問。
2.2.缺點
儘管線程局部變量自 1998 年以來就可用,但該API 包含三個主要的設計缺陷。
首先,每個線程局部變量都是可變的,並且允許任何代碼隨時調用 setter 方法。因此,數據可以在組件之間以任何方向流動,這使得很難理解哪個組件更新共享狀態以及以什麼順序更新。
其次,當我們使用set
方法寫入線程的化身時,數據會在線程的整個生命週期內保留,或者直到線程調用remove
方法。萬一開發人員忘記調用remove
方法,數據將在內存中保留的時間超過必要的時間。
最後,父線程的線程局部變量可以被子線程繼承。當我們創建繼承線程局部變量的子線程時,新線程將需要為所有父線程局部變量分配額外的存儲空間。
2.3.虛擬線程
隨著 Java 19 中虛擬線程的可用性,線程局部變量的缺點變得更加緊迫。
虛擬線程是由 JDK 而不是操作系統管理的輕量級線程。因此,許多虛擬線程共享同一個操作系統線程,允許開發人員使用大量虛擬線程。
由於虛擬線程是Thread
的實例,因此它們可以使用線程局部變量。但是,如果數百萬個虛擬線程具有可變的線程局部變量,則內存佔用量可能會很大。
因此,Java 20 引入了作用域值 API 作為一種解決方案,以維護為支持數百萬個虛擬線程而構建的不可變和可繼承的每線程數據。
3.範圍值
作用域值可以在組件之間安全高效地共享不可變數據,而無需求助於方法參數。作為 Loom 項目的一部分,它們與虛擬線程和結構化並發一起開發。
3.1.在線程之間共享數據
與線程局部變量類似,作用域值使用多個化身,每個線程一個。此外,它們通常被聲明為公共靜態字段,可以很容易地被許多組件訪問:
public final static ScopedValue<User> LOGGED_IN_USER = ScopedValue.newInstance();
另一方面,作用域值被寫入一次,然後是不可變的。範圍值僅在線程執行的有限時間內可用:
ScopedValue.where(LOGGED_IN_USER, user.get()).run(
() -> service.getData()
);
where
方法需要一個作用域值和一個它應該綁定到的對象。當調用run
方法時,作用域值被綁定,創建一個對當前線程唯一的化身,然後執行 lambda 表達式。
在run
方法的生命週期內,任何方法,無論是直接還是間接從表達式調用,都能夠讀取範圍內的值。但是,當run
方法完成時,綁定將被銷毀。
作用域變量的有限生命週期和不變性有助於簡化關於線程行為的推理。不變性有助於確保更好的性能,並且數據僅以一種方式傳輸:從調用者到被調用者。
3.2.繼承作用域值
使用StructuredTaskScope
創建的所有子線程自動繼承作用域值。子線程可以使用為父線程中的作用域值建立的綁定:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<Optional<Data>> internalData = scope.fork(
() -> internalService.getData(request)
);
Future<String> externalData = scope.fork(externalService::getData);
try {
scope.join();
scope.throwIfFailed();
Optional<Data> data = internalData.resultNow();
// Return data in the response and set proper HTTP status
} catch (InterruptedException | ExecutionException | IOException e) {
response.setStatus(500);
}
}
在這種情況下,我們仍然可以從通過fork
方法創建的子線程中運行的服務訪問範圍內的值。但是,與線程局部變量不同,不會將作用域值從父線程複製到子線程。
3.3.重新綁定作用域值
由於作用域值是不可變的,因此它們不支持用於更改存儲值的set
方法。但是,我們可以為有限代碼段的調用重新綁定一個作用域值。
例如,我們可以使用where
方法通過將其設置為null
來隱藏run
中調用的方法的作用域值:
ScopedValue.where(Server.LOGGED_IN_USER, null).run(service::extractData);
但是,一旦該代碼段終止,原始值將再次可用。我們應該注意到run
方法的返回類型是 void。如果我們的服務正在返回一個值,我們可以使用call
方法來啟用對返回值的處理。
4. 網頁範例
現在讓我們看一個實際示例,說明我們如何在經典 Web 框架用例中應用作用域值來共享登錄用戶的數據。
4.1.經典網絡框架
Web 服務器根據傳入請求對用戶進行身份驗證,並使已登錄用戶的數據可用於處理請求的代碼:
public void serve(HttpServletRequest request, HttpServletResponse response) throws InterruptedException, ExecutionException {
Optional<User> user = authenticateUser(request);
if (user.isPresent()) {
Future<?> future = executor.submit(() ->
controller.processRequest(request, response, user.get())
);
future.get();
} else {
response.setStatus(401);
}
}
處理請求的控制器通過方法參數接收登錄用戶的數據:
public void processRequest(HttpServletRequest request, HttpServletResponse response, User loggedInUser) {
Optional<Data> data = service.getData(request, loggedInUser);
// Return data in the response and set proper HTTP status
}
服務還從控制器接收登錄用戶的數據,但不使用它。相反,它只是將信息傳遞給存儲庫:
public Optional<Data> getData(HttpServletRequest request, User loggedInUser) {
String id = request.getParameter("data_id");
return repository.getData(id, loggedInUser);
}
在存儲庫中,我們最終使用登錄用戶的數據來檢查用戶是否有足夠的權限:
public Optional<Data> getData(String id, User loggedInUser) {
return loggedInUser.isAdmin()
? Optional.of(new Data(id, "Title 1", "Description 1"))
: Optional.empty();
}
在更複雜的 Web 應用程序中,請求處理可以擴展到大量方法。儘管登錄用戶的數據可能只在少數幾種方法中需要,但我們可能需要將其傳遞給所有這些方法。
使用方法參數傳遞信息會使我們的代碼產生噪音,我們很快就會超過每個方法推薦的三個參數。
4.2.應用範圍值
另一種方法是將登錄用戶的數據存儲在可以從任何方法訪問的作用域值中:
public void serve(HttpServletRequest request, HttpServletResponse response) {
Optional<User> user = authenticateUser(request);
if (user.isPresent()) {
ScopedValue.where(LOGGED_IN_USER, user.get())
.run(() -> controller.processRequest(request, response));
} else {
response.setStatus(401);
}
}
我們現在可以從所有方法中刪除loggedInUser
參數:
public void processRequest(HttpServletRequest request, HttpServletResponse response) {
Optional<Data> data = internalService.getData(request);
// Return data in the response and set proper HTTP status
}
我們的服務不必關心將登錄用戶的數據傳遞到存儲庫:
public Optional<Data> getData(HttpServletRequest request) {
String id = request.getParameter("data_id");
return repository.getData(id);
}
相反,存儲庫可以通過調用作用域值的get
方法來檢索登錄用戶的數據:
public Optional<Data> getData(String id) {
User loggedInUser = Server.LOGGED_IN_USER.get();
return loggedInUser.isAdmin()
? Optional.of(new Data(id, "Title 1", "Description 1"))
: Optional.empty();
}
在此示例中,應用範圍變量可確保我們的代碼更具可讀性和可維護性。
4.3.運行孵化器預覽
要運行上面的示例並在 Java 20 中試驗作用域值,我們需要啟用預覽功能並添加並發孵化器模塊:
$ javac --enable-preview -source 20 --add-modules jdk.incubator.concurrent *.java
$ java --enable-preview --add-modules jdk.incubator.concurrent Server.class
通過向編譯器和 surefire 插件添加相同的兩個參數,可以使用 Maven 實現相同的目的:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>20</source>
<target>20</target>
<compilerArgs>
<arg>--enable-preview</arg>
<arg>--add-modules=jdk.incubator.concurrent</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>--enable-preview --add-modules=jdk.incubator.concurrent</argLine>
</configuration>
</plugin>
5.結論
在本文中,我們探討了作用域值,這是 Java 20 的孵化器預覽功能。我們將作用域值與線程局部變量進行了比較,並解釋了創建新 API 以在線程內和線程間共享不可變數據的動機。
我們探討瞭如何使用作用域值在線程之間共享數據、重新綁定它們的值以及在子線程中繼承它們。然後,我們看到瞭如何在經典的 Web 框架示例中應用作用域值來共享登錄用戶的數據。最後,我們看到啟用孵化器預覽以在 Java 20 中試驗作用域值。
一如既往,完整的源代碼可在 GitHub 上獲得。