- JAVA
Java getOrDefault・computeIfAbsent・merge
getOrDefault・computeIfAbsent・mergeとは、「キーがあるかどうかの確認」と「値の取得・登録・更新」を1回の呼び出しで済ませるMapのメソッドです。getOrDefault は読むだけ、computeIfAbsent は無いときだけ作って入れる、merge は無ければ入れて有れば合算します。 いずれも Java 8 で Map インターフェースに加わりました。
この記事では、よく似た6つのメソッド(getOrDefault・putIfAbsent・computeIfAbsent・computeIfPresent・compute・merge)を、「キーがない時/ある時/値が null の時」の動きで一覧にし、すべてに実行結果を付けます。単語カウントの4通りの書き方、Map<String, List<String>> への追加を1行にする定番、関数の中で同じMapを更新したときの ConcurrentModificationException まで扱います。
確認環境:Ubuntu 24.04 / OpenJDK 21.0.10。掲載コードはすべて java ファイル名.java で実行した結果です。
6つのメソッドの早見表
キーがない時・値がある時・値が null の時に、それぞれ何が起きるかをまとめました(HashMap で実行した結果に基づきます)。
| メソッド | キーがない時 | 値がある時 | 値が null の時 | 戻り値 | Mapを書き換えるか |
|---|---|---|---|---|---|
getOrDefault(k, 既定値) |
既定値を返す | 今の値を返す | null を返す | 今の値か既定値 | 書き換えない |
putIfAbsent(k, v) |
v を入れる | 何もしない | v を入れる | 前の値(なければ null) | ない時・null の時だけ |
computeIfAbsent(k, 関数) |
関数の結果を入れる | 何もしない | 関数の結果を入れる | 入っている値(新旧どちらか) | ない時・null の時だけ |
computeIfPresent(k, 関数) |
何もしない | 関数の結果で更新 | 何もしない | 新しい値(なければ null) | 値がある時だけ |
compute(k, 関数) |
関数に null を渡し、結果を入れる | 関数の結果で更新 | 関数に null を渡し、結果を入れる | 新しい値 | 毎回 |
merge(k, v, 関数) |
v を入れる | 関数(今の値, v) で更新 | v を入れる | 新しい値 | 毎回 |
目的から選ぶ場合は次の表が便利です。
| やりたいこと | 使うメソッド | 例 |
|---|---|---|
| 無ければ既定値で読みたい(Mapは変えない) | getOrDefault | map.getOrDefault(key, 0) |
| 回数を数える・合計する | merge | map.merge(key, 1, Integer::sum) |
| キーごとの List に追加する | computeIfAbsent | map.computeIfAbsent(key, k -> new ArrayList<>()).add(v) |
| 時間のかかる処理の結果を覚えておく | computeIfAbsent | cache.computeIfAbsent(key, this::load) |
| ある時だけ更新する | computeIfPresent | map.computeIfPresent(key, (k, v) -> v + 1) |
| 条件に合えば消す | computeIfPresent・compute | 関数が null を返すとエントリが消える |
| 用意済みの値を、無い時だけ入れる | putIfAbsent | map.putIfAbsent(key, value) |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java getOrDefault・computeIfAbsent・merge」
6つのメソッドを3つの状態で動かす
早見表の元になった実行結果です。none(キーなし)、have(値 10)、nul(値が null)の3つの状態に対して、6つのメソッドを1回ずつ呼びます。
import java.util.*;
import java.util.function.Function;
public class Six {
/** 3つの状態の Map を作る: none=キーなし / have=値あり(10) / nul=値がnull */
static Map<String, Integer> fresh() {
Map<String, Integer> m = new HashMap<>();
m.put("have", 10);
m.put("nul", null);
return m;
}
static void show(String title, Function<String, Function<Map<String, Integer>, Integer>> op) {
System.out.println("■ " + title);
for (String key : List.of("none", "have", "nul")) {
Map<String, Integer> m = fresh();
Integer ret = op.apply(key).apply(m);
System.out.printf(" %-4s 戻り値=%-4s Map=%s%n", key, ret, m);
}
}
public static void main(String[] args) {
System.out.println("初期状態: " + fresh());
show("getOrDefault(k, 0)", k -> m -> m.getOrDefault(k, 0));
show("putIfAbsent(k, 1)", k -> m -> m.putIfAbsent(k, 1));
show("computeIfAbsent(k, x -> 1)", k -> m -> m.computeIfAbsent(k, x -> 1));
show("computeIfPresent(k, (x, v) -> v + 1)", k -> m -> m.computeIfPresent(k, (x, v) -> v + 1));
show("compute(k, (x, v) -> v == null ? 1 : v + 1)", k -> m -> m.compute(k, (x, v) -> v == null ? 1 : v + 1));
show("merge(k, 1, Integer::sum)", k -> m -> m.merge(k, 1, Integer::sum));
}
}
実行結果:
初期状態: {nul=null, have=10}
■ getOrDefault(k, 0)
none 戻り値=0 Map={nul=null, have=10}
have 戻り値=10 Map={nul=null, have=10}
nul 戻り値=null Map={nul=null, have=10}
■ putIfAbsent(k, 1)
none 戻り値=null Map={nul=null, have=10, none=1}
have 戻り値=10 Map={nul=null, have=10}
nul 戻り値=null Map={nul=1, have=10}
■ computeIfAbsent(k, x -> 1)
none 戻り値=1 Map={nul=null, have=10, none=1}
have 戻り値=10 Map={nul=null, have=10}
nul 戻り値=1 Map={nul=1, have=10}
■ computeIfPresent(k, (x, v) -> v + 1)
none 戻り値=null Map={nul=null, have=10}
have 戻り値=11 Map={nul=null, have=11}
nul 戻り値=null Map={nul=null, have=10}
■ compute(k, (x, v) -> v == null ? 1 : v + 1)
none 戻り値=1 Map={nul=null, have=10, none=1}
have 戻り値=11 Map={nul=null, have=11}
nul 戻り値=1 Map={nul=1, have=10}
■ merge(k, 1, Integer::sum)
none 戻り値=1 Map={nul=null, have=10, none=1}
have 戻り値=11 Map={nul=null, have=11}
nul 戻り値=1 Map={nul=1, have=10}
結果から読み取れるポイントを、メソッドごとに見ていきます。
getOrDefault:読むだけでMapは変わらない
getOrDefault は3つの状態すべてで Map が元のままです。既定値は返されるだけで、Mapには入りません。
もう1つ、nul の行で戻り値が null になっている点に注目してください。キーがあって値が null のとき、getOrDefault は既定値ではなく null を返します。 「キーがあるか」で判定しているためです。値に null が入りうる Map では、戻り値の null チェックが引き続き必要です。
putIfAbsent と computeIfAbsent:戻り値が違う
どちらも「無い時(または null の時)だけ入れる」メソッドですが、戻り値の意味が逆です。
| 初めて入れた時の戻り値 | 既にあった時の戻り値 | 第2引数 | |
|---|---|---|---|
| putIfAbsent | null(前の値がないので) | 既にあった値 | 値そのもの |
| computeIfAbsent | 今入れた値 | 既にあった値 | 値を作る関数 |
computeIfAbsent は「必ず Map に入っている値」が返るので、続けて .add(...) のようにメソッドをつなげられます。putIfAbsent は初回に null が返るため、つなげて書くと NullPointerException になります(後の節で再現します)。
computeIfPresent・compute・merge:更新する3つ
- computeIfPresent は、値がある時だけ関数を呼びます。キーがない時・値が null の時は何もしません。
- compute は毎回関数を呼びます。キーがない時は、関数の第2引数に null が渡されます。そのため
v == null ? 1 : v + 1のような null の分岐を書きます。 - merge は「無ければ第2引数の値をそのまま入れ、有れば関数で今の値と合わせる」動きです。null の分岐を自分で書かなくてよいので、数える・足す処理が最も短くなります。
単語カウントを4通りで書き比べる
「単語ごとの出現回数を数える」処理を、4通りの書き方で比べます。
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
public class WordCount {
public static void main(String[] args) {
List<String> words = List.of("apple", "banana", "apple", "cherry", "banana", "apple");
// 1. if 文(Java 7 までの書き方)
Map<String, Integer> c1 = new TreeMap<>();
for (String w : words) {
if (c1.containsKey(w)) {
c1.put(w, c1.get(w) + 1);
} else {
c1.put(w, 1);
}
}
System.out.println("1. if 文 : " + c1);
// 2. getOrDefault
Map<String, Integer> c2 = new TreeMap<>();
for (String w : words) {
c2.put(w, c2.getOrDefault(w, 0) + 1);
}
System.out.println("2. getOrDefault : " + c2);
// 3. merge
Map<String, Integer> c3 = new TreeMap<>();
for (String w : words) {
c3.merge(w, 1, Integer::sum);
}
System.out.println("3. merge : " + c3);
// 4. Stream の groupingBy + counting(値は Long になる)
Map<String, Long> c4 = words.stream()
.collect(Collectors.groupingBy(Function.identity(), TreeMap::new, Collectors.counting()));
System.out.println("4. groupingBy : " + c4);
// 参考: compute でも書ける
Map<String, Integer> c5 = new TreeMap<>();
for (String w : words) {
c5.compute(w, (k, v) -> v == null ? 1 : v + 1);
}
System.out.println("参考 compute : " + c5);
}
}
実行結果:
1. if 文 : {apple=3, banana=2, cherry=1}
2. getOrDefault : {apple=3, banana=2, cherry=1}
3. merge : {apple=3, banana=2, cherry=1}
4. groupingBy : {apple=3, banana=2, cherry=1}
参考 compute : {apple=3, banana=2, cherry=1}
| 書き方 | 1単語あたりのコード | 特徴 |
|---|---|---|
| 1. if 文 | 5行 | Java 7 までの書き方。containsKey・get・put で3回Mapを引く |
| 2. getOrDefault | 1行 | 読みやすい。get と put で2回Mapを引く |
| 3. merge | 1行 | 最も短い。Mapを引くのは1回 |
| 4. groupingBy+counting | ループなし | Stream で集計。値の型は Long になる |
ループの中で数えるなら merge(key, 1, Integer::sum)、List や Stream から一気に集計するなら groupingBy と counting が定番です。groupingBy の結果は値が Long になるので、受け取る型を Map<String, Long> にします。
mergeで合計・最大・連結・Mapの合算
merge の第3引数を替えると、同じ形で合計・最大値・文字列の連結ができます。
import java.util.*;
public class Others {
record Order(String member, int amount, String item) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("sato", 1200, "ノート"), new Order("suzuki", 800, "ペン"),
new Order("sato", 300, "付箋"), new Order("suzuki", 2500, "椅子"),
new Order("sato", 4000, "机"));
Map<String, Integer> total = new TreeMap<>(); // 合計
Map<String, Integer> max = new TreeMap<>(); // 最大
Map<String, String> items = new TreeMap<>(); // 文字列の連結
Map<String, Integer> count = new TreeMap<>(); // 件数
for (Order o : orders) {
total.merge(o.member(), o.amount(), Integer::sum);
max.merge(o.member(), o.amount(), Math::max);
items.merge(o.member(), o.item(), (a, b) -> a + "・" + b);
count.merge(o.member(), 1, Integer::sum);
}
System.out.println("合計 : " + total);
System.out.println("最大 : " + max);
System.out.println("連結 : " + items);
System.out.println("件数 : " + count);
// 2つの Map を足し合わせる
Map<String, Integer> april = new TreeMap<>(Map.of("pen", 3, "note", 5));
Map<String, Integer> may = Map.of("pen", 4, "desk", 1);
may.forEach((k, v) -> april.merge(k, v, Integer::sum));
System.out.println("Map の合算: " + april);
// あるときだけ更新(なければ何もしない)
Map<String, Integer> point = new TreeMap<>(Map.of("sato", 100));
point.computeIfPresent("sato", (k, v) -> v + 50);
point.computeIfPresent("tanaka", (k, v) -> v + 50);
System.out.println("computeIfPresent: " + point);
// 似た名前の replace / replaceAll
point.replace("sato", 0);
point.replace("tanaka", 0); // キーがなければ何もしない
point.put("suzuki", 30);
point.replaceAll((k, v) -> v + 1);
System.out.println("replace / replaceAll: " + point);
}
}
実行結果:
合計 : {sato=5500, suzuki=3300}
最大 : {sato=4000, suzuki=2500}
連結 : {sato=ノート・付箋・机, suzuki=ペン・椅子}
件数 : {sato=3, suzuki=2}
Map の合算: {desk=1, note=5, pen=7}
computeIfPresent: {sato=150}
replace / replaceAll: {sato=1, suzuki=31}
| やりたいこと | 書き方 |
|---|---|
| 件数を数える | map.merge(key, 1, Integer::sum) |
| 合計する | map.merge(key, amount, Integer::sum) |
| 最大値を残す | map.merge(key, amount, Math::max) |
| 文字列をつなぐ | map.merge(key, s, (a, b) -> a + "・" + b) |
| 別のMapを足し込む | other.forEach((k, v) -> map.merge(k, v, Integer::sum)) |
後半の computeIfPresent は、sato には 50 を足し、Map にいない tanaka には何もしていません。名前の似た replace(k, v) も「キーがある時だけ置き換える」メソッドで、replaceAll は全エントリの値を関数で置き換えます。
Listへの追加をcomputeIfAbsentで1行にする
チームごとにメンバー名を集める Map<String, List<String>> は、computeIfAbsent の出番です。
import java.util.*;
public class MultiMap {
record Member(String name, String team) {}
public static void main(String[] args) {
List<Member> members = List.of(
new Member("佐藤", "開発"), new Member("鈴木", "営業"),
new Member("高橋", "開発"), new Member("田中", "総務"), new Member("伊藤", "営業"));
// 直す前: null チェックして List を作る
Map<String, List<String>> before = new LinkedHashMap<>();
for (Member m : members) {
List<String> list = before.get(m.team());
if (list == null) {
list = new ArrayList<>();
before.put(m.team(), list);
}
list.add(m.name());
}
System.out.println("直す前 : " + before);
// 直した後: computeIfAbsent で 1 行
Map<String, List<String>> after = new LinkedHashMap<>();
for (Member m : members) {
after.computeIfAbsent(m.team(), k -> new ArrayList<>()).add(m.name());
}
System.out.println("computeIfAbsent : " + after);
// getOrDefault で同じことを書くと Map に入らない
Map<String, List<String>> g = new LinkedHashMap<>();
for (Member m : members) {
g.getOrDefault(m.team(), new ArrayList<>()).add(m.name());
}
System.out.println("getOrDefault : " + g);
// 既定値の List を 1 つ使い回すと、別のキーの分まで混ざる
List<String> shared = new ArrayList<>();
Map<String, List<String>> g2 = new LinkedHashMap<>();
for (Member m : members) {
List<String> list = g2.getOrDefault(m.team(), shared);
list.add(m.name());
g2.put(m.team(), list);
}
System.out.println("既定値を使い回し : " + g2);
// putIfAbsent は戻り値が「前の値」なので、初回は null が返る
Map<String, List<String>> p = new LinkedHashMap<>();
try {
p.putIfAbsent("開発", new ArrayList<>()).add("佐藤");
} catch (NullPointerException e) {
System.out.println("putIfAbsent(...).add : " + e.getMessage());
}
// 入れ子の Map も同じ形で書ける(チーム → 名前の頭文字 → 人数)
Map<String, Map<Character, Integer>> nested = new TreeMap<>();
for (Member m : members) {
nested.computeIfAbsent(m.team(), k -> new TreeMap<>())
.merge(m.name().charAt(0), 1, Integer::sum);
}
System.out.println("入れ子の Map : " + nested);
}
}
実行結果:
直す前 : {開発=[佐藤, 高橋], 営業=[鈴木, 伊藤], 総務=[田中]}
computeIfAbsent : {開発=[佐藤, 高橋], 営業=[鈴木, 伊藤], 総務=[田中]}
getOrDefault : {}
既定値を使い回し : {開発=[佐藤, 鈴木, 高橋, 田中, 伊藤], 営業=[佐藤, 鈴木, 高橋, 田中, 伊藤], 総務=[佐藤, 鈴木, 高橋, 田中, 伊藤]}
putIfAbsent(...).add : Cannot invoke "java.util.List.add(Object)" because the return value of "java.util.Map.putIfAbsent(Object, Object)" is null
入れ子の Map : {営業={伊=1, 鈴=1}, 総務={田=1}, 開発={佐=1, 高=1}}
map.computeIfAbsent(key, k -> new ArrayList<>()).add(value) の1行で、「List が無ければ作って入れる」と「追加する」が済みます。 公式ドキュメント(Map)にも、1つのキーに複数の値を持たせる書き方として同じ形が載っています。
同じことを他のメソッドで書くとどうなるかも、実行結果に出ています。
| 書き方 | 結果 | 理由 |
|---|---|---|
getOrDefault(key, new ArrayList<>()).add(v) |
Map が空のまま | 既定値の List は Map に入らない |
| 既定値の List を1つ使い回して put する | 全チームに全員が入る | すべてのキーが同じ List を指している |
putIfAbsent(key, new ArrayList<>()).add(v) |
NullPointerException | 初回の戻り値が null |
computeIfAbsent(key, k -> new ArrayList<>()).add(v) |
期待どおり | 関数がキーごとに新しい List を作り、Map に入れてから返す |
最後の「入れ子の Map」の例のように、computeIfAbsent で内側の Map を用意し、続けて merge で数える書き方も組み合わせとしてよく使います。
putIfAbsent と computeIfAbsent は処理が走るタイミングが違う
値を作るのに時間がかかる場合(ファイルの読み込み、計算など)は、いつその処理が走るかが大事になります。
import java.util.*;
public class Lazy {
static int calls = 0;
static String load(String key) {
calls++;
System.out.println(" load(" + key + ") を実行");
return key.toUpperCase();
}
public static void main(String[] args) {
Map<String, String> cache = new HashMap<>();
cache.put("a", "A");
System.out.println("putIfAbsent(\"a\", load(\"a\"))");
cache.putIfAbsent("a", load("a")); // キーがあっても load は走る
System.out.println("getOrDefault(\"a\", load(\"a\"))");
cache.getOrDefault("a", load("a")); // 同じく走る
System.out.println("computeIfAbsent(\"a\", Lazy::load)");
cache.computeIfAbsent("a", Lazy::load); // キーがあるので走らない
System.out.println("computeIfAbsent(\"b\", Lazy::load)");
cache.computeIfAbsent("b", Lazy::load); // キーがないので 1 回だけ走る
cache.computeIfAbsent("b", Lazy::load);
System.out.println("load の呼び出し回数: " + calls + " / cache=" + cache);
}
}
実行結果:
putIfAbsent("a", load("a"))
load(a) を実行
getOrDefault("a", load("a"))
load(a) を実行
computeIfAbsent("a", Lazy::load)
computeIfAbsent("b", Lazy::load)
load(b) を実行
load の呼び出し回数: 3 / cache={a=A, b=B}
putIfAbsent("a", load("a"))とgetOrDefault("a", load("a"))は、メソッドを呼ぶ前に引数のload("a")が先に評価されます。キーが既にあっても、毎回 load が走ります。computeIfAbsent("a", Lazy::load)は関数を渡すだけなので、キーが無い時にだけ load が走ります。2回目のcomputeIfAbsent("b", ...)では走っていません。
キャッシュのように「無い時だけ作りたい」処理は computeIfAbsent を選びます。逆に、既に手元にある値を入れるだけなら putIfAbsent で十分です。
関数がnullを返すとエントリが消える
compute 系と merge は、関数が null を返したときの動きが決まっています。
import java.util.*;
public class NullReturn {
static Map<String, Integer> fresh() {
Map<String, Integer> m = new HashMap<>();
m.put("have", 10);
return m;
}
public static void main(String[] args) {
Map<String, Integer> m;
m = fresh();
System.out.println("computeIfAbsent(none, k -> null) 戻り値=" + m.computeIfAbsent("none", k -> null) + " Map=" + m);
m = fresh();
System.out.println("computeIfPresent(have, (k,v) -> null) 戻り値=" + m.computeIfPresent("have", (k, v) -> null) + " Map=" + m);
m = fresh();
System.out.println("compute(have, (k,v) -> null) 戻り値=" + m.compute("have", (k, v) -> null) + " Map=" + m);
m = fresh();
System.out.println("compute(none, (k,v) -> null) 戻り値=" + m.compute("none", (k, v) -> null) + " Map=" + m);
m = fresh();
System.out.println("merge(have, 1, (a,b) -> null) 戻り値=" + m.merge("have", 1, (a, b) -> null) + " Map=" + m);
// 応用: 在庫を 1 減らし、0 になったらエントリごと消す
Map<String, Integer> stock = new HashMap<>(Map.of("pen", 2));
for (int i = 0; i < 2; i++) {
stock.computeIfPresent("pen", (k, v) -> v > 1 ? v - 1 : null);
System.out.println("1つ出庫: " + stock);
}
// merge の第2引数に null は渡せない
try {
fresh().merge("have", null, Integer::sum);
} catch (NullPointerException e) {
System.out.println("merge(k, null, f): " + e);
}
// compute で null の値に + 1
try {
Map<String, Integer> c = new HashMap<>();
c.compute("none", (k, v) -> v + 1);
} catch (NullPointerException e) {
System.out.println("compute(none, (k,v) -> v + 1): " + e.getMessage());
}
// 変更できない Map
try {
Map.of("a", 1).merge("a", 1, Integer::sum);
} catch (UnsupportedOperationException e) {
System.out.println("Map.of(...).merge: " + e);
}
try {
Map.of("a", 1).getOrDefault(null, 0);
} catch (NullPointerException e) {
System.out.println("Map.of(...).getOrDefault(null, 0): " + e);
}
}
}
実行結果:
computeIfAbsent(none, k -> null) 戻り値=null Map={have=10}
computeIfPresent(have, (k,v) -> null) 戻り値=null Map={}
compute(have, (k,v) -> null) 戻り値=null Map={}
compute(none, (k,v) -> null) 戻り値=null Map={have=10}
merge(have, 1, (a,b) -> null) 戻り値=null Map={}
1つ出庫: {pen=1}
1つ出庫: {}
merge(k, null, f): java.lang.NullPointerException
compute(none, (k,v) -> v + 1): Cannot invoke "java.lang.Integer.intValue()" because "<parameter2>" is null
Map.of(...).merge: java.lang.UnsupportedOperationException
Map.of(...).getOrDefault(null, 0): java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because "o" is null
| メソッド | 関数が null を返した時 |
|---|---|
| computeIfAbsent | 何も入らない(戻り値も null) |
| computeIfPresent | そのエントリが消える |
| compute | そのエントリが消える(元から無ければ無いまま) |
| merge | そのエントリが消える |
この性質を使うと、「在庫を1つ減らし、0になったらエントリごと消す」処理が computeIfPresent("pen", (k, v) -> v > 1 ? v - 1 : null) の1行で書けます。値を null にして残したいときは、これらのメソッドではなく put(key, null) を使います。
実行結果の後半は、例外になる場面です。
| 書き方 | 例外 | 直し方 |
|---|---|---|
merge(k, null, 関数) |
NullPointerException | 第2引数に null を渡さない |
compute(key, (k, v) -> v + 1) でキーがない |
NullPointerException(v が null) | v == null ? 1 : v + 1 と分岐するか merge を使う |
Map.of(...) で作った Map に merge |
UnsupportedOperationException | new HashMap<>(Map.of(...)) に入れ直す |
Map.of(...) で作った Map に getOrDefault(null, 0) |
NullPointerException | null のキーで引かない |
computeIfAbsentの中で同じMapを更新するとConcurrentModificationException
computeIfAbsent に渡す関数の中で、同じ Map を更新するとエラーで止まります。 再帰でフィボナッチ数を求めながら結果を覚える、という書き方で起きやすい例です。
import java.util.*;
public class Cme {
static Map<Integer, Long> memo = new HashMap<>();
// うまくいかない例: computeIfAbsent の関数の中で、同じ Map を更新している
static long fib(int n) {
if (n < 2) return n;
return memo.computeIfAbsent(n, k -> fib(k - 1) + fib(k - 2));
}
public static void main(String[] args) {
System.out.println(fib(20));
}
}
実行結果(スタックトレースは同じ3行の繰り返しのため、途中を省略しています):
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.HashMap.computeIfAbsent(HashMap.java:1229)
at Cme.fib(Cme.java:9)
at Cme.lambda$fib$0(Cme.java:9)
at java.base/java.util.HashMap.computeIfAbsent(HashMap.java:1228)
at Cme.fib(Cme.java:9)
...
at Cme.main(Cme.java:13)
fib(20) の計算中に、関数の中から fib(19) が呼ばれ、その中でまた同じ memo に computeIfAbsent しています。外側の computeIfAbsent が終わる前に内側が Map を書き換えるため、HashMap が変更を検知して例外を投げます。スレッドが1つでも起きる点が、名前から受ける印象と異なるところです。
公式ドキュメント(Map)には「関数は計算中にこの Map を変更すべきでない」と書かれており、HashMap のドキュメントには、変更を検知した場合に可能な範囲で ConcurrentModificationException を投げると明記されています。
再帰でなくても、関数の中で別のキーを put するだけで同じ例外になります。compute・merge、そして TreeMap でも同様です。
import java.util.*;
public class Cme2 {
public static void main(String[] args) {
Map<String, Integer> m = new HashMap<>();
try {
m.computeIfAbsent("a", k -> {
m.put("b", 2); // 関数の中で同じ Map に別のキーを足す
return 1;
});
} catch (ConcurrentModificationException e) {
System.out.println("computeIfAbsent: " + e + " / Map=" + m);
}
Map<String, Integer> m2 = new HashMap<>(Map.of("a", 1));
try {
m2.compute("a", (k, v) -> { m2.put("b", 2); return v + 1; });
} catch (ConcurrentModificationException e) {
System.out.println("compute : " + e + " / Map=" + m2);
}
Map<String, Integer> m3 = new HashMap<>(Map.of("a", 1));
try {
m3.merge("a", 1, (x, y) -> { m3.put("b", 2); return x + y; });
} catch (ConcurrentModificationException e) {
System.out.println("merge : " + e + " / Map=" + m3);
}
// TreeMap でも同じ
Map<String, Integer> t = new TreeMap<>();
try {
t.computeIfAbsent("a", k -> { t.put("b", 2); return 1; });
} catch (ConcurrentModificationException e) {
System.out.println("TreeMap : " + e + " / Map=" + t);
}
}
}
実行結果:
computeIfAbsent: java.util.ConcurrentModificationException / Map={b=2}
compute : java.util.ConcurrentModificationException / Map={a=1, b=2}
merge : java.util.ConcurrentModificationException / Map={a=1, b=2}
TreeMap : java.util.ConcurrentModificationException / Map={b=2}
直し方:計算をMapの外に出す
計算は Map のメソッドの外で済ませ、結果だけを put する形に直します。
import java.util.*;
public class CmeFix {
static Map<Integer, Long> memo = new HashMap<>();
// 直した後: 計算は Map の外で済ませ、結果だけ put する
static long fib(int n) {
if (n < 2) return n;
Long cached = memo.get(n);
if (cached != null) return cached;
long value = fib(n - 1) + fib(n - 2);
memo.put(n, value);
return value;
}
public static void main(String[] args) {
System.out.println("fib(20) = " + fib(20));
System.out.println("fib(90) = " + fib(90));
System.out.println("memo の件数 = " + memo.size());
}
}
実行結果:
fib(20) = 6765
fib(90) = 2880067194370816120
memo の件数 = 89
| 場面 | 書き方 |
|---|---|
| 関数の中で同じ Map に触れない(通常のキャッシュ) | computeIfAbsent(key, 関数) のままでよい |
| 関数の中で同じ Map を更新する(再帰のメモ化など) | get → 無ければ計算 → put に分ける |
複数スレッドで数えるならConcurrentHashMapのmerge
複数スレッドから同じ Map で数える場合は、ConcurrentHashMap を使います。4スレッドで10万回ずつ、合計40万回 merge した結果です。
import java.util.*;
import java.util.concurrent.*;
public class Chm {
public static void main(String[] args) throws Exception {
Map<String, Integer> plain = new HashMap<>();
Map<String, Integer> conc = new ConcurrentHashMap<>();
int threads = 4, loops = 100_000;
ExecutorService pool = Executors.newFixedThreadPool(threads);
List<Future<?>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
futures.add(pool.submit(() -> {
for (int i = 0; i < loops; i++) {
conc.merge("hit", 1, Integer::sum);
plain.merge("hit", 1, Integer::sum);
}
}));
}
for (Future<?> f : futures) f.get();
pool.shutdown();
System.out.println("期待値 : " + threads * loops);
System.out.println("ConcurrentHashMap : " + conc.get("hit"));
System.out.println("HashMap : " + plain.get("hit"));
Map<String, Integer> chm = new ConcurrentHashMap<>();
try {
chm.computeIfAbsent("a", k -> chm.computeIfAbsent("a", k2 -> 1));
} catch (IllegalStateException e) {
System.out.println("同じキーの再帰更新 : " + e);
}
try {
chm.put("x", null);
} catch (NullPointerException e) {
System.out.println("ConcurrentHashMap に null の値: " + e);
}
}
}
実行結果(HashMap の値は実行のたびに変わります):
期待値 : 400000
ConcurrentHashMap : 400000
HashMap : 386901
同じキーの再帰更新 : java.lang.IllegalStateException: Recursive update
ConcurrentHashMap に null の値: java.lang.NullPointerException
ConcurrentHashMap の merge・compute・computeIfAbsent は、1回の呼び出しがひとまとまりで行われるため、期待どおり 400000 になりました。HashMap は同時更新に対応していないので、数が足りなくなっています。get してから put する書き方は、ConcurrentHashMap でも2回の呼び出しの間に他のスレッドが入り込めるので、merge にまとめるのが確実です。
あわせて、ConcurrentHashMap ならではの挙動が2つあります。
- 関数の中で同じキーをもう一度更新すると
IllegalStateException: Recursive updateになります。 - null のキー・null の値は入りません(NullPointerException)。
つまずきやすい点のチェック表
| 起きること | 原因 | 直し方 |
|---|---|---|
| getOrDefault したのに Map が空のまま | getOrDefault は Map に入れない | computeIfAbsent を使うか、戻り値を put する |
| getOrDefault が null を返す | キーがあり、値が null | 値に null を入れない。または戻り値を確認する |
putIfAbsent(...).add(...) で NullPointerException |
初回の戻り値は null | computeIfAbsent に替える |
| キーがあるのに重い処理が毎回走る | 引数は呼び出し前に評価される | computeIfAbsent に関数で渡す |
compute で NullPointerException |
キーがない時は v が null | null の分岐を書くか merge を使う |
| 更新したはずのエントリが消えた | 関数が null を返した | 残したい値を返す。消す意図なら問題なし |
| ConcurrentModificationException | 関数の中で同じ Map を更新した | 計算を外に出し、get と put に分ける |
| UnsupportedOperationException | Map.of などの変更できない Map |
new HashMap<>(...) に入れ直す |
| groupingBy の結果を Integer で受けられない | counting の結果は Long | Map<String, Long> で受ける |
| 複数スレッドで数が合わない | HashMap は同時更新に非対応 | ConcurrentHashMap の merge を使う |
よくある質問
Q. getOrDefault と computeIfAbsent はどう使い分けますか? A. Map を変えずに「無ければこの値として扱う」だけなら getOrDefault、無い時に値を作って Map に入れておきたいなら computeIfAbsent です。List や Set を値に持つ Map では、Map に入れる必要があるので computeIfAbsent を使います。
Q. merge と compute はどちらを使えばよいですか? A. 「初期値+合わせ方」で書ける処理(数える、足す、最大を残す、つなぐ)は merge が短く書けます。キーの内容によって計算が変わる、値がない時とある時でまったく別の処理をする、といった場合は compute が向いています。
Q. merge(key, 1, Integer::sum) の Integer::sum は何をしていますか?
A. (a, b) -> a + b と同じ意味のメソッド参照です。a に今の値、b に第2引数の 1 が渡され、足した結果が新しい値になります。キーがない時は関数は呼ばれず、1 がそのまま入ります。
Q. これらのメソッドは Java のどのバージョンから使えますか? A. 6つとも Java 8 で Map インターフェースに追加されました。HashMap・LinkedHashMap・TreeMap・ConcurrentHashMap のどれでも同じ書き方で使えます。
まとめ
- getOrDefault は読むだけ。既定値は Map に入らず、値が null のキーでは null が返る。
- putIfAbsent は前の値、computeIfAbsent は入っている値を返す。メソッドをつなげるなら computeIfAbsent。
- 数える・足すは
merge(key, 1, Integer::sum)が最も短い。合計・最大・連結も同じ形で書ける。 - Listへの追加は
computeIfAbsent(key, k -> new ArrayList<>()).add(v)の1行。 - computeIfPresent・compute・merge は、関数が null を返すとエントリが消える。
- 関数の中で同じ Map を更新すると ConcurrentModificationException。計算を外に出して get と put に分ける。
- 複数スレッドで数えるなら ConcurrentHashMap の merge を使う。
参考・出典
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java getOrDefault・computeIfAbsent・merge」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。