HashMap・LinkedHashMap・TreeMapの違いとは、取り出すときの「順序」の違いです。HashMapは順序を決めず、LinkedHashMapは入れた順、TreeMapはキーの昇順で並びます。 追加・検索・削除といった基本の使い方は3つとも同じで、new するクラスを替えるだけで順序の性質が切り替わります。

この記事では、同じデータを3種類のMapに入れた実行結果を並べたうえで、計算量・null の可否・用途を早見表にまとめます。さらに TreeMap だけが持つ範囲検索(floorKey・headMap など)、LinkedHashMap で作る LRU キャッシュ、Java 21 で加わった SequencedMap まで、すべて動かした結果つきで紹介します。

確認環境:Ubuntu 24.04 / OpenJDK 21.0.10。掲載コードはすべて java ファイル名.java で実行した結果です。

HashMap・LinkedHashMap・TreeMapの早見表

項目 HashMap LinkedHashMap TreeMap
取り出す順序 決まっていない 入れた順(設定でアクセス順) キーの昇順(Comparator で変更可)
内部のしくみ ハッシュ表 ハッシュ表+双方向の連結リスト 赤黒木(平衡二分探索木)
get・put・remove の計算量 O(1) が目安 O(1) が目安 O(log n)
null のキー 1つ入る 1つ入る 入らない(NullPointerException)
null の値 入る 入る 入る
キーに必要なもの equals と hashCode equals と hashCode Comparable か Comparator
範囲検索(floorKey・headMap) なし なし あり
先頭・末尾の取得(Java 21) なし firstEntry・lastEntry・reversed firstEntry・lastEntry・reversed
複数スレッドからの同時更新 非対応 非対応 非対応
向いている用途 順序が不要な検索・集計 表示順を保ちたい一覧、LRU キャッシュ 並べ替え済みの一覧、範囲検索、ランク判定

迷ったら HashMap、順番を見せたいなら LinkedHashMap、キーで並べたい・範囲で探したいなら TreeMap が基本の選び方です。

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

(記載例)出典:株式会社RJC「Java HashMap・LinkedHashMap・TreeMapの違い」

同じデータを入れて出力順を比べる

まず、6件のデータを同じ順番で3種類のMapに入れ、そのまま表示します。

import java.util.*;

public class Order3 {
    public static void main(String[] args) {
        String[] names = {"orange", "apple", "melon", "banana", "kiwi", "grape"};
        int[] stock = {30, 12, 5, 48, 7, 21};

        Map<String, Integer> hash = new HashMap<>();
        Map<String, Integer> linked = new LinkedHashMap<>();
        Map<String, Integer> tree = new TreeMap<>();
        for (int i = 0; i < names.length; i++) {
            hash.put(names[i], stock[i]);
            linked.put(names[i], stock[i]);
            tree.put(names[i], stock[i]);
        }
        System.out.println("入れた順      : " + String.join(", ", names));
        System.out.println("HashMap       : " + hash);
        System.out.println("LinkedHashMap : " + linked);
        System.out.println("TreeMap       : " + tree);

        // 既にあるキーへ上書きしたとき
        hash.put("orange", 99); linked.put("orange", 99); tree.put("orange", 99);
        System.out.println("--- orange を上書き ---");
        System.out.println("HashMap       : " + hash);
        System.out.println("LinkedHashMap : " + linked);
        System.out.println("TreeMap       : " + tree);

        // 消して入れ直したとき
        linked.remove("orange"); linked.put("orange", 99);
        System.out.println("--- LinkedHashMap で remove してから put ---");
        System.out.println("LinkedHashMap : " + linked);
    }
}

実行結果:

入れた順      : orange, apple, melon, banana, kiwi, grape
HashMap       : {orange=30, banana=48, apple=12, kiwi=7, grape=21, melon=5}
LinkedHashMap : {orange=30, apple=12, melon=5, banana=48, kiwi=7, grape=21}
TreeMap       : {apple=12, banana=48, grape=21, kiwi=7, melon=5, orange=30}
--- orange を上書き ---
HashMap       : {orange=99, banana=48, apple=12, kiwi=7, grape=21, melon=5}
LinkedHashMap : {orange=99, apple=12, melon=5, banana=48, kiwi=7, grape=21}
TreeMap       : {apple=12, banana=48, grape=21, kiwi=7, melon=5, orange=99}
--- LinkedHashMap で remove してから put ---
LinkedHashMap : {apple=12, melon=5, banana=48, kiwi=7, grape=21, orange=99}

