JavaのHashMapとは、「キー」と「値」をペアで保存し、キーを指定して値をすばやく取り出せるクラスです。 putで入れてgetで取り出すのが基本で、値が無いときの既定値はgetOrDefault、件数の集計はmerge、グループ分けはcomputeIfAbsentで短く書けます。

この記事では、主要メソッド20種の早見表と実行結果、ループ4種(entrySet・keySet・forEach・Stream)の比較、単語カウントとグループ分けのコピペ例、ConcurrentModificationExceptionの再現と直し方、LinkedHashMap・TreeMap・ConcurrentHashMap・Map.ofとの違いを解説します。コードはすべてOpenJDK 21.0.10で実行し、出力をそのまま貼り付けています。

Java HashMapの早見表(主要メソッド一覧)

まずはこの表で「やりたいこと」からメソッドを探してください。 「実行結果」列は、後述のMapMethods.javaをJava 21.0.10で動かしたときの戻り値です(在庫数を表すMap<String, Integer>で確認)。

やりたいこと メソッド 戻り値 実行結果の例
追加・上書き put(k, v) 前の値(無ければnull) 新規null/上書き10
値を取り出す get(k) 値(無ければnull) あり12/なしnull
無いときの既定値つきで取り出す getOrDefault(k, 既定値) 値か既定値 0
キーがあるか調べる containsKey(k) boolean true
値があるか調べる containsValue(v) boolean(全件を走査) true
無いときだけ追加 putIfAbsent(k, v) 前の値(新規ならnull) 既存12/新規null
加算・連結して更新(集計) merge(k, v, 関数) 更新後の値 既存13/新規1
計算して更新 compute(k, (k, v) -> …) 更新後の値 10
無いときだけ計算して追加 computeIfAbsent(k, k -> …) 現在の値 6
あるときだけ計算して更新 computeIfPresent(k, (k, v) -> …) 更新後の値 20
あるときだけ置き換え replace(k, v) 前の値(無ければnull) 3/なしnull
旧値が一致したら置き換え replace(k, 旧, 新) boolean true
削除 remove(k) 削除した値 1
値も一致したら削除 remove(k, v) boolean false
件数・空か size()/isEmpty() int/boolean 5/false
キー・値・ペアの一覧 keySet()/values()/entrySet() ビュー(元のMapとつながる) [eraser, note, …]
全部の値を変換 replaceAll((k, v) -> …) なし {eraser=60, …}
ループ forEach((k, v) -> …) なし 後述
条件で削除 entrySet().removeIf(…) boolean 後述
全部消す clear() なし {}

迷ったときの選び方は次のとおりです。

場面 おすすめ ひとこと
無いキーでnullを避けたい getOrDefault 数値の初期値0、文字列の初期値""に
出現回数・合計を数える merge(k, 1, Integer::sum) 1行で「無ければ1、あれば+1」
キーごとにリストへ追加 computeIfAbsent(k, x -> new ArrayList<>()).add(v) グループ分けの定番
初回だけ登録したい putIfAbsent 戻り値は「前の値」なので初回はnull
ループ中に削除 entrySet().removeIf 拡張forの中でremoveしない
複数スレッドで更新 ConcurrentHashMap mergeが原子的に動く

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

(記載例)出典:株式会社RJC「Java HashMapの使い方|ループ・getOrDefault・merge早見表」

HashMapの基本(宣言・import・実行方法)

HashMapはjava.utilパッケージにあるクラスで、変数の型はMapインターフェースで受けるのが一般的です。 型をMapにしておくと、あとでLinkedHashMapやTreeMapに差し替えるときに1か所の変更で済みます。

import java.util.HashMap;
import java.util.Map;

Map<String, Integer> stock = new HashMap<>();  // キーがString、値がInteger
stock.put("pen", 10);
int n = stock.get("pen");                        // 10
項目 内容
型の書き方 Map<キーの型, 値の型>。intではなくIntegerなどを使う
キー 重複できない。同じキーでputすると上書き
値 重複できる。nullも入れられる
順序 保証されない(追加順にもキー順にもならない)
速さ get・putは件数が増えてもほぼ一定時間(平均O(1))

この記事のコードは、クラス名と同じファイル名で保存し、java ファイル名.javaでそのまま実行できます(Java 11以降。端末はUTF-8)。

$ java -version
openjdk version "21.0.10" 2026-01-20
$ java MapMethods.java

