top bar

글 목록

2016년 4월 2일 토요일

[JAVA] Deal with 'ConcurrentModificationException'

개요



 자바의 Collection을 다루다 보면, 'ConcurrentModificationException'을 마주칠 때가 있다. 이는 동시성 이슈와 관련된 Exception이기때문에 병렬 스레드 기반으로 동작하는 어플리케이션이 아니라면 거의 접할 일이 없겠지만, 과거에 뭣도 모를때(?) 아주 스트레스를 준 녀석이라 조금 근본적인 관점에서 접근 하고자 한다.


기본 개념



https://docs.oracle.com/javase/7/docs/api/java/util/ConcurrentModificationException.html

위의 공식 문서를 요약하자면 이렇다.

해당 Exception은 어떠한 한 오브젝트에 대하여 허가되지 않은 변경이 동시적으로 이루어질때 발생한다. 그렇다면 '허가되지 않은 변경' 이란건 무엇일까?

간단히 말하면 한 스레드가 어떤 Collection을 반복자(iterator)를 이용하여 순회하고 있을때, 다른 한스레드가 해당 Collection에 접근하여 변경을 시도하는 경우이다. 하지만 꼭 멀티스레드 환경에서만 발생 하는것은 아니다. 싱글 스레드 환경에서도 발생할 수 있는데, 위와 같이 어떤 Collection을 순회하고 있는 반복문 안에서, 순회되고 있는 Collection에 대한 변경이 시도 될 때 또한 해당 Exception이 발생 하게 된다.


예제를 통해 알아보자



먼저 2개의 스레드와 'staticList'라는 이름을 가진 shared resource가 존재한다는 것을 전제로, 아래와 같은 시나리오를 구성해 보았다

일단 staticList에는 Integer형의 요소가 1부터 10까지 들어 있다.
public static List<Integer> staticList = new ArrayList<Integer>() {
    private static final long serialVersionUID = 1L;
    {
        add(1);
        add(2);
        add(3);
        add(4);
        add(5);
        add(6);
        add(7);
        add(8);
        add(9);
        add(10);
    }
};

1번 스레드는 staticList를 단순히 순회하며 1초마다 해당 리스트의 요소를 출력한다.
/**
 * @author asuraiv
 */
public class Thread1 implements Runnable {

    public void run() {
        
        for(Integer val : ConcurrentModifyTest.staticList) {
            
            System.out.println(val);
            
            try {
                TimeUnit.MILLISECONDS.sleep(1000);
            } catch (InterruptedException e) {
                System.err.println(e);
            }
        }
    }
}

2번 스레드는 처음 구동되고 나서 3초간 대기하고 있다가, staticList에서 '5'요소를 삭제한다. (index가 아니라 Integer형의 '5' 오브젝트를 삭제)
/**
 * @author asuraiv
 */
public class Thread2 implements Runnable {

    public void run() {     
        
        try {
            TimeUnit.MILLISECONDS.sleep(3000);
        } catch (InterruptedException e) {
            System.err.println(e);
        }
        
        ConcurrentModifyTest.staticList.remove((Integer)5);
    }
}

그림으로 표현하면 아래와 같을 것이다.



이제 시나리오는 세워 졌으니, main메소드를 이용해 돌려보자.
/**
 * @author asuraiv
 */
public class ConcurrentModifyTest {
    
    public static List<Integer> staticList = new ArrayList<Integer>() {
        private static final long serialVersionUID = 1L;
        {
            add(1);
            add(2);
            add(3);
            add(4);
            add(5);
            add(6);
            add(7);
            add(8);
            add(9);
            add(10);
        }
    };

    public static void main(String[] args) {
        
        ExecutorService executor = Executors.newFixedThreadPool(2);
        executor.execute(new Thread1());
        executor.execute(new Thread2());
    }
}

결과는 아래와 같다
1
2
3
4
Exception in thread "pool-1-thread-1" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
    at java.util.ArrayList$Itr.next(ArrayList.java:831)
    at com.hong.study.concurrent.Thread1.run(Thread1.java:14)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
    at java.lang.Thread.run(Thread.java:745)
우리가 원하는 'ConcurrentModificationException' 이 발생했다.

1번스레드가 Integer 5요소를 출력하기 전에 대기하고 있던 2번스레드가 5를 삭제해 버렸다. 순회하고 있는 List에 '허가되지 않은 변경'이 발생한 것을 탐지한 iterator는 바로 'ConcurrentModificationException'을 발생 시킨다.

그렇다. 해당 Exception은 Iterator가 발생시키는 것이다. 그렇다면 향상된 for문이 아닌 Iterator를 사용하지 않는 일반 for문으로 순회할 때는 어떤 일이 발생할까? 해당 포문을 아래와 같이 변경한다.
int size = ConcurrentModifyTest.staticList.size();
for (int i = 0; i < size; i++) {
      .
      .
      .

결과는 아래와 같다.
1
2
3
4
6
7
8
9
10
Exception in thread "pool-1-thread-1" java.lang.IndexOutOfBoundsException: Index: 9, Size: 9
    at java.util.ArrayList.rangeCheck(ArrayList.java:635)
    at java.util.ArrayList.get(ArrayList.java:411)
    at com.hong.study.concurrent.Thread1.run(Thread1.java:17)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
    at java.lang.Thread.run(Thread.java:745)
보는 바와 같이 ConcurrentModificationException이 아닌 IndexOutOfBoundsException이 발생한다. 출력 된 결과도 보면 '5'가 빠져 있다. 최초 size가 10 이므로 for문이 i = 0 부터 9까지 순회하게 되는데, 중간에 요소 하나가 삭제되고 size가 9가 되어 버렸기 때문이다. 때문에 인덱스 바운더리 관련 Exception이 발생한 것이다.


마지막으로 싱글 스레드에서 발생 할 수 있는 경우를 살펴보자.
/**
 * @author Jupyo Hong
 */
public class ConcurrentModifyTest2 {

    public static void main(String[] args) {
        
        List<Integer> list = new ArrayList<Integer>();
        list.add(1);
        list.add(2);
        list.add(3);
        list.add(4);
        list.add(5);
        list.add(6);
        list.add(7);
        list.add(8);
        list.add(9);
        list.add(10);
        
        for(Integer val : list) {
            if(val == 5) {
                list.remove((Integer)5);
            }
        }
    }
}
향상된 for문을 사용해 순회 도중, 순회 되고 있는 리스트의 요소를 삭제하는 코드이다.
역시나 아래와 같이 'ConcurrentModificationException'이 떨어진다
Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
    at java.util.ArrayList$Itr.next(ArrayList.java:831)
    at com.hong.study.concurrent.ConcurrentModifyTest2.main(ConcurrentModifyTest2.java:32)

싱글 스레드라고 할지라도 순회 되고있는 리스트가 중간에 변경이 된건 마찬가지 이기 때문에 해당 Exception이 발생하는 것도 이상하지 않다.

마치며..



 자바 개발을 하면서 평소에 가장 다루기 어렵고 힘든 분야라고 하면 GC관련 성능이슈와 동시성 이슈라고 생각해 왔다. 해당 분야를 전부 파악하기란 매우 어렵겠지만, 차근차근 실무와 이론적인 공부를 병행해 가면서 지식의 깊이를 더해야 겠다는 생각을 하게 되었다.

이제 'java.util.concurrent' 패키지에서 제공하는 라이브러리들에 관해서 정리 해봐야 겠다.
(언제가 될지는 모름...)