資料處理是 Java 開發的基礎,但其中隱藏著許多容易忽略的陷阱。本章涵蓋空值處理、BigDecimal 精度、集合操作、日期時間等核心議題。
空值處理陷阱#
NullPointerException 的五種常見場景#
NullPointerException 是 Java 中最常見的例外,以下是最容易出現的五種場景:
private List<String> wrongMethod(FooService fooService, Integer i, String s, String t) {
log.info("result {} {} {} {}",
i + 1, // 1. Integer 自動拆箱
s.equals("OK"), // 2. 字串比較
s.equals(t), // 3. 兩個可能為 null 的字串比較
new ConcurrentHashMap<String, String>().put(null, null) // 4. 容器不支持 null
);
if (fooService.getBarService().bar().equals("OK")) // 5. 級聯呼叫
log.info("OK");
return null; // 6. 回傳 null 的方法
}正確做法#
private List<String> rightMethod(FooService fooService, Integer i, String s, String t) {
log.info("result {} {} {} {}",
// 1. 使用 Optional 處理 Integer
Optional.ofNullable(i).orElse(0) + 1,
// 2. 把字面量放在前面
"OK".equals(s),
// 3. 使用 Objects.equals 比較兩個可能為 null 的字串
Objects.equals(s, t),
// 4. 使用支持 null 的 HashMap
new HashMap<String, String>().put(null, null)
);
// 5. 使用 Optional 處理級聯呼叫
Optional.ofNullable(fooService)
.map(FooService::getBarService)
.filter(barService -> "OK".equals(barService.bar()))
.ifPresent(result -> log.info("OK"));
// 6. 回傳空集合而非 null
return new ArrayList<>();
}
// 呼叫時使用 Optional 處理可能為 null 的回傳值
@GetMapping("right")
public int right() {
return Optional.ofNullable(rightMethod(...))
.orElse(Collections.emptyList())
.size();
}處理空指針的原則:
- 字串比較把字面量放前面:
"OK".equals(s)- 兩個變數比較用
Objects.equals()- 使用
Optional優雅處理可能為 null 的值- 回傳集合時回傳空集合而非 null
POJO 中 null 的含義陷阱#
問題現象#
JSON 反序列化時,用戶端「不傳某個屬性」和「傳 null」,在 POJO 中都表現為 null,但業務含義完全不同。
// 錯誤示範:DTO 和 Entity 共用同一個 POJO
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private String name;
private String nickname;
private Integer age;
private Date createDate = new Date(); // 有默認值
}上述設計會導致以下問題:
- 無法區分「不傳」和「傳 null」
- 字段的默認值會覆蓋資料庫中的原有值
- DTO 和 Entity 共用會暴露不該暴露的字段
正確做法#
分離 DTO 和 Entity,使用 Optional 區分 null 含義
// DTO:只暴露需要的字段,使用 Optional 區分 null 含義
@Data
public class UserDto {
private Long id;
private Optional<String> name; // 不傳為 null,傳 null 為 Optional.empty()
private Optional<Integer> age;
}
// Entity:設置資料庫約束和默認值
@Data
@Entity
@DynamicUpdate // Hibernate 動態生成 UPDATE SQL
public class UserEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
private Integer age;
@Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private Date createDate;
}@PostMapping("right")
public UserEntity right(@RequestBody UserDto user) {
if (user == null || user.getId() == null)
throw new IllegalArgumentException("用戶Id不能為空");
UserEntity userEntity = userEntityRepository.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("用戶不存在"));
// 只有傳了 name(不為 null)才更新
if (user.getName() != null) {
userEntity.setName(user.getName().orElse("")); // 傳 null 則設為空字串
}
userEntity.setNickname("guest" + userEntity.getName());
// 年齡必須傳有效值
if (user.getAge() != null) {
userEntity.setAge(user.getAge()
.orElseThrow(() -> new IllegalArgumentException("年齡不能為空")));
}
return userEntityRepository.save(userEntity);
}MySQL 中 NULL 的三個坑#
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 錯誤:SUM(NULL) 回傳 null 而非 0
@Query(nativeQuery = true, value = "SELECT SUM(score) FROM `user`")
Long wrong1();
// 錯誤:COUNT(字段) 不統計 NULL 值
@Query(nativeQuery = true, value = "SELECT COUNT(score) FROM `user`")
Long wrong2();
// 錯誤:= NULL 無法匹配 NULL 值
@Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score = null")
List<User> wrong3();
}正確做法#
// 使用 IFNULL 處理 SUM
@Query(nativeQuery = true, value = "SELECT IFNULL(SUM(score), 0) FROM `user`")
Long right1();
// 使用 COUNT(*) 統計所有記錄
@Query(nativeQuery = true, value = "SELECT COUNT(*) FROM `user`")
Long right2();
// 使用 IS NULL 比較 NULL 值
@Query(nativeQuery = true, value = "SELECT * FROM `user` WHERE score IS NULL")
List<User> right3();MySQL NULL 處理要點:
SUM(NULL)回傳 null,使用IFNULL(SUM(col), 0)轉換COUNT(字段)不統計 NULL,使用COUNT(*)統計總數= NULL永遠為 false,使用IS NULL或IS NOT NULL
BigDecimal 精度陷阱#
問題現象#
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.0 - 0.8); // 0.19999999999999996
System.out.println(4.015 * 100); // 401.49999999999994
System.out.println(123.3 / 100); // 1.2329999999999999
double amount1 = 2.15;
double amount2 = 1.10;
if (amount1 - amount2 == 1.05) // false!
System.out.println("OK");原因分析#
計算機以二進制存儲浮點數,0.1 的二進制表示是無限循環的
0.0 0011 0011 0011...,無法精確表達。
// 錯誤:使用 Double 構造方法
System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
// 輸出:0.3000000000000000166533453693773481063544750213623046875正確做法#
// 正確:使用 String 構造方法
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); // 0.3
System.out.println(new BigDecimal("1.0").subtract(new BigDecimal("0.8"))); // 0.2
System.out.println(new BigDecimal("4.015").multiply(new BigDecimal("100"))); // 401.500
System.out.println(new BigDecimal("123.3").divide(new BigDecimal("100"))); // 1.233BigDecimal 第一原則:務必使用 String 構造方法或
BigDecimal.valueOf()初始化。
BigDecimal 比較陷阱#
// 錯誤:equals 比較 value 和 scale
System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1"))); // false
// 正確:使用 compareTo 只比較 value
System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1")) == 0); // trueHashSet/HashMap 中使用 BigDecimal
// 問題:equals 不同導致無法找到
Set<BigDecimal> hashSet1 = new HashSet<>();
hashSet1.add(new BigDecimal("1.0"));
System.out.println(hashSet1.contains(new BigDecimal("1"))); // false
// 方案一:使用 TreeSet(使用 compareTo 比較)
Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
System.out.println(treeSet.contains(new BigDecimal("1"))); // true
// 方案二:存入前去掉尾部零
Set<BigDecimal> hashSet2 = new HashSet<>();
hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZeros())); // true數值溢出陷阱#
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808(靜默溢出)
System.out.println(l + 1 == Long.MIN_VALUE); // true正確做法#
// 方案一:使用 Math.addExact 在溢出時拋出例外
try {
System.out.println(Math.addExact(Long.MAX_VALUE, 1));
} catch (ArithmeticException ex) {
System.out.println("溢出!"); // 會執行到這裡
}
// 方案二:使用 BigInteger 處理大數運算
BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
System.out.println(i.add(BigInteger.ONE).toString()); // 9223372036854775808
// 轉換回 long 時檢測溢出
try {
long l = i.add(BigInteger.ONE).longValueExact();
} catch (ArithmeticException ex) {
System.out.println("BigInteger out of long range");
}集合操作陷阱#
Arrays.asList 的三個坑#
坑一:基本類型陣列#
int[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
System.out.println(list.size()); // 1(整個陣列作為一個元素)
System.out.println(list.get(0).getClass()); // class [I(int 陣列)正確做法:
// 方案一:使用 Arrays.stream (Java 8+)
int[] arr1 = {1, 2, 3};
List<Integer> list1 = Arrays.stream(arr1).boxed().collect(Collectors.toList());
// 方案二:使用包裝類陣列
Integer[] arr2 = {1, 2, 3};
List<Integer> list2 = Arrays.asList(arr2);坑二:回傳的 List 不支持增刪#
String[] arr = {"1", "2", "3"};
List list = Arrays.asList(arr);
list.add("4"); // UnsupportedOperationException
Arrays.asList回傳的是Arrays的內部類ArrayList,不是java.util.ArrayList,不支持 add/remove 操作。
坑三:修改原陣列會影響 List#
String[] arr = {"1", "2", "3"};
List list = Arrays.asList(arr);
arr[1] = "4";
System.out.println(list); // [1, 4, 3](List 也被修改了)正確做法:
String[] arr = {"1", "2", "3"};
List<String> list = new ArrayList<>(Arrays.asList(arr)); // 創建獨立的 ArrayList
arr[1] = "4";
list.add("5");
System.out.println(list); // [1, 2, 3, 5](不受原陣列影響)List.subList 的 OOM 陷阱#
問題現象#
private static List<List<Integer>> data = new ArrayList<>();
private static void oom() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100000)
.boxed().collect(Collectors.toList());
data.add(rawList.subList(0, 1)); // 只保存一個元素的子列表
}
}
// 結果:OutOfMemoryError原因分析#
subList回傳的SubList是原 List 的視圖,持有原 List 的引用。上例中 1000 個包含 10 萬元素的 List 都無法被 GC。
SubList 的其他問題#
List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
List<Integer> subList = list.subList(1, 4);
// 修改 subList 會影響原 List
subList.remove(1);
System.out.println(list); // [1, 2, 4, 5, 6, 7, 8, 9, 10](3 被刪除了)
// 修改原 List 後遍歷 subList 會拋例外
list.add(0);
subList.forEach(System.out::println); // ConcurrentModificationException正確做法#
// 方案一:使用 new ArrayList 創建獨立副本
List<Integer> subList = new ArrayList<>(list.subList(1, 4));
// 方案二:使用 Stream 的 skip 和 limit
List<Integer> subList = list.stream()
.skip(1)
.limit(3)
.collect(Collectors.toList());ArrayList vs LinkedList 效能迷思#
教科書說 LinkedList 插入是 O(1),但實際測試中 ArrayList 幾乎在所有場景都更快。
效能測試結果(10 萬元素,10 萬次操作)#
| 操作 | LinkedList | ArrayList |
|---|---|---|
| 隨機訪問 | 6.6 秒 | 11 毫秒 |
| 隨機插入 | 9.3 秒 | 1.5 秒 |
原因分析#
LinkedList 的插入操作是 O(1) 的前提是已經有目標節點的指針。但實際使用時需要先遍歷找到節點,這個過程是 O(n)。
// LinkedList 的 add(index, element) 實現
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index)); // 需要先呼叫 node(index)
}
// node(index) 需要遍歷鏈結串列
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}實踐建議:在 90% 的場景下,ArrayList 都是更好的選擇。只有在大量頭部插入/刪除的場景才考慮 LinkedList。
HashMap vs ArrayList 搜索效能#
// ArrayList 搜索:O(n)
List<Order> list = ...;
Order result = list.stream()
.filter(order -> order.getOrderId() == searchId)
.findFirst()
.orElse(null);
// HashMap 搜索:O(1)
Map<Integer, Order> map = ...;
Order result = map.get(searchId);| 方式 | 100 萬元素,1000 次搜索 | 內存佔用 |
|---|---|---|
| ArrayList | 3.4 秒 | 21 MB |
| HashMap | 108 毫秒 | 72 MB |
HashMap 搜索效能是 ArrayList 的 30 倍以上,但內存佔用是 3 倍多。需要根據業務場景權衡時間和空間。
總結:資料處理檢查清單#
| 檢查項目 | 正確做法 |
|---|---|
| null 判斷 | 使用 Optional、Objects.equals、字面量在前 |
| POJO 設計 | 分離 DTO 和 Entity,用 Optional 區分 null 含義 |
| MySQL NULL | IFNULL、COUNT(*)、IS NULL |
| BigDecimal | String 構造方法、compareTo 比較 |
| 數值溢出 | Math.xxxExact 或 BigInteger |
| Arrays.asList | new ArrayList 包裝、使用 Stream |
| List.subList | new ArrayList 或 Stream skip/limit |
| 集合搜索 | 大量搜索考慮用 HashMap |
| List 選擇 | 優先 ArrayList,除非大量頭部操作 |