結論

java.util.ConcurrentModificationException は「for 文などでコレクションを回している途中に、そのコレクションを直接変更した」ときに出る例外です。別スレッドが無くても、1つのスレッドの拡張 for 文の中で list.remove() を呼ぶだけで出ます。直し方の第一候補は、削除なら removeIf、条件が複雑なら Iterator.remove()、複数スレッドなら ConcurrentHashMap などの並行コレクションです。

この記事でわかること

  • この例外になる原因12種の再現コード・スタックトレース原文・直したあとの出力
  • 最後から2番目の要素だけ例外にならず、最後の要素では例外になる理由(modCount と cursor の実測)
  • 直し方ごとの速さ(10万件の実測)と、場面ごとにどれを選ぶかの判断表

2026年11月5日更新|OpenJDK 21.0.12.1(Ubuntu 24.04.5 LTS)で実行確認

この記事で実際に動かしたもの:例外を出す Java のコード17本と直したコードを OpenJDK 21.0.12.1 でコンパイル・実行し、スタックトレースの原文、modCount の変化(リフレクションで読み出し)、直し方9通りの処理時間を取りました。

flowchart TD
    E[例外が出た] --> S{別スレッド?}
    S -->|はい| C[並行<br>コレクション]
    S -->|いいえ| L{削除?}
    L -->|はい| R[removeIf<br>it.remove]
    L -->|いいえ| N[別リストに<br>集める]

やりたいことごとに、いちばん早い方法を先にまとめます。

やりたいこと いちばん早い方法 詳しい節
ConcurrentModificationException の意味を知りたい 回している途中にそのコレクションの件数を変えた、という合図 例外の意味
どの行が原因か知りたい スタックトレースの自分のファイルの行(for の行)の、ループの中を見る 例外の意味
java のリストをループ中に削除したい list.removeIf(x -> 条件) 直し方5つ
削除のほかに処理もしたい Iterator を自分で回して it.remove() 直し方5つ
ループ中に追加したい 追加分を別のリストに集め、ループの後で addAll 原因1〜3
Map の走査中に削除したい map.values().removeIf(...) か entrySet().removeIf(...) 原因4〜6
複数スレッドで使うリスト・Map で出る ConcurrentHashMap・CopyOnWriteArrayList に替える 別スレッドからの変更
例外は出ないが結果がおかしい 最後から2番目の削除を疑い、removeIf に書き換える 最後から2番目の要素

