자바 ConcurrentModificationException에 대해 알아보고 해결책을 정리합니다.
학습할 내용은 다음과 같습니다.
- What is ConcurrentModificationException?
- How does Java knows to throw ConcurrentModificationExeption?
- Solution - using Iterator()
- Various Solutions
References
- Oracle Java Document
- What is transient variable in Java - Serialization Example
- Avoid ConcurrentModificationException while looping over Java ArrayList?
- 자바 직렬화, 그것이 알고싶다. 훑어보기편 by 우아한 형제들
자바에서 ConcurrentModificationException은 NullPointerException처럼 자주 발생하는 예외입니다.
이런 예외는 Multi threads 환경에서 또는 객체의 변경이 허용되지 않는 환경에서 concurrent modification이 일어날 때 이 예외가 발생할 수 있습니다.
예를 들면 한 스레드가 Collection을 Iterating 하고 있을 때 다른 스레드에서 Collection을 modify 하는 경우ConcurrentModificationException이 발생할 수 있습니다.
핵심은 여러 개의 스레드에서 동시 작업하고 있을 때만 발생하는 게 아니라 싱글 스레드 환경에서도 Collection을 fail-fast iterator하고 있을 때 Collection을 modify 하는 걸 허용하지 않는다는 점입니다.
여기서 말하는 modify는 Collection의 사이즈를 변경시키는 행동을 말합니다. (e.g add() or remove())
예시는 다음과 같습니다.
public static void main(String[] args) {
List<String> listOfPhones = new ArrayList<String>(Arrays.asList( "iPhone 6S", "iPhone 6", "iPhone 5", "Samsung Galaxy 4", "Lumia Nokia"));
// This is wrong way, will throw ConcurrentModificationException
for(String phone : listOfPhones){
if(phone.startsWith("iPhone")){
listOfPhones.remove(phone); // will throw exception
}
}
}
Collection에서 ConcurrentModificationExeption이 발생하는 이유를 아는 것은 transient variable인 modCount때문입니다.
modCount는 ArrayList같은 Collection에서 일어나는 Structural modifications이 일어날 때마다 값이 증가하는 식으로 기록을 합니다.
이걸 통해 변경을 알 수 있습니다.
ArrayList에서 Iterate를 할 때 현재의 modCount를 기록합니다.
int expectedModCount = modCount;
그 후 Iterator의 next() 메소드를 통해 다음 요소로 순회를 할 때 expectedModCount가 modCount와 같지 않다면 ConcurrentModfiicationException이 발생합니다.
실제 ArrayList의 내부 구현은 다음과 같습니다.
public E next() {
checkForComodification();
int i = cursor;
if (i >= size) throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
// 이 메소드에서 expectedModCount와 modCount를 비교합니다.
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
// Structural modifications을 발생시키는 메소드 입니다.
public E remove(int index) {
Objects.checkIndex(index, size);
final Object[] es = elementData;
@SuppressWarnings("unchecked") E oldValue = (E) es[index];
fastRemove(es, index);
return oldValue;
}
// Structural modifications이 일어날 때 modCount가 증가하는 걸 볼 수 있습니다.
private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
es[size = newSize] = null;
}
자바에서 ConcurrentModificationException을 해결하는 방법으로는 ArrayList의 remove() 함수를 쓰는 것 대신에 Iterator의 remove()를 통해 해결할 수 있습니다.
ArrayList의 iterator() 메소드를 호출하면 내부적으로 Iterator를 구현한 클래스를 반환합니다. 그러므로 Structural modifications을 일으키는 remove() 함수를 재정의하게 되고 remove()를 호출하면 expectedModCount를 현재의 modCount로 overwrite해서 ConcurrentModificationException이 일어나는 걸 막을 수 있습니다.
ArrayList의 Iterator() 내부 코드는 다음과 같습니다.
public Iterator<E> iterator() {
return new Itr();
}
// Iterator를 상속받은 클래스 입니다.
private class Itr implements Iterator<E> {
...
// 새롭게 정의한 remove 함수입니다. expectedModCount가 값이 증가한 modCount에 덮여써지는 걸 볼 수 있습니다.
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
}
Iterator를 이용한 솔루션 예시는 다음과 같습니다.
public static void main(String[] args) {
List<String> listOfPhones = new ArrayList<String>(Arrays.asList( "iPhone 6S", "iPhone 6", "iPhone 5", "Samsung Galaxy 4", "Lumia Nokia"));
// This is wrong way, will throw ConcurrentModificationException
for(Iterator<String> itr = listOfPhones.iterator(); itr.hasNext();){
String phone = itr.next();
if(phone.startsWith("iPhone")){
itr.remove(); // right call
}
}
}
이 외에도 ConcurrentModificationException을 피하는 방법은 다양하게 있습니다.
for-each 루프를 유지하는 방법입니다. iterating 동안에 제거할 요소를 모두 저장해둔 후 removeAll() 메소드를 통해 처리합니다.
List<Integer> integers = newArrayList(1, 2, 3);
List<Integer> toRemove = newArrayList();
for (Integer integer : integers) {
if(integer == 2) {
toRemove.add(integer);
}
}
integers.removeAll(toRemove);
assertThat(integers).containsExactly(1, 3);
자바 8에서 소개된 Collection interface에 있는 removeIf() 메소드를 통해 처리할 수 있습니다.
List<Integer> integers = newArrayList(1, 2, 3);
integers.removeIf(i -> i == 2);
assertThat(integers).containsExactly(1, 3);
마찬가지로 자바 8에서 도입된 stream API와 Functional interface를 통해 처리할 수 있습니다.
Collection<Integer> integers = newArrayList(1, 2, 3);
List<String> collected = integers
.stream()
.filter(i -> i != 2)
.map(Object::toString)
.collect(toList());
assertThat(collected).containsExactly("1", "3");