Backend

99 Ngày Java — Ngày 37: Iterator & duyệt collection

SSite Admin
2 tháng 09, 2026 7 phút đọc 0 lượt xem
99 Ngày Java — Ngày 37: Iterator & duyệt collection

Suốt 36 ngày ta viết for (var x : ds) như hít thở, mà chưa lần nào hỏi: ai đang đi qua từng phần tử hộ mình? Câu trả lời là Iterator — con trỏ ba method nằm dưới mọi vòng lặp for-each. Hiểu nó thì hai chuyện tự sáng ra: vì sao xóa phần tử giữa vòng lặp lại ném ConcurrentModificationException, và vì sao removeIf mới là câu trả lời đúng chứ không phải mẹo duyệt ngược.

Sketchnote Ngày 37: Iterator và duyệt collection — for-each là đường cú pháp của Iterator, cơ chế fail-fast với modCount, ConcurrentModificationException, removeIf và it.remove(), forEach cùng collection an toàn đa luồng

Iterator — con trỏ nằm dưới mọi for-each

Vòng lặp for-each không phải phép màu của trình biên dịch dành riêng cho mảng và List. Nó là đường cú pháp gọi tới Iterator — và chính vì thế mà mọi thứ trong bộ Collection, từ ArrayList tới TreeSet hay ArrayDeque, đều duyệt được bằng cùng một câu lệnh.

// for-each mà bạn viết mỗi ngày chỉ là ĐƯỜNG CÚ PHÁP.
for (String ten : danhSach) { System.out.println(ten); }

// Trình biên dịch dịch nó thành đúng đoạn này:
Iterator<String> it = danhSach.iterator();
while (it.hasNext()) {
    String ten = it.next();
    System.out.println(ten);
}

// Iterator chỉ có ba method — một con trỏ đi MỘT CHIỀU, MỘT LẦN:
//   hasNext()  còn phần tử phía trước không?
//   next()     lấy phần tử rồi nhích con trỏ
//   remove()   xóa phần tử vừa next() trả về

// Vì sao mọi Collection đều for-each được? Vì tất cả đều implements Iterable,
// và Iterable chỉ đòi đúng MỘT method: iterator().
// Class của bạn cũng vậy — implements Iterable là for-each được ngay.
  • Hợp đồng chia đôi rất gọn: Iterable nói "tôi duyệt được" và chỉ đòi một method iterator(); Iteratorcái con trỏ dùng một lần thật sự đi qua dữ liệu.

  • Đi một chiều, một lần: không có previous(), không quay lại đầu. Cần lùi hoặc chèn giữa chừng thì List cho bạn ListIterator với previous(), set(), add().

  • Iterable chỉ có một method, class của bạn cũng for-each được ngay khi implements nó — một trong những interface đáng cài nhất của JDK.

ConcurrentModificationException — vì sao nổ

Đây là exception mà gần như mọi người gặp trước khi hiểu, và nó không hề "khó tính vô cớ". Cái tên hơi gây hiểu lầm: không cần đa luồng mới bị — một luồng duy nhất, sửa collection ngay giữa vòng lặp của chính nó, là đủ.

// Đoạn code "hiển nhiên đúng" này NỔ:
List<String> ds = new ArrayList<>(List.of("An", "Bình", "Chi"));
for (String ten : ds) {
    if (ten.startsWith("B")) ds.remove(ten);   // ConcurrentModificationException!
}

// Vì sao? ArrayList giữ một biến đếm số lần CẤU TRÚC bị đổi: modCount.
// Lúc tạo Iterator, nó chụp lại giá trị đó vào expectedModCount.
// Mỗi lần next(), Iterator so hai số — lệch nhau là ném exception NGAY.

// Đây là cơ chế FAIL-FAST: thà nổ ngay tại dòng sai còn hơn để bạn
// duyệt sót phần tử rồi đi tìm bug ở một nơi hoàn toàn khác vài tuần sau.