確認環境:Ubuntu 24.04.5 LTS(2 CPU)の上で、OpenJDK 21.0.12.1(javac・java は環境に入っていたものをそのまま使用、追加のインストールなし)を使いました。例外を出すコード(拡張 for 文での remove・add・sort、最後から2番目と最後の要素の削除、HashMap の keySet・entrySet、TreeMap、HashSet、ArrayDeque、forEach、Stream、subList、HashMap.computeIfAbsent の再帰、別スレッドからの変更)を1本ずつ実行してスタックトレースの原文を取り、直したコードの出力も同じ環境で取りました。modCount と cursor の値は --add-opens java.base/java.util=ALL-UNNAMED を付けてリフレクションで読み出しています。速さは10万件のリストから偶数を消す処理を、ウォームアップ3回のあと5回測った中央値です(-XX:ActiveProcessorCount=1)。例外の定義・フェイルファスト・各コレクションの振る舞いは Java SE 21 の API ドキュメント(Oracle)で確かめました。Java 8・11・17 では実行していませんが、ここで扱う例外の条件は API ドキュメント上 Java 8 以降で同じです(行番号はJDKの版で変わります)。図は mermaid で描画を確かめています(確認日 2026年11月5日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ConcurrentModificationExceptionの原因と直し方12例」

ConcurrentModificationExceptionの意味(エラー文とスタックトレースの読み方)

この節では、例外名の日本語訳と、スタックトレースのどの行を見れば原因の場所が分かるかが分かります。

「ConcurrentModificationException」を日本語にすると「同時変更の例外」です。「Concurrent(同時の)」という名前から複数スレッドの問題だと思われがちですが、Java SE 21 の API ドキュメントには次のように書かれています。

この例外は、オブジェクトが別のスレッドによって並行して更新されていないことを必ずしも示しているわけではありません。単一のスレッドが、オブジェクトの規約に違反する一連のメソッドを発行した場合、オブジェクトはこの例外をスローします。

つまり「回している最中に、回している本人が中身を変えた」でも出ます。いちばん多い形を手元で実行しました。4人の名前から「鈴木」を消すつもりのコードです。

import java.util.*;
public class C1 {
  public static void main(String[] a) {
    List<String> names = new ArrayList<>(List.of("佐藤", "鈴木", "高橋", "田中"));
    for (String n : names) {
      if (n.equals("鈴木")) {
        names.remove(n);
      }
    }
    System.out.println(names);
  }
}
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
	at C1.main(C1.java:5)

スタックトレースは「自分のファイルの行」を見る

スタックトレースは上から「例外が起きた場所 → 呼び出し元」の順に並びます。上の2行は JDK の中(java.base/java.util...)なので、最初に出てくる自分のクラスの行(ここでは C1.java:5)を探します。

ここで大事なのは、5行目は remove の行ではなく for の行だということです。remove した瞬間には何も起きず、次の周回で拡張 for 文が内部の Iterator.next() を呼んだときに「変更された」と気づいて例外を投げます。原因は、for の行が指すループの中にあります。

スタックトレースの上の行(JDK の中)

ArrayList$Itr.checkForComodification:変更を見つけた場所

ArrayList$Itr.next:次の要素を取ろうとした

HashMap$HashIterator.nextNode:Map・Set の場合

自分のコードの行(ここを直す)

拡張 for 文・forEach・stream() の行

そのループの中の remove・add・put・sort

ループの中で呼んでいるメソッドの中も含む

自分で確かめる手順

1

自分で確かめる①:Caused by まで下を見る

Spring などのフレームワークの中で出ると、上のほうに別の例外が並びます。ターミナルなら grep -n -A3 "ConcurrentModificationException" ログファイル で、例外名の直後3行(JDK の行と自分の行)だけを抜き出せます。手元のログでは、例外名の3行下に at C1.main(C1.java:5) が出ました。

Linux・Mac のターミナル、Windows は Git Bash など

2

自分で確かめる②:ループの中で件数を表示する

ループの先頭に System.out.println(n + " size=" + names.size()); を1行入れます。周回の途中で size が減ったり増えたりしていれば、そのループの中で変更しています。次の節の実測では、鈴木を消した直後に size が 4 から 3 に減り、その次の周回で例外が出ています。

どの IDE・OS でも同じ

3

別スレッドかどうかを決める

ループの中に変更が見当たらなければ、別のスレッドが同じリスト・Map を変えています。共有しているフィールド(static や Spring の singleton Bean のフィールド)を探します。

次の節の原因一覧へ

ConcurrentModificationExceptionが起きる仕組み(modCountとexpectedModCount)

この節では、Java がどうやって「途中で変更された」ことに気づくのかが、実際の数値で分かります。

ArrayList や HashMap は「件数を変えた回数」を数えています。この回数が modCount(AbstractList の protected フィールド)です。iterator() で Iterator を作ると、Iterator はその時点の値を expectedModCount に覚えます。next() のたびに2つを比べ、違っていれば例外を投げます。手元の JDK で javap を使うと、Iterator の実体(ArrayList$Itr)が実際にこのフィールドを持っていました。

$ javap -p 'java.util.ArrayList$Itr'
Compiled from "ArrayList.java"
class java.util.ArrayList$Itr implements java.util.Iterator<E> {
  int cursor;
  int lastRet;
  int expectedModCount;
  final java.util.ArrayList this$0;
  java.util.ArrayList$Itr(java.util.ArrayList);
  public boolean hasNext();
  public E next();
  public void remove();
  public void forEachRemaining(java.util.function.Consumer<? super E>);
  final void checkForComodification();
}

拡張 for 文 for (String n : names) は、コンパイルすると Iterator の hasNext() と next() を呼ぶループになります。そこで、同じ流れを Iterator で書き、毎周 cursor(次に読む番号)・size・modCount・expectedModCount を表示しました。

## 鈴木 を消す
hasNext=true  cursor=0 size=4 modCount=0 expected=0
  読んだ: 佐藤
hasNext=true  cursor=1 size=4 modCount=0 expected=0
  読んだ: 鈴木
  remove(鈴木) → modCount=1
hasNext=true  cursor=2 size=3 modCount=1 expected=0
  → java.util.ConcurrentModificationException

remove で modCount が 0 → 1 になり、Iterator が覚えている expected は 0 のままです。次の next() で 1 と 0 を比べて例外になりました。例外は「壊れた結果のまま進む」のを防ぐための安全装置で、API ドキュメントはこれを「フェイルファスト(すぐに失敗する)」と呼んでいます。

件数が変わらない set() は例外にならない

ArrayList の API ドキュメントは「構造の変更」を「要素を追加・削除する操作、または内部の配列の大きさを明示的に変える操作」と定義し、「要素の値を置き換えるだけ(set)は構造の変更ではない」と書いています。手元でもループ中の l.set(0, 9) は例外にならず [9, 2, 3] になりました。ただし Collections.sort や list.sort は件数が変わらなくても modCount を増やすため、ループ中に呼ぶと例外になります(原因3)。

1例外になる操作

add・remove・clear・addAll・removeAll、ArrayList の sort、Map の put(新しいキー)・remove

2例外にならない操作

List の set、Map の既存キーへの put(値の上書き)、Map.Entry.setValue

3Iterator 自身の操作は通る

it.remove()、ListIterator の add・set は expectedModCount も合わせて更新する

ConcurrentModificationExceptionの原因一覧(12種)

この節では、この例外になる原因の全体像と、どの節で直し方を説明しているかが分かります。

原因は「1つのスレッドの中でループ中に変えた」か「別のスレッドが変えた」かの2系統です。手元で再現できた12種を表にしました。

No. 原因 スタックトレースの特徴 直し方
1拡張 for 文の中で list.remove()ArrayList$Itr.nextremoveIf・it.remove()
2拡張 for 文の中で list.add()ArrayList$Itr.next別のリストに集めて後で追加
3ループの中で sortArrayList$Itr.nextループの前に並べ替える
4HashMap の keySet を回して removeHashMap$KeyIterator.nextkeySet().removeIf など
5entrySet を回して新しいキーを putHashMap$EntryIterator.next別の Map に集めて putAll
6computeIfAbsent の中で同じ Map を変更HashMap.computeIfAbsent再帰を外に出す
7forEach のラムダの中で変更ArrayList.forEachremoveIf
8Stream の終端処理の中で元のリストを変更Spliterator.forEachRemainingtoList() で新しく作る
9元のリストを変えたあとに subList を使うArrayList$SubList.checkForComodificationnew ArrayList<>(subList)
10TreeMap・HashSet・ArrayDeque でも同じTreeMap$PrivateEntryIterator など各 removeIf
11別スレッドが同じリストを変更自分のコードに変更が見当たらない並行コレクション・synchronized
12(例外なし)最後から2番目を消して結果がずれる出ないremoveIf
  1. 1自分のクラスの最初の行を見つけるJDK の行(java.base/...)の下にある、自分のファイル名と行番号
  2. 2その行のループの中を読むremove・add・put・sort・clear と、呼んでいるメソッドの中
  3. 3JDK の行のクラス名で種類を決めるArrayList$Itr・HashMap$KeyIterator・SubList・forEach・Spliterator
  4. 4ループの中に変更が無ければ別スレッド共有しているフィールドを探し、並行コレクションに替える

原因1〜3:拡張for文の中でListをremove・add・sortした

この節では、java のリストをループ中に削除・追加・並べ替えしたときの例外と、それぞれの直し方が分かります。

原因1:ループ中の remove

前の節の C1 がこの形です。削除だけなら removeIf が1行で済みます。削除の前後で別の処理もしたいときは、Iterator を自分で回して it.remove() を使います。どちらも手元で例外なく動きました。

// 直し方A:removeIf(Java 8 以降)
List<String> n2 = new ArrayList<>(List.of("佐藤", "鈴木", "高橋", "田中"));
n2.removeIf(n -> n.equals("鈴木") || n.equals("田中"));
System.out.println(n2);

// 直し方B:Iterator.remove
List<String> names = new ArrayList<>(List.of("佐藤", "鈴木", "高橋", "田中"));
Iterator<String> it = names.iterator();
while (it.hasNext()) {
  String n = it.next();
  if (n.equals("鈴木")) it.remove();   // names.remove(n) ではなく it.remove()
}
System.out.println(names);
[佐藤, 高橋]
[佐藤, 高橋, 田中]

Iterator で回しているのに例外になる書き方

例外になるwhile (it.hasNext()) の中で names.remove(n)。Iterator を使っていても、リストに直接頼むと expectedModCount が合わない
→
代わりにit.remove()。Iterator が自分で modCount と expectedModCount をそろえる

覚え方:回している人に消してもらう

原因2:ループ中の add

「2 があったら 99 を足す」コードです。追加も件数が変わるので同じ例外になりました。

List<Integer> nums = new ArrayList<>(List.of(1, 2, 3));
for (Integer x : nums) {
  if (x == 2) nums.add(99);
}
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
	at C3.main(C3.java:5)

追加分は別のリストに集めて、ループが終わってから addAll します。追加した要素もループで処理したいなら、番号で回して size() を毎回読む書き方(for (int i = 0; i < nums.size(); i++))にすれば Iterator を使わないので例外になりません。途中に挿入したいときは ListIterator.add が使えます。手元では次の出力になりました。

List<Integer> nums = new ArrayList<>(List.of(1, 2, 3));
List<Integer> extra = new ArrayList<>();
for (Integer x : nums) if (x == 2) extra.add(99);
nums.addAll(extra);
System.out.println(nums);

List<String> s = new ArrayList<>(List.of("a", "b", "c"));
ListIterator<String> li = s.listIterator();
while (li.hasNext()) {
  String v = li.next();
  if (v.equals("b")) { li.set("B"); li.add("b2"); }   // 置き換えと挿入
}
System.out.println(s);
[1, 2, 3, 99]
[a, B, b2, c]

原因3:ループ中の sort

件数が変わらない Collections.sort(nums) でも、ArrayList の sort は modCount を増やすため例外になりました。並べ替えはループの前に1回だけ行います。

Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
	at C10.main(C10.java:5)

最後から2番目の要素だけ例外にならない理由(例外なしで結果がずれる)

この節では、同じ書き方でも消す位置によって例外が出たり出なかったりする理由と、例外が出ない方が危ない場合が分かります。

C1 と同じコードで、消す名前を「高橋」(4人中3番目=最後から2番目)に変えると、例外が出ずに終わりました。

## 高橋 を消す
hasNext=true  cursor=0 size=4 modCount=0 expected=0
  読んだ: 佐藤
hasNext=true  cursor=1 size=4 modCount=0 expected=0
  読んだ: 鈴木
hasNext=true  cursor=2 size=4 modCount=0 expected=0
  読んだ: 高橋
  remove(高橋) → modCount=1
hasNext=false cursor=3 size=3 modCount=1 expected=0
結果: [佐藤, 鈴木, 田中]

理由は、hasNext() が「cursor と size が違うか」しか見ていないからです。高橋を読んだ時点で cursor は 3、削除で size も 3 になったので、hasNext() が false を返してループが終わります。modCount を比べる next() が呼ばれないため、例外の機会がありません。その代わり田中は一度も読まれていません。

逆に、最後の要素(田中)を消すと例外になります。削除後に size が 3、cursor が 4 になり、「違う」ので hasNext() が true を返して next() に進むためです。

## 田中 を消す
hasNext=true  cursor=3 size=4 modCount=0 expected=0
  読んだ: 田中
  remove(田中) → modCount=1
hasNext=true  cursor=4 size=3 modCount=1 expected=0
  → java.util.ConcurrentModificationException
消した要素(4件中) 削除後の cursor / size 結果
1番目(佐藤)・2番目(鈴木)1/3・2/3例外
3番目(高橋)=最後から2番目3/3例外なし。最後の要素を読み飛ばす
4番目(田中)=最後4/3例外

例外が出ない方が困る例

タグの一覧から「tmp」を全部消すつもりのコードです。tmp が最後から2番目と最後に並んでいると、例外が出ずに1つ残りました。

List<String> tags = new ArrayList<>(List.of("java", "tmp", "tmp"));
for (String t : tags) {
  if (t.equals("tmp")) tags.remove(t);
}
System.out.println(tags);   // tmp を全部消したつもり
[java, tmp]

テストで例外が出なかったからといって、その書き方が正しいとは限りません。API ドキュメントも「この例外に頼って正しさを保つプログラムを書くのは誤り」「バグを見つけるためだけに使うべき」と書いています。ループ中の直接の削除は、例外が出ても出なくても書き換えます。番号で前から remove(i) する書き方も、消した直後の要素を飛ばします(手元で [1, 2, 2, 3, 2] から 2 を消すと [1, 2, 3] が残りました)。番号で消すなら後ろから回します([1, 3] になりました)。

原因4〜6・10:Mapの走査中にremove・putした(HashMap・TreeMap・computeIfAbsent)

この節では、HashMap を回しながらキーを消したり足したりしたときの例外と、Map 向けの直し方が分かります。

原因4:keySet を回して remove

在庫が0の商品を消すつもりのコードです。HashMap の Iterator(HashMap$KeyIterator)で例外になりました。

Map<String, Integer> stock = new HashMap<>();
stock.put("りんご", 0); stock.put("みかん", 5); stock.put("ぶどう", 0); stock.put("もも", 3);
for (String k : stock.keySet()) {
  if (stock.get(k) == 0) stock.remove(k);
}
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1605)
	at java.base/java.util.HashMap$KeyIterator.next(HashMap.java:1628)
	at C4.main(C4.java:6)

