Javaで「キーごとに値をまとめる」「同じキーが来たらカウントを増やす」という処理を書くとき、毎回 containsKey で分岐していませんか。
Map(キーと値を対応付けて保存する入れ物)には、値がないときだけ作る computeIfAbsent と、既存の値に新しい値を統合する merge が用意されています。どちらも便利ですが、null の扱いと並行処理には注意が必要です。
結論:何をしたいかで選ぶ
| やりたいこと | 選ぶメソッド | 典型例 |
|---|---|---|
| キーがない場合に初期値を作る | computeIfAbsent |
キーごとに List を作る |
| 既存値へ値を足す・結合する | merge |
URLごとのアクセス件数を数える |
| 存在する値も含めて再計算する | compute |
カウンターの増減など |
| 値を読むだけ | getOrDefault |
キーがなければ0を返す |
ここでの「キーがない」は厳密には「キーに対応する値が null」の場合も含みます。HashMap は null 値を許可しますが、ConcurrentHashMap は許可しません。
1. computeIfAbsent:必要なときだけListを生成する
ユーザーを部署別にまとめる場面を考えます。
Map<String, List<String>> members = new HashMap<>();
members.computeIfAbsent("backend", key -> new ArrayList<>()).add("佐藤");
members.computeIfAbsent("backend", key -> new ArrayList<>()).add("田中");
members.computeIfAbsent("frontend", key -> new ArrayList<>()).add("鈴木");
System.out.println(members.get("backend")); // [佐藤, 田中]
computeIfAbsent(key, mappingFunction) は、値がなければ mappingFunction(値を作る関数)を実行し、結果を登録して返します。すでに値があれば、作成関数を実行せずその値を返します。
上の例では backend の ArrayList は初回だけ生成され、2回目は同じリストに追加されます。key はラムダ式(-> で書く短い関数)が受け取る引数です。使わないなら ignored などの名前でも構いません。
従来の get → put の分岐より「存在しなければ作る」という意図が読みやすくなります。
ハマりどころ:作成関数がnullを返す場合
Map<String, String> cache = new HashMap<>();
String value = cache.computeIfAbsent("missing", key -> null);
System.out.println(value); // null
System.out.println(cache.containsKey("missing")); // false
作成関数が null を返すとエントリーは追加されません。次回も作成関数が呼ばれます。「見つからなかったこと」もキャッシュしたい場合は、Optional や別の状態表現を検討します。
2. merge:キー単位のカウントアップに向く
Map<String, Integer> requests = new HashMap<>();
for (String path : List.of("/orders", "/users", "/orders")) {
requests.merge(path, 1, Integer::sum);
}
System.out.println(requests.get("/orders")); // 2
System.out.println(requests.get("/users")); // 1
merge(key, value, remappingFunction) は、値がなければ value をそのまま登録します。既存の値があれば remappingFunction(古い値と追加値を合成する関数)で新しい値を決めます。
Integer::sum はメソッド参照で、ここでは (oldValue, addedValue) -> oldValue + addedValue と同じ意味です。初回は1を保存し、2回目以降は既存値に1を足すため、アクセス件数の集計などに向きます。
ハマりどころ:統合関数がnullを返すと削除される
Map<String, Integer> points = new HashMap<>();
points.put("user1", 10);
points.merge("user1", 1, (oldValue, addedValue) -> null);
System.out.println(points.containsKey("user1")); // false
merge の第2引数の値はnullにできません。一方、第3引数の関数が null を返した場合は、登録済みのキーが削除されます。「計算できなかったからnullを返す」のつもりで書くと、データが消える可能性があります。
3. 並行処理ではHashMapのまま使わない
ここまでの HashMap は、1つのスレッドで処理する例です。スレッドはプログラムの処理の実行単位です。複数のHTTPリクエストが同じ HashMap を更新するときは、computeIfAbsent や merge を使うだけではスレッド安全になりません。
Map インターフェースの標準実装は、これらのメソッドに対して原子性(同時操作に途中で割り込まれない性質)を保証していません。共有データなら用途に応じて ConcurrentHashMap などを使います。
ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
counts.computeIfAbsent("/orders", ignored -> new LongAdder()).increment();
counts.computeIfAbsent("/orders", ignored -> new LongAdder()).increment();
System.out.println(counts.get("/orders").sum()); // 2
LongAdder は複数スレッドからの加算に向いたカウンターです。この組み合わせは ConcurrentHashMap の公式APIドキュメントでも紹介されています。ただし、更新が続いている最中の sum() は厳密な一瞬のスナップショットではありません。課金の確定件数など、完全な整合性が必要な処理はDBトランザクションなど別の仕組みで保証しましょう。
また、ConcurrentHashMap が安全に管理するのはMapの登録・更新です。値に格納した通常の ArrayList への並行 add まで安全にするわけではありません。キー別リストを共有するときは、値側の同期方法も設計してください。
4. ラムダ式の中でMap自体を更新しない
// NG例:計算中に同じMapを更新する
map.computeIfAbsent("a", key -> {
map.put("b", "value");
return "created";
});
computeIfAbsent や merge に渡す関数から、同じMapへ別の更新を加えないようにします。実装によって例外になったり、並行更新時に問題が起きたりします。
特に ConcurrentHashMap.computeIfAbsent の作成関数は、短く単純な処理にすることが公式に推奨されています。重いDB問い合わせや外部API通信をその中で実行すると、他スレッドのMap更新を待たせる場合があります。リトライ、期限切れ(TTL)、失敗時の再試行などが必要なキャッシュは、専用のキャッシュライブラリも検討しましょう。
5. 動作確認できる最小サンプル
MapUpdateTips.java というファイルに次を保存して実行できます。
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class MapUpdateTips {
public static void main(String[] args) {
Map<String, List<String>> byTeam = new HashMap<>();
byTeam.computeIfAbsent("backend", ignored -> new ArrayList<>()).add("佐藤");
byTeam.computeIfAbsent("backend", ignored -> new ArrayList<>()).add("田中");
if (!byTeam.get("backend").equals(List.of("佐藤", "田中"))) {
throw new AssertionError("grouping failed");
}
Map<String, Integer> requests = new HashMap<>();
for (String path : List.of("/orders", "/users", "/orders")) {
requests.merge(path, 1, Integer::sum);
}
if (!Integer.valueOf(2).equals(requests.get("/orders"))) {
throw new AssertionError("counting failed");
}
ConcurrentHashMap<String, LongAdder> concurrent = new ConcurrentHashMap<>();
concurrent.computeIfAbsent("/orders", ignored -> new LongAdder()).increment();
if (concurrent.get("/orders").sum() != 1L) {
throw new AssertionError("concurrent count failed");
}
System.out.println("OK: grouping, merge and counter");
}
}
javac MapUpdateTips.java
java MapUpdateTips
javac はJavaソースを .class ファイルにコンパイルするコマンド、java はコンパイルしたクラスを実行するコマンドです。この例はJava 9以降の List.of を使用しています(computeIfAbsent と merge 自体はJava 8から)。
実行結果: OK: grouping, merge and counter
まとめ
-
computeIfAbsentは「存在しなければ初期化」。Listのグループ分けや遅延初期化に使う。 -
mergeは「値を合成」。集計に便利だが、統合関数のnullは削除を意味する。 - 並行アクセスがあるならMap自体だけでなく、値の可変オブジェクトも安全性を検討する。
- Mapの更新関数には重い処理や同じMapへの再更新を入れない。