// Bẫy phụ: xóa phần tử ÁP CHÓT thì KHÔNG nổ — vòng lặp kết thúc sớm
// vì hasNext() thấy cursor == size, nên không kịp kiểm tra. Duyệt sót
// một phần tử trong im lặng — còn khó chịu hơn cả exception.
  • modCount đếm thay đổi cấu trúc — thêm, xóa, thay đổi kích thước. Đổi giá trị một phần tử có sẵn thì không tính.

  • Iterator chụp giá trị đó lúc sinh ra rồi so lại ở mỗi bước. Lệch nghĩa là mặt đất đã dịch chuyển dưới chân nó — dữ liệu nó sắp trả về có thể sai.

  • Fail-fast là tính năng, không phải phiền toái: nổ ngay tại dòng gây lỗi rẻ hơn rất nhiều so với âm thầm bỏ sót phần tử.

  • Cái bẫy tinh vi: xóa phần tử áp chót thì không nổ mà lặng lẽ duyệt thiếu — lý do đủ để không bao giờ tin vào mẹo "xóa kiểu này không thấy lỗi".

Xóa đúng cách: removeIf và it.remove()

Biết vì sao nổ rồi thì cách sửa hiện ra ngay: đừng sửa collection sau lưng iterator — hoặc nói ý định bằng removeIf, hoặc xóa qua chính iterator để nó tự cập nhật sổ sách.

// CÁCH ĐÚNG SỐ 1 — removeIf (Java 8+), gọn nhất, nên là mặc định:
ds.removeIf(ten -> ten.startsWith("B"));
// Một dòng, không Iterator lộ ra ngoài, ArrayList còn cài đè bản tối ưu:
// duyệt MỘT lượt và dồn mảng MỘT lần — O(n), thay vì n lần remove O(n) = O(n²).

// CÁCH ĐÚNG SỐ 2 — tự cầm Iterator khi điều kiện phức tạp hoặc cần xóa có trạng thái:
Iterator<String> it = ds.iterator();
while (it.hasNext()) {
    if (it.next().startsWith("B")) it.remove();   // XÓA QUA ITERATOR — hợp lệ
}
// it.remove() tự cập nhật expectedModCount, nên hai con số không bao giờ lệch.

// CÁCH SAI PHỔ BIẾN — duyệt ngược bằng index để "né" exception:
for (int i = ds.size() - 1; i >= 0; i--) if (...) ds.remove(i);
// Chạy đúng, nhưng nói lên ý định kém hơn removeIf và dễ sai chỉ số.

// LƯU Ý: List.of(...) và Arrays.asList(...) là BẤT BIẾN / cố định kích thước
// → removeIf trên chúng ném UnsupportedOperationException, không phải CME.
  • removeIf là lựa chọn mặc định: đọc ra ý định ("bỏ những phần tử thỏa điều kiện") thay vì mô tả cách đi vòng, và ArrayList cài đè nó thành một lượt duyệt O(n) thay vì O(n²).

  • it.remove() dành cho lúc điều kiện phức tạp, cần trạng thái, hoặc phải xóa dựa trên nhiều phần tử liền kề.

  • Nhớ phân biệt hai exception: sửa List.of(...) cho UnsupportedOperationException (bất biến — Ngày 32), còn sửa giữa vòng lặp mới cho ConcurrentModificationException.

forEach, duyệt Map và chuyện đa luồng

Từ Java 8, ngoài "đưa tôi con trỏ" còn có kiểu ngược lại: "đây là việc cần làm, anh tự đi đi". Đó là forEach — và với Map thì nó gọn hơn hẳn cách lặp key rồi get() từng cái.

// forEach — nhận hành động thay vì trả con trỏ:
ds.forEach(System.out::println);

// Duyệt Map: ĐỪNG lặp keySet rồi get() từng cái — hai lần tra cho một việc.
for (Map.Entry<String, Integer> e : diem.entrySet()) {
    System.out.println(e.getKey() + ": " + e.getValue());
}
diem.forEach((ten, d) -> System.out.println(ten + ": " + d));   // gọn hơn nữa

