1. 引言
Jackson 的 ObjectMapper 位於大多數 Java JSON 管道的核心。由於構建和配置它涉及到類路徑、模塊發現以及多個內部緩存的預熱,許多團隊都疑惑:我們是否應該保持一個單一的共享映射器,還是為每次調用創建一個新的映射器?
在本教程中,我們將回顧相關的權衡利弊以及 Jackson 的真實線程安全性保證,通過 JUnit 5 演示一個真實的競爭條件,並以務實的指導結束,以便我們今天就能採用。
2. 為什麼開發者選擇使用靜態的 ObjectMapper?
創建 ObjectMapper 不僅僅是分配一個 POJO。
它會加載和註冊 Modules,構建 Serializer/Deserializer 緩存,掃描註解,並連接默認格式化器。
在每個請求上執行這些操作可能會變得昂貴。因此,通常會看到一個輔助工具,例如:
public final class JsonUtils {
public static final ObjectMapper MAPPER = new ObjectMapper();
}
一句代碼為整個JVM提供了一個可複用的映射器,對於降低延遲非常有效,但前提是我們必須正確處理配置。
3. 併發安全性:Jackson 提供的保證
在深入探討併發問題之前,我們先來看一下 Jackson 提供的保證:
- 使用後不可變。 官方 Javadoc 聲明,ObjectMapper 在所有配置完成之前,是完全線程安全的,通過一個 volatile 寫入安裝了一個全新的不可變 SerializationConfig/ DeserializationConfig 實例。
- 配置方法複製,而非修改。 諸如 enable()、disable() 或 configure() 等調用,通過一個 volatile 寫入,安裝了一個全新的不可變 SerializationConfig/ DeserializationConfig 實例。 現有寫入器保持舊的快照,因此併發的切換不會損壞數據。
- 可變協作破壞了合同。 如果我們通過 setDateFormat() 注入一個狀態化、非線程安全的對象(例如 java.text.SimpleDateFormat),我們重新引入了不安全的共享狀態。
因此,危險在於 Jackson 僅僅委託給它的可變輔助器。
4. 重用對性能的影響
單例映射器能夠帶來以下優勢:
- 零冷啓動成本 – 模塊發現和標註掃描僅執行一次
- 熱序列化緩存 – 昂貴的 序列化器 保持在內存中
- 減少垃圾 – 每個請求只分配它需要的增量緩衝區
如果映射器不斷重新配置或克隆,這些優勢將消失,因此我們應該追求“一次配置,永久使用”。
5. 全局映射器的缺點
使用單一的、應用程序範圍內的ObjectMapper可以避免冗餘代碼,但同時也可能導致微妙的錯誤。所有以下問題都源於代碼庫中的每個部分都與同一個可變實例進行通信。
5.1. 泄漏配置
由於只有一個映射器,因此在某個地方進行的配置更改會在其他地方泄漏。
@Test
void whenRegisteringDateFormatGlobally_thenAffectsAllConsumers() throws Exception {
Map<String, Date> payload = singletonMap("today",
Date.from(LocalDate.of(1998, 2, 9).atTime(12, 0).toInstant(ZoneOffset.UTC)));
String before = GLOBAL_MAPPER.writeValueAsString(payload);
assertEquals("{\"today\":887025600000}", before);
GLOBAL_MAPPER.setDateFormat(new SimpleDateFormat("yyyy-MM-dd"));
String after = GLOBAL_MAPPER.writeValueAsString(payload);
assertEquals("{\"today\":\"1998-02-09\"}", after);
}雖然序列化 LocalDate 的生產代碼沒有被修改,但一旦另一個類註冊了 DateFormat,其輸出就會發生變化。
5.2. 測試中的隱式耦合
如果測試用例共享全局映射器,則必須以固定的順序運行,或者手動重置它——否則,它們會留下隱藏的狀態:
@Test
@Order(1)
void givenCustomDateFormat_whenConfiguredFirst_thenPasses() throws Exception {
GLOBAL_MAPPER.setDateFormat(new SimpleDateFormat("dd-MM-yyyy"));
Map<String, Date> payload = Collections.singletonMap("date",
Date.from(LocalDate.of(1998, 2, 9).atTime(12, 0).toInstant(ZoneOffset.UTC)));
String json = GLOBAL_MAPPER.writeValueAsString(payload);
assertEquals("{\"date\":\"09-02-1998\"}", json);
}
@Test
@Order(2)
void givenDefaultDateFormat_whenRunAfterMutation_thenFails() throws Exception {
Map<String, Date> payload = Collections.singletonMap("date",
Date.from(LocalDate.of(1998, 2, 9).atTime(12, 0).toInstant(ZoneOffset.UTC)));
String json = GLOBAL_MAPPER.writeValueAsString(payload);
assertNotEquals("{\"date\":887025600000}", json);
}第二個測試只有在先於第一個測試運行的情況下才能成功——在重構或並行執行過程中,這種隱式依賴很容易被忽略。
5.3. 衝突要求
不同消費者可能需要不兼容的設置。使用全局映射器時,最後一次配置生效。<em>DateFormat</em> 是可變的,因此全局更改可能會破壞之前的預期:
@Test
void whenSwitchingDateFormatGlobally_thenEndpointsCollide() throws Exception {
SimpleDateFormat iso = new SimpleDateFormat("yyyy-MM-dd");
GLOBAL_MAPPER.setDateFormat(iso);
Map<String, Date> payload = Collections.singletonMap(
"dob",
Date.from(LocalDate.of(1990, 10, 5).atTime(12, 0).toInstant(ZoneOffset.UTC)));
String forA = GLOBAL_MAPPER.writeValueAsString(payload);
assertEquals("{\"dob\":\"1990-10-05\"}", forA);
SimpleDateFormat european = new SimpleDateFormat("dd/MM/yyyy");
GLOBAL_MAPPER.setDateFormat(european);
String forB = GLOBAL_MAPPER.writeValueAsString(payload);
assertEquals("{\"dob\":\"05/10/1990\"}", forB);
String nowBrokenForA = GLOBAL_MAPPER.writeValueAsString(payload);
assertNotEquals(forA, nowBrokenForA);
}5.4. 競爭條件
讓我們創建一個可能導致競爭條件的場景。我們將使用 <em >setDateFormat()</em> 來實現,因為它本身並不具備線程安全特性:
@Test
void whenSimpleDateFormatChanges_thenConflictHappens() throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
GLOBAL_MAPPER.setDateFormat(format);
Callable<String> task = () -> GLOBAL_MAPPER.writeValueAsString(Map.of("key",
Date.from(LocalDate.of(1998, 2, 9).atTime(12, 0).toInstant(ZoneOffset.UTC))));
Callable<Void> mutator = () -> {
format.applyPattern("dd-MM-yyyy");
return null;
};
Future<String> taskResult1 = POOL.submit(task);
assertEquals("{\"key\":\"1998-02-09\"}", taskResult1.get());
POOL.submit(mutator).get();
Future<String> taskResult2 = POOL.submit(task);
assertEquals("{\"key\":\"09-02-1998\"}", taskResult2.get());
}如我們所見,修改 format 也會導致 ObjectMapper 的變異,並且結果會有所不同。
6. 範圍限定的替代方案
當我們不想創建全局實例,也不想在每次使用 ObjectMapper 時創建新的實例時,我們需要尋找替代方案。 讓我們看看如何對 ObjectMapper 進行範圍限定,以找到一個平衡點。
6.1. 依賴注入 (Spring)
Spring Bean 默認情況下是單例,因此您無需使用靜態狀態,即可在每個 ApplicationContext 中暴露一個映射器。
@Configuration
public class JacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.build();
}
}
6.2. 一次性調整的輕量級副本
如果我們需要,例如,為單個響應進行美化排版,我們可以創建映射器的副本,而不是修改全局映射器:
ObjectMapper localCopy =
globalMapper.copy().enable(SerializationFeature.INDENT_OUTPUT);
克隆會重用大部分內部資源,但同時保護父映射器免受進一步修改。下面我們來看它的實際應用。
@Test
void whenUsingCopyScopedMapper_thenNoInterference() throws Exception {
ObjectMapper localCopy = GLOBAL_MAPPER.copy().enable(SerializationFeature.INDENT_OUTPUT);
assertEquals("{\n \"key\" : \"value\"\n}", localCopy.writeValueAsString(Map.of("key", "value")));
assertEquals("{\"key\":\"value\"}", GLOBAL_MAPPER.writeValueAsString(Map.of("key", "value")));
}通過這個單元測試,我們可以證明本地副本確實不會修改全局映射器。
7. 結論
在本文中,我們討論了 靜態 ObjectMapper。只要我們完成所有配置並在第一次調用之前避免注入可變輔助對象,它就完全安全。當這種紀律難以或不可能時,我們應該優先考慮 DI 單例或廉價的 copy() 調用。
最重要的是,將可變、不可線程安全的對象(如 SimpleDateFormat)保持在全局作用域之外,讓 Jackson 完成其設計目的——在多線程中提供快速、可預測的 JSON 處理。