[Java] ConcurrentModificationException
정의
java.util.ConcurrentModificationException (CME) 는 fail-fast iterator 가 컬렉션의 구조 변경을 탐지했을 때 던지는 unchecked 예외 (RuntimeException).
이름과 달리 반드시 동시성 (멀티스레드) 문제일 때만 발생하는 것이 아니다. 단일 스레드에서도 자기 자신을 잘못 수정하면 발생한다.
java.util 의 거의 모든 컬렉션 (ArrayList, LinkedList, Vector, HashMap, TreeMap, HashSet, TreeSet) 의 iterator 가 던질 수 있다.
가장 흔한 두 시나리오
1. 순회 중 자기 수정 (단일 스레드)
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer x : list) { // iterator 사용
if (x % 2 == 0) {
list.remove(x); // ❌ CME 던짐
}
}
for-each 는 내부적으로 iterator() 를 호출. 그 iterator 가 만들어진 뒤 list.remove() 가 modCount 를 증가시킨다. 다음 it.next() 에서 검사 실패 → CME.
2. 멀티스레드 동시 수정
List<Integer> shared = new ArrayList<>();
// Thread A
for (Integer x : shared) { /* 읽기 */ }
// Thread B (동시 실행)
shared.add(99); // ← Thread A 의 다음 next() 에서 CME
이 경우 CME 는 best-effort 신호. CME 가 안 나도 안전한 것은 아니다 (더 무서운 [silent corruption] 가능).
메시지에 없는 정보
CME 의 메시지에는 누가 수정했는지 가 적혀 있지 않다.
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
at MyApp.main(MyApp.java:23)
Itr.next() 가 trigger 일 뿐, 진짜 원인은 그 사이에 일어난 다른 어딘가의 수정. 디버깅을 위해서는:
- iterator 와 같은 스택 위쪽 코드 부터 살핀다. 같은 메서드 안에 add/remove 가 있는지.
- 공유 컬렉션이라면 다른 스레드의 호출 을 추적한다. 보통 lambda, async, scheduled task.
ArrayList등에 디버거를 걸어modCount변화를 추적 한다.
해결 패턴
Iterator.remove() 사용
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
Integer x = it.next();
if (x % 2 == 0) {
it.remove(); // ✓ iterator 가 expectedModCount 도 함께 갱신
}
}
Iterator.remove() 는 컬렉션의 remove() 를 호출한 뒤 expectedModCount = modCount 로 동기화한다. 그래서 CME 가 발생하지 않는다.
removeIf 사용 (Java 8+)
list.removeIf(x -> x % 2 == 0);
내부적으로 Iterator.remove() 와 같은 안전한 방식. 더 짧고 명확.
복사본 순회
for (Integer x : new ArrayList<>(list)) { // 복사본 순회
if (x % 2 == 0) {
list.remove(x); // ✓ 원본은 수정
}
}
복사 비용을 감수하는 대신 가장 단순한 해결.
동시성 컬렉션 사용
진짜 멀티스레드 환경이라면 fail-fast 컬렉션 자체를 피한다.
// 읽기 많음, 쓰기 적음
List<Integer> list = new CopyOnWriteArrayList<>();
// 키-값
Map<String, Integer> map = new ConcurrentHashMap<>();
// 큐
Queue<Task> q = new ConcurrentLinkedQueue<>();
이들은 CME 를 던지지 않으면서 동시 수정/순회를 지원한다.
잡으면 안 된다
// ❌ 안 좋은 패턴
try {
for (Integer x : list) {
if (cond) list.remove(x);
}
} catch (ConcurrentModificationException e) {
// 무시
}
이 catch 는 버그를 숨길 뿐. iterator 가 일관성 없는 상태로 더 진행하지 않은 것은 다행이지만, 그래도 “왜 동시 수정이 일어났는가” 라는 진짜 문제는 그대로다.
IMPORTANT
CME 는 잡지 말고 고쳐라. “왜 이게 던져졌는가” 라는 질문에서 출발해 코드의 동시성 가정을 다시 본다. CME 가 사라지도록 위의 해결 패턴 중 하나를 적용.
자주 만나는 추가 사례
Stream.forEach 도 동일
list.stream().forEach(x -> {
if (cond) list.remove(x); // ❌ CME
});
내부적으로 iterator 사용. 같은 함정.
HashMap 순회 중 put
Map<String, Integer> map = new HashMap<>();
for (var entry : map.entrySet()) {
map.put("new", 1); // ❌ CME (key 가 새로 추가되면 구조 변경)
}
값만 바꾸는 것 (entry.setValue(...)) 은 OK.
nested loop
for (Integer a : listA) {
for (Integer b : listB) {
if (cond) listA.remove(a); // ❌ outer 의 CME
}
}
자주 놓치는 케이스, outer loop 에 영향.
참고
- fail-fast iterator
- ArrayList
- LinkedList
- Vector
- CopyOnWriteArrayList
- Brian Goetz, Java Concurrency in Practice, §5.1.2
이 글의 용어 (5개)
- [Java] ArrayListjava
- 정의 는 동적 배열 (dynamic array) 로 구현된 . 내부적으로 를 들고 다니며, 용량이 부족해지면 새 배열로 옮긴다. Java 의 가장 흔한 컬렉션 구현체. 인덱스 기…
- [Java] CopyOnWriteArrayListjava
- 정의 는 쓰기 시 배열 전체를 복사 하는 thread-safe 구현. 읽기에는 lock 이 전혀 없고, 쓰기에는 으로 직렬화한다. (JSR-166) 의 컬렉션. 읽기 압도적 다,…
- [Java] Fail-Fast Iteratorjava
- 정의 Fail-Fast Iterator 는 순회 도중 컬렉션의 구조가 변경되면 즉시 을 던지는 iterator. , , 등 대부분의 컬렉션의 iterator 가 이 방식. 목적은…
- [Java] LinkedListjava
- 정의 는 양방향 연결 리스트 (doubly linked list) 로 구현된 이자 . 각 원소는 와 포인터를 가진 노드로 보관된다. 와 같은 List 이지만, 메모리 레이아웃과 …
- Geometry Basic: 벡터, CCW, 외적, 내적algorithm
- 정의 기하 알고리즘의 밑바탕. 대부분의 2D/3D 기하 문제는 벡터 연산, 내적/외적, CCW 판정 세 가지의 조합으로 풀립니다. 벡터 2D 벡터: 두 점 $A = (ax, ay…
💬 댓글