Hình dung bạn điểm danh một danh sách khách đánh số, vừa đọc vừa gạch tên ai đã về. Gạch khách số 3 trong bốn người, danh sách co lại còn ba, con trỏ của bạn đang ở vị trí thứ ba — thế là bạn nghĩ "đã tới cuối rồi" và dừng, không bao giờ nhìn tới khách số 4. Không ai báo bạn sai. Đó chính xác là chuyện xảy ra khi sửa một collection trong lúc duyệt nó — và điều đáng sợ là Java thường đập tay cảnh báo bạn, nhưng đúng một trường hợp thì không.

Ai viết Java cũng từng gặp ConcurrentModificationException khi xoá phần tử trong lúc duyệt. Nó khó chịu, nhưng ít ra nó nói cho bạn biết. Bài này về trường hợp ngược lại: cùng đoạn mã sai, mà Java không ném ngoại lệ, và bạn nhận về một kết quả sai một cách im lặng.

Ba lần thử, ba kết quả

Cùng một danh sách [a, b, c, d], cùng kiểu vòng lặp, chỉ khác phần tử bị xoá:

for (String s : ds) {
    if (s.equals(X)) ds.remove(s);
}
  xoá phần tử giữa     -> ConcurrentModificationException
  xoá phần tử ÁP CHÓT  -> chạy xong, KHÔNG lỗi
  xoá phần tử cuối     -> ConcurrentModificationException

Xoá c — phần tử áp chót — thì không có ngoại lệ nào.

Nhưng kết quả thì sai:

  danh sách sau : [a, b, d]
  số vòng lặp   : 3  <- 'd' KHÔNG được duyệt!

Vòng lặp chạy 3 lần thay vì 4. Phần tử d bị bỏ qua hoàn toàn. Nếu bên trong vòng lặp bạn đang gửi email, ghi sổ, hay cộng tiền, thì d vừa bị bỏ sót mà không ai biết.

Vì sao lại thế

for-each thực chất là Iterator viết gọn. Trình biên dịch dịch nó thành:

Iterator<String> it = ds.iterator();
while (it.hasNext()) {
    String s = it.next();
    ...
}

ArrayList giữ một biến đếm tên modCount, tăng lên mỗi lần cấu trúc thay đổi — thêm, xoá, nhưng không phải khi chỉ sửa giá trị. Iterator ghi nhớ giá trị modCount lúc được tạo, rồi mỗi lần next() nó so lại. Lệch nhau là ném ConcurrentModificationException.

Điểm mấu chốt: phép kiểm chỉ nằm trong next(), không nằm trong hasNext().

Và hasNext() của ArrayList chỉ là:

public boolean hasNext() {
    return cursor != size;
}

Giờ ghép lại. Danh sách 4 phần tử, xoá phần tử ở chỉ số 2:

Sau khi duyệt tới c, con trỏ ở vị trí 3. Xoá xong thì size còn 3.

Vòng tiếp theo, hasNext() so cursor != size → 3 != 3 → false. Vòng lặp kết thúc êm ru. Đúng cái danh sách khách co lại đúng bằng vị trí con trỏ.

next() không bao giờ được gọi lần nữa, nên phép kiểm modCount không bao giờ chạy.

Đây gọi là cơ chế fail-fast — cố gắng báo lỗi sớm, nhưng tài liệu của Java nói rất rõ: nó không bảo đảm, và không được dùng để kiểm tra tính đúng đắn của chương trình. Nó là công cụ phát hiện lỗi, không phải hàng rào an toàn.

Ba cách xoá an toàn

  removeIf             : [a, c, d]
  iterator.remove()    : [a, c, d]
  lọc ra danh sách mới : [a, c, d]

removeIf — ngắn nhất, và nên là lựa chọn mặc định:

ds.removeIf(s -> s.equals("b"));

Iterator.remove() — khi cần logic phức tạp hơn một biểu thức:

for (var it = ds.iterator(); it.hasNext(); ) {
    if (canXoa(it.next())) it.remove();
}

Chỉ it.remove() mới hợp lệ, vì nó cập nhật cả modCount lẫn vị trí con trỏ. Gọi ds.remove() bên trong vòng lặp iterator thì vẫn hỏng.

Tạo danh sách mới — cách sạch nhất khi bạn không cần sửa bản gốc:

List<String> ketQua = ds.stream().filter(s -> !canXoa(s)).toList();

Với Map, phiên bản tương ứng là:

map.entrySet().removeIf(e -> e.getValue() == 2);
map.values().removeIf(v -> v == null);
map.keySet().removeIf(k -> k.startsWith("tmp_"));
  entrySet().removeIf : {a=1, c=3}

Ba khung nhìn này đều "sống", nên xoá trên chúng là xoá thật trong map — đúng cơ chế khung nhìn đã gặp ở bài TreeMap.

Sửa giá trị thì không sao

  sửa nội dung phần tử : [x!] (không lỗi)
  set() theo chỉ số    : [a!, b!] (không lỗi)

modCount chỉ tăng khi cấu trúc đổi. Sửa nội dung một đối tượng trong danh sách, hoặc gọi list.set(i, x), đều không đụng tới nó.

Nên vòng lặp này hoàn toàn hợp lệ:

for (int i = 0; i < ds.size(); i++) {
    ds.set(i, ds.get(i).toUpperCase());
}

Còn cách gọn hơn là ds.replaceAll(String::toUpperCase).

Collection an toàn khi vừa duyệt vừa sửa

  CopyOnWriteArrayList -> chạy xong, KHÔNG lỗi
  ConcurrentHashMap    -> chạy xong, KHÔNG lỗi

