Java 개발을 한 사람들이라면 ConcurrentModificationException 에 대해 들어보았을 것이다.

  • Collection에서 순회중, 데이터의 조작이 발생하는 경우 ( 이름만 보면 분명 Concurrent 인데 멀티스레드가 아닌 단일 스레드 환경 에서도 해당 예외가 발생한다. )
  • thread가 간섭 하면서 발생

이라고 흔히들 알고 있을 것 같다. 

 

오늘은 대체 이 ConcurrentModificationException 이 내부적으로 어떤 로직에 의해 throw 되는 것인지 알아보기로 했다. 


ConcurrentModificationException 발생 시켜 보기

  • 내부적으로 Iterator 를 사용하는 코드가 존재하는 경우 발생하게 된다.
    • 가장 흔하게 볼 수 있는 것은 이미 해당 Collection 으로부터 Iterator 가 생성되어 순회를 진행하는 가운데, 동일 Collection 인스턴스 에서 제공하는 멤버함수인 remove 를 사용하여 자료구조 내 원소를 삭제 하려는 경우에 발생하는 것이다.
  • 아래와 같은 enhanced for loop 역시 Iterator방식으로 순회하고 있기 때문에 아래와 같은 상황이라면 ConcurrentModificationException 이 발생하게 된다.
Set<Integer> set = new HashSet<>();
Iterator it = set.iterator();
while(it.hasNext()){
	int target = (int)it.next());
	set.remove(target);
}

Iterator 의 remove 메서드를 직접 사용한다면?

이번 글을 쓰게 된 계기다.
백기선님의 이펙티브 자바를 수강한 동료들과 함께 스터디 를 진행했다.
강의 내용은 모르지만, 강의 속에서는 iterator 의 remove() 를 호출 할 경우, 순회도중에 삭제를 하더라도 예외가 발생하지 않는다고 했다고 한다. 
여기서 궁금해졌다. iterator 의 remove() 를 사용하기만 하면 문제가 없는걸까??

Iterator 객체로 순회하며, 동일한 Iterator 로 remove 하는 경우

@Test
public void remove_using_iterator_while_iterating_success() {
        Iterator<Integer> it = list.iterator();

        while (it.hasNext()) {
            if (it.next() == toBeRemovedElement) {
                it.remove();
            }
        }

        Assertions.assertThat(list).doesNotContain(toBeRemovedElement);
}

정상 동작한다.

Iterator 객체로 순회하며, 다른 Iterator 객체로 remove 하는 경우

@Test
@ExceptionTest(ConcurrentModificationException.class)
public void access_using_other_iterator1_object_while_iterating_with_iterator2_object_fail() {
        Iterator<Integer> it1 = list.iterator();
        Iterator<Integer> it2 = list.iterator();

        while(it1.hasNext()) {
            if (it1.next() == toBeRemovedElement) {
                it1.remove();
            }
        }

        if(it2.hasNext()){
            Assertions.assertThatExceptionOfType(ConcurrentModificationException.class)
                .isThrownBy(() -> it2.next());
        }

}

it1 에서 발생한 변화로 it2 에서는 next() 로 원소에 접근하려는 순간부터 예외가 발생해 버렸다.


ConcurrentModification 을 발생시키는 부분

대체 ConcurrentModificaiton 은 어디에서 발생하는 걸까?

 

테스트에서 사용한 자료구조가 ArrayList 였기에 해당 클래스로 이동해서 천천히 살펴보니, checkForComodification() 라는 이름의 메서드가 보였다.

  • Itr 은 ArrayList 에 최적화된 Iterator구현체다
    • 여기에 존재하는 checkForComodification 은 Itr 내의 메서드 를 호출 할 때 마다(hasNext() 제외) 내부적으로 호출되고 있었다.

checkForModification 는 매우 간단한 로직을 갖고 있다.

modCount 가 expectedModCount 와 같지 않을 경우 오늘의 주인공 ConcurrentXX 예외를 던져버린다.

 

 

modCount 너는 누구냐

checkForModification에서 사용하는 modCount 는 뭐하는 애일까? 

이 변수의 위치를 찾아보면 AbstractList 에 위치함을 볼 수 있다. 

 

protected 로 선언되었기에 하위클래스 객체들 에서 접근 가능한 변수다.