Map の keySet()・values()・entrySet() は元の Map とつながった「ビュー」なので、ビューの removeIf で消すと元の Map から消えます。値で決めるなら values()、キーと値の両方を見るなら entrySet() を使います。

stock.values().removeIf(v -> v == 0);
// キーと値の両方で決めるとき
// stock.entrySet().removeIf(e -> e.getValue() == 0 && !e.getKey().equals("みかん"));
System.out.println(new TreeMap<>(stock));   // 表示の順をそろえるため TreeMap に入れて表示
{みかん=5, もも=3}

原因5:entrySet を回して新しいキーを put

既存のキーの値を上書きする put は件数が変わらないので通りますが、新しいキーの put は件数が増えるので例外になります。

Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1605)
	at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1638)
	at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1636)
	at C9.main(C9.java:6)

追加分を別の Map に集め、ループの後で putAll します。手元では {a=1, ax=1, b=2, bx=2} になりました。値だけを書き換えるなら map.replaceAll((k, v) -> v * 2) や entry.setValue(...) が使えます。

原因6:computeIfAbsent の中で同じ Map を変更

ループが無くても出る形です。メモ化(計算結果を Map に覚えておく)でフィボナッチ数を求めるとき、computeIfAbsent の関数の中から同じ Map の computeIfAbsent を再帰で呼ぶと、Java 9 以降の HashMap は例外を投げます。手元の Java 21 でも出ました。

static Map<Integer, Long> memo = new HashMap<>();
static long fib(int n) {
  if (n < 2) return n;
  return memo.computeIfAbsent(n, k -> fib(k - 1) + fib(k - 2));
}
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.HashMap.computeIfAbsent(HashMap.java:1229)
	at C13.fib(C13.java:6)
	at C13.lambda$fib$0(C13.java:6)
	at java.base/java.util.HashMap.computeIfAbsent(HashMap.java:1228)
	at C13.fib(C13.java:6)
    ...

HashMap の API ドキュメントは computeIfAbsent について「マッピング関数は計算中にこの Map を変更すべきではない」「変更を検出した場合、最善努力で ConcurrentModificationException を投げる」と書いています。再帰の結果は関数の外で計算してから put します(Long v = memo.get(n); if (v == null) { v = fib(n-1) + fib(n-2); memo.put(n, v); })。

原因10:TreeMap・HashSet・ArrayDeque でも同じ

java.util の一般的なコレクションは、どれも同じ仕組みを持っています。手元で1つずつ試した結果です。

コレクション ループ中の操作 結果
TreeMapkeySet を回して removeConcurrentModificationException
HashSet回しながら addConcurrentModificationException
ArrayDeque回しながら addLastConcurrentModificationException
ConcurrentHashMapkeySet を回して put例外なし(足したキーが途中で見えることがある)
CopyOnWriteArrayList回しながら add例外なし(ループは作ったときの中身を回る)