※java -versionの1行目は手元の環境の表示です。2行目以降(ビルド番号など)は省略しています。

主要メソッドを全部動かして戻り値を確かめる

HashMapのメソッドは、戻り値を知っておくと1行で書ける処理が増えます。 特にput・putIfAbsent・replaceは「前の値」を返し、merge・computeは「更新後の値」を返す点がポイントです。

import java.util.HashMap;
import java.util.Map;

public class MapMethods {
    public static void main(String[] args) {
        Map<String, Integer> stock = new HashMap<>();

        System.out.println("put(新規)        : " + stock.put("pen", 10));
        System.out.println("put(上書き)      : " + stock.put("pen", 12));
        stock.put("note", 5);
        stock.put("clip", 30);
        System.out.println("get(あり)        : " + stock.get("pen"));
        System.out.println("get(なし)        : " + stock.get("tape"));
        System.out.println("getOrDefault     : " + stock.getOrDefault("tape", 0));
        System.out.println("containsKey      : " + stock.containsKey("note"));
        System.out.println("containsValue    : " + stock.containsValue(30));
        System.out.println("putIfAbsent(既存): " + stock.putIfAbsent("pen", 99) + " -> " + stock.get("pen"));
        System.out.println("putIfAbsent(新規): " + stock.putIfAbsent("tape", 3) + " -> " + stock.get("tape"));
        System.out.println("merge(既存)      : " + stock.merge("pen", 1, Integer::sum));
        System.out.println("merge(新規)      : " + stock.merge("glue", 1, Integer::sum));
        System.out.println("compute          : " + stock.compute("note", (k, v) -> v == null ? 1 : v * 2));
        System.out.println("computeIfAbsent  : " + stock.computeIfAbsent("eraser", k -> k.length()));
        System.out.println("computeIfPresent : " + stock.computeIfPresent("clip", (k, v) -> v - 10));
        System.out.println("replace          : " + stock.replace("tape", 4));
        System.out.println("replace(なし)    : " + stock.replace("stapler", 1));
        System.out.println("replace(旧値一致): " + stock.replace("tape", 4, 6));
        System.out.println("remove           : " + stock.remove("glue"));
        System.out.println("remove(値一致)   : " + stock.remove("tape", 999));
        System.out.println("size / isEmpty   : " + stock.size() + " / " + stock.isEmpty());
        System.out.println("keySet           : " + stock.keySet());
        System.out.println("values           : " + stock.values());
        System.out.println("entrySet         : " + stock.entrySet());
        stock.replaceAll((k, v) -> v * 10);
        System.out.println("replaceAll       : " + stock);
        stock.clear();
        System.out.println("clear            : " + stock + " isEmpty=" + stock.isEmpty());
    }
}

実行結果(Java 21.0.10):

put(新規)        : null
put(上書き)      : 10
get(あり)        : 12
get(なし)        : null
getOrDefault     : 0
containsKey      : true
containsValue    : true
putIfAbsent(既存): 12 -> 12
putIfAbsent(新規): null -> 3
merge(既存)      : 13
merge(新規)      : 1
compute          : 10
computeIfAbsent  : 6
computeIfPresent : 20
replace          : 3
replace(なし)    : null
replace(旧値一致): true
remove           : 1
remove(値一致)   : false
size / isEmpty   : 5 / false
keySet           : [eraser, note, tape, pen, clip]
values           : [6, 10, 6, 13, 20]
entrySet         : [eraser=6, note=10, tape=6, pen=13, clip=20]
replaceAll       : {eraser=60, note=100, tape=60, pen=130, clip=200}
clear            : {} isEmpty=true

putは上書き前の値10を返し、putIfAbsentは既存キーには何もせず、replaceは無いキーを追加しません。 keySetが追加順になっていない点も確認できます(HashMapは順序を保証しない)。

keySet()・values()・entrySet()はコピーではなく「ビュー」です。 ビューから要素を削除すると元のMapからも消えます。この性質は、後半のremoveIfで活用します。

getとgetOrDefaultの違い(キーが無いときとnullの扱い)

getはキーが無いとnullを返し、getOrDefaultは指定した既定値を返します。 ただし、キーがあって値がnullのときは、getOrDefaultもnullを返す点に注意が必要です。

import java.util.HashMap;
import java.util.Map;

public class NullPitfall {
    public static void main(String[] args) {
        Map<String, Integer> m = new HashMap<>();
        m.put("a", null);
        System.out.println("get(\"a\")              = " + m.get("a"));
        System.out.println("get(\"zzz\")            = " + m.get("zzz"));
        System.out.println("containsKey(\"a\")      = " + m.containsKey("a"));
        System.out.println("getOrDefault(\"a\", 0)  = " + m.getOrDefault("a", 0));
        System.out.println("putIfAbsent(\"a\", 7)   = " + m.putIfAbsent("a", 7) + " -> " + m.get("a"));

        m.put("b", 5);
        m.merge("b", 0, (oldV, newV) -> null);
        System.out.println("merge で null を返す   = " + m + " (b が消える)");
        m.put("c", 1);
        m.compute("c", (k, v) -> null);
        System.out.println("compute で null を返す = " + m + " (c が消える)");

        m.put(null, 100);
        System.out.println("null キー              = " + m.get(null));

        try {
            int n = m.get("zzz");
            System.out.println(n);
        } catch (NullPointerException e) {
            System.out.println("int に代入: " + e);
        }
    }
}

実行結果(Java 21.0.10):

get("a")              = null
get("zzz")            = null
containsKey("a")      = true
getOrDefault("a", 0)  = null
putIfAbsent("a", 7)   = null -> 7
merge で null を返す   = {a=7} (b が消える)
compute で null を返す = {a=7} (c が消える)
null キー              = 100
int に代入: java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null

この結果からわかる4つのルールです。

ルール 内容 対処
getのnullは2通り 「キーが無い」と「値がnull」を区別できない 区別したいときはcontainsKeyを使う
getOrDefaultは「キーが無いとき」だけ既定値 値がnullで登録済みならnullが返る そもそもnullを値に入れない設計にする
putIfAbsentは値がnullなら上書きする 「無い」とみなされて7が入った 初期化用途ならむしろ便利
merge・computeでnullを返すとキーが消える 関数の戻り値nullは「削除」の合図 「0になったら消す」処理に使える

int n = m.get(...)とintへ直接代入すると、キーが無いときNullPointerExceptionになります(Integer→intの自動変換のため)。数値のMapではgetOrDefault(key, 0)が安全です。

HashMapをループする4つの方法(同じデータで比較)

Mapのループは「entrySet」「keySet」「forEach」「Stream」の4通りです。迷ったらentrySetの拡張for、短く書くならforEachを選びます。 同じデータで4つを並べて比べます。表示順を固定するため、ここでは追加順を保つLinkedHashMapを使っています(メソッドはHashMapと同じです)。

import java.util.LinkedHashMap;
import java.util.Map;

public class MapLoops {
    public static void main(String[] args) {
        Map<String, Integer> score = new LinkedHashMap<>();
        score.put("佐藤", 82);
        score.put("鈴木", 95);
        score.put("高橋", 67);

        System.out.println("① entrySet + 拡張for");
        for (Map.Entry<String, Integer> e : score.entrySet()) {
            System.out.println("  " + e.getKey() + "=" + e.getValue());
        }

        System.out.println("② keySet + get");
        for (String name : score.keySet()) {
            System.out.println("  " + name + "=" + score.get(name));
        }

        System.out.println("③ forEach(BiConsumer)");
        score.forEach((name, point) -> System.out.println("  " + name + "=" + point));

        System.out.println("④ Stream(80点以上を点数の高い順)");
        score.entrySet().stream()
            .filter(e -> e.getValue() >= 80)
            .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
            .forEach(e -> System.out.println("  " + e.getKey() + "=" + e.getValue()));
    }
}

実行結果(Java 21.0.10):

① entrySet + 拡張for
  佐藤=82
  鈴木=95
  高橋=67
② keySet + get
  佐藤=82
  鈴木=95
  高橋=67
③ forEach(BiConsumer)
  佐藤=82
  鈴木=95
  高橋=67
④ Stream(80点以上を点数の高い順)
  鈴木=95
  佐藤=82

4つのループの比較表です。

方法 短さ キーと値を同時に break・returnで抜ける 外側のローカル変数の更新 向いている場面
① entrySet+拡張for 普通 できる できる できる 迷ったらこれ。途中で抜ける処理
② keySet+get 普通 getで再検索 できる できる キーだけ使うとき
③ forEach 短い できる できない できない 全件を表示・転記するだけ
④ Stream 処理による できる できない(filter等で表現) できない 絞り込み・並べ替え・集計

keySet+getはループのたびにキーで再検索するので、両方使うならentrySetが素直です。 なお、100万件のHashMap<Integer, Integer>を4つの方法で合計する簡易計測(このマシン、2コア、6回まわした後半3回)では、どれも15〜90ミリ秒で回ごとに順位が入れ替わる程度の差でした。速さより「途中で抜けたいか」「並べ替えたいか」で選ぶのがおすすめです。

単語の出現回数を数える(mergeのコピペ例)

出現回数のカウントはmap.merge(キー, 1, Integer::sum)の1行が定番です。 「キーが無ければ1を入れる、あれば今の値に1を足す」を1回の呼び出しで行います。同じ処理をgetOrDefault版とStream版でも書いて比べます。

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Collectors;

public class WordCount {
    public static void main(String[] args) {
        String text = "apple banana apple cherry banana apple";
        String[] words = text.split(" ");

        // 1. merge(おすすめ)
        Map<String, Integer> count = new HashMap<>();
        for (String w : words) {
            count.merge(w, 1, Integer::sum);
        }
        System.out.println("merge        : " + count);

        // 2. getOrDefault
        Map<String, Integer> count2 = new HashMap<>();
        for (String w : words) {
            count2.put(w, count2.getOrDefault(w, 0) + 1);
        }
        System.out.println("getOrDefault : " + count2);

        // 3. Stream(Collectors.groupingBy + counting)。値は Long
        Map<String, Long> count3 = Arrays.stream(words)
            .collect(Collectors.groupingBy(Function.identity(), TreeMap::new, Collectors.counting()));
        System.out.println("Stream       : " + count3);

        // 多い順に並べる(同数ならキーの順)
        count.entrySet().stream()
            .sorted(Map.Entry.<String, Integer>comparingByValue().reversed()
                .thenComparing(Map.Entry.comparingByKey()))
            .forEach(e -> System.out.println("  " + e.getKey() + " : " + e.getValue()));
    }
}

実行結果(Java 21.0.10):

merge        : {banana=2, cherry=1, apple=3}
getOrDefault : {banana=2, apple=3, cherry=1}
Stream       : {apple=3, banana=2, cherry=1}
  apple : 3
  banana : 2
  cherry : 1

数は同じですが、merge版とgetOrDefault版で表示順が違います。 appleとcherryは容量16のHashMapで同じ格納位置(バケット)に入るキーで(hashCodeから計算して確認)、その中の並びが追加の書き方で変わったためです。順序に意味を持たせたいときはTreeMap(キー順)かLinkedHashMap(追加順)を使います。

合計金額や在庫数の積み上げも同じ形で書けます。例えばtotal.merge(商品コード, 数量, Integer::sum)です。文字列の連結ならmap.merge(k, s, (a, b) -> a + "," + b)のように関数を変えるだけです。

グループ分けする(computeIfAbsentのコピペ例)

キーごとにリストへ追加する処理はcomputeIfAbsent(キー, k -> new ArrayList<>()).add(値)で書けます。 キーが無ければ空のリストを作って登録し、そのリストを返してくれるので、続けてaddできます。

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class Grouping {
    record Task(String owner, String title) {}

    public static void main(String[] args) {
        List<Task> tasks = List.of(
            new Task("佐藤", "見積書作成"),
            new Task("鈴木", "備品発注"),
            new Task("佐藤", "議事録整理"),
            new Task("高橋", "在庫確認"),
            new Task("鈴木", "会議室予約"));

        // computeIfAbsent: キーが無ければ空リストを作ってから add
        Map<String, List<String>> byOwner = new TreeMap<>();
        for (Task t : tasks) {
            byOwner.computeIfAbsent(t.owner(), k -> new ArrayList<>()).add(t.title());
        }
        System.out.println("computeIfAbsent : " + byOwner);

        // Stream 版
        Map<String, List<String>> byOwner2 = tasks.stream()
            .collect(Collectors.groupingBy(Task::owner, TreeMap::new,
                Collectors.mapping(Task::title, Collectors.toList())));
        System.out.println("groupingBy      : " + byOwner2);

        // よくある書き間違い: putIfAbsent の戻り値は「前の値」なので初回は null
        Map<String, List<String>> wrong = new TreeMap<>();
        try {
            wrong.putIfAbsent("佐藤", new ArrayList<>()).add("見積書作成");
        } catch (NullPointerException e) {
            System.out.println("putIfAbsent(...).add -> " + e.getClass().getSimpleName());
        }
    }
}

実行結果(Java 21.0.10):

computeIfAbsent : {佐藤=[見積書作成, 議事録整理], 鈴木=[備品発注, 会議室予約], 高橋=[在庫確認]}
groupingBy      : {佐藤=[見積書作成, 議事録整理], 鈴木=[備品発注, 会議室予約], 高橋=[在庫確認]}
putIfAbsent(...).add -> NullPointerException

putIfAbsent(...).add(...)と書くと、初回は戻り値がnullなのでNullPointerExceptionになります。 putIfAbsentは「前の値」を返し、computeIfAbsentは「現在の値(今作ったものを含む)」を返す、という違いがそのまま出ています。グループ分けにはcomputeIfAbsentを使ってください。

merge・compute・putIfAbsentの使い分け

似たメソッドが多いので、「キーが無いとき」「キーがあるとき」に何をするかで整理します。

メソッド キーが無いとき キーがあるとき 戻り値 典型的な用途
put(k, v) 追加 上書き 前の値 普通の登録
putIfAbsent(k, v) 追加 何もしない(値がnullなら上書き) 前の値 初回だけ登録
computeIfAbsent(k, f) f(k)を追加 何もしない 現在の値 グループ分け・キャッシュ
computeIfPresent(k, f) 何もしない f(k, v)で更新 更新後の値 あるときだけ更新
compute(k, f) f(k, null)を追加 f(k, v)で更新 更新後の値 有無で分岐する計算
merge(k, v, f) vを追加 f(旧, v)で更新 更新後の値 集計(カウント・合計・連結)
replace(k, v) 何もしない 上書き 前の値 あるときだけ置換

共通ルール:関数(f)がnullを返すと、そのキーは削除されます。 たとえば在庫を減らして0になったら消す処理は、次のように書けます。

// 在庫を1減らし、0になったらキーごと削除
stock.computeIfPresent("pen", (k, v) -> v - 1 == 0 ? null : v - 1);

compute系のラムダの中で、同じMapにputやremoveをしないでください。 HashMapのJavadocでは、関数の中でMapを変更した場合、ベストエフォートでConcurrentModificationExceptionを投げると説明されています。

ループ中に削除する(ConcurrentModificationExceptionの直し方)

拡張forでループしている最中にmap.remove(key)を呼ぶと、ConcurrentModificationExceptionが発生します。 削除はIterator.remove()かremoveIfで行います。在庫0の備品を消す例で、直す前と後を比べます。

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

public class CmeDemo {
    static Map<String, Integer> sample() {
        Map<String, Integer> m = new HashMap<>();
        m.put("pen", 0);
        m.put("note", 5);
        m.put("clip", 0);
        m.put("tape", 3);
        return m;
    }

    public static void main(String[] args) {
        // 直す前: 拡張for の中で map.remove
        Map<String, Integer> m1 = sample();
        try {
            for (String key : m1.keySet()) {
                if (m1.get(key) == 0) {
                    m1.remove(key);
                }
            }
        } catch (Exception e) {
            System.out.println("拡張for + remove : " + e);
        }

        // 直した後 1: Iterator.remove
        Map<String, Integer> m2 = sample();
        Iterator<Map.Entry<String, Integer>> it = m2.entrySet().iterator();
        while (it.hasNext()) {
            if (it.next().getValue() == 0) {
                it.remove();
            }
        }
        System.out.println("Iterator.remove  : " + m2);

        // 直した後 2: removeIf(Java 8+)
        Map<String, Integer> m3 = sample();
        m3.values().removeIf(v -> v == 0);
        System.out.println("values().removeIf: " + m3);

        Map<String, Integer> m4 = sample();
        m4.entrySet().removeIf(e -> e.getKey().startsWith("p"));
        System.out.println("entrySet().removeIf: " + m4);

        // 値の更新は setValue なら OK
        Map<String, Integer> m5 = sample();
        for (Map.Entry<String, Integer> e : m5.entrySet()) {
            e.setValue(e.getValue() + 1);
        }
        System.out.println("setValue         : " + m5);
    }
}

実行結果(Java 21.0.10):

拡張for + remove : java.util.ConcurrentModificationException
Iterator.remove  : {note=5, tape=3}
values().removeIf: {note=5, tape=3}
entrySet().removeIf: {note=5, tape=3, clip=0}
setValue         : {note=6, tape=4, pen=1, clip=1}

なぜ例外になるのか:HashMapは内部に「構造が変わった回数(modCount)」を持っていて、イテレータは作られた時点の回数を覚えています。ループ中にMap自身のremoveで件数が変わると回数がずれ、次のnext()で検知してConcurrentModificationExceptionを投げます(fail-fast)。イテレータ自身のremove()なら回数を合わせながら消すので例外になりません。

やりたいこと 書き方 備考
値の条件で削除 map.values().removeIf(v -> 条件) 一番短い
キーと値の両方で判定 map.entrySet().removeIf(e -> 条件)
削除しながら別の処理もしたい Iterator+it.remove() ログ出力などを挟める
値だけ更新 entry.setValue(新しい値) または replaceAll 件数が変わらないので例外にならない
ループ中にキーを追加 別のMapに集めて、ループ後にputAll

List・Setでのループ中の削除は「Java List Set Map 使い分け」の記事で紹介しています。

HashMap・LinkedHashMap・TreeMap・ConcurrentHashMapの違い

Mapの実装クラスは「順序」「nullを入れられるか」「スレッド安全か」で選びます。 同じ4件を追加して、表示順とnullの扱いを比べました。

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class ImplCompare {
    static void fill(Map<String, Integer> m) {
        m.put("delta", 4);
        m.put("alpha", 1);
        m.put("charlie", 3);
        m.put("bravo", 2);
    }

    static String tryNull(Map<String, Integer> m) {
        String k, v;
        try { m.put(null, 0); k = "OK"; } catch (Exception e) { k = e.getClass().getSimpleName(); }
        try { m.put("x", null); v = "OK"; } catch (Exception e) { v = e.getClass().getSimpleName(); }
        return "nullキー=" + k + " / null値=" + v;
    }

    public static void main(String[] args) {
        List<Map<String, Integer>> maps = List.of(
            new HashMap<>(), new LinkedHashMap<>(), new TreeMap<>(), new ConcurrentHashMap<>());
        for (Map<String, Integer> m : maps) {
            fill(m);
            System.out.printf("%-18s %s%n", m.getClass().getSimpleName(), m);
        }
        System.out.println();
        for (Map<String, Integer> m : List.<Map<String, Integer>>of(
                new HashMap<>(), new LinkedHashMap<>(), new TreeMap<>(), new ConcurrentHashMap<>())) {
            System.out.printf("%-18s %s%n", m.getClass().getSimpleName(), tryNull(m));
        }
    }
}

実行結果(Java 21.0.10):

HashMap            {bravo=2, alpha=1, delta=4, charlie=3}
LinkedHashMap      {delta=4, alpha=1, charlie=3, bravo=2}
TreeMap            {alpha=1, bravo=2, charlie=3, delta=4}
ConcurrentHashMap  {bravo=2, alpha=1, delta=4, charlie=3}

HashMap            nullキー=OK / null値=OK
LinkedHashMap      nullキー=OK / null値=OK
TreeMap            nullキー=NullPointerException / null値=OK
ConcurrentHashMap  nullキー=NullPointerException / null値=NullPointerException

比較表(null列は上の実行結果、計算量・スレッド安全性は公式Javadocの記述によります):

クラス 順序 nullキー null値 get・putの速さ スレッド安全 選ぶ場面
HashMap 保証なし 1つだけ可 可 平均O(1) なし 基本はこれ
LinkedHashMap 追加順(アクセス順も設定可) 1つだけ可 可 平均O(1) なし 表示順を追加順にしたい、LRUキャッシュ
TreeMap キーの昇順(Comparator指定可) 不可 可 O(log n) なし キー順の表示、範囲検索(floorEntry等)
ConcurrentHashMap 保証なし 不可 不可 平均O(1) あり 複数スレッドからの読み書き
Map.of/Map.copyOf 保証なし 不可 不可 平均O(1) 変更不可なので安全に共有可 定数・設定値

複数スレッドでHashMapを更新するとどうなるか

HashMapを複数スレッドから同時に更新すると、更新が失われて数が合わなくなります。 8スレッドで10万回ずつmergeで数えた結果です。

import java.util.*;
import java.util.concurrent.*;

public class ThreadDemo {
    static int run(Map<String, Integer> map) throws Exception {
        try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
            for (int t = 0; t < 8; t++) {
                pool.submit(() -> {
                    for (int i = 0; i < 100_000; i++) {
                        map.merge("count", 1, Integer::sum);
                    }
                });
            }
        } // close() で全タスクの終了を待つ(Java 19+)
        return map.get("count");
    }

    public static void main(String[] args) throws Exception {
        System.out.println("期待値            : " + 8 * 100_000);
        for (int i = 1; i <= 3; i++) {
            System.out.println("HashMap 試行" + i + "     : " + run(new HashMap<>()));
        }
        System.out.println("ConcurrentHashMap : " + run(new ConcurrentHashMap<>()));
    }
}

実行結果(Java 21.0.10、2コアのマシン。HashMapの数値は実行のたびに変わります):

期待値            : 800000
HashMap 試行1     : 522060
HashMap 試行2     : 209671
HashMap 試行3     : 658176
ConcurrentHashMap : 800000

ConcurrentHashMapのmergeはキー単位で原子的に実行されるため、期待どおり80万になりました。 ただしConcurrentHashMapでもcontainsKeyしてからputのように2回に分けて呼ぶと、間に他のスレッドが割り込めます。「調べてから更新」はputIfAbsent・computeIfAbsent・mergeの1回の呼び出しにまとめます。

Map.ofで作るMapとHashMapの違い

Map.of(Java 9以降)は、変更できない(不変の)Mapを1行で作るメソッドです。 定数の対応表には便利ですが、putできない・null不可・キー重複で例外、という違いがあります。

import java.util.*;

public class MapOfDemo {
    public static void main(String[] args) {
        Map<String, Integer> fixed = Map.of("pen", 10, "note", 5);
        System.out.println("Map.of        : " + fixed.get("pen"));
        try { fixed.put("clip", 1); } catch (Exception e) { System.out.println("put          -> " + e); }
        try { Map.of("pen", 1, "pen", 2); } catch (Exception e) { System.out.println("キー重複     -> " + e); }
        try { Map.of("pen", null); } catch (Exception e) { System.out.println("null値       -> " + e); }
        try { fixed.containsKey(null); } catch (Exception e) { System.out.println("containsKey(null) -> " + e); }


        Map<String, Integer> editable = new HashMap<>(fixed);
        editable.put("clip", 1);
        System.out.println("new HashMap<>(Map.of(...)) で変更可: " + editable.containsKey("clip"));

        Map<String, Integer> copy = Map.copyOf(editable);
        System.out.println("Map.copyOf は変更不可: " + copy.getClass().getSimpleName());

        Map<String, Integer> view = Collections.unmodifiableMap(editable);
        editable.put("tape", 2);
        System.out.println("unmodifiableMap は元の変更が見える: " + view.containsKey("tape"));
        System.out.println("Map.copyOf はコピー時点のまま   : " + copy.containsKey("tape"));
    }
}

実行結果(Java 21.0.10):

Map.of        : 10
put          -> java.lang.UnsupportedOperationException
キー重複     -> java.lang.IllegalArgumentException: duplicate key: pen
null値       -> java.lang.NullPointerException
containsKey(null) -> java.lang.NullPointerException
new HashMap<>(Map.of(...)) で変更可: true
Map.copyOf は変更不可: MapN
unmodifiableMap は元の変更が見える: true
Map.copyOf はコピー時点のまま   : false
作り方 変更 null キー重複 元の変更の反映 使いどころ
new HashMap<>() できる 可 上書き ― 普通に読み書きする
Map.of(k1, v1, …) できない 不可(containsKey(null)も例外) 例外 ― 10組までの定数
Map.copyOf(map) できない 不可 ― 反映されない(コピー) 受け取った値を固定して保持
Collections.unmodifiableMap(map) できない 元に従う ― 反映される(ビュー) 読み取り専用で公開

初期値つきで変更もしたいときはnew HashMap<>(Map.of(...))と書くのが簡単です。 Map.ofは引数が10組(20個)までなので、それ以上はMap.ofEntriesを使います。なおMap.ofの表示順も保証されません。不変コレクション全般(List.of・Set.of)の話は「Java List Set Map 使い分け」の記事にまとめています。

Java 19〜21で増えたMap関連のメソッド

Java 19でHashMap.newHashMap(件数)、Java 21でLinkedHashMapなどに先頭・末尾を扱うメソッド(SequencedMap)が追加されました。 次の断片はimport java.util.*;をしたmainの中で実行しています。

Map<String, Integer> m = HashMap.newHashMap(100);   // Java 19+: 100件入れても拡張しない容量で作る
LinkedHashMap<String, Integer> lm = new LinkedHashMap<>();
lm.put("b", 2);
lm.put("c", 3);
lm.putFirst("a", 1);                                 // Java 21+
System.out.println(lm + " first=" + lm.firstEntry() + " last=" + lm.lastEntry());
System.out.println("reversed=" + lm.reversed() + " poll=" + lm.pollFirstEntry() + " -> " + lm);

実行結果(Java 21.0.10):

{a=1, b=2, c=3} first=a=1 last=c=3
reversed={c=3, b=2, a=1} poll=a=1 -> {b=2, c=3}
メソッド 追加された版 内容
HashMap.newHashMap(n) Java 19 n件入れても容量の拡張が起きないサイズで作る(new HashMap<>(n)のnは容量なので意味が違う)
putFirst/putLast Java 21 先頭・末尾に追加(LinkedHashMap)
firstEntry/lastEntry/pollFirstEntry Java 21 先頭・末尾の取得・取り出し(SequencedMap)
reversed() Java 21 逆順のビュー

Java 25について:手元の環境はJava 21.0.10のため、Java 22〜25では実行していません。公式のAPI差分一覧(Java SE 25の「New API」)を確認したところ、HashMap・Map・LinkedHashMap・TreeMap・ConcurrentHashMapに追加されたメソッドはありませんでした。この記事のコードはJava 25でもそのまま使える書き方です。

よくあるつまずきと直し方のチェックリスト

症状・エラー 原因 直し方
getがnullを返す キーが無い/値がnull/キーのequals未実装 containsKeyで確認、getOrDefault、キーをrecordに
int x = map.get(k);でNullPointerException nullをintへボクシング解除 map.getOrDefault(k, 0)
putIfAbsent(...).add(...)でNullPointerException 初回の戻り値がnull computeIfAbsent(k, x -> new ArrayList<>()).add(...)
ループ中のremoveでConcurrentModificationException fail-fastイテレータが変更を検知 removeIfかIterator.remove()
Map.ofにputしてUnsupportedOperationException 不変のMap new HashMap<>(Map.of(...))
複数スレッドで数が合わない HashMapは同時更新に非対応 ConcurrentHashMap+merge

よくある質問

Q. HashMapとMapは何が違いますか? A. Mapはインターフェース(約束ごと)で、HashMapはその実装クラスです。 変数はMap<String, Integer> m = new HashMap<>();とMap型で受けておくと、後からLinkedHashMapやTreeMapに替えやすくなります。

Q. getOrDefaultの既定値はMapに登録されますか? A. 登録されません。 getOrDefaultは取り出すだけでMapを変更しないので、size()も変わりません。登録もしたい場合はcomputeIfAbsentかputIfAbsentを使います。

Q. HashMapをループするとき、一番よい方法はどれですか? A. キーと値を両方使うならentrySetの拡張for、短く書くならforEachです。 途中でbreakしたい場合は拡張for、絞り込みや並べ替えをしたい場合はStreamが向いています。100万件の簡易計測では、どれも数十ミリ秒で大きな差はありませんでした。

まとめ

  • HashMapはキーで値をすばやく引くクラス。型はMapで受け、キーは重複不可、順序は保証されない
  • 無いキーの既定値はgetOrDefault。ただし値がnullで登録済みならnullが返る
  • カウント・合計はmerge(k, 1, Integer::sum)、グループ分けはcomputeIfAbsent(k, x -> new ArrayList<>()).add(v)
  • ループはentrySetの拡張forかforEach。Streamは絞り込み・並べ替え向き
  • ループ中の削除はremoveIfかIterator.remove()でConcurrentModificationExceptionを避ける
  • 順序ならLinkedHashMap/TreeMap、複数スレッドならConcurrentHashMap、定数はMap.of
  • Java 21.0.10で全コードを実行して確認。Java 22〜25でMap関連のAPI追加はない(公式のAPI差分で確認)

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

(記載例)出典:株式会社RJC「Java HashMapの使い方|ループ・getOrDefault・merge早見表」

株式会社RJCでは、HashMapのような毎日使う道具の特徴を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?