* 출처
객체의 상태를 동시에 수정을 하는 것이 허용하지 않을 때 발생시키는 ConcurrentModificationException이 있습니다.
ConcurrentModificationException의 쓰임으로 Javadoc API에서 예로 든 것은 하나의 쓰레드가 컬렉션을 순회(iterate) 중일 때, 다른 하나의 쓰레드가
컬렉션을 수정하는 경우입니다. 컬렉션의 모든 요소를 살펴보려고 순회하고 있는데, 컬렉션이 늘었다 줄었다 하는 경우를 생각해보세요.
매우 불안정한 순회가 될 것입니다.
재미있는 생각이 떠올랐습니다. 만일 학교에서 숙제 검사를 하는 경우를 생각해보죠.
요즘도 이런 용어를 쓰는지 모르지만, 1분단부터 차례로 선생님이 학생들의 숙제를 검사하게 됩니다.
만약에 학생들이 자유롭게 자리를 비울 수 있다거나, 검사 도중에 자리를 바꿔 앉을 수 있다면 제대로 된 검사가 불가능할 것입니다. ^^
ConcurrentModificationException은 반드시 다수의 쓰레드가 동반된 상황과 관계된 것은 아니라고 API는 전합니다.
If a single thread issues a sequence of method invocations that violates the contract of an object, the object may throw this exception. |
'숙제 검사'를 제대로 수행하기 위해서는 학생들이 검사를 받는 시점에 자리에 위치해야 합니다.
이것이 숙제검사의 Contract으로 볼 수 있습니다.
위에서 fail-fast 라는 용어가 나오는데요. 이것은 이렇게 설명할 수 있습니다.
학생이 검사를 받기 전에 자리를 비웠다고 해서 반드시 문제가 생기는 것은 아닙니다.
비웠다가도 검사하는 시점에 본인의 위치에 있기만 하면 문제가 될 것이 없습니다.
그러나, 통제의 편의를 위해서는 이러한 문제도 엄격하게 다룰 수 있죠.
이와 같은 정책이 fail-fast 라고 할 수 있습니다.
API에서는 ConcurrentModificationException 구현을 너무 신뢰하지 말라는 듯한 문구가 있습니다.
Fail-fast operations throw ConcurrentModificationException on a best-effort basis. |
민재님이 발견한 재미있는 현상이 적절한 예가 아닌가 생각되네요.
public void testArrayList() { List<String> strings = new ArrayList<String>(); strings.add("1"); strings.add("2"); strings.add("3"); strings.add("4"); strings.add("5"); strings.add("6"); strings.add("7"); strings.add("8"); for (String string : strings) if ("7".equals(string)) strings.remove(string); } |
비교하는 문자열이 7인 경우에만 ConcurrentModificationException가 발생하지 않습니다.
원인을 알아보기 위해서는 ArrayList의 구현을 찾아봐야겠죠.
실제로는 AbstractList의 내부 클래스인 Itr에 구현되어 있습니다.
private class Itr implements Iterator<E> { ... int expectedModCount = modCount; |
modCount는 컬렉션이 수정된 횟수를 임시로 기록하는 값입니다. Iterator를 생성하면서 그 값을 복사해둡니다.
그리고, iterator 객체에서 next() 혹은 remove() 메소드가 호출되면 복사한 값 즉, iterator 생성 시점의 수정 횟수와 현재의 수정 횟수를 비교합니다.
두 수치가 갖지 않으면,ConcurrentModificationException를 발생시킵니다.
그렇다면 앞의 예제에서 예외가 발생하는 과정을 추정해보고, 왜 "7"을 비교할 때는 예외가 나지 않는지 확인해보죠.
for (String string : strings) if ("7".equals(string)) strings.remove(string); |
JDK5에서 지원하는 간결한 for문입니다. foreach 구문이라고도 하죠. 실제로는 strings라는 이름의 ArrayList 객체에의 iterator() 메소드를 호출하게 됩니다.
보다 정확하게 이야기하면 Iteratable 인터페이스를 구현한 객체의 iterator() 메소드를 호출하죠. iterator() 메소드는 Iterator 객체를 반환하는데,
hasNext() 메소드를 호출하여 true가 반환되면 next() 메소드를 호출하여 결과를 반환하는 방식으로 컬렉션의 요소들을 순차적으로 반환합니다.
매우 복잡해보이는 일을 foreach 로 표현할 수 있어 편리해진 것이죠.
위의 구문을 다음과 같이 변경할 수도 있습니다.
구문을 더 복잡해지지만, 내부적인 동작을 보여주는데는 더 유리하죠.
String string = null; for (Iterator it = strings.iterator(); it.hasNext();) { string = (String)it.next(); if ("7".equals(string)) strings.remove(string); } |
앞에서 Iterator의 next()가 ConcurrentModificationException를 발생시킬 수 있다고 했죠.
foreach 수행 중에 암묵적으로 next()가 호출된다는 사실을 기억하세요.
위 코드의 remove() 메소드는 Iterator 객체의 것이 아니라 ArrayList의 행위입니다.
ArrayList의 remove()에서 아래의 코드를 발견할 수 있습니다.
modCount++; |
iterator 생성 시점의 수정 횟수와 현재의 수정 횟수를 비교합니다!
ConcurrentModificationException이 발생하겠죠.
그렇다면 "7"의 경우는 왜 발생하지 않을까요? 또한, "8"로 비교를 수행해도 의외의 결과를 얻습니다.
소스를 한참 뒤져보고서야 해답은 Iterator 객체의 next()와 hasNext()에 있음을 알게 되었습니다.
public boolean hasNext() { return cursor != size(); } public E next() { ... lastRet = cursor++; |
cursor 값은 0부터 시작하여 하나씩 증가하죠.
이를 효과적으로 표현하기 위해서 예제 코드를 수정해보겠습니다.
public void testArrayList() { List<String> strings = new ArrayList<String>(); strings.add("1"); strings.add("2"); strings.add("3"); strings.add("4"); strings.add("5"); strings.add("6"); strings.add("7"); strings.add("8"); String string = null; int coursor = 0; for (Iterator it = strings.iterator(); it.hasNext();) { string = (String) it.next(); System.out.print(++coursor + " : "); if ("7".equals(string)) strings.remove(string);
System.out.println(coursor == strings.size()); } } |
출력 값은 다음과 같습니다.
1 : false 2 : false 3 : false 4 : false 5 : false 6 : false 7 : true |
"7"의 경우엔 hasNext()가 false를 반환하게 됩니다.
결국, 그 다음 요소는 순회가 안되고 무시되어 버리죠. ^^
그렇다면 "8"은 어떻습니까? 앞의 예제의 "7"을 "8"로 변경하여 수행해보면, 8번 반복이 아니라 9번 반복이 수행됩니다.
원인은 Iterator 객체의 hasNext() 메소드의 구현이 크기 비교가 아니라 수치가 동일한가를 비교하고 있기 때문이죠. ^^
이러한 현상에 대해 API에서 on a best-effort basis라고 귀뜸해준 것이라면 매우 적절한 처사라 생각됩니다.
Fail-fast operations throw ConcurrentModificationException on a best-effort basis. |
[읽어 볼 만한 글]
Fail-fast Iterator에 대한 멀티쓰레드 무결성 해결방법
링크가 혹깨질염려가 있어서.~ 위의 내용을 간략하게 설명해볼께요.~
하부 콜렉션 객체에 변경이 일어나 순차적 접근에 실패하면 Enumeration 객체는 실패를 무시하고 순차적 접근을 끝까지 제공한다.
Iterator 객체는 하부 콜렉션 객체에 변경이 일어나 순차적 접근에 실패하면 ConcurrentModificationException 예외를 발생한다.
이처럼 순차적 접근에 실패하면 예외를 발생하도록 되어 있는 방식을 fail-fast라고 한다.
이를 해결하는 2가지 방법이 있지요.
1. 스냅샷으로 데이터 링크 자체를 위해 리스트객체로 다시 한번 감싸는 방법.
public Iterator snapshotIterator(Collection collection) { return new ArrayList(collection).iterator(); } |
위와같이 하면 어떻게 될까요.~ 컬렉션 객체를 다시 새로운 리스트로 감쌉니다.
즉 collection안의 데이터가 변경되서 없어질지라도, iterator하는 것은 new ArrayList()로 감싼 객체링크들이니 문제가 없는 것이죠.
2. 동기화 유틸을 이용하는방법.
Collection c = Collections.synchronizedCollection(myCollection); |
이 과정을 다음처럼 콜렉션 구현 객체를 생성할 때 실행하면 효과적이다.
Set c = Collections.synchronizedSet(new HashSet(...)); SortedSet c = Collections.synchronizedSortedSet(new TreeSet(...)); List c = Collections.synchronizedList(new ArrayList(...)); Map c = Collections.synchronizedMap(new HashMap()); SortedMap c = Collections.synchronizedSortedMap(new HashSortedMap()); |
잠깐!!! 이렇게 생성된 콜렉션 구현 객체는 쓰레드에 안전하므로 스냅샷을 만들지 않고 synchronized 블럭을 이용하여 콜렉션 뷰의 실패를 막을 수 있다.
synchronized(c) { for (Iterator it = c.iterator() ; it.hasNext() ;) { System.out.println(it.next()); } } |
'IT_Programming > Java' 카테고리의 다른 글
[번역] 왜 Null이 나쁜가? (0) | 2015.11.12 |
---|---|
[펌] 람다가 이끌어 갈 모던 Java (0) | 2015.09.30 |
Java Reference와 GC (0) | 2015.02.16 |
NullPointerException을 예방하는 방법 / [번역] 왜 Null이 나쁜가? (0) | 2014.11.10 |
[JAVA] 열거타입 enum (0) | 2013.12.04 |