ConcurrentModificationException

mtak·2024년 7월 9일
0

영한님의 자바 중급 강의를 보며 코드를 작성해 보다가 신기한 에러를 마주했다.
난 멀티쓰레딩을 한 적이 없는데 어째서 동시성 에러가 발생한걸까?

ConcurrentModificationException, 머선 에러입니까?

코드 주인님(?)의 설명에 의하면

This exception may be thrown by methods that have detected concurrent modification of an object when such modification is not permissible.
For example, it is not generally permissible for one thread to modify a Collection while another thread is iterating over it. In general, the results of the iteration are undefined under these circumstances. Some Iterator implementations (including those of all the general purpose collection implementations provided by the JRE) may choose to throw this exception if this behavior is detected. Iterators that do this are known as fail-fast iterators, as they fail quickly and cleanly, rather that risking arbitrary, non-deterministic behavior at an undetermined time in the future.
Note that this exception does not always indicate that an object has been concurrently modified by a different thread. If a single thread issues a sequence of method invocations that violates the contract of an object, the object may throw this exception. For example, if a thread modifies a collection directly while it is iterating over the collection with a fail-fast iterator, the iterator will throw this exception.
Note that fail-fast behavior cannot be guaranteed as it is, generally speaking, impossible to make any hard guarantees in the presence of unsynchronized concurrent modification. Fail-fast operations throw ConcurrentModificationException on a best-effort basis. Therefore, it would be wrong to write a program that depended on this exception for its correctness: ConcurrentModificationException should be used only to detect bugs.

누가: fail-fast iterator가 던지는 에러이다.
언제: 객체 동시 수정을 막아놨는데 어떤 놈(들)이 수정을 시도할 때
즉 다음과 같은 두가지 케이스가 나온다.
1. 한 스레드가 collection을 순회하느라 수정을 막아놨는데 다른 스레드가 그 collection을 수정하려 들 때.
2. 단일 스레드가 (contract)설정한 순서대로 실행하라고 했는데! 그 순서대로 메소드를 실행하지 않았을 때

뭐 만들다 그래됬느뇨?

<요구사항>

  • 각 Cards.class는 1 ~ 13 까지의 숫자를 가지고, 각 숫자마다 spade, heart, dua, clover(맞나?)를 가진다. (그렇다 카드는 총 52장이 되겠다.)
  • 카드 뭉치는 Deck.class이다.
  • Player.class 는 2명이다.
  • 각 플레이어가 5장의 카드를 보여줄 때, 1.숫자 작은 것 2. spade, heart, dua, clover 순서로 보여줘야 한다.
  • 게임의 순서는, 먼저 Deck에 있는 카드를 섞고, 플레이어들은 카드를 5장 씩 뽑은 다음 카드의 합이 큰 사람이 이기는거다.
package collection.compare;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class Card implements Comparable<Card>{
	public static final int MAX_NUMBER = 13;
	public static final int MIN_NUMBER = 1;

	@Override
	public int compareTo(Card o) {
		if (number < o.number)
			return -1;
		if (number > o.number)
			return 1;
		return shape.compareTo(o.shape);
	}

	public enum Shape {
		SPADE("\u2660"), HEART("\u2665"), DIA("\u2666"), CLOVER("\u2663"),
		;
		private String value;

		Shape(String value) {
			this.value = value;
		}

		public String getValue() {
			return value;
		}
	}

	private int number;
	private Shape shape;

	public int getNumber() {
		return number;
	}

	public Shape getShape() {
		return shape;
	}

	public Card(int number, Shape shape) {
		if (!(MIN_NUMBER <= number && number <= MAX_NUMBER))
			throw new OutOfRangeException("card number out of boundary");
		this.number = number;
		this.shape = shape;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		Card card = (Card)o;
		return number == card.number && shape == card.shape;
	}

	@Override
	public int hashCode() {
		return Objects.hash(number, shape);
	}

	@Override
	public String toString() {
		return String.format("%d(%s)", number, shape.value);
	}
}
package collection.compare;

import java.util.Arrays;
import java.util.List;

public class Player {
	private List<Card> cards;
	private String name;

	public Player(String name) {
		this.name = name;
	}

	public String getName() {
		return name;
	}

	public void peekCard(final Deck deck, int count) {
		cards = deck.getCards(count);
	}

	public int getSum() {
		if (cards == null) {
			throw new NoCardException("No card to me");
		}
		int sum = 0;
		for (Card card : cards) {
			sum += card.getNumber();
		}
		return sum;
	}
	@Override
	public String toString() {
		return String.format("%s의 카드: %s, 합계:%d", name,cards, getSum());
	}
}
package collection.compare;

import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class Deck {
	private static List<Card> cards = new ArrayList<>();

	public Deck() {
		for (int i = Card.MIN_NUMBER; i <= Card.MAX_NUMBER; i++) {
			for (Card.Shape value : Card.Shape.values()) {
				cards.add(new Card(i, value));
			}
		}
	}

	public void shuffle() {
		Collections.shuffle(cards);
	}

	public List<Card> getCards(int count) {
		if (isEmpty()) {
			throw new NoCardException("No card anymore");
		}
		List<Card> result = cards.subList(0, count);
//스포하자면 이 위에 줄이 틀린 것이다. 아래와 같이 수정되어야 한다. 
//		List<Card> result = new ArrayList<>(cards.subList(0, count));
		cards.removeAll(result);
		return result;
	}

	public boolean isEmpty() {
		return cards.isEmpty();
	}
}
package collection.compare;