Hai họ này không fail-fast, nhưng theo hai cách khác nhau:

CopyOnWriteArrayList — mỗi lần ghi là sao chép cả mảng. Iterator giữ ảnh chụp tại thời điểm tạo, nên nó không bao giờ thấy thay đổi và không bao giờ ném lỗi.

Hợp khi: đọc rất nhiều, ghi rất ít. Danh sách listener là ví dụ kinh điển. Còn nếu ghi thường xuyên thì mỗi lần ghi tốn O(n) — thảm hoạ.

ConcurrentHashMap — iterator "yếu nhất quán": nó thấy dữ liệu tại thời điểm tạo cộng với một phần các thay đổi sau đó, không bảo đảm cụ thể. Không ném ngoại lệ, nhưng cũng không cho bạn ảnh chụp nhất quán.

Điểm chung: cả hai đều không phải cách chữa cho lỗi xoá-trong-vòng-lặp một luồng. Dùng chúng vì bạn cần nhiều luồng, không phải để né ConcurrentModificationException.

Cái tên gây hiểu lầm

ConcurrentModificationException nghe như lỗi của lập trình đồng thời, nên nhiều người thấy nó trong mã một luồng là bối rối.

Chữ "concurrent" ở đây nghĩa là sửa trong lúc đang duyệt, không phải "nhiều luồng". Phần lớn trường hợp bạn gặp nó là mã hoàn toàn tuần tự.

Tất nhiên nó cũng xuất hiện khi nhiều luồng thật sự — và khi đó nó là dấu hiệu bạn đang thiếu đồng bộ hoá, một vấn đề nghiêm trọng hơn nhiều so với vòng lặp sai.

ListIterator: đi hai chiều

Với List, có một iterator mạnh hơn:

var it = ds.listIterator();
while (it.hasNext()) {
    String s = it.next();
    if (canSua(s)) it.set(s.toUpperCase());   // sửa tại chỗ
    if (canThem(s)) it.add("mới");            // chèn vào giữa
}

ListIterator thêm được set, add, đi ngược bằng hasPrevious/previous, và cho biết chỉ số hiện tại bằng nextIndex. Đây là cách duy nhất chèn phần tử vào giữa trong lúc duyệt mà không hỏng.

Rút lại

Đừng sửa cấu trúc collection trong lúc for-each nó. Dùng removeIf, Iterator.remove(), hoặc tạo bản mới.

Đừng tin vào việc không có ngoại lệ. Trường hợp áp chót cho thấy fail-fast là phát hiện lỗi, không phải bảo đảm.

ConcurrentModificationException trong mã nhiều luồng là chuyện khác — đó là thiếu đồng bộ, và ta sẽ nói ở chặng đồng thời.

Muốn tự thấy trong ba mươi giây: chạy đoạn xoá phần tử áp chót ở đầu bài và in ra số vòng lặp thực tế. Ba vòng thay vì bốn, không ngoại lệ, không cảnh báo. Nếu trong dự án của bạn có chỗ nào xoá phần tử trong for-each mà "chạy vẫn ổn", đó chính là chỗ nên xem lại ngay hôm nay.

Mẫu số chung

Cái danh sách khách co lại dưới tay bạn không phải chuyện riêng của Java — sửa một collection trong lúc duyệt nó là mối nguy chung của mọi ngôn ngữ, chỉ khác nhau ở chỗ ngôn ngữ phản ứng thế nào. Và dải phản ứng ấy trải rất rộng. C# giống Java nhất: List và Dictionary ném InvalidOperationException "Collection was modified" — cùng cơ chế fail-fast, cùng chỗ không bảo đảm. Python nửa nọ nửa kia: sửa kích thước một dict khi đang duyệt thì nó ném RuntimeError: dictionary changed size during iteration, nhưng xoá phần tử một list khi đang duyệt thì không báo gì và lặng lẽ bỏ sót — đúng cái bẫy áp chót ở trên. Go đi hướng khác hẳn: đặc tả cho phép xoá khỏi map khi đang range và bảo đảm an toàn, còn thêm vào thì không xác định — một quyết định thiết kế rõ ràng thay vì để bạn tự đoán. C++ ở đầu tệ nhất: sửa container làm iterator "mất hiệu lực" và đó là hành vi không xác định — không có hàng rào nào, chương trình có thể chạy sai, hỏng dữ liệu, hoặc sập tuỳ hôm.

Điểm chung, và là thứ đáng mang theo: đừng bao giờ vừa duyệt vừa sửa cấu trúc của cùng một collection — vì thứ tốt nhất bạn nhận được chỉ là một ngoại lệ, và thứ tệ nhất là một kết quả sai âm thầm hoặc một vụ sập ngẫu nhiên. Cách chữa hội tụ ở mọi ngôn ngữ về đúng ba lối: lọc ra một collection mới, dùng đúng phương thức xoá-khi-duyệt mà thư viện cung cấp (removeIf, Iterator.remove), hoặc gom các phần tử cần xoá lại rồi xoá sau vòng lặp. Và bài học sâu hơn — thứ áp cho cả nghề, không riêng vòng lặp: "chạy không báo lỗi" không phải là "chạy đúng". Fail-fast là món quà, không phải lời hứa.

Ngày mai: bộ sưu tập bất biến — List.of khác Collections.unmodifiableList và List.copyOf ở chỗ nào, và cái nào mới thật sự an toàn khi trả dữ liệu ra ngoài.