0. 들어가며
멘토님께서 순회중 컬렉션의 크기를 바꾸면 어떻게 되는지 직접 수행해 보라고 권유해 주셨습니다.
마침 Collection 내부구조에 대해 공부도 해야겠다, 구조도 파악할겸 로그를 따라가며 구조를 파악해 보았습니다.
근데 이 과정이 생각보다 너무 재밌어서 공유하고자 포스팅을 하게 되었습니다.
1. ConcurrentModificationException을 발생시키기
순회중에 컬렉션의 요소를 삭제하는 코드를 실행시켜 보겠습니다.
public void removeInLoop1(){
List<Integer> nums = new ArrayList<>();
for (int i = 1; i < 11; i++) {
nums.add(i);
}
for (Integer number : nums) {
if (number % 2 == 0) {
nums.remove(number);
}
}
}
ConcurrentModificationException이 발생했습니다. exit code가 1인것으로 보아 예외에 의한 비정상적인 종료임도 알 수 있습니다.
로그가 몇 줄 안되고 명확하네요.
첫재줄부터 차근차근 추적해 보겠습니다.
2. ConcurrentModificationException이란?
ConcurrentModificationException가 발생했으며, 친철하게 링크가 걸려있습니다. 눌러서 살펴보겠습니다.
좀 길지만, 핵심만 추려보면 다음과 같습니다.
it is not generally permissible for one thread to modify a Collection while another thread is iterating over it
…중략
If a single thread issues a sequence of method invocations that violates the contract of an object, the object may throw this exception.
멀티 스레드 환경에서 컬렉션 객체가 순회하는 동안 다른 스레드가 해당 컬렉션 객체를 수정했을시에 발생하며,
단일 스레드일지라도 순회중인 컬렉션 객체를 수정하면 발생한다고 합니다.
그럼 어느 부분에서 해당 예외를 발생시킨 걸까요? 로그를 추적해 보겠습니다.
3. 로그 추적하기
3-1. checkForComodification
ConcurrentModificationException은 java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)에서 발생했습니다. 링크로 해당 코드를 찾아가 보겠습니다.
modeCount란 변수가 expectidModCount와 달라 ConcurrentModificationException이 발생했습니다.
조건문만 봐도 modeCount가 기대하거나 예상한 값이 아니여서 발생했다는걸 알 수 있습니다.
그럼 해당 메서드는 어디서 호출했는지 추적해 보겠습니다.
3-1. ArrayList$Itr.next()
checkForComodification()메서드는 java.util.ArrayList$Itr.next(ArrayList.java.967)가 호출했습니다.
오, ConcurrentModificationException외에도 여러 예외가 보입니다.
명칭들이 명확해 별다른 주석 없이도 어떻게 메서드가 실행되는지 책 읽듯이 읽힙니다. 이렇게 개발할 수 있으면 좋겠다...
현재 커서의 다음 원소를 출력해 주면서 여러 예외를 검증하네요.
로그를 살펴보면 next() 메서드는 ArrayList의 내부 클래스인 Itr에서 호출되었다고 합니다.
그럼 Itr클래스는 무엇일까요?
expectedModCount를 발견했습니다! 하지만 이는 원인을 파악할떄 더 살펴보고 우선 로그 추적을 계속해 봅시다.
Itr의 정체는 ArrayList내부에 Iterator를 상속한 InnerClass였습니다.
그리고 해당 내부클래스의 인스턴스를 반환하는 iterator()메소드가 존재합니다.
그렇다면 우리가 for문을 사용하며 어디선가 ArrayList의 Iterator객체를 받아 next()함수로 순회를 했단 것이겠죠?
next는 어디서 호출했을까요?
3-1. 드디어 다시 작성한 코드로...
for문에서 실행했음을 알수 있습니다. 로그를 추적해본 결과 다음을 유추할 수 있습니다.
1. For문은 ArrayList의 iterator()메소드를 호출해 Iterator를 구현한 내부 클래스 'Itr'을 반환받아 사용한다.
2. For문의 순회는 Iterator의 next()를 사용해 이루어진다.
3. checkForComodification의 조건은 modCount != expectedModCount이다.
for문 내부에 컬렉션에 대한 메서드는 nums.remove(number)밖에 존재하지 않습니다.
그러므로 modCount가 기대값과 다르게 만든 범인은 ArrayList.remve()로 예상해볼 수 있겠네요.
ArrayList.remve()를 보며 원인을 찾아 보겠습니다.
4. 원인 파악
4-1. ArrayList.remove()
아쉽게도 modCount에 대한 내용은 찾아볼수가 없네요.
하지만 배열을 인덱스 순으로 탐색하며 매개변수와 동등비교 합니다.
여기서, 배열의 마지막까지 동등한 값이 없다면 false를 반환하지만, 같은 값이 있다면 fastRemove() 메서드를 호출합니다.
fastRemove() 메서드를 살펴보겠습니다.
4-2. ArrayList.fastRemove()
앗! 찾았다! 첫줄부터 modCount를 증가시킵니다.
그리고 삭제된 인덱스 뒤에 값이 더 있다면 System.arraycopy()를 호출합니다.
배열 요소들의 인덱스를 복사해 i-1인덱스에 삽입하는 작업을 바로 여기서 하는거였군요 ㅎㅎ 재밌습니다.
각설하고, 여기까지 봤을떄 modCount가 증가되면서 expectedModCount와 불일치하기 때문에 next()에서 ConcurrentModificationException를 발생시킨것이라 추측할 수 있습니다.
modCount는 도데체 무엇이고 expectedModCount값은 언제 설정되는 걸까요? modCount부터 찾아보겠습니다.
4-3. modCount
으음? 갑자기 AbstractList가 튀어나왔습니다.
당황하지 말라는 듯이 엄청난 양의 주석이 반겨주는군요. 살펴보겠습니다.
The number of times this list has been structurally modified.
...중략
If the value of this field changes unexpectedly, the iterator (or list iterator) will throw a ConcurrentModificationException in response to the next, remove, previous, set or add operations.
...중략
modCount는 배열이 구조적으로 변경된 횟수라고 합니다.
순회 중 목록의 구조적 변경이 이루어지면 ConcurrentModificationException이 발생한다고 합니다.
expectedModCount와 modCount의 비교를 통하여 순회 중의 구조적 수정을 감지해 예외를 발생시켰던 것이네요.
그렇다면 remove()말고도 배열의 구조를 변경시키는 다른 메서드들도 modCount를 증가시키겠죠?
ArrayList 문서에서 modCount++로 탐색하게 되면 trimToSize, ensureCapacity(), add(), clear(), sort()등등 여러 메서드들을 발견할 수 있습니다. 공통점은 모두 배열의 구조를 변경하는 메서드란 점이구요.
다시 돌아와서, 로그를 추적하며 봤던 Itr클래스를 다시 보겠습니다.
4-4. ArrayList$Itr
ArrayList의 Iterator 인스턴스를 생성할 때, modCount값을 expectedModCount로 복사했었죠? 앗!
모든 수수께끼가 풀렸습니다
for문을 실행시키면서 iterator의 expectedModCount값을 ArrayList의 현재 modCount값으로 복사합니다.
그 후, ArrayList의 remove() 메서드를 호출해 modCount가 1이 증가합니다.
그래서 Iterator의 next()를 수행할 때 expectedModCount와 modCount가 같지 않았습니다.
따라서 이를 순회 중 배열의 구조변경이라 판단하고 ConcurrentModificationException를 발생시킨 것이였군요!
5. 정리
public void removeInLoop1(){
List<Integer> nums = new ArrayList<>();
for (int i = 1; i < 11; i++) {
nums.add(i);
}
for (Integer number : nums) {
if (number % 2 == 0) {
nums.remove(number);
}
}
}
- for문은 nums의 iterator 인스턴스 number를 생성한다.
이때 nums의 modCount값을 expectedModCount변수에 복사한다. - iterator의 next()메서드를 사용해 순회를 실시힌다.
- ArrayList의 remove()메서드가 실행되며 ArrayList의 modCount가 증가한다.
- 3번을 실행 후, 배열의 다음 원소로 순회하기 위해 iterator의 next()메서드를 호출한다.
이때, modCount의 값이 expectedModCount값과 다르므로 ConcurrentModificationException를 발생시킨다.
6. 번외 - 순회중에 배열의 구조를 변경할 수 있는 방법
Iterator.remove()를 사용하면 ConcurrentModificationException를 발생시키지 않고
순회중에 배열의 구조를 변경할 수 있습니다.
public void removeInLoop2(){
List<Integer> nums = new ArrayList<>();
for (int i = 1; i < 11; i++) {
nums.add(i);
}
Iterator<Integer> iterator = nums.iterator();
while (iterator.hasNext()) {
Integer number = iterator.next();
if (number % 2 == 0) {
iterator.remove();
}
}
System.out.println(nums);
}
Iterator.remove()는 원소를 삭제하고 증가한 modCount값을 다시한번 expectedModCount에 복사하기 때문입니다.
따라서, 원소 삭제 후에 next()메서드를 수행해도 modCount값과 expectedModCount값이 같아
ConcurrentModificationException가 발생하지 않습니다
아니, 배열 순회중에 배열의 구조변경이 일어나면 예외를 발생한다고 했는데 이게 무슨 일일까요?
Iterator.remove()를 보시면 ArrayList.remove()가 포함되어 있지만 전, 후 처리하는 코드가 있습니다.
삭제 전엔 원본배열이 구조적으로 변경이 되었는지 확인합니다.
삭제 후엔 Iterator가 다루고 있는 cursor의 위치를 현재index의 -1위치로 당겨줍니다.
이렇듯, 순회중에 원본배열의 원소를 삭제해도 Iterator의 순회에 영향이 미치지 않도록 조정해 줬기 때문에 사용이 가능한 것입니다.
Iterater문서의 remove()메서드로 이동해 주석을 살펴보도록 하죠.
This method can be called only once per call to next.
...중략
The behavior of an iterator is unspecified if the underlying collection is modified while the iteration is in progress in any way other than by calling this method, unless an overriding class has specified a concurrent modification policy.
...중략
Throws:
UnsupportedOperationException – if the remove operation is not supported by this iterator
IllegalStateException – if the next method has not yet been called, or the remove method has already been called after the last call to the next method
remove()는 Iterator가 반환한 마지막 요소를 제거합니다.
next()호출당 한번만 호출할 수 있다.
(8버전)이외의 방법으로 컬렉선의 구조 수정이 이루어질 경우엔 동작이 지정되지 않는다.
(18버전) romove()를 호출하거나, 동시 수정 정책을 세워 override한 remove()외의 순회도중 수정의 동작은 지정되지 않는다.
7. 마무리
로그를 따라서 예외 발생 원인을 이렇게까지 구조적으로 파헤쳐 본 적이 없었는데, 정말 즐거운 학습이였습니다.
시간가는줄도 모르고 캡쳐하고 정리하고 찾아보고....ㅎㅎㅎ
이렇게 예외에 대해 분석해 디버깅하는게 정석이겠지만, 이제야 해보게 되었다는게 좀 부끄럽긴 합니다 ㅎㅎ...
앞으로는 예외를 대하는데에 있어서 태도가 바뀌게 될 것 같네요. 정말 좋았던 작업이였습니다.
참고
https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/util/Iterator.html
https://docs.oracle.com/javase/8/docs/api/
https://codechacha.com/ko/java-concurrentmodificationexception/
https://velog.io/@youngerjesus/Java-avoid-ConcurrentModificationException
'Language > JAVA' 카테고리의 다른 글
Int와 Integer의 차이 (0) | 2022.09.29 |
---|---|
JavaScript)문자열 자르기 - substring, slice() [짧] (0) | 2022.09.08 |
Checked, Unchecked Exception (0) | 2022.08.10 |
JavaBean이란? + 자바빈 규약 (0) | 2022.04.16 |
Oracle JDK Archive Link (0) | 2021.12.01 |