public class CardGameMain {
	public static void main(String[] args) {
		Player player1 = new Player("플레이어1");
		Player player2 = new Player("플레이어2");
		Deck deck = new Deck();
		startGame(player1, deck, player2);
		doGame(player1, player2);

	}

	private static Player doGame(Player player1, Player player2) {
		Player won = null;
		if (player1.getSum() > player1.getSum()) {
			won = player1;
		} else if (player1.getSum() < player2.getSum()) {
			won = player2;
		}
		if (won == null) {
			System.out.println("무승부");
		} else {
			System.out.println(won.getName() + " 승리");
		}
		return won;
	}

	private static void startGame(Player player1, Deck deck, Player player2) {
		deck.shuffle();
		player1.peekCard(deck, 5);
		player2.peekCard(deck, 5);
		System.out.println(player1);
		System.out.println(player2);
	}
}

✔발생한 에러: java.util.ConcurrentModificationException

Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$SubList.checkForComodification(ArrayList.java:1497)
	at java.base/java.util.ArrayList$SubList.listIterator(ArrayList.java:1366)
	at java.base/java.util.AbstractList.listIterator(AbstractList.java:313)
	at java.base/java.util.ArrayList$SubList.iterator(ArrayList.java:1362)
	at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:451)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at collection.compare.Player.getSum(Player.java:27)
	at collection.compare.Player.toString(Player.java:35)
	at java.base/java.lang.String.valueOf(String.java:4465)
	at java.base/java.io.PrintStream.println(PrintStream.java:1187)
	at collection.compare.CardGameMain.startGame(CardGameMain.java:32)
	at collection.compare.CardGameMain.main(CardGameMain.java:8)
Disconnected from the target VM, address: '127.0.0.1:64349', transport: 'socket'

🤔그래서 어느 부분에서 익셉션 터진거야?

public class Player {
	private List<Card> cards;
	...
	public getSum() {
    ...
    	for (Card card: cards)
    ...
    }
}

저게 무슨 상황인데?

for-each 문을 좀 풀어보죠

Card card;
for(Iterator var2 = this.cards.iterator(); var2.hasNext(); sum += card.getNumber()) {
	card = (Card)var2.next();
}

그렇다. 위에서 ConcurrentMethodificationException이 발생하는 케이스 두번째에 해당한다.

원인이 뭐였는데?

먼저, ConcurrentModificationException은 마치 "동시성 문제"에만 발생하는 것 처럼 지어놨지만, 본질적으론 "동시 수정" 뿐 아니라 "예상치 못한 수정"을 감지할 때도 발생한다.

java이 컬렉션들은 내부적으로 "수정 횟수"(modCount임. 인텔리제이에 치면 나옴.)를 추적한다.
그리고 Iterator는 생성될 때 컬렉션의 "수정 횟수" 를 기억하고, 순회 중에 "수정 횟수"가 변경되었는지 확인한다.
위에 for-each문에서 갖다 쓰는 cards객체는 다음 지점에서 만들어지는데

  public class Player {
  ...
  	public List<Card> getCards(int count) {
  	...
  	List<Card> result = cards.subList(0, count);
	cards.removeAll(result);
	return result;
  	}
  ...
  }

subList()는 원본 리스트인 cards와 직접적으로 연결되어 특정 범위를 참조하고 있는 창문(view)을 반환한다.
그렇다 원본이 바뀌거나 view가 바뀌면 서로에게 영향을 준다(뷰가 참조하는 원본의 modCount가 바뀐다).
그리고 여기서 removeAll()은 cards의 result 부분을 제거하고, 졸지에 그 부분을 참조하고 있던 result는 죽지도 못하고(null도 못되고) 낙동강 오리알(유효하지 않은 참조를 하고 있는)이 된다. 이 때 removeAll()은 원본인 cards의 수정 횟수를 변경한다. 우리의 낙동강 오리알 result는 Players.class 안 for (Card card : cards)에서 내부적으로 cards의 Iterator가 생성되고, 이 iterator가 hasNext()를 시전하던 중 modCount가 지가 알던 거랑 다르다는 사실을 깨닫고 에러를 뱉게 된다.

😎야 너두 만들 수 있어 view
배열의 짝수 인덱스만 보여주는 view를 만들어보자.
AbstractList, AbstractSet 등의 추상 클래스를 상속받아 잘 구현하면 쉽다.

import java.util.AbstractList;
import java.util.List;
public class EvenIndexView<E> extends AbstractList<E> {
    private final List<E> originalList;
    public EvenIndexView(List<E> list) {
        this.originalList = list;
    }
    @Override
    public E get(int index) {
        return originalList.get(index * 2);
    }
    @Override
    public int size() {
        return (originalList.size() + 1) / 2;
    }
    @Override
    public E set(int index, E element) {
        return originalList.set(index * 2, element);
    }
}
// 사용 예
List<Integer> original = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
List<Integer> evenView = new EvenIndexView<>(original);
System.out.println(evenView); // 출력: [0, 2, 4, 6, 8]
profile
노는게 젤 조아. 친구들 모여라!!

0개의 댓글