자바 ConcurrentModificationException

Jeongmin Yeo (Ethan)·2021년 1월 5일
5

Java

목록 보기
2/4
post-thumbnail

자바 ConcurrentModificationException에 대해 알아보고 해결책을 정리합니다.

학습할 내용은 다음과 같습니다.

  • What is ConcurrentModificationException?
  • How does Java knows to throw ConcurrentModificationExeption?
  • Solution - using Iterator()
  • Various Solutions

References


1. What is ConcurrentModificationException?

자바에서 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
         }
     } 
}

2. How does Java knows to throw ConcurrentModificationExeption?

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;
}

3. Solution - using ListIterator()

자바에서 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
        }
     }
}

4. Various Solutions

이 외에도 ConcurrentModificationException을 피하는 방법은 다양하게 있습니다.

Not Removing During Iteration

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);

Using removeIf()

자바 8에서 소개된 Collection interface에 있는 removeIf() 메소드를 통해 처리할 수 있습니다.

List<Integer> integers = newArrayList(1, 2, 3);

integers.removeIf(i -> i == 2);

assertThat(integers).containsExactly(1, 3);

Filtering Using Streams

마찬가지로 자바 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");
profile
좋은 습관을 가지고 싶은 평범한 개발자입니다.

0개의 댓글