前言
在 Java 開發中,物件參考(Object Reference)是一個基礎概念,但也是最容易被忽略的陷阱來源。特別是在處理可變物件(Mutable Object)時,不當的物件共享會導致意想不到的副作用。
問題現象
典型的 Bug 場景
在 JPA 實體類別中,我們經常會看到這樣的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Entity @Table(name = "CONTACT_RF") public class ContactRf { private Calendar insertDate; private Calendar updateDate; @PrePersist public void onPrePersist() { Calendar calendar = DateUtils.getUTCCalendar(Calendar.getInstance(), false); this.insertDate = calendar; this.updateDate = calendar; } @PreUpdate public void onPreUpdate() { this.updateDate = DateUtils.getUTCCalendar(Calendar.getInstance(), false); } }
|
觀察到的異常行為
新增一筆資料後,發現:
insertDate
和 updateDate
的值竟然不相同
- 後續對物件的任何修改都會影響到之前設定的欄位
- 在不同的執行環境下,問題的表現可能不一致
根本原因分析
1. 物件參考共享的陷阱
問題的核心在於這行程式碼:
1 2 3
| Calendar calendar = DateUtils.getUTCCalendar(Calendar.getInstance(), false); this.insertDate = calendar; this.updateDate = calendar;
|
這裡發生了什麼:
1 2 3 4
| Calendar object@123 = new Calendar(...) insertDate -> object@123 updateDate -> object@123
|
2. 可變物件的副作用
查看 DateUtils.getUTCCalendar()
的實作:
1 2 3 4 5 6
| public static Calendar getUTCCalendar(Calendar cal, boolean isQuerySql) { String offsetStr = AthenaConstants.getDBTZStr(); cal.add(Calendar.HOUR_OF_DAY, Integer.parseInt(parts[0])); return cal; }
|
關鍵問題:
Calendar
是可變物件(Mutable Object)
- 方法直接修改傳入的物件,而不是建立新物件
- 回傳的是被修改過的同一個物件參考
3. 問題的連鎖反應
1 2 3 4 5 6 7 8
| Calendar cal1 = Calendar.getInstance(); Calendar result = DateUtils.getUTCCalendar(cal1, false);
this.insertDate = result; this.updateDate = result;
|
解決方案詳解
方案 1:建立獨立物件實例(立即修復)
1 2 3 4 5 6
| @PrePersist public void onPrePersist() { this.insertDate = DateUtils.getUTCCalendar(Calendar.getInstance(), false); this.updateDate = DateUtils.getUTCCalendar(Calendar.getInstance(), false); }
|
優點:簡單、立即可用、不需修改現有工具類別
缺點:仍然依賴可變物件
方案 2:修改工具方法,實作防御性複製
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public static Calendar getUTCCalendar(Calendar cal, boolean isQuerySql) { Calendar newCal = (Calendar) cal.clone(); String offsetStr = AthenaConstants.getDBTZStr(); if (isQuerySql) { if (offsetStr.contains("+")) { offsetStr = offsetStr.replace('+', '-'); } else { offsetStr = offsetStr.replace('-', '+'); } } String[] parts = offsetStr.split(":"); newCal.add(Calendar.HOUR_OF_DAY, Integer.parseInt(parts[0])); return newCal; }
|
優點:從根源解決問題,避免副作用
缺點:需要修改既有的工具方法,可能影響其他程式碼
方案 3:使用不可變物件(最佳實務)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Entity @Table(name = "CONTACT_RF") public class ContactRf { @Column(name = "INS_DAT", updatable = false) private Instant insertDate; @Column(name = "upd_dat") private Instant updateDate; @PrePersist public void onPrePersist() { Instant now = Instant.now(); this.insertDate = now; this.updateDate = now; } }
|
優點:從設計上避免問題,執行緒安全
缺點:需要重構現有程式碼
相關的物件導向陷阱
1. 集合物件的淺複製問題
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| private List<String> items = new ArrayList<>();
public List<String> getItems() { return items; }
public List<String> getItems() { return new ArrayList<>(items); }
public List<String> getItems() { return Collections.unmodifiableList(items); }
|
2. 建構子參數的可變物件問題
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class Person { private Date birthDate; public Person(Date birthDate) { this.birthDate = birthDate; } }
public Person(Date birthDate) { this.birthDate = new Date(birthDate.getTime()); }
|
3. Getter/Setter 的物件洩露
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public Date getBirthDate() { return birthDate; }
public Date getBirthDate() { return new Date(birthDate.getTime()); }
public LocalDate getBirthDate() { return birthDate; }
|
檢測和預防策略
1. 程式碼審查檢查清單
2. 單元測試驗證
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Test public void testObjectReferenceIndependence() { ContactRf entity = new ContactRf(); entity.onPrePersist(); Calendar originalInsertDate = entity.getInsertDate(); Calendar originalUpdateDate = entity.getUpdateDate(); assertNotSame("insertDate 和 updateDate 不應該是同一個物件", originalInsertDate, originalUpdateDate); originalUpdateDate.add(Calendar.HOUR, 1); assertNotEquals("修改 updateDate 不應該影響 insertDate", originalInsertDate.getTime(), originalUpdateDate.getTime()); }
|
3. 靜態分析工具
使用 SpotBugs、SonarQube 等工具檢測:
EI_EXPOSE_REP
:回傳內部可變物件參考
EI_EXPOSE_REP2
:儲存外部可變物件參考
最佳實務建議
1. 優先使用不可變物件
1 2 3 4
| private final Instant timestamp = Instant.now(); private final String name = "example"; private final List<String> items = Collections.unmodifiableList(Arrays.asList("a", "b"));
|
2. 實作防御性複製
1 2 3 4 5 6 7 8
| public void setItems(List<String> items) { this.items = new ArrayList<>(items); }
public List<String> getItems() { return new ArrayList<>(items); }
|
3. 使用 Builder 模式
1 2 3 4 5 6 7
| @Builder(toBuilder = true) public class ContactRf { private final Instant insertDate; private final Instant updateDate; }
|
4. 明確的方法命名
1 2 3 4 5 6 7 8
| public Calendar createUTCCalendar(Calendar source, boolean isQuerySql) { }
public void modifyToUTC(Calendar calendar, boolean isQuerySql) { }
|
總結
這個看似簡單的時間欄位問題,實際上反映了 Java 程式設計中的一個根本性問題:可變物件的不當共享。
問題的層次分析
- 表面問題:時間欄位值不一致
- 直接原因:物件參考共享
- 根本原因:可變物件設計和防御性程式設計的缺失
核心教訓
- 物件參考是雙刃劍:提高效能但增加複雜性
- 可變物件需要謹慎處理:特別是在多線程和物件共享場景
- 防御性程式設計是必要的:不要假設調用者會正確使用你的 API
- 不可變物件是更安全的選擇:從設計上避免副作用
這個案例提醒我們,在 Java 開發中,理解物件參考的行為和實作適當的物件管理策略是寫出穩定、可維護程式碼的基礎。