ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 자바 Collection Iterator - ConcurrentModificationException
    자바(Java) 강의 2019. 5. 5. 10:08

    자바에서 Iterator를 사용해 보았다면, 한번쯤은 ConcurrentModificationException을 봤을 것이다. 이번 포스트에서는 ConcurrentModificationException은 어떤 경우에 발생하는지, ConcurrentModificationException을 해결하는 다양한 코딩 방법에 대해 설명하도록 하겠다.

    목표

    • ConcurrentModificationException
    • for( ; ; )를 이용한 방법
    • CopyOnWriteArray를 이용한 방법
    • 나중에 하기
    • Java 8 : removeIf와 lambda expression을 이용한 방법

    ConcurrentModificationException

    ConcurrentModificationException은 언제 발생할까? 이 예외는 어떤 쓰레드가 Iterator가 반복중인 Collection을 수정하는 경우 발생한다. 어떤 쓰레드는 현재 반복문이 실행하고있는 쓰레드일 수도 있고, 전혀 다른 쓰레드일 수도 있다. 예를 보자.

    import java.util.ArrayList;
    import java.util.Iterator;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);

    for(Integer i : list) {
    list.remove(i);
    }
    }
    } Exception in thread "main" java.util.ConcurrentModificationException at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909) at java.util.ArrayList$Itr.next(ArrayList.java:859) at Main.main(Main.java:14)

    for-each문은 Iterator를 사용한다. 이 반복문 내부에서 add/remove등과 처럼 리스트를 수정하는 오퍼레이션을 하는 경우 ConcurrentModificationException이 발생한다.

    import java.util.ArrayList;
    import java.util.List;

    public class Main {
    static List<Integer> list = new ArrayList<>();

    public static void main(String[] args) {
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);

    for(Integer i : list) {
    new Thread(() -> list.remove(i)).run();
    }
    }
    }

    마찬가지로 다른 쓰레드에서 Iterator로 반복중인 List를 수정하려 하면 ConcurrentModificationException이 발생한다.

    왜 그냥 add/remov를 하게 만들지 ConcurrentModificationException을 발생시키는 것일까? 예를들어 위의 반복문에서 remove대신 add(i)를 한다고 해보자. 한번 반복이 실행 될 때마다 리스트에는 값이 하나 더 더해질 것이다. 그러면 이 반복문은 언제 끝나는가? 끝나지 않을것이다. 본의아니게 무한루프를 만들게 된 것이다. 또 다른 예는 아래와 같다.

    import java.util.ArrayList;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);

    int length = list.size();
    for(int i = 0; i < length; i++) {
    list.remove(list.get(i));
    }
    }
    } Exception in thread "main" java.lang.IndexOutOfBoundsException: Index: 2, Size: 1 at java.util.ArrayList.rangeCheck(ArrayList.java:657) at java.util.ArrayList.get(ArrayList.java:433) at Main.main(Main.java:14)

    나는 위와 같은 에러를 학창시절에 많이 봤었다. 그리고 놀랍게도 내가 다녔던 회사에서도 본적이 있다. 문제가 무엇인가? 우리는 length를 반복문 실행 전에 할당하고 이 값을 for문에서 사용한다. 그런데 반복문 안에서 엘리먼트를 지우면 그 반복문의 크기가 변한다. 크기만 변하는가? 삭제된 엘리먼트의 자리를 채워야 하므로 엘리먼트 하나를 삭제할 때 마다 모든 인덱스가 하나씩 이동한다. 

    import java.util.ArrayList;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);

    System.out.println("삭제 전");
    for(int i = 0; i < list.size(); i++) {
    System.out.println("인덱스: " + i + " - 값:" + list.get(i));
    }

    System.out.println("삭제 후");
    list.remove(list.get(1));
    for(int i = 0; i < list.size(); i++) {
    System.out.println("인덱스: " + i + " - 값:" + list.get(i));
    }
    }
    } 삭제 전 인덱스: 0 - 값:1 인덱스: 1 - 값:2 인덱스: 2 - 값:3 삭제 후 인덱스: 0 - 값:1 인덱스: 1 - 값:3

    리스트에서 인덱스 1의 엘리먼트를 삭제하기 전엔 인덱스 2에 3이 들어있지만, 삭제 후에는 인덱스 1에 3이 들어있다. 반복문 내부에서 여러가지 작업을 하는경우 리스트를 조작하면 이렇게 엘리먼트의 인덱스가 실시간으로 계속 바뀔수 있다. 특히 다른 쓰레드에서 값을 수정하고 있다면, 이런 문제를 발견하는것 부터가 난관일 수 있다.

    그렇다면 add/remove같은 작업을 하지 말란것인가? 아니다. Iterator로 반복하는 엘리먼트의 수정은 어떻게 해야하는지 알아보도록 하자.



    for( ; ; )를 이용한 방법

    첫번째 방법은 이미 위에서 설명했다. 

    import java.util.ArrayList;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);

    for(int i = 0; i < list.size(); i++) {
    if( i == 1 ) {
    list.remove(i);
    }
    System.out.println("인덱스: " + i + " - 값:" + list.get(i));
    }
    }
    }

    또는 Iterator를 이용한 방법이 있다.

    import java.util.ArrayList;
    import java.util.Iterator;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);

    Iterator<Integer> it = list.iterator();
    while (it.hasNext()) {
    Integer i = it.next();
    System.out.println("값:" + i);
    if (i == 2) {
    it.remove();
    }
    }

    System.out.println("---------------");
    for (Integer i : list) {
    System.out.println("값:" + i);
    }
    }
    } 값:1 값:2 값:3 값:4 --------------- 값:1 값:3 값:4

    대신 이 방법으로 할때는 설명한 것 처럼 무한루프나, IndexOutOfBoundary 예외 또는 실행시 발생할 수 있는 여러가지 경우를 고려해야 한다.

    CopyOnWriteArray를 이용한 방법

    import java.util.ArrayList;
    import java.util.List;
    import java.util.concurrent.CopyOnWriteArrayList;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new CopyOnWriteArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);

    for(Integer i : list) {
    System.out.println("값:" + i);
    if( i == 2) {
    list.remove(i);
    }
    }

    System.out.println("--------");
    for(Integer i : list) {
    System.out.println("값:" + i);
    }
    }
    } 값:1 값:2 값:3 -------- 값:1 값:3

    CopyOnWriteArrayList는 java.util.concurrent패키지에 포함된 클래스로, 컬렉션에 어떤 수정작업이 일어나는 경우 새 컬렉션을 만들어 수정한다. 이후 Synchronization은 JVM이 알아서 해결해 준다. 따라서 위처럼 반복문 내부에서 add를 하더라도, 이 add는 내부적으로 복사된 새 ArrayList에 더해지므로 ConcurrentModificationException이 발생하지 않는다. 반복을 마친 후 다시 반복해 보면 add했던 값들이 모두 들어간 것을 확인할 수 있다. 이 방법은 컬렉션에 값이 많이 들어있고 자주 수정해야 하는 경우 성능이 좋지 않을 수 있다. 수정할 때 마다 같은 크기의 컬렉션을 항상 새로 만들고 값을 복사해 넣어야 하기 때문이다. 

    나중에 하기

    import java.util.ArrayList;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);
    List<Integer> toRemove = new ArrayList<>();

    for (Integer i : list) {
    if (i == 2) {
    toRemove.add(i);
    }
    System.out.println("값:" + i);
    }

    list.removeAll(toRemove);
    System.out.println("--------");
    for (Integer i : list) {
    System.out.println("값:" + i);
    }
    }
    } 값:1 값:2 값:3 값:4 -------- 값:1 값:3 값:4

    이 방법은 우리가 자체적으로 다른 리스트를 만들고 해당 리스트에 수정할 값을 넣은 후 반복이 끝난 후 한꺼번에 수정하는 것이다. 리스트가 하나 더 필요하지만 CopyOnWriteArrayList가 add가 한번 불릴 때마다 새 리스트를 만들고 값을 복사하는것에 비하면 상대적으로 효율적임을 유추할 수 있다.

    Java 8 : removeIf와 lambda expression을 이용한 방법

    이 방법은 조건에 맞은 엘리먼트를 삭제하고 싶을 때만 사용할 수 있다. 
    import java.util.ArrayList;
    import java.util.List;

    public class Main {
    public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);

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

    for (Integer i : list) {
    System.out.println("값:" + i);
    }
    }
    } 값:1 값:3 값:4

    자바 8부터는 Collection에서 removeIf라는 메서드를 제공한다. 이 메서드의 인자로 Predicate을 넣어준다. removeIf 메서드 내부에서 인자로 들어가는 람다 표현(i -> i==2)이 true이면 해당 엘리먼트를 삭제하고, 아니면 놔둔다. 우리 대신

    이번 포스트에서는 ConcurrentModificationException이 발생하는 이유와 해결 방법에 대해 설명 해 보았다. 이 포스트를 통해 새로운 것을 배웠거나 포스트가 유용하다 생각되면 하트를 누르자.

    댓글

f.software engineer @ All Right Reserved