따라서 Itr 의 경우 ArrayList 의 Nested Class 이기 때문에 Itr 을 생성한 ArrayList 객체에 존재하는 이 modCount 를 바라볼 수 가 있다.

 

 

modCount 는 list 가 구조적으로 변경된 횟수를 나타내는 변수로, 이 구조적인 변화에는 “리스트의 size 가 변경되는 경우” 가 포함된다. 따라서 size 를 1 줄이는 remove 도 포함된다.

이 필드는 iterator() 과 listIterator() 메서드에 의해 반환되는 list iterator 구현체 에 의해 사용된다. 만약 modCount 값이 예상치 못하게 변경된 경우 (그 iterator 객체 입장에서는 예상치 못하게 변경된 경우..) 그 iterator 로 next, remove, previous, set ,add 연산을 수행하려고 할 경우 예외를 던진다.

 

여기서 얘기하는 fail-fast 부분은 이해가 되지 않았다. 나는 단순하게 순회하는 가운데 예외가 발생해야할 상황이 발생할 경우, 즉시 예외를 던져 빠르게 실패하도록 하는 것이라고 생각했다.

 

이를 읽고는 modCount에 대해 팀원 들은 “낙관적 락” 과 비슷한 것 같다는 의견을 내 주었다.

어떤 느낌인지는 알 것 같았다. 

 

일반적인 낙관적 락은

1. 실제 공유하는 값을 복사해 두고

-> 2. 각자에서 변경을 가하고

-> 3. 공유하는 값에 대해 실제 이 업데이트 사항을 반영해야 하는 시점이면 "공유하는 값의 현재 값" 과 "내가 복사하던 시점의 공유 값" 을 비교해, 자신이 복사하던 시점과 다른 값을 갖고 있을경우 자신의 업데이트 값도 버리고, 자신을 실패시킨다. 

 

비슷하게 생각하면 이것도 동시성 이슈처럼 본 것 같긴하다는 생각이 들었다. 

 

위의 테스트에서는 어떤 일이 일어났는지 살펴보자

위로 올라가 코드를 보기 귀찮으니 다시 가져왔다

@Test
@ExceptionTest(ConcurrentModificationException.class)
public void access_using_other_iterator1_object_while_iterating_with_iterator2_object_fail() {
        Iterator<Integer> it1 = list.iterator();
        Iterator<Integer> it2 = list.iterator();

        while(it1.hasNext()) {
            if (it1.next() == toBeRemovedElement) {
                it1.remove();
            }
        }

        if(it2.hasNext()){
						// 이 지점에 breakPoint 를 걸어보자
            Assertions.assertThatExceptionOfType(ConcurrentModificationException.class)
                .isThrownBy(() -> it2.next());
        }

}

 

it2 에서는 expectedModCount가 3이다.

즉 it2 가 생성되던 시점에 복사해온 modCount 값은 3이었던 것이다.

하지만 이미 it1 에서 호출한 remove()로 인해 AbstractList 의 modCount 는 4로 변경되었다.

공식문서에서 말하는 것 처럼 이러한 상황에서 it2 의 next를 호출하면 checkForConcurrentModification() 호출로 인해 예외가 발생하게 된다.

Collection , 그러니까 ArrayList 에서의 remove() 는?

내부적으로 호출하는 fastRemove() 에서 modCount 값을 변경한다.

따라서 동일하게 예외가 발생하게 되는 것이다.

결론

List 는 내부적으로 modCount 라는 변수를 사용해, 해당 자료구조에 생기는 구조적인 변화의 횟수를 추적하고 있는다.

Iterator 객체를 생성할 경우, 해당 객체를 생성하는 시점의 modCount 를 자신의 로컬 변수로 복사해 둔다.

그리고는 Iterator 의 hasNext 를 제외한 메소드들을 호출 할 때면 매번 “실제 modCount “ 값과의 비교를 통해 어디선가 해당 컬렉션에 대한 변화를 수행했는지를 체크한다.  

이것이 감지될 경우, 낙관적 락 처럼 동시성 이슈로 취급하여 Conccurent 라는 이름이 붙은 예외를 던진다.

 

참조

https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/util/ArrayList.html https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/util/AbstractList.html#modCount

복사했습니다!