// forEach cũng KHÔNG cho sửa collection bên trong — vẫn CME như for-each.
// Muốn vừa duyệt vừa đổi GIÁ TRỊ (không đổi cấu trúc) thì có sẵn:
diem.replaceAll((ten, d) -> d + 1);        // Map
ds.replaceAll(String::toUpperCase);         // List
// Đổi giá trị KHÔNG phải thay đổi cấu trúc → modCount không tăng → an toàn.

// Đa luồng: fail-fast không phải cơ chế đồng bộ, chỉ là báo động.
// Cần vừa duyệt vừa ghi thật sự thì dùng collection chuyên dụng:
//   CopyOnWriteArrayList — chụp ảnh mảng lúc duyệt, ghi thì chép mảng mới
//   ConcurrentHashMap    — iterator "weakly consistent", không bao giờ ném CME
  • Duyệt Map thì đi bằng entrySet() hoặc forEach: lặp keySet() rồi get()tra hai lần cho một việc.

  • replaceAll là cách hợp lệ để vừa duyệt vừa đổi giá trị — vì không đụng tới cấu trúc nên modCount đứng yên.

  • Đa luồng thì fail-fast không cứu được bạn, nó chỉ là chuông báo. Cần duyệt trong khi luồng khác ghi thì dùng CopyOnWriteArrayList (đọc nhiều, ghi ít) hoặc ConcurrentHashMap (Ngày 34).

Bài tập nhỏ

  • Tái hiện lỗi: tạo ArrayList ba phần tử, xóa phần tử thứ hai trong for-each để thấy exception — rồi xóa phần tử áp chót để thấy nó im lặng duyệt thiếu.

  • Viết lại cùng logic đó bằng removeIf, rồi bằng it.remove(); so ba bản xem bản nào đọc ra ý định nhanh nhất.

  • Cho một class TuanLe implements Iterable<String> trả về bảy ngày trong tuần — rồi duyệt nó bằng for-each để thấy interface một method là đủ.

  • Đo removeIf và vòng lặp gọi list.remove(x) trên danh sách 100.000 phần tử, xóa một nửa — khoảng cách O(n) với O(n²) hiện ra rất rõ.

  • Chạy hai luồng trên một HashMap: một luồng duyệt, một luồng ghi. Đổi sang ConcurrentHashMap và xem exception biến mất.

Kết lại

Bốn ý gói lại hôm nay: for-each là đường cú pháp của Iterator, và Iterable chỉ đòi đúng một method; ConcurrentModificationException sinh ra từ modCount lệch expectedModCountfail-fast là tính năng; xóa thì mặc định removeIf, phức tạp thì it.remove(); còn đa luồng thật thì phải dùng collection chuyên dụng chứ fail-fast không đồng bộ hộ ai. Ngày 38 ta trả lời câu hỏi kế tiếp của việc duyệt: sắp xếp — Comparable và Comparator, thứ tự tự nhiên và thứ tự tùy biến. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 37: Redis

Đưa cache ra khỏi tiến trình: spring.cache.type redis và TTL, đổi serializer JDK sang JSON, RedisTemplate cho counter và rate limit, session store dùng chung — cùng bốn cái bẫy khi Redis chết.

2 thg 9, 20268 phút1
99 Ngày Java — Ngày 36: Queue, Deque & Stack

FIFO xếp hàng và LIFO chồng đĩa, hai bộ method offer/poll/peek, ArrayDeque mảng vòng một mình hai vai (và bản án cho class Stack) — cùng PriorityQueue trên binary heap với bài toán top-K.

1 thg 9, 20269 phút6
99 Ngày Spring — Ngày 36: Caching với @Cacheable

Tốc độ lớn nhất là không truy vấn: cache abstraction với @Cacheable, key SpEL và hai luật sống còn, bộ đôi @CacheEvict/@CachePut giữ cache thật, TTL với Caffeine — và bốn cái bẫy caching.

1 thg 9, 202610 phút4