読み取れることは次の3点です。

  • HashMap:入れた順とも辞書順とも違う並びになります。キーのハッシュ値で置き場所が決まるためです。
  • LinkedHashMap:入れた順のまま出てきます。既にあるキーに put で上書きしても位置は変わりません。位置を末尾に移したいときは remove してから put します。
  • TreeMap:キーの昇順(文字列なら辞書順)で出てきます。入れた順は残りません。

HashMapの順序は件数によって変わる

「HashMap も毎回同じ順で出るから、その順番を当てにしてよいのでは」と感じることがあります。次のコードは、6件入れたあとに20件を追加し、同じ20件を消して中身を元に戻したものです。

import java.util.*;

public class Resize {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        for (String k : List.of("orange", "apple", "melon", "banana", "kiwi", "grape")) {
            map.put(k, 0);
        }
        System.out.println("6件のとき        : " + map.keySet());

        // 20件足してから同じ20件を消す(中身は元の6件に戻る)
        for (int i = 0; i < 20; i++) map.put("tmp" + i, 0);
        for (int i = 0; i < 20; i++) map.remove("tmp" + i);
        System.out.println("増やして戻した後  : " + map.keySet());
    }
}

実行結果:

6件のとき        : [orange, banana, apple, kiwi, grape, melon]
増やして戻した後  : [kiwi, apple, melon, banana, grape, orange]

中身は同じ6件なのに、並びが変わりました。件数が増えると内部の表が広がり、置き場所が計算し直されるためです。公式ドキュメント(HashMap)にも、順序は保証されず、時間がたっても同じとは限らないと書かれています。順序に意味を持たせたい処理では、LinkedHashMap か TreeMap を選ぶのが確実です。

計算量と内部のしくみ

HashMap LinkedHashMap TreeMap
get・put・remove・containsKey O(1) が目安 O(1) が目安 O(log n)
全件ループにかかる時間 容量+件数に比例 件数に比例 件数に比例
追加のメモリ 基準 前後のリンクの分だけ多い 木のノードの分だけ多い
最小・最大のキー 全件を調べる 入れた順の先頭・末尾のみ firstKey・lastKey ですぐ取れる

公式ドキュメントでは、HashMap の get・put は「ハッシュ関数が要素をうまく分散させる前提で一定時間」、TreeMap の containsKey・get・put・remove は「log(n) の時間を保証」と説明されています。LinkedHashMap は HashMap に双方向の連結リストを足した作りで、全件ループは容量に関係なく件数に比例します。

O(log n) は件数が100万件でも約20回の比較で済む計算です。通常の業務データの件数では、速度差よりも「どの順序がほしいか」で選ぶほうが、読みやすく直しやすいコードになります。

nullのキーと値を入れたときの挙動

null の扱いは TreeMap だけが異なります。null のキーは、TreeMap では put・get・containsKey のどれでも NullPointerException になります。 null の値は3つとも入ります。

import java.util.*;

public class NullKey {
    public static void main(String[] args) {
        Map<String, Integer> hash = new HashMap<>();
        hash.put(null, 1);
        hash.put("a", null);
        System.out.println("HashMap       : " + hash + " / get(null)=" + hash.get(null));

        Map<String, Integer> linked = new LinkedHashMap<>();
        linked.put("a", null);
        linked.put(null, 1);
        System.out.println("LinkedHashMap : " + linked + " / get(null)=" + linked.get(null));

        Map<String, Integer> tree = new TreeMap<>();
        tree.put("a", null);                       // 値の null は入る
        System.out.println("TreeMap(値null): " + tree);
        try {
            tree.put(null, 1);                     // キーの null
        } catch (NullPointerException e) {
            System.out.println("TreeMap put(null, 1)   : " + e);
        }
        try {
            tree.get(null);
        } catch (NullPointerException e) {
            System.out.println("TreeMap get(null)      : " + e);
        }
        try {
            tree.containsKey(null);
        } catch (NullPointerException e) {
            System.out.println("TreeMap containsKey(null): " + e);
        }

        // Comparator で null の置き場所を決めれば入る
        Map<String, Integer> nullable = new TreeMap<>(Comparator.nullsFirst(Comparator.naturalOrder()));
        nullable.put("b", 2);
        nullable.put(null, 0);
        nullable.put("a", 1);
        System.out.println("nullsFirst の TreeMap : " + nullable);
    }
}

実行結果:

HashMap       : {null=1, a=null} / get(null)=1
LinkedHashMap : {a=null, null=1} / get(null)=1
TreeMap(値null): {a=null}
TreeMap put(null, 1)   : java.lang.NullPointerException
TreeMap get(null)      : java.lang.NullPointerException
TreeMap containsKey(null): java.lang.NullPointerException
nullsFirst の TreeMap : {null=0, a=1, b=2}

TreeMapにnullキーを入れたときのNullPointerException

スタックトレースまで見てみます。

import java.util.*;

public class NullTrace {
    public static void main(String[] args) {
        Map<String, Integer> tree = new TreeMap<>();
        tree.put("a", 1);
        tree.put(null, 2);
    }
}

実行結果:

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:233)
    at java.base/java.util.TreeMap.put(TreeMap.java:844)
    at java.base/java.util.TreeMap.put(TreeMap.java:569)
    at NullTrace.main(NullTrace.java:7)

TreeMap はキー同士を compareTo で比べて置き場所を決めます。null は比べられないため、put の入り口で Objects.requireNonNull によって止まります。put だけでなく get(null) や containsKey(null) でも同じ例外になる点が、HashMap との違いです。

直し方は2つあります。

直し方 書き方 向いている場面
null を入れる前に除く if (key != null) tree.put(key, value); null のキーに意味がないとき
null の位置を Comparator で決める new TreeMap<>(Comparator.nullsFirst(Comparator.naturalOrder())) 「未設定」を1件として扱いたいとき

上の実行結果の最後の行のとおり、nullsFirst を渡した TreeMap では null のキーが先頭に入ります。

TreeMapの範囲検索:floorKey・ceilingKey・headMap・descendingMap

TreeMap を選ぶ一番の理由は、「このキー以下で最大のもの」「この範囲のキーだけ」をすぐに取り出せることです。購入金額の下限から会員ランクを決める例で確かめます。

import java.util.*;

public class Nav {
    public static void main(String[] args) {
        // 購入金額の下限 → 会員ランク
        NavigableMap<Integer, String> rank = new TreeMap<>();
        rank.put(0, "レギュラー");
        rank.put(10000, "ブロンズ");
        rank.put(30000, "シルバー");
        rank.put(50000, "ゴールド");
        System.out.println("map               : " + rank);

        System.out.println("floorKey(30000)   : " + rank.floorKey(30000));
        System.out.println("floorKey(29999)   : " + rank.floorKey(29999));
        System.out.println("lowerKey(30000)   : " + rank.lowerKey(30000));
        System.out.println("ceilingKey(30000) : " + rank.ceilingKey(30000));
        System.out.println("ceilingKey(30001) : " + rank.ceilingKey(30001));
        System.out.println("higherKey(30000)  : " + rank.higherKey(30000));
        System.out.println("floorKey(-1)      : " + rank.floorKey(-1));
        System.out.println("ceilingKey(50001) : " + rank.ceilingKey(50001));

        System.out.println("floorEntry(42000) : " + rank.floorEntry(42000));
        System.out.println("42000円のランク    : " + rank.floorEntry(42000).getValue());

        System.out.println("firstKey / lastKey: " + rank.firstKey() + " / " + rank.lastKey());
        System.out.println("headMap(30000)        : " + rank.headMap(30000));
        System.out.println("headMap(30000, true)  : " + rank.headMap(30000, true));
        System.out.println("tailMap(30000)        : " + rank.tailMap(30000));
        System.out.println("tailMap(30000, false) : " + rank.tailMap(30000, false));
        System.out.println("subMap(10000, 50000)  : " + rank.subMap(10000, 50000));
        System.out.println("descendingMap()       : " + rank.descendingMap());
        System.out.println("descendingKeySet()    : " + rank.descendingKeySet());

        // headMap はビュー:元の Map の変更が見える
        SortedMap<Integer, String> head = rank.headMap(30000);
        rank.put(5000, "ライト");
        System.out.println("put(5000) 後の head   : " + head);
        try {
            head.put(40000, "範囲外");
        } catch (IllegalArgumentException e) {
            System.out.println("head.put(40000, ...)  : " + e);
        }

        System.out.println("pollFirstEntry()      : " + rank.pollFirstEntry());
        System.out.println("取り出した後           : " + rank);
    }
}

実行結果:

map               : {0=レギュラー, 10000=ブロンズ, 30000=シルバー, 50000=ゴールド}
floorKey(30000)   : 30000
floorKey(29999)   : 10000
lowerKey(30000)   : 10000
ceilingKey(30000) : 30000
ceilingKey(30001) : 50000
higherKey(30000)  : 50000
floorKey(-1)      : null
ceilingKey(50001) : null
floorEntry(42000) : 30000=シルバー
42000円のランク    : シルバー
firstKey / lastKey: 0 / 50000
headMap(30000)        : {0=レギュラー, 10000=ブロンズ}
headMap(30000, true)  : {0=レギュラー, 10000=ブロンズ, 30000=シルバー}
tailMap(30000)        : {30000=シルバー, 50000=ゴールド}
tailMap(30000, false) : {50000=ゴールド}
subMap(10000, 50000)  : {10000=ブロンズ, 30000=シルバー}
descendingMap()       : {50000=ゴールド, 30000=シルバー, 10000=ブロンズ, 0=レギュラー}
descendingKeySet()    : [50000, 30000, 10000, 0]
put(5000) 後の head   : {0=レギュラー, 5000=ライト, 10000=ブロンズ}
head.put(40000, ...)  : java.lang.IllegalArgumentException: key out of range
pollFirstEntry()      : 0=レギュラー
取り出した後           : {5000=ライト, 10000=ブロンズ, 30000=シルバー, 50000=ゴールド}

近いキーを探す4つのメソッド

メソッド 返すキー 30000 を渡した結果 覚え方
floorKey(k) k 以下で最大 30000 床(同じ値を含む)
lowerKey(k) k より小さい中で最大 10000 床より下(同じ値を含まない)
ceilingKey(k) k 以上で最小 30000 天井(同じ値を含む)
higherKey(k) k より大きい中で最小 50000 天井より上(同じ値を含まない)

該当するキーがないときは null が返ります(floorKey(-1) と ceilingKey(50001) の行)。キーと値をまとめてほしいときは floorEntry・ceilingEntry を使います。rank.floorEntry(42000).getValue() の1行で「42000円はシルバー」と判定でき、if 文を並べるより境界値の追加・変更が楽になります。

範囲を切り出すメソッド

メソッド 範囲 境界を含むか
headMap(to) to より前 to を含まない
headMap(to, true) to まで to を含む
tailMap(from) from から後ろ from を含む
tailMap(from, false) from より後ろ from を含まない
subMap(from, to) from から to の手前まで from を含み、to を含まない
descendingMap() 全体を逆順にしたもの ―

headMap・tailMap・subMap・descendingMap が返すのはコピーではなく「ビュー」です。元の TreeMap に put(5000, ...) すると、先に取っておいた head にも 5000 が現れています。ビュー側から範囲外のキーを入れようとすると IllegalArgumentException: key out of range で止まります。切り出した結果を独立して持ちたいときは new TreeMap<>(rank.headMap(30000)) のようにコピーします。

宣言する型で使えるメソッドが決まる

floorKey などは Map インターフェースにはありません。変数を Map 型で宣言すると、次のコンパイルエラーになります。

import java.util.*;

public class DeclErr {
    public static void main(String[] args) {
        Map<Integer, String> rank = new TreeMap<>();
        rank.put(0, "レギュラー");
        System.out.println(rank.floorKey(100));
    }
}
DeclErr.java:7: error: cannot find symbol
        System.out.println(rank.floorKey(100));
                               ^
  symbol:   method floorKey(int)
  location: variable rank of type Map<Integer,String>
1 error
error: compilation failed

宣言の型は、使いたい機能に合わせて選びます。

宣言する型 使えるようになる主なメソッド 代入できるクラス
Map get・put・remove・entrySet など基本操作 3つすべて
SequencedMap(Java 21) firstEntry・lastEntry・reversed・pollFirstEntry LinkedHashMap・TreeMap
SortedMap firstKey・lastKey・headMap・tailMap・subMap TreeMap
NavigableMap floorKey・ceilingKey・lowerKey・higherKey・descendingMap TreeMap

基本は Map で宣言し、範囲検索を使う変数だけ NavigableMap にすると、あとで実装クラスを差し替えやすくなります。

TreeMapの並び順をComparatorで変える

TreeMap のコンストラクタに Comparator を渡すと、並び順を自由に決められます。降順、大文字小文字を区別しない順、複数条件の順をまとめて確かめます。

import java.math.BigDecimal;
import java.util.*;

public class Comp {
    record Point(int x, int y) {}

    public static void main(String[] args) {
        // 1. 降順
        Map<String, Integer> desc = new TreeMap<>(Comparator.reverseOrder());
        desc.put("orange", 30); desc.put("apple", 12); desc.put("melon", 5);
        System.out.println("降順              : " + desc);

        // 2. 大文字小文字を区別しない(同じとみなされたキーは上書き)
        Map<String, Integer> ci = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
        ci.put("Apple", 1); ci.put("apple", 2); ci.put("APPLE", 3);
        System.out.println("CASE_INSENSITIVE  : " + ci + " / get(\"aPPle\")=" + ci.get("aPPle"));

        // 3. 文字数 → 辞書順 の複合 Comparator
        Map<String, Integer> byLen = new TreeMap<>(
                Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
        byLen.put("orange", 30); byLen.put("apple", 12); byLen.put("melon", 5); byLen.put("kiwi", 7);
        System.out.println("文字数→辞書順      : " + byLen);

        // 3'. thenComparing を付け忘れると、同じ文字数のキーが 1 件にまとまる
        Map<String, Integer> lenOnly = new TreeMap<>(Comparator.comparingInt(String::length));
        lenOnly.put("orange", 30); lenOnly.put("apple", 12); lenOnly.put("melon", 5); lenOnly.put("kiwi", 7);
        System.out.println("文字数だけ         : " + lenOnly);

        // 4. Comparable でないキーを Comparator なしで入れる
        try {
            Map<Point, String> points = new TreeMap<>();
            points.put(new Point(1, 2), "p");
        } catch (ClassCastException e) {
            System.out.println("Comparable でないキー: " + e.getMessage().split(" \\(")[0]);
        }

        // 5. equals と compareTo が食い違うキー(BigDecimal)
        Map<BigDecimal, String> h = new HashMap<>();
        Map<BigDecimal, String> t = new TreeMap<>();
        for (String s : List.of("1.0", "1.00")) {
            h.put(new BigDecimal(s), s);
            t.put(new BigDecimal(s), s);
        }
        System.out.println("BigDecimal HashMap : size=" + h.size() + " " + h);
        System.out.println("BigDecimal TreeMap : size=" + t.size() + " " + t);
    }
}

実行結果:

降順              : {orange=30, melon=5, apple=12}
CASE_INSENSITIVE  : {Apple=3} / get("aPPle")=3
文字数→辞書順      : {kiwi=7, apple=12, melon=5, orange=30}
文字数だけ         : {kiwi=7, apple=5, orange=30}
Comparable でないキー: class Comp$Point cannot be cast to class java.lang.Comparable
BigDecimal HashMap : size=2 {1.0=1.0, 1.00=1.00}
BigDecimal TreeMap : size=1 {1.0=1.00}

押さえておきたい点は3つです。

  1. TreeMap は「Comparator が 0 を返す2つのキー」を同じキーとみなします。 String.CASE_INSENSITIVE_ORDER では Apple・apple・APPLE が1件にまとまり、値は最後の 3 に上書きされました(キーは最初に入れた Apple のまま)。
  2. 複合 Comparator は thenComparing まで書く。 文字数だけで比べると apple と melon が同じキー扱いになり、4件入れたのに3件になります。thenComparing(Comparator.naturalOrder()) を足すと4件とも残ります。
  3. キーのクラスは Comparable を実装するか、Comparator を渡す。 どちらもないと、最初の put で ClassCastException になります。

最後の BigDecimal の例は、equals と compareTo の結果が食い違うクラスをキーにした場合です。HashMap は equals で比べるので 1.0 と 1.00 は別のキー(2件)、TreeMap は compareTo で比べるので同じキー(1件)になります。Mapの種類を替えると件数が変わることがあるので、キーに BigDecimal を使うときは stripTrailingZeros() などで表記をそろえておくと安心です。

LinkedHashMapのアクセス順とLRUキャッシュ

LinkedHashMap には、順序の持ち方が2つあります。

順序の持ち方 作り方 順序が変わるタイミング
挿入順(既定) new LinkedHashMap<>() 新しいキーを入れたときだけ
アクセス順 new LinkedHashMap<>(16, 0.75f, true) get や put で触れるたびに末尾へ移動
import java.util.*;

public class AccessOrder {
    public static void main(String[] args) {
        Map<String, Integer> insertion = new LinkedHashMap<>();            // 挿入順(既定)
        Map<String, Integer> access = new LinkedHashMap<>(16, 0.75f, true); // アクセス順
        for (String k : List.of("A", "B", "C")) {
            insertion.put(k, 0);
            access.put(k, 0);
        }
        insertion.get("A"); access.get("A");
        insertion.put("B", 1); access.put("B", 1);
        System.out.println("挿入順   : " + insertion);
        System.out.println("アクセス順: " + access);

        // アクセス順の Map を回しながら get するとどうなるか
        try {
            for (String k : access.keySet()) {
                access.get(k);
            }
        } catch (ConcurrentModificationException e) {
            System.out.println("ループ中の get: " + e);
        }
    }
}

実行結果:

挿入順   : {A=0, B=1, C=0}
アクセス順: {C=0, A=0, B=1}
ループ中の get: java.util.ConcurrentModificationException

アクセス順では、get("A") で A が末尾へ、put("B", 1) で B がさらに末尾へ移りました。先頭には「最も長く使われていないもの」が残ります。

最後の行にも注目してください。アクセス順の LinkedHashMap では、get も「構造の変更」にあたるため、keySet を回しながら get すると ConcurrentModificationException になります。ループの中で値がほしいときは、entrySet() を回して getValue() で受け取ります。

removeEldestEntryでLRUキャッシュを作る

LRU(Least Recently Used)キャッシュは、件数の上限を超えたら「最も長く使われていないもの」から消すキャッシュです。アクセス順の LinkedHashMap に removeEldestEntry を組み合わせると、十数行で作れます。

import java.util.*;

public class Lru {
    /** 最大 capacity 件まで保持し、あふれたら最も長く使われていないものを消す */
    static class LruCache<K, V> extends LinkedHashMap<K, V> {
        private final int capacity;

        LruCache(int capacity) {
            super(16, 0.75f, true);   // 第3引数 true = アクセス順
            this.capacity = capacity;
        }

        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            boolean over = size() > capacity;
            if (over) {
                System.out.println("  (あふれたので削除: " + eldest.getKey() + ")");
            }
            return over;
        }
    }

    public static void main(String[] args) {
        LruCache<String, String> cache = new LruCache<>(3);
        cache.put("A", "ページA");
        cache.put("B", "ページB");
        cache.put("C", "ページC");
        System.out.println("put A,B,C   : " + cache.keySet());

        cache.get("A");                       // A を使う → 末尾(最新)へ
        System.out.println("get(A)      : " + cache.keySet());

        cache.put("D", "ページD");            // 4件目 → 先頭(最古)の B が消える
        System.out.println("put D       : " + cache.keySet());

        cache.getOrDefault("C", "");          // getOrDefault もアクセス扱い
        System.out.println("getOrDefault(C): " + cache.keySet());

        cache.containsKey("A");               // containsKey は順序を変えない
        System.out.println("containsKey(A): " + cache.keySet());

        cache.put("E", "ページE");
        System.out.println("put E       : " + cache.keySet());
    }
}

実行結果:

put A,B,C   : [A, B, C]
get(A)      : [B, C, A]
  (あふれたので削除: B)
put D       : [C, A, D]
getOrDefault(C): [A, D, C]
containsKey(A): [A, D, C]
  (あふれたので削除: A)
put E       : [D, C, E]

get(A) で A が最新になり、4件目の D を入れた時点で最古の B が消えました。getOrDefault もアクセス扱いになり、containsKey では順序が変わらないことも読み取れます。

removeEldestEntry は新しいエントリを入れた直後に呼ばれ、true を返すと先頭(最古)のエントリが消えます。公式ドキュメント(LinkedHashMap)では、アクセス扱いになるメソッドとして put・putIfAbsent・get・getOrDefault・compute・computeIfAbsent・computeIfPresent・merge が挙げられています。

このクラスは複数スレッドからの同時利用には対応していません。 複数スレッドで共有するときは Collections.synchronizedMap(...) で包み、同時に1つのスレッドだけが触れるようにします。

Java 21のSequencedMap:firstEntry・lastEntry・reversed

Java 21 で SequencedMap インターフェースが加わり、LinkedHashMap と TreeMap で「先頭」「末尾」「逆順」を同じメソッド名で扱えるようになりました。

import java.util.*;

public class Seq {
    public static void main(String[] args) {
        SequencedMap<String, Integer> linked = new LinkedHashMap<>();
        linked.put("orange", 30);
        linked.put("apple", 12);
        linked.put("melon", 5);

        System.out.println("linked            : " + linked);
        System.out.println("firstEntry()      : " + linked.firstEntry());
        System.out.println("lastEntry()       : " + linked.lastEntry());
        System.out.println("reversed()        : " + linked.reversed());

        linked.putFirst("kiwi", 7);           // 先頭に入れる
        linked.putLast("orange", 31);         // 既存キーを末尾へ移す
        System.out.println("putFirst/putLast  : " + linked);

        System.out.println("sequencedKeySet().reversed(): " + linked.sequencedKeySet().reversed());
        System.out.println("pollFirstEntry()  : " + linked.pollFirstEntry());
        System.out.println("pollLastEntry()   : " + linked.pollLastEntry());
        System.out.println("残り              : " + linked);

        // reversed() はビュー。元の Map に入れると逆順側にも見える
        SequencedMap<String, Integer> rev = linked.reversed();
        linked.put("grape", 21);
        System.out.println("put 後の rev      : " + rev);

        // TreeMap も SequencedMap
        SequencedMap<String, Integer> tree = new TreeMap<>(linked);
        System.out.println("tree              : " + tree);
        System.out.println("tree.firstEntry() : " + tree.firstEntry());
        System.out.println("tree.reversed()   : " + tree.reversed());
        try {
            tree.putFirst("zzz", 1);
        } catch (UnsupportedOperationException e) {
            System.out.println("tree.putFirst()   : " + e);
        }
        try {
            tree.firstEntry().setValue(100);
        } catch (UnsupportedOperationException e) {
            System.out.println("firstEntry().setValue(): " + e);
        }
        System.out.println("空の Map の firstEntry(): " + new LinkedHashMap<String, Integer>().firstEntry());
    }
}

実行結果:

linked            : {orange=30, apple=12, melon=5}
firstEntry()      : orange=30
lastEntry()       : melon=5
reversed()        : {melon=5, apple=12, orange=30}
putFirst/putLast  : {kiwi=7, apple=12, melon=5, orange=31}
sequencedKeySet().reversed(): [orange, melon, apple, kiwi]
pollFirstEntry()  : kiwi=7
pollLastEntry()   : orange=31
残り              : {apple=12, melon=5}
put 後の rev      : {grape=21, melon=5, apple=12}
tree              : {apple=12, grape=21, melon=5}
tree.firstEntry() : apple=12
tree.reversed()   : {melon=5, grape=21, apple=12}
tree.putFirst()   : java.lang.UnsupportedOperationException
firstEntry().setValue(): java.lang.UnsupportedOperationException
空の Map の firstEntry(): null
メソッド 動き LinkedHashMap TreeMap
firstEntry()・lastEntry() 先頭・末尾を返す(空なら null) 使える 使える
pollFirstEntry()・pollLastEntry() 先頭・末尾を取り出して削除 使える 使える
reversed() 逆順のビューを返す 使える 使える
putFirst(k, v)・putLast(k, v) 先頭・末尾に入れる(既存キーは移動) 使える UnsupportedOperationException

注意点は次の3つです。

  • TreeMap の putFirst・putLast は使えません。 位置はキーの比較で決まるため、場所を指定して入れる操作は用意されていません。
  • firstEntry などが返すエントリは変更できないコピーです。 setValue を呼ぶと UnsupportedOperationException になります。値を変えたいときは put を使います。
  • reversed() はビューです。 元の Map に put した grape が、先に取っておいた rev の先頭にも現れています。

HashMap には順序がないため SequencedMap ではなく、SequencedMap 型の変数に new HashMap<>() を代入するとコンパイルエラー(incompatible types)になります。

使い分けの判断表とフロー

やりたいこと 選ぶMap 理由
IDから会員情報を引く HashMap 順序が不要で、検索が中心
設定ファイルの項目を書かれた順に表示する LinkedHashMap 入れた順がそのまま表示順になる
並べ替えた結果を Map のまま持ち回る LinkedHashMap 並べ替えた順で入れれば、その順が保たれる
直近に使った100件だけ覚えておく LinkedHashMap(アクセス順) removeEldestEntry で LRU になる
一覧を常にキー順で表示する TreeMap 追加のたびに並べ替え直さなくてよい
金額や日付の区切りからランク・料金を決める TreeMap floorEntry で境界の判定が1行になる
期間内のデータだけ取り出す TreeMap subMap・headMap・tailMap で切り出せる
flowchart TD
    A[Mapを使いたい] --> B{取り出す順序に意味があるか}
    B -- ない --> H[HashMap]
    B -- ある --> C{どんな順序か}
    C -- 入れた順 --> L[LinkedHashMap]
    C -- 使った順 --> L2[LinkedHashMap アクセス順]
    C -- キーの大小順 --> T[TreeMap]

つまずきやすい点と直し方

起きること 原因 直し方
表示順が入れた順にならない HashMap を使っている LinkedHashMap に替える
TreeMap で NullPointerException null のキーを put・get した null を除くか Comparator.nullsFirst を渡す
TreeMap で ClassCastException キーが Comparable でない Comparable を実装するか Comparator を渡す
TreeMap に入れた件数が減った Comparator が 0 を返すキーが複数ある thenComparing で区別できる条件を足す
"10" が "9" より前に並ぶ 文字列キーは辞書順 キーを Integer にするか、数値で比べる Comparator を渡す
floorKey が cannot find symbol 変数を Map 型で宣言している NavigableMap 型で宣言する
アクセス順のMapのループで例外 ループ中の get が構造の変更になる entrySet を回して getValue を使う

よくある質問

Q. LinkedHashMap と TreeMap は HashMap よりどのくらい遅くなりますか? A. LinkedHashMap は get・put とも HashMap と同じ O(1) が目安で、リンクをつなぐ分のわずかな手間とメモリが加わります。TreeMap は O(log n) で、100万件でも1回の操作あたり約20回の比較です。件数が数万件までの一覧や集計であれば、順序の要件で選んで差し支えありません。

Q. HashMap を後からキー順に並べたいときはどうしますか? A. new TreeMap<>(hashMap) と書くと、キー昇順に並んだコピーができます。降順なら new TreeMap<>(Comparator.reverseOrder()) を作って putAll します。値の順に並べたいときは、entrySet を Stream で並べ替えて LinkedHashMap に集めます。

Q. Java 20 以前で LinkedHashMap の先頭を取り出すには? A. SequencedMap は Java 21 で加わったインターフェースなので、それより前では map.entrySet().iterator().next() で先頭を取り出します。末尾は全件を回す必要があります。Java 21 以降なら firstEntry()・lastEntry() で書けます。

Q. 複数スレッドから使うときはどれを選びますか? A. 3つとも同時更新には対応していません。順序が不要なら ConcurrentHashMap、キー順が必要なら ConcurrentSkipListMap が候補になります。ConcurrentSkipListMap は公式ドキュメントで SequencedMap の実装クラスとして挙げられています。

まとめ

  • 3つの違いは取り出す順序:HashMap は順序なし、LinkedHashMap は入れた順、TreeMap はキーの昇順。
  • HashMap の並びは件数で変わるため、順序が必要なら LinkedHashMap か TreeMap を選ぶ。
  • 計算量は HashMap・LinkedHashMap が O(1) 目安、TreeMap が O(log n)。速度より「ほしい順序」で選ぶ。
  • TreeMap は null のキーが入らない(put・get・containsKey とも NullPointerException)。null の値は入る。
  • 範囲検索は TreeMap:floorKey・ceilingKey・headMap・tailMap・descendingMap。変数は NavigableMap 型で宣言する。
  • LRU キャッシュは LinkedHashMap:new LinkedHashMap<>(16, 0.75f, true) と removeEldestEntry の組み合わせ。
  • Java 21 の SequencedMap で、LinkedHashMap と TreeMap の firstEntry・lastEntry・reversed が共通の書き方になった。

参考・出典

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

(記載例)出典:株式会社RJC「Java HashMap・LinkedHashMap・TreeMapの違い」

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