使用 Lambda 進行延遲字段初始化
一、簡介
通常,當我們使用需要執行昂貴或緩慢方法的資源(例如資料庫查詢或 REST 呼叫)時,我們傾向於使用本地快取或私有欄位。一般來說,lambda 函數允許我們使用方法作為參數並推遲方法的執行或完全省略它。
在本教程中,我們將展示使用 lambda 函數延遲初始化欄位的不同方法。
2. Lambda 替換
讓我們實現我們自己的解決方案的第一個版本。作為第一次迭代,我們將提供LambdaSupplier
類別:
public class LambdaSupplier<T> {
protected final Supplier<T> expensiveData;
public LambdaSupplier(Supplier<T> expensiveData) {
this.expensiveData = expensiveData;
}
public T getData() {
return expensiveData.get();
}
}
LambdaSupplier
透過延遲的Supplier.get()
執行實作欄位的延遲初始化。如果多次呼叫getData()
方法,則Supplier.get()
方法也會被多次呼叫。因此,此類的行為與Supplier
介面完全相同。每次呼叫getData()
方法時都會執行底層方法。
為了展示這種行為,讓我們來寫一個單元測試:
@Test
public void whenCalledMultipleTimes_thenShouldBeCalledMultipleTimes() {
@SuppressWarnings("unchecked") Supplier<String> mockedExpensiveFunction = Mockito.mock(Supplier.class);
Mockito.when(mockedExpensiveFunction.get())
.thenReturn("expensive call");
LambdaSupplier<String> testee = new LambdaSupplier<>(mockedExpensiveFunction);
Mockito.verify(mockedExpensiveFunction, Mockito.never())
.get();
testee.getData();
testee.getData();
Mockito.verify(mockedExpensiveFunction, Mockito.times(2))
.get();
}
正如預期的那樣,我們的測試案例驗證了Supplier.get()
函數被呼叫了兩次。
3. 懶惰的供應商
由於LambdaSupplier
無法緩解多次呼叫問題,因此我們實現的下一步發展旨在保證昂貴方法的單一執行。 LazyLambdaSupplier
透過將回傳值快取到私有欄位來擴展LambdaSupplier
的實作:
public class LazyLambdaSupplier<T> extends LambdaSupplier<T> {
private T data;
public LazyLambdaSupplier(Supplier<T> expensiveData) {
super(expensiveData);
}
@Override
public T getData() {
if (data != null) {
return data;
}
return data = expensiveData.get();
}
}
此實作將傳回的值儲存到私有欄位data
中,以便該值可以在連續呼叫中重複使用。
以下測試案例驗證新實作在順序呼叫時不會進行多次呼叫:
@Test
public void whenCalledMultipleTimes_thenShouldBeCalledOnlyOnce() {
@SuppressWarnings("unchecked") Supplier<String> mockedExpensiveFunction = Mockito.mock(Supplier.class);
Mockito.when(mockedExpensiveFunction.get())
.thenReturn("expensive call");
LazyLambdaSupplier<String> testee = new LazyLambdaSupplier<>(mockedExpensiveFunction);
Mockito.verify(mockedExpensiveFunction, Mockito.never())
.get();
testee.getData();
testee.getData();
Mockito.verify(mockedExpensiveFunction, Mockito.times(1))
.get();
}
本質上,這個測試案例的模板和我們之前的測試案例是一樣的。重要的差異在於,在第二種情況下,我們驗證模擬函數只被呼叫一次。
為了表明該解決方案不是線程安全的,讓我們編寫一個並發執行的測試案例:
@Test
public void whenCalledMultipleTimesConcurrently_thenShouldBeCalledMultipleTimes() throws InterruptedException {
@SuppressWarnings("unchecked") Supplier mockedExpensiveFunction = Mockito.mock(Supplier.class);
Mockito.when(mockedExpensiveFunction.get())
.thenAnswer((Answer) invocation -> {
Thread.sleep(1000L);
return "Late response!";
});
LazyLambdaSupplier testee = new LazyLambdaSupplier<>(mockedExpensiveFunction);
Mockito.verify(mockedExpensiveFunction, Mockito.never())
.get();
ExecutorService executorService = Executors.newFixedThreadPool(4);
executorService.invokeAll(List.of(testee::getData, testee::getData));
executorService.shutdown();
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
Mockito.verify(mockedExpensiveFunction, Mockito.times(2))
.get();
}
在上面的測試中, Supplier.get()
函數被呼叫了兩次。為了實現這一點, ExecutorService
同時呼叫兩個呼叫LazyLambdaSupplier.getData()
函數的執行緒。此外,我們加入到mockedExpensiveFunction
Thread.sleep()
呼叫保證當兩個執行緒呼叫getData()
函數時欄位data
仍然為null
。
4. 線程安全解決方案
最後,讓我們解決上面演示的線程安全限制。為了實現這一點,我們需要使用同步資料存取和線程安全值包裝器,即AtomicReference
。
讓我們結合到目前為止所學到的知識來編寫LazyLambdaThreadSafeSupplier
:
public class LazyLambdaThreadSafeSupplier<T> extends LambdaSupplier<T> {
private final AtomicReference<T> data;
public LazyLambdaThreadSafeSupplier(Supplier<T> expensiveData) {
super(expensiveData);
data = new AtomicReference<>();
}
public T getData() {
if (data.get() == null) {
synchronized (data) {
if (data.get() == null) {
data.set(expensiveData.get());
}
}
}
return data.get();
}
}
為了解釋為什麼這種方法是執行緒安全的,我們需要想像多個執行緒同時呼叫getData()
方法。執行緒確實會阻塞,執行將按順序進行,直到data.get()
呼叫不為 null。一旦data
欄位初始化完成,多個執行緒就可以並發存取它。
乍一看,有人可能會認為getData()
方法中的雙 null 檢查是多餘的,但事實並非如此。事實上,外部 null 檢查確保當data.get()
不為 null 時,執行緒不會阻塞在同步區塊上。
為了驗證我們的實作是線程安全的,讓我們以與之前的解決方案相同的方式提供單元測試:
@Test
public void whenCalledMultipleTimesConcurrently_thenShouldBeCalledOnlyOnce() throws InterruptedException {
@SuppressWarnings("unchecked") Supplier mockedExpensiveFunction = Mockito.mock(Supplier.class);
Mockito.when(mockedExpensiveFunction.get())
.thenAnswer((Answer) invocation -> {
Thread.sleep(1000L);
return "Late response!";
});
LazyLambdaThreadSafeSupplier testee = new LazyLambdaThreadSafeSupplier<>(mockedExpensiveFunction);
Mockito.verify(mockedExpensiveFunction, Mockito.never())
.get();
ExecutorService executorService = Executors.newFixedThreadPool(4);
executorService.invokeAll(List.of(testee::getData, testee::getData));
executorService.shutdown();
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
Mockito.verify(mockedExpensiveFunction, Mockito.times(1))
.get();
}
5. 結論
在本文中,我們展示了使用 lambda 函數延遲初始化欄位的不同方法。透過使用這種方法,我們可以避免多次執行昂貴的呼叫並推遲它們。我們的範例可以用作本地快取或 Project Lombok 的惰性獲取器的替代方案。
與往常一樣,我們的範例的原始程式碼可以在 GitHub 上取得。