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期連続の黒字決算。

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

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