[Java] ConcurrentModificationException 발생
개요
Java GUI 로 게임을 만들어 보다가 다음과 같은 예외가 발생했다.
공식 문서에는 이렇게 정의되어 있다.
This exception may be thrown by methods that have detected concurrent modification of an object when such modification is not permissible. ...<생략>
번역을 해보면 "이 예외는 동시 수정이 허용되지 않은 객체에서 동시 수정이 발생할 경우 수정을 감지한 메서드에서 throw 될 수 있다." 이다. 즉, 객체의 상태를 동시에 수정하는 것을 방지하고 싶을 때 throw 하는 예외이다.
해당 예외는 Collection을 사용하는 중 흔하게 만날 수 있으며 필자는 Java의 향상된 for문을 사용하다가 만나게 되었다.
이번 포스팅에서는 필자의 코드를 통해 분석하여 발생한 이유와 해결하기 위하여 행했던 방법들을 소개하려 한다.
목차
- 예외 발생 원인 분석
- 해결 과정
- 추가 해결법 CopyOnWriteArrayList
예외 발생 원인 분석
결과부터 말하면 예외 원인은 반복자를 이용한 순회 중에 list에 수정(구조적 변화)이 생겼기 때문이다.
필자는 해당 프로젝트에서 유닛의 총알 연속 발사를 구현하는 중이었다. 총알 발사버튼을 누르고 있으면 연속 발사를 되게끔 구현했는데 문제는 총알과 총알 사이의 딜레이를 짧게 했을 때 발생했다.
시험 결과 발사된 총알의 개수와는 상관 없고 총알 발사 딜레이를 짧게 할수록 더 빠르게 예외가 발생했다. 디버깅 결과 내가 작성한 코드에서 예외 발생 지점은 다음과 같다.
private boolean userBulletPaint(...) {
int size = userBullets.size(); // 사용자가 발사한 총알 리스트
for(Bullet bullet : userBullets) { // <-------- 예외 발생 지점
if(!bullet.paint(...)) { // 총알을 그린다.
return false;
}
}
return true;
}
위의 코드는 게임 패널에 그려야 하는 사용자의 총알들을 그리는 로직이다. 더 깊게 들어가 보자.
public class ArrayList<E> ...{
public Iterator<E> iterator() {
return new Itr();
}
...
private class Itr implements Iterator<E> {
...
int expectedModCount = modCount;
...
public E next() {
checkForComodification(); // <-------------- 예외 발생 지점
...
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
}
향상된 for문은 내부적으로 iterator()를 호출하여 반복자를 반환 받아 사용하기 때문에 Iterable<>을 구현한 클래스만 사용가능하다. ArrayList는 Itr
인스턴스가 반복자로 사용되며, 반복자를 이용하여 순회할 때 checkForComodification()
을 통해 순회 도중에 리스트의 구조적 변화를 금지한다.
어떻게 구조적 변화를 감지하는지 더 자세히 알아보자면, ArrayList는 인스턴스가 생성된 후 부터 add나 remove와 같은 리스트의 크기가 변하는 메서드를 호출하면 modCount
를 1 증가시킨다. 즉 modCount는 리스트의 구조적 상태를 변화시킨 횟수를 의미한다. ArrayList의 반복자 Itr 인스턴스는 생성될 때 modCount를 복사해서 가지고 있고 next()나 remove()를 호출하면 가지고 있는 원본 modCount(expectedModCount)와 현재 리스트의 modCount를 비교하여 순회 중 리스트의 구조적 변화가 있는지 확인한다. 만약 변화가 발견 된다면, 즉 modCount와 expectedModCount가 다르다면 ConcurrentModificationException()
을 throw 한다.
이제 에러가 발생한 필자의 코드를 다시 보자.
private boolean userBulletPaint(...) {
for(Bullet bullet : userBullets) { // <-------- 예외 발생 지점
if(!bullet.paint(...)) { // 총알을 그린다.
return false;
}
}
return true;
}
에러가 발생한 이유는 다음과 같다.
- for문을 순회하기 위해 반복자를 생성한다. 이때 modCount는 0이라고 가정한다. 즉 expectedModCount 또한 0이다.
- 반복자의 next()를 호출한다.
- 이때 next()에서 checkForComodification()을 호출하기 전에 총알 발사버튼을 눌러 userBullets.add(bullet)이 호출된다. modCount는 1이 된다.
- 총알 발사기능은 for 문을 순회하는 스레드와는 다른 스레드가 수행하기 때문에 가능하다.
- next()는 이어서 checkForComodification()을 호출한다. 현재 modCount는 1이고, expectedModCount는 0이기 때문에 ConcurrentModificationException이 발생한다.
한 마디로 정리하면 에러 발생 원인은 총알들을 그리라는 명령을 받아서 그리고 있는데 갑자기 누가 총알을 추가하여 그리라고 명령받은 총알의 개수보다 많아진 것이다.
해결 과정
에러 발생 원인을 쉽게 다시 정리해보면 ArrayList를 그대로 사용하면서 해결 할 방법은 두 가지 정도가 떠오른다.
- 총알들을 그리기 전에 총알들을 복사하여 추가나 삭제가 될 수 있는 원본이 아니라 복사한 것을 보고 그리는 것이다.
- 총알들을 그리기 전에 개수를 미리 세어두고 추가가 되든 없어지든 무시하고 미리 센 개수 만큼 그리는 것이다.
두 방법 모두 현재 상태를 스냅샷을 찍어 스냅샷을 그리는 아이디어다.
private boolean userBulletsPaint1(Graphics2D g2d, ImageObserver imageObserver) { // 첫 번째 방법
List<Bullet> copyBullets = new ArrayList<>(userBullets); // 복사 한다.
for(Bullet bullet : copyBullets) {
if(!bullet.paint(g2d, imageObserver))
return false;
}
}
private boolean userBulletsPaint2(Graphics2D g2d, ImageObserver imageObserver) { // 두 번째 방법
int size = userBullets.size(); // 미리 개수를 센다.
for (int i = 0; i < size; i++) {
try {
Bullet bullet = userBullets.get(i);
if (!bullet.paint(g2d, imageObserver))
return false;
} catch (IndexOutOfBoundsException ex) {
// 마지막 총알을 그릴 때 GC에 의해서 이미 삭제되었다면 이 예외가 발생한다.
// 무시해도 문제가 없다.
}
}
}
멀티 쓰레드 환경이기 때문에 좀 더 부가적인 설명을 하자면 userBullets에 수정 메서드를 호출하는 스레드는 총 2개 있고 각각 add, remove 하나씩을 담당하여 수행하기 때문에 충돌이 일어나지는 않는다. 따라서 thread safe한 Vector를 굳이 사용할 필요가 없다. 사용한다고해서 ConcurrentModificationException 이 해결되지도 않는다.
다시 돌아와서 위의 코드들은 문제가 없을까?
첫 번째 방법은 리스트를 복사하여 복사한 리스트를 그린다. 리스트를 복사하기 때문에 메모리가 더 많이 소요되지만 문제는 해결된다.
두 번째 방법은 개수를 저장하여 그린다. 만약에 스냅샷을 찍었을 때 총알의 개수가 8개라고 가정해보자. 마지막 8번째 총알을 그리려 할 때 해당 총알이 이미 리스트에서 삭제되었고, GC에 의해 총알 객체 또한 이미 삭제된 상태라면 IndexOutOfException이 발생한다. 그래서 try-catch로 예외 처리까지 해야지 문제가 해결된다. 리스트를 복사할 필요가 없기 때문에 두 번째 방법이 더 효율적일 것 같다. 필자는 두 번째 방법을 선택했다.
추가 해결법 CopyOnWriteArrayList
Java는 userBulletsPaint() 처럼 리스트를 읽는 행위가 자주 일어날 때 사용하면 좋은 컬렉션인 CopyOnWriteArrayList
를 제공한다.
내부 코드와 공식문서를 보면 CopyOnWriteArrayList
는 다음과 같은 특징이 있다.
- add 나 remove와 같은 수정 메서드들이 동기화되어 있어서 thread safe 하다.
- add 나 remove와 같은 수정 메서드들을 호출하면 내부적으로 새로운 배열을 생성하여 복사(shallow copy)하고 해당 배열을 수정하여 원본과 교체한다.
- CopyOnWriteArrayList의 반복자는 리스트의 배열을 스냅샷(shallow copy)으로 저장하고 배열을 직접 순회한다. 즉 modCount가 필요 없다.
문제가 없을지 시나리오를 점검해보자.
- CopyOnWriteArrayList로 총알 리스트 userBullets를 생성한다.
- userBullets은 내부에 배열A를 갖는다.
- 반복자를 생성하여 순회하며 총알들을 그린다.
- 반복자는 배열A 레퍼런스를 스냅샷으로 가진다.
- 순회 중 총알 리스트에 총알을 추가하거나 삭제한다.
- 총알 추가나 삭제하는 행위가 수행되면 배열A를 복사하여 배열B를 생성하고 수정하여 원본과 교체한다.
- 즉 userBullets의 내부 배열은 배열B로 교체된다.
- 이어서 그린다.
- 반복자는 배열A를 계속해서 그린다.
총알의 추가나 삭제는 매번 새로운 배열을 생성하여 적용되므로 반복자가 갖고있는 배열에는 영향을 미치지 않는다. 하지만 수정 메서드를 호출할 때마다 배열을 새로 생성해야해서 성능 이슈가 발생할 수 있다.
마지막으로, Composite 패턴으로 ArrayList의 add, remove 기능과 CopyOnWriteArrayList의 반복자를 합성하면 그것도 나름 괜찮은 방법일 것 같다.