資料處理是 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 NULLIS 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.233

BigDecimal 第一原則:務必使用 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);  // true
HashSet/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 [Iint 陣列

正確做法

// 方案一:使用 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 萬次操作)#

操作LinkedListArrayList
隨機訪問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 次搜索內存佔用
ArrayList3.4 秒21 MB
HashMap108 毫秒72 MB

HashMap 搜索效能是 ArrayList 的 30 倍以上,但內存佔用是 3 倍多。需要根據業務場景權衡時間和空間。


總結:資料處理檢查清單#

檢查項目正確做法
null 判斷使用 Optional、Objects.equals、字面量在前
POJO 設計分離 DTO 和 Entity,用 Optional 區分 null 含義
MySQL NULLIFNULL、COUNT(*)、IS NULL
BigDecimalString 構造方法、compareTo 比較
數值溢出Math.xxxExact 或 BigInteger
Arrays.asListnew ArrayList 包裝、使用 Stream
List.subListnew ArrayList 或 Stream skip/limit
集合搜索大量搜索考慮用 HashMap
List 選擇優先 ArrayList,除非大量頭部操作