事の始まり
とあるプロジェクトでJavaのバージョン更新(8→17)を行っていた時のこと。
java.util.HashMapに対してcomputeIfAbsentを呼び出しているところでConcurrentModificationExceptionが発生するようになってしまいました。
この部分は旧バージョンから特にコードをいじっていないところ、かつ複数スレッドから参照されるものではなかったのでJavaのバージョンに起因するものでないかと考えました。
原因のコード
調査すると同じHashMapのインスタンスに対してcomputeIfAbsentが入れ子になっていることがわかりました。
簡単に書くと以下のようなコードです。
Map<String, String> map = new HashMap<>();
map.computeIfAbsent("k1", tk1 -> map.computeIfAbsent(tk1, tk2 -> "value1"));
実際には"k1"というキーに"value1"が入るだけなので入れ子になっている意味がまったくないコードなのですが、Java8では動作していました。
※なお、内側のcomputeIfAbsentに渡すキーをtk1から"k2"のような外側と違うキーにしても同じ結果となることを確認しています。
実験と結果
Java8では動作していたコードがJava17でConcurrentModificationExceptionが出てしまったことから、どのバージョンから動作(内部実装)が変わっているのか実験してみることにしました。
なお、HashMapの内部実装でのExceptionだったことからTreeMapでも実験を行っています。
以下が結果です。(〇はエラーなし。×はConcurrentModificationException発生)
| Javaバージョン | HashMap | TreeMap |
|---|---|---|
| 8 | 〇 | 〇 |
| 9 | × | 〇 |
| 10 | × | 〇 |
| 11 | × | 〇 |
| 12 | × | 〇 |
| 13 | × | 〇 |
| 14 | × | 〇 |
| 15 | × | × |
| 16 | × | × |
| 17 | × | × |
HashMapは9から変わっているのに対してTreeMapは15からなのがちょっと意外でした。
まとめ
Javaの更新ドキュメントをすべてチェックしたわけではないので見逃している可能性はありますが、これに関してはAPIの仕様変更というより内部処理変更に伴う副作用(?)のように見えました。
今回Javaのバージョンによって挙動が変わったことから調査しましたが、そもそも元のコードに問題(computeIfAbsentを入れ子にする必要がない)があるので、実際のプロジェクトではコード側を修正してどのバージョンのJavaでも動くようにしています。