HashMap と List の基本的な使い方は関連記事「Java List Set Map 使い分け|選び方フローと実測つき」で扱っています。

原因7〜9:forEach・Stream・subListでConcurrentModificationExceptionが出る場合

この節では、拡張 for 文を使っていないのに例外が出る3つの形と、スタックトレースでの見分け方が分かります。

原因7:forEach のラムダの中で変更

names.forEach(n -> { if (...) names.remove(n); }) も同じ例外になります。スタックトレースには Iterator ではなく ArrayList.forEach が出ます。ArrayList の forEach は、最後まで回したあとに modCount を比べるので、forEach の行が自分の行として出ました。

Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1598)
	at C5.main(C5.java:5)

原因8:Stream の中で元のリストを変更

Stream の forEach の中から元のリストに add すると、ArrayListSpliterator.forEachRemaining で例外になりました。

List<String> names = new ArrayList<>(List.of("佐藤", "鈴木", "高橋"));
names.stream().filter(n -> n.startsWith("佐")).forEach(n -> names.add(n + "2"));
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1714)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at C7b.main(C7b.java:5)

注意したいのは、Stream の中で remove すると、ConcurrentModificationException より先に別の例外が出る場合があることです。Integer のリストから偶数を Stream の中で消したところ、NullPointerException になりました。削除で後ろの要素が前に詰められ、末尾に残った null を Stream が読んだためです。

List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));
nums.stream().filter(x -> x % 2 == 0).forEach(x -> nums.remove(x));
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<parameter1>" is null
	at C7.lambda$main$0(C7.java:6)
	at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:178)
	at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1708)
    ...

Stream は「元を変えずに新しく作る」使い方をします。List<Integer> odd = nums.stream().filter(x -> x % 2 != 0).toList(); のように結果を別のリストで受け取ります。for 文から Stream への書き換えは関連記事「Java Stream APIの使い方|for文からの書き換え例と注意点」で扱っています。

原因9:元のリストを変えたあとに subList を使う

subList は元のリストの一部を「窓」のように見るビューで、コピーではありません。元のリストに add したあとで subList を表示すると、表示(toString の中の Iterator 作成)で例外になりました。ループを使っていないのに出るので、見落としやすい形です。

List<Integer> all = new ArrayList<>(List.of(1, 2, 3, 4, 5));
List<Integer> head = all.subList(0, 3);
all.add(6);
System.out.println(head);
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:4454)
	at java.base/java.io.PrintStream.println(PrintStream.java:1187)
	at C6.main(C6.java:7)

ArrayList の API ドキュメントは「元のリストが subList 以外の方法で構造的に変更されると、subList の振る舞いは未定義になる」と書いています。あとで元を変えるなら new ArrayList<>(all.subList(0, 3)) でコピーしておきます。手元では [1, 2, 3] [1, 2, 3, 4, 5, 6] と、両方とも正しく表示されました。

原因11:別スレッドからの変更(マルチスレッドで出るConcurrentModificationException)

この節では、自分のループの中に変更が見当たらないのに例外が出る場合の原因と、スレッドをまたぐときの直し方が分かります。

1つのスレッドが ArrayList を読み続け、もう1つのスレッドが同じリストに add・remove を繰り返すコードを3回実行しました。3回とも、読む側のループで例外になりました。スタックトレースに出るのは読む側の行だけで、変更した側の行は出ません。

List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) list.add(i);
Thread writer = new Thread(() -> {
  for (int i = 0; i < 100000; i++) { list.add(i); list.remove(list.size() - 1); }
});
writer.start();
long sum = 0;
for (int r = 0; r < 1000; r++) for (int x : list) sum += x;   // 11行目
writer.join();
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
	at C8.main(C8.java:11)

API ドキュメントによると、スレッドをまたぐ場合の検出は「最善努力」で、必ず例外になるとは限りません。例外が出ずに、数が合わない・要素が欠けるといった壊れ方をすることもあります。スレッドで共有するコレクションは、次のどれかで守ります。

方法 向いている場面 手元の結果 注意
ConcurrentHashMap複数スレッドで読み書きする Map走査中の remove・put で例外なし走査中の変更が見えるかは決まっていない
CopyOnWriteArrayList読むのが多く、変更がまれなリスト(設定・リスナー一覧)走査中の add で例外なし変更のたびに配列を丸ごとコピー
Collections.synchronizedList既存の ArrayList を手早く守るsynchronized (list) で囲んで例外なし回すときは自分で synchronized が必要
スレッドごとに別のリスト集計してから最後にまとめる共有しないので起きないまとめる処理を1か所に

Collections の API ドキュメントは synchronizedList について「Iterator・Spliterator・Stream で回すときは、利用者が返されたリストで手動で同期することが必須」と書いています。Collections.synchronizedList に替えただけで、回す側を synchronized (sync) { for (...) } で囲まないと、同じ例外の可能性が残ります。手元では囲んだ版を1,000回回して例外は出ませんでした。ConcurrentHashMap の使い方は関連記事「Java AtomicIntegerとConcurrentHashMapの使い方」で、スレッドまわりのほかの落とし穴は関連記事「Java並行処理の落とし穴8選|volatile・デッドロック」で扱っています。

ConcurrentModificationExceptionの直し方5つ(Iterator.remove・removeIf・コピー・ConcurrentHashMap・CopyOnWriteArrayList)

この節では、5つの直し方の書き方と、どれがどの場面に合うかが分かります。

直し方 書き方 使える場面 使えない場面
① removeIflist.removeIf(x -> 条件)条件で消すだけ(List・Set・Map のビュー)消しながら別の処理もしたい
② Iterator.removewhile (it.hasNext()) { ... it.remove(); }消す前後にログ・集計をしたい追加したい(ListIterator.add なら可)
③ 別のリスト・コピーstream().filter().toList()/追加分を集めて addAll追加・put、元を変えたくない元のリストを他から参照している
④ ConcurrentHashMapnew ConcurrentHashMap<>()複数スレッドの Mapnull のキー・値を入れる
⑤ CopyOnWriteArrayListnew CopyOnWriteArrayList<>()読み中心で複数スレッドのリスト頻繁に変更する・大きいリスト

CopyOnWriteArrayList の API ドキュメントによると、Iterator は作った時点の配列を見るため ConcurrentModificationException を投げないことが保証されています。その代わり、Iterator 自身の remove・set・add には対応していません。手元でも it.remove() は java.lang.UnsupportedOperationException になりました。CopyOnWriteArrayList で消すときは removeIf を使います。

とりあえず例外を止める書き方

避けたいcatch (ConcurrentModificationException e) {} で握りつぶす、または1スレッドの処理なのに ArrayList を CopyOnWriteArrayList に替える
→
代わりに1スレッドなら removeIf・it.remove() に書き換える。例外はバグの場所を教える合図なので、原因の行を直す

CopyOnWriteArrayList に替えると、次の節のとおり10万件の削除が約13倍遅くなった

Iterator の基本(hasNext・next・remove、ListIterator、Iterable の自作)は関連記事「Java Iteratorの使い方|ループ中の削除とIterable実装」で扱っています。この記事は例外の原因の切り分けと直し方の選び方を、そちらは Iterator そのものの使い方を中心にしています。

直し方の速さの比較(10万件のリストで実測)

この節では、同じ「偶数を全部消す」処理を9通りで書いたときの処理時間と、遅くなる書き方の理由が分かります。

0〜99,999 の Integer を入れた ArrayList から偶数(5万件)を消す処理を、9通りで測りました。各回にリストを作る時間(1.31ミリ秒)を含みます。

N=100000(偶数を全部消す)  java 21.0.12.1
Iterator.remove (ArrayList)        中央値    233.73 ms  残り 50000 件
removeIf (ArrayList)               中央値      1.84 ms  残り 50000 件
新しいリストに残す (for)           中央値      2.81 ms  残り 50000 件
stream().filter().toList()         中央値      1.16 ms  残り 50000 件
後ろから index で remove           中央値    118.98 ms  残り 50000 件
コピーを回して元を remove(Object)  中央値   1393.39 ms  残り 50000 件
Iterator.remove (LinkedList)       中央値      2.28 ms  残り 50000 件
removeIf (CopyOnWriteArrayList)    中央値      1.97 ms  残り 50000 件
for中にremove (CopyOnWrite)        中央値   3100.49 ms  残り 50000 件
1.84msremoveIf(ArrayList)1回の走査でまとめて詰める
233.73msIterator.remove(ArrayList)removeIf の約127倍
3100msCopyOnWrite の for 中 remove削除のたびに配列を丸ごとコピー

