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.
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.
Thử ba mươi giây
Chạy đoạn xoá phần tử áp chót ở đầu bài, 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.
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.