AIによる要約
ConcurrentModificationExceptionは、コレクションをIteratorで走査している途中に、そのIteratorが想定しない方法で要素の追加や削除が行われたときに発生する代表的な実行時例外です。名前にConcurrentとありますが、複数スレッドがなくても、拡張for文の中でList.removeを呼ぶだけで発生します。条件に合う要素を一括削除するならremoveIf、走査しながら現在要素を削除するならIterator.remove、元データを残して絞り込み結果を作るならStreamのfilterなどを選びます。例外をcatchして続行せず、走査方法と変更方法を同じ方針にそろえます。


業務システムでは、検索結果から無効データを除外する、送信対象だけを残す、期限切れ明細を削除する、といった処理がよくあります。そのとき、拡張for文の中で元のListへremoveすると、ConcurrentModificationExceptionが発生することがあります。
例外が毎回同じ位置で出るとは限らず、要素数や削除位置によって見え方が変わるため、「たまたま動いたコード」がレビューを通る危険もあります。削除、更新、絞り込みの目的を分け、適切なAPIを選びます。
この記事のポイント
- 1スレッドでもConcurrentModificationExceptionは起きる
- 拡張for文は内部でIteratorを使っている
- 条件削除ならremoveIfが第一候補
- 走査中の現在要素ならIterator.removeを使える
- 元Listを残すならfilterで新しいListを作る
- catchして処理継続するのは修正にならない
ConcurrentModificationExceptionが起きるコード
まず、典型的な失敗例を確認します。
List<String> statuses =
new ArrayList<>(List.of("ACTIVE", "STOP", "ACTIVE"));
for (String status : statuses) {
if (status.equals("STOP")) {
statuses.remove(status);
}
}拡張for文は、内部でIteratorを使ってListを順番に読みます。その走査中にstatuses.remove(status)でListの構造を直接変更すると、Iteratorが把握している状態と実際のListがずれます。次の要素を取得するタイミングなどで、そのずれを検知して例外を投げます。
問題はremoveという名前ではなく、「走査に使っているIteratorを通さず、元コレクションの構造を変更したこと」です。
1スレッドでも発生する理由
例外名のConcurrentから、複数スレッドによる同時更新だけを想像しがちです。しかし、この例外は単一スレッドでも発生します。走査処理と変更処理が同時期に進み、Iteratorが許可していない形で構造変更されたことが原因です。
ArrayListなどのIteratorは、走査開始後の構造変更を可能な範囲で早く検知するfail-fastの動作をします。ただし、この例外が必ず発生することや、例外が出なければ安全であることを前提にしてはいけません。
注意
ConcurrentModificationExceptionを処理分岐に利用してはいけません。例外は不正な変更を検知するためのもので、業務ロジックの判定結果ではありません。
条件に合う要素を削除するならremoveIf
目的が「条件に合う要素を元Listから削除する」なら、removeIfが読みやすい選択です。
List<String> statuses =
new ArrayList<>(List.of("ACTIVE", "STOP", "ACTIVE"));
boolean removed = statuses.removeIf(
status -> status.equals("STOP"));
System.out.println(statuses); // [ACTIVE, ACTIVE]
System.out.println(removed); // true条件と削除の意図が1か所にまとまり、添字やIteratorを手で管理しなくて済みます。戻り値は1件以上削除した場合にtrueです。
nullを含む可能性があるListでstatus.equals("STOP")と書くとNullPointerExceptionになります。その仕様を許すなら、定数側から比較するかObjects.equalsを使います。
statuses.removeIf(
status -> Objects.equals(status, "STOP"));走査しながら現在要素を削除するならIterator.remove
削除と同時に複数の処理を行い、現在の要素を明示的に扱う必要がある場合は、Iterator自身のremoveを使えます。
Iterator<Order> iterator = orders.iterator();
while (iterator.hasNext()) {
Order order = iterator.next();
if (order.isExpired()) {
auditLog.recordExpired(order.getId());
iterator.remove();
}
}iterator.remove()は、直前にnext()で返された要素を削除します。next()より前に呼ぶ、同じ要素に2回呼ぶ、といった使い方はIllegalStateExceptionになります。
単純な条件削除ならremoveIfの方が短く、複雑な副作用が必要ならIteratorを検討します。ただし、削除ループ中にDB更新や外部通信まで行うと失敗時の状態が複雑になるため、対象抽出と更新処理を分ける設計も考えます。
元のListを残すなら新しいListを作る
入力されたListを変更してよいか分からない場合や、呼び出し元も同じListを使う場合は、削除せずに必要な要素だけを集めます。
List<Order> activeOrders = orders.stream()
.filter(order -> !order.isExpired())
.toList();この書き方は「元データを変更する」処理ではなく、「条件に合う別の結果を作る」処理です。Javaのバージョンや利用するCollectorによって、結果Listの変更可否が異なるため、後続でaddやremoveが必要かも確認します。
List<Order> activeOrders = orders.stream()
.filter(order -> !order.isExpired())
.collect(Collectors.toCollection(ArrayList::new));後から変更するListが必要なら、可変Listを明示的に作る方法があります。「短いからStream」ではなく、元を変更するか、新しい結果を返すかで決めます。
添字ループなら後ろから削除する
添字が業務上必要な場合、前から削除すると要素が左へ詰まり、次の要素を飛ばす可能性があります。
// NG: 削除後に要素が詰まり、確認を飛ばす可能性がある
for (int i = 0; i < orders.size(); i++) {
if (orders.get(i).isExpired()) {
orders.remove(i);
}
}後ろから前へ進めば、削除した位置より前の添字は変わりません。
for (int i = orders.size() - 1; i >= 0; i--) {
if (orders.get(i).isExpired()) {
orders.remove(i);
}
}ただし、単純な条件削除を添字ループへする必要はありません。添字が必要な理由を説明できないならremoveIfの方が意図を読み取りやすいです。
Mapを走査中に削除する場合
Mapでも、entrySet()を拡張for文で回しながらmap.removeすると同じ問題が起きます。条件削除ならentrySet().removeIfを使えます。
cache.entrySet().removeIf(
entry -> entry.getValue().isExpired());キー、値、エントリのどれを条件にするかがコードから分かるようにします。並行アクセスがある共有Mapなら、単一スレッドの削除方法とは別に、スレッドセーフなコレクションと排他制御を設計する必要があります。
例外をcatchしてループを続けない
// NG: どこまで削除できたか分からない
try {
for (Order order : orders) {
if (order.isExpired()) {
orders.remove(order);
}
}
} catch (ConcurrentModificationException e) {
log.warn("削除中に競合しました");
}例外時点までにListが一部変更されている可能性があり、処理結果が中途半端になります。例外名を「競合」と解釈して再試行するのではなく、正しい走査・変更APIへ修正します。
現場での選び方
| やりたいこと | 候補 | 判断ポイント |
|---|---|---|
| 条件に合う要素を元Listから削除 | removeIf | 条件が簡潔に表せる |
| 現在要素を処理しながら削除 | Iterator.remove | 走査と削除を同じIteratorで行う |
| 元Listを残して絞り込む | Stream.filter | 結果Listの変更可否も決める |
| 添字が必要で削除する | 後ろ向きfor | 添字が必要な理由がある |
| 複数スレッドで共有 | 別途並行制御を設計 | 単なるremoveIfでは解決しない |
テストで連続する削除対象を入れる
削除処理のテストでは、対象が1件だけのデータでは不具合を見逃します。先頭、末尾、連続、全件、0件を用意します。
@Test
void 期限切れ注文をすべて削除する() {
List<Order> orders = new ArrayList<>(List.of(
expiredOrder(1L),
expiredOrder(2L),
activeOrder(3L)));
orders.removeIf(Order::isExpired);
assertEquals(List.of(3L),
orders.stream().map(Order::getId).toList());
}元Listを変更しない仕様なら、入力Listが処理後も同じであることもテストします。例外が出ないことだけでなく、残る要素と順序を確認します。
コードレビューで伝えるコメント例
拡張for文はIteratorで走査しているため、ループ内の
orders.remove(order)はConcurrentModificationExceptionになる可能性があります。今回は期限切れ要素を元Listから除外するだけなので、orders.removeIf(Order::isExpired)にすると意図が明確です。呼び出し元でも元Listを使う場合は、filterで別Listを返す仕様か確認したいです。
修正APIだけでなく、元Listを変更してよいかという契約まで確認すると、別の副作用を防げます。
提出前のセルフチェック
レビュー前に確認すること
- 拡張for文の中で元コレクションを変更していないか
- 元Listを変更してよい仕様か
- 単純な条件削除ならremoveIfを使えるか
- Iterator.removeはnextの後に呼んでいるか
- 添字削除で要素を飛ばしていないか
- nullを含む条件式を考慮したか
- 結果Listの変更可否を確認したか
- 例外をcatchして続行していないか
- 連続・先頭・末尾・全件削除をテストしたか
- 複数スレッド共有なら別途排他を検討したか
Javaのコレクションを学ぶ参考書
Listの削除では、removeの使い方だけでなく、Iterator、変更可能性、Streamで新しい結果を作る考え方を一緒に理解すると、現場のコードを安全に選べます。
スッキリわかるJava入門 実践編 第5版
基礎文法の次に必要な、現場寄りのJava知識を補う。
コレクション、ジェネリクス、ラムダ式、ストリームなど、業務コードで出会いやすい機能を入門編の次に学べます。
- Java基礎の次に何を学ぶか迷っている
- コレクションやStreamを整理したい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
ConcurrentModificationExceptionは、複数スレッドがなくても、Iteratorによる走査中に元コレクションを別の方法で構造変更すると発生します。拡張for文の中でList.removeを呼ぶ書き方は避けます。
条件削除ならremoveIf、現在要素を走査しながら削除するならIterator.remove、元Listを残すならfilterで別Listを作ります。例外をcatchするのではなく、元データを変更するかという仕様からAPIを選び、連続する削除対象を含むテストを追加してください。
Iteratorの削除仕様はOracle Java公式APIでも確認できます。