ArrayList の Iterator.remove() は、1件消すたびに後ろの要素を全部1つ前に詰めます。5万回の削除で毎回数万件を動かすので、件数の2乗に比例して遅くなります。removeIf は残す要素を1回の走査で前に詰めるだけなので、件数に比例する時間で終わりました。同じ Iterator.remove でも LinkedList は詰める作業が無いので 2.28ミリ秒でした。

「コピーを回して元を remove(Object)」は、削除のたびに先頭から値を探し、さらに詰めるので最も遅い部類になりました。CopyOnWriteArrayList の for 中 remove は、削除のたびに配列全体をコピーするため 3.1秒かかり、同じ CopyOnWriteArrayList でも removeIf なら 1.97ミリ秒でした。

件数が数百件までなら、どの書き方でも差は1ミリ秒未満です。数千件を超えるリストの削除は removeIf か filter().toList() を選ぶと、速さと読みやすさの両方を満たせます。

似たエラーとの違い(UnsupportedOperationException・IllegalStateException・NullPointerException)

この節では、ループ中の変更で出る ConcurrentModificationException 以外の例外と、それぞれの原因が分かります。

直そうとして書き換えたあとに、別の例外に変わることがあります。手元で1つずつ出した結果です。

操作 出た例外(原文、java.lang. は省略) 原因
List.of(1, 2, 3).removeIf(...)UnsupportedOperationExceptionList.of は変更できないリスト
Arrays.asList(1, 2, 3) の it.remove()UnsupportedOperationException: remove大きさを変えられないリスト
CopyOnWriteArrayList の it.remove()UnsupportedOperationExceptionスナップショットの Iterator は変更不可
it.remove() を2回続けて呼ぶIllegalStateExceptionnext() 1回につき remove() は1回まで
Stream の中で元を removeNullPointerException詰められた末尾の null を読んだ

List.of と Arrays.asList は、new ArrayList<>(List.of(...)) のように変更できるリストに入れ直してから消します。変更できる・できないの一覧は関連記事「Java List.ofとArrays.asListの違い|変更可否の比較表」で扱っています。

よくあるエラー/つまずき

この節では、ConcurrentModificationException を直すときに引っかかりやすい点が、原文つきで分かります。

つまずき 出たもの(原文) 直し方
remove の行を探しても見つからないat C1.main(C1.java:5)(for の行)for の行のループの中を見る
Iterator で回したのに例外ArrayList$Itr.nextlist.remove を it.remove() に
テストでは出ず本番で出る最後から2番目の削除は例外なし位置に関係なく removeIf に
ループが無いのに出るArrayList$SubList.checkForComodificationsubList をコピーする
ループが無いのに出る(Map)HashMap.computeIfAbsent再帰を関数の外で計算
synchronizedList に替えても出るArrayList$Itr.checkForComodification回す側も synchronized (list)
CopyOnWrite に替えたら遅いfor 中 remove で 3100msremoveIf か ArrayList に戻す
removeIf で別の例外UnsupportedOperationExceptionnew ArrayList<>(...) に入れ直す
  • 1ループの中で、回しているコレクションそのものを変えていないかremove・add・put・sort・clear と、ループの中で呼ぶメソッドの中
  • 2削除は removeIf で書けないかList・Set・Map の values()・keySet()・entrySet() で使える
  • 3subList を保持したまま元のリストを変えていないかあとで元を変えるなら new ArrayList でコピー
  • 4複数スレッドで同じコレクションを共有していないかstatic・singleton の Bean のフィールドは特に注意
  • 5例外を catch で握りつぶしていないか例外が消えても結果のずれは残る

判断表:ConcurrentModificationExceptionの直し方の選び方

この節では、場面ごとにどの直し方を選ぶかが一覧で分かります。

場面 選ぶ直し方 選ばない方がよいもの
1スレッドで、条件に合う要素を消すだけremoveIfCopyOnWriteArrayList への置き換え
消しながらログ・集計もしたいIterator と it.remove()拡張 for 文の中で list.remove
大きいリスト(数千件以上)を絞り込むremoveIf・filter().toList()ArrayList の Iterator.remove を何万回も
ループ中に要素を足したい別のリストに集めて addAll/ListIterator.add拡張 for 文の中で add
Map から条件で消したいvalues().removeIf・entrySet().removeIfkeySet を回して map.remove
Map の値だけ書き換えたいreplaceAll・entry.setValue新しい Map を作り直して代入(不要なら)
複数スレッドで読み書きする MapConcurrentHashMapHashMap のまま、catch で握りつぶす
複数スレッドで読むことが多いリストCopyOnWriteArrayList頻繁に変更するリストへの使用
一部だけを切り出して保持したいnew ArrayList<>(list.subList(a, b))subList のまま保持して元を変更

よくある質問

この節では、ConcurrentModificationException について検索で多い疑問への答えが分かります。

Q. ConcurrentModificationException はマルチスレッドでしか起きませんか?

A. いいえ。1つのスレッドでも、拡張 for 文や forEach で回している途中に、そのコレクションを直接変更すると起きます。検索で多い原因のほとんどはこちらです。

Q. 拡張 for 文で remove したいとき、一番簡単な書き方は?

A. ループをやめて list.removeIf(x -> 条件) の1行にするのが簡単です。手元の10万件でも 1.84ミリ秒で終わり、速さの面でも有利でした。

Q. なぜ remove の行ではなく for の行が表示されるのですか?

A. 変更そのものは成功していて、次の周回で Iterator の next() が modCount の違いに気づいた時点で例外を投げるからです。スタックトレースの行はループの入口を指します。

Q. 最後から2番目の要素を消すと例外にならないのはバグですか?

A. 仕様の範囲です。hasNext() が cursor と size だけを比べるため、ループがそのまま終わります。ただし最後の要素を読み飛ばしているので、書き方は直す対象です。

Q. 普通の for 文(番号で回す)なら例外になりませんか?

A. for (int i = 0; i < list.size(); i++) は Iterator を使わないので例外にはなりません。ただし前から remove(i) すると次の要素を飛ばします。番号で消すなら後ろから回します。

Q. Iterator.remove と removeIf はどちらを使えばよいですか?

A. 条件で消すだけなら removeIf、消す前後に別の処理(ログ・通知・集計)が要るなら Iterator.remove です。ArrayList で大量に消すなら removeIf の方が速くなります。

Q. CopyOnWriteArrayList に替えれば解決しますか?

A. 例外は止まりますが、変更のたびに配列をコピーするので、1スレッドの削除の直し方としては向きません。手元では for 中の remove が 3.1秒かかりました。読むことが多く変更がまれな、複数スレッドのリスト向けです。

Q. HashMap を回しながら値を書き換えると例外になりますか?

A. 既存のキーの値を put で上書きする、entry.setValue を使う、のどちらも件数が変わらないので例外になりません。新しいキーの put と remove は例外になります。

Q. ConcurrentHashMap なら走査中に何をしても安全ですか?

A. 例外は出ませんが、走査中に足したキーが見えるかどうかは決まっていません。手元では足したキー「ax」がループで読まれ、さらに「axx」が足されました。走査しながら同じ Map に足す処理は、結果が変わりうる前提で書きます。

Q. catch で ConcurrentModificationException を捕まえて続けてもよいですか?

A. API ドキュメントは「この例外に頼って正しさを保つプログラムを書くのは誤り」としています。捕まえても、途中までしか処理されていない状態が残ります。原因の行を直します。

Q. Java のバージョンで違いはありますか?

A. 拡張 for 文・Iterator・HashMap の条件は Java 8 以降の API ドキュメントで同じです。removeIf は Java 8、Stream.toList() は Java 16 から使えます。スタックトレースの行番号(ArrayList.java:1095 など)は JDK の版で変わります。

まとめ

この節では、記事の要点を5つにまとめます。

  • ConcurrentModificationException は「回している途中にそのコレクションの件数を変えた」合図で、1つのスレッドでも出る
  • スタックトレースの自分の行は for の行を指し、原因はそのループの中にある
  • 最後から2番目の削除は例外が出ないが最後の要素を読み飛ばす。例外が出なくても書き換える
  • 直し方は、1スレッドの削除なら removeIf(10万件で 1.84ms)、処理つきなら Iterator.remove、追加なら別のリストに集める、subList はコピー
  • 複数スレッドは ConcurrentHashMap・CopyOnWriteArrayList・synchronized ブロックで守り、catch で握りつぶさない

参考・出典

この節では、この記事で確かめた公式の資料が分かります(確認日 2026年11月5日)。

  • Oracle「ConcurrentModificationException(Java SE 21 API ドキュメント 日本語版)」https://docs.oracle.com/javase/jp/21/docs/api/java.base/java/util/ConcurrentModificationException.html
  • Oracle「ArrayList(Java SE 21 API)」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ArrayList.html
  • Oracle「HashMap(Java SE 21 API)」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/HashMap.html
  • Oracle「Collections(Java SE 21 API)」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Collections.html
  • Oracle「ConcurrentHashMap(Java SE 21 API)」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html
  • Oracle「CopyOnWriteArrayList(Java SE 21 API)」https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/CopyOnWriteArrayList.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ConcurrentModificationExceptionの原因と直し方12例」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり