LinkedHashMap в Java и LRU-кэш
Что выведет этот код? Map<String, Integer> m = new LinkedHashMap<>(16, 0.75f, true);, три вызова put() и обычный цикл for (String k : m.keySet()) System.out.println(k + "=" + m.get(k));. Программа напечатает первую пару и упадёт с ConcurrentModificationException: в режиме порядка доступа даже get() перестраивает карту. Почему так происходит и как на этом поведении строится LRU-кэш, разберём в уроке.
LinkedHashMap (linked hash map) — это реализация интерфейса Map из пакета java.util, которая расширяет HashMap и дополнительно связывает все записи двусвязным списком. Благодаря списку LinkedHashMap обходит пары в предсказуемом порядке: по умолчанию — в порядке вставки, а с флагом accessOrder = true — в порядке последнего обращения. Операции get() и put() при этом остаются O(1) в среднем, как у HashMap.
Как устроена LinkedHashMap внутри
LinkedHashMap работает как обычная хеш-таблица HashMap, но каждая запись дополнительно хранит две ссылки — before и after — на соседей в двусвязном списке, а сама карта помнит его начало (head) и конец (tail). Поиск по ключу идёт через хеш-таблицу, а обход — по списку, поэтому порядок предсказуем.
Внутренний класс записи выглядит так (упрощённо, по исходникам JDK):
static class Entry<K, V> extends HashMap.Node<K, V> {
Entry<K, V> before, after; // соседи в двусвязном списке
// hash, key, value, next унаследованы от HashMap.Node
}
transient LinkedHashMap.Entry<K, V> head; // самая «старая» запись
transient LinkedHashMap.Entry<K, V> tail; // самая «новая» запись
final boolean accessOrder; // false — порядок вставки, true — порядок доступа При вставке нового ключа запись попадает и в бакет хеш-таблицы, и в конец списка. При удалении она убирается из обоих мест. Как хеш-таблица находит бакет, что такое коллизии и resize, разобрано в уроке HashMap: как устроена внутри — LinkedHashMap всё это наследует без изменений.
Пример: порядок вставки сохраняется, повторный put() существующего ключа меняет только значение, а после remove() ключ при новой вставке уходит в конец.
import java.util.LinkedHashMap;
import java.util.Map;
public class LinkedHashMapExample {
public static void main(String[] args) {
Map<String, Double> salaries = new LinkedHashMap<>();
salaries.put("Петров", 123.22);
salaries.put("Сидоров", 1378.00);
salaries.put("Иванов", 3434.34);
salaries.forEach((key, value) -> System.out.println(key + ": " + value));
salaries.put("Петров", 200.0); // ключ уже есть: значение заменится, позиция — нет
System.out.println(salaries);
salaries.remove("Сидоров");
salaries.put("Сидоров", 1500.0); // после удаления — снова в конец
System.out.println(salaries);
}
} Вывод:
Петров: 123.22
Сидоров: 1378.0
Иванов: 3434.34
{Петров=200.0, Сидоров=1378.0, Иванов=3434.34}
{Петров=200.0, Иванов=3434.34, Сидоров=1500.0} Неочевидный плюс
Обход HashMap проходит по всем бакетам таблицы, включая пустые, поэтому его время пропорционально ёмкости (capacity). Обход LinkedHashMap идёт по списку и зависит только от числа элементов (size()). Если карту создали на 1 000 000 бакетов, а положили 10 пар, LinkedHashMap переберёт их заметно быстрее.
Цена списка — память: две лишние ссылки на каждую запись и немного более медленные put()/remove(), потому что нужно поддерживать связи. Тот же приём использует LinkedHashSet: внутри него лежит LinkedHashMap (подробнее — в уроке Set, HashSet, LinkedHashSet).
Порядок: HashMap, LinkedHashMap и TreeMap
Три основные реализации Map по-разному отвечают на вопрос «в каком порядке я получу пары при обходе»: HashMap порядок не гарантирует, LinkedHashMap возвращает пары в порядке вставки (или доступа), TreeMap сортирует их по ключам. Выбирать между ними стоит именно по этому признаку.
import java.util.*;
public class OrderCompare {
public static void main(String[] args) {
Map<Integer, String> hash = new HashMap<>();
Map<Integer, String> linked = new LinkedHashMap<>();
Map<Integer, String> tree = new TreeMap<>();
for (Map<Integer, String> m : List.of(hash, linked, tree)) {
m.put(30, "a");
m.put(5, "b");
m.put(17, "c");
}
System.out.println("HashMap: " + hash);
System.out.println("LinkedHashMap: " + linked);
System.out.println("TreeMap: " + tree);
}
} Вывод:
HashMap: {17=c, 5=b, 30=a}
LinkedHashMap: {30=a, 5=b, 17=c}
TreeMap: {5=b, 17=c, 30=a} Порядок HashMap здесь определяется номерами бакетов (17 → 1, 5 → 5, 30 → 14 при ёмкости 16) — это деталь реализации текущих JDK, а не гарантия: после resize или на другой версии он может измениться.
| Класс | Порядок обхода | Структура | get / put | null-ключ |
|---|---|---|---|---|
HashMap | Не гарантирован | Хеш-таблица | O(1) в среднем | Один разрешён |
LinkedHashMap | Вставки или доступа (accessOrder) | Хеш-таблица + двусвязный список | O(1) в среднем | Один разрешён |
TreeMap | По возрастанию ключей или по Comparator | Красно-чёрное дерево | O(log n) | NullPointerException при естественном порядке |
Если нужна именно сортировка, а не сохранение порядка, — это задача TreeMap: см. урок TreeMap, SortedMap и NavigableMap. Остальные реализации (Hashtable, ConcurrentHashMap, EnumMap и др.) сравниваются в уроке Интерфейс Map.
Конструкторы LinkedHashMap
У LinkedHashMap пять конструкторов и, начиная с Java 19, статическая фабрика newLinkedHashMap(int). Четыре конструктора повторяют HashMap, а пятый принимает флаг accessOrder — только через него включается порядок доступа, нужный для LRU-кэша.
| Конструктор / метод | Что создаёт | Порядок |
|---|---|---|
LinkedHashMap() | Пустую карту: ёмкость 16, load factor 0.75 | Вставки |
LinkedHashMap(int initialCapacity) | Пустую карту с заданной начальной ёмкостью | Вставки |
LinkedHashMap(int initialCapacity, float loadFactor) | Пустую карту с ёмкостью и коэффициентом загрузки | Вставки |
LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) | Пустую карту; при accessOrder = true — в режиме порядка доступа | Вставки или доступа |
LinkedHashMap(Map<? extends K, ? extends V> m) | Копию карты m; пары идут в порядке обхода m | Вставки |
LinkedHashMap.newLinkedHashMap(int numMappings) (Java 19) | Карту, рассчитанную на numMappings пар без resize | Вставки |
Копирующий конструктор удобен, чтобы «заморозить» текущий порядок произвольной карты: new LinkedHashMap<>(treeMap) сохранит отсортированный порядок TreeMap, но дальнейшие вставки пойдут в конец, без сортировки.
Порядок доступа: accessOrder = true
В режиме порядка доступа (accessOrder = true) LinkedHashMap при каждом обращении к существующему ключу переносит его запись в конец списка. В начале остаётся пара, к которой дольше всех не обращались, — именно её вытесняет LRU-кэш (Least Recently Used, «давно не использованный»).
Map<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
System.out.println(map); // {a=1, b=2, c=3}
map.get("a"); // доступ: "a" уходит в конец
System.out.println(map); // {b=2, c=3, a=1}
map.containsKey("b"); // НЕ доступ: порядок не меняется
System.out.println(map); // {b=2, c=3, a=1}
map.put("b", 20); // put существующего ключа — тоже доступ
System.out.println(map); // {c=3, a=1, b=20} Какие операции считаются «доступом», описано в Javadoc класса:
| Операция | Перемещает запись в конец? | Комментарий |
|---|---|---|
get(), getOrDefault() | Да | Если ключ найден |
put(), putIfAbsent() | Да | И для нового, и для существующего ключа |
compute(), computeIfAbsent(), computeIfPresent(), merge() | Да | Если после вызова запись существует |
replace() | Только если значение действительно заменено | replace(k, old, new) с неверным old порядок не трогает |
putAll() | Да, по одному доступу на каждую пару | — |
containsKey(), containsValue() | Нет | Удобно, чтобы проверить ключ, не «освежая» его |
Обход keySet(), values(), entrySet() | Нет | entry.getValue() и entry.setValue() тоже не считаются доступом |
Важно
В режиме accessOrder = true вызов get() — это структурное изменение карты: он увеличивает внутренний счётчик modCount. Поэтому обход с одновременным get() бросает ConcurrentModificationException (разбор — в разделе На чём чаще всего ошибаются), а в многопоточном коде синхронизировать приходится даже чтение.
removeEldestEntry и LRU-кэш
LRU-кэш на LinkedHashMap пишется в десяток строк: создайте карту с accessOrder = true и переопределите защищённый метод removeEldestEntry(), чтобы он возвращал true, когда размер превысил лимит. Тогда после каждой вставки нового ключа карта сама удалит самую давно использованную пару.
Сигнатура метода:
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) Его вызывают put(), putAll(), putIfAbsent(), computeIfAbsent(), compute() и merge() — но только когда в карту добавлен новый ключ. Параметр eldest — первая запись списка. В LinkedHashMap метод всегда возвращает false, то есть ничего не удаляет.
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LruCache(int capacity) {
super(16, 0.75f, true); // true — порядок доступа
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity; // превысили лимит — удалить самую старую пару
}
public static void main(String[] args) {
LruCache<Integer, String> cache = new LruCache<>(3);
cache.put(1, "один");
cache.put(2, "два");
cache.put(3, "три");
System.out.println(cache);
cache.get(1); // 1 стал самым свежим
cache.put(4, "четыре"); // вытесняется 2 — к нему дольше всех не обращались
System.out.println(cache);
cache.put(3, "ТРИ"); // существующий ключ: обновление + доступ, без вытеснения
System.out.println(cache);
cache.put(5, "пять"); // вытесняется 1
System.out.println(cache);
System.out.println(cache.get(2)); // 2 уже вытеснен
}
} Вывод:
{1=один, 2=два, 3=три}
{3=три, 1=один, 4=четыре}
{1=один, 4=четыре, 3=ТРИ}
{4=четыре, 3=ТРИ, 5=пять}
null Если создать ту же карту без флага (super(16, 0.75f, false) или просто super()), получится FIFO-кэш: вытесняется не давно использованная, а давно добавленная пара, и get() на порядок не влияет.
Ограничения такого кэша
- Не потокобезопасен. Для общего доступа оберните его:
Map<K, V> safe = Collections.synchronizedMap(new LruCache<>(100));. Синхронизация нужна и на чтение, потому чтоget()меняет порядок. Обход такой обёртки всё равно заключают вsynchronized (safe) { ... }. - Лимит — в штуках, а не в байтах, и нет срока жизни записей (TTL). Для нагруженных кэшей с TTL, статистикой и конкурентным доступом используют библиотеку Caffeine.
- Наследование открывает весь API
LinkedHashMap. Если это нежелательно, храните карту в приватном поле класса-кэша и наружу отдавайте толькоget()/put().
Общий обзор потокобезопасных коллекций (ConcurrentHashMap, ConcurrentSkipListMap и др.) — в уроке Коллекции в Java.
LinkedHashMap и SequencedMap (Java 21)
С Java 21 (JEP 431) LinkedHashMap реализует интерфейс SequencedMap, поэтому у неё появились методы для работы с концами: firstEntry(), lastEntry(), pollFirstEntry(), pollLastEntry(), putFirst(), putLast() и reversed(). До Java 21 получить последнюю пару можно было только полным обходом карты.
| Метод | Что делает | На пустой карте / граничный случай |
|---|---|---|
firstEntry(), lastEntry() | Возвращают первую / последнюю пару, не удаляя её | Возвращают null (не исключение) |
pollFirstEntry(), pollLastEntry() | Возвращают и удаляют первую / последнюю пару | Возвращают null |
putFirst(k, v), putLast(k, v) | Вставляют пару в начало / конец | Существующий ключ получает новое значение и переезжает в начало / конец |
reversed() | Представление карты в обратном порядке | Живое: изменения видны в исходной карте и наоборот |
sequencedKeySet(), sequencedValues(), sequencedEntrySet() | Упорядоченные представления ключей, значений и пар | У них есть reversed(), getFirst(), getLast() |
import java.util.LinkedHashMap;
public class SequencedMapExample {
public static void main(String[] args) {
LinkedHashMap<String, Integer> steps = new LinkedHashMap<>();
steps.put("build", 1);
steps.put("test", 2);
steps.put("deploy", 3);
System.out.println(steps.firstEntry()); // build=1
System.out.println(steps.lastEntry()); // deploy=3
steps.putFirst("checkout", 0);
System.out.println(steps); // {checkout=0, build=1, test=2, deploy=3}
steps.putLast("build", 1); // существующий ключ переезжает в конец
System.out.println(steps); // {checkout=0, test=2, deploy=3, build=1}
System.out.println(steps.reversed()); // {build=1, deploy=3, test=2, checkout=0}
System.out.println(steps.pollFirstEntry()); // checkout=0
System.out.println(steps); // {test=2, deploy=3, build=1}
}
} Нюанс с типом переменной
Методы firstEntry() и putFirst() объявлены в SequencedMap, а не в Map. С объявлением Map<String, Integer> steps = new LinkedHashMap<>(); строка steps.firstEntry() не скомпилируется (cannot find symbol) — объявляйте переменную как SequencedMap или LinkedHashMap. На Java 17 и ниже этих методов нет вовсе.
На чём чаще всего ошибаются
Самая частая ошибка с LinkedHashMap — вызывать get() внутри обхода карты в режиме accessOrder = true: это структурное изменение, и итератор бросает ConcurrentModificationException. Остальные промахи связаны с ожиданием сортировки и с тем, когда именно срабатывает removeEldestEntry().
Что выведет этот код?
Map<String, Integer> m = new LinkedHashMap<>(16, 0.75f, true);
m.put("a", 1);
m.put("b", 2);
m.put("c", 3);
for (String k : m.keySet()) {
System.out.println(k + "=" + m.get(k));
} a=1
Exception in thread "main" java.util.ConcurrentModificationException m.get("a") переносит «a» в конец и увеличивает modCount; на следующем шаге итератор замечает изменение и бросает исключение. Исправление — обходить entrySet(): значение берётся из самой записи, порядок не меняется.
for (Map.Entry<String, Integer> e : m.entrySet()) {
System.out.println(e.getKey() + "=" + e.getValue());
}
// Вывод:
// a=1
// b=2
// c=3 Подробнее о том, как итераторы отслеживают изменения, — в уроке Iterator. Другие типичные промахи:
- Ждут сортировки.
LinkedHashMapтолько запоминает порядок вставки; ключи 30, 5, 17 так и останутся в этом порядке. Для сортировки нуженTreeMap. - Создают карту через конструктор без флага и удивляются, что LRU-кэш вытесняет «горячие» ключи. Без
accessOrder = trueполучается FIFO. - Ждут вытеснения при обновлении.
put()существующего ключа не добавляет новую пару, поэтомуremoveEldestEntry()не вызывается и размер не растёт. - Сравнивают карты с учётом порядка.
equals()уMapпорядок не учитывает:LinkedHashMap{a=1, b=2} равнаLinkedHashMap{b=2, a=1} и дажеHashMapс теми же парами.
Часто задаваемые вопросы
Стоит ли всегда использовать LinkedHashMap вместо HashMap?
Нет. LinkedHashMap расходует больше памяти (две дополнительные ссылки на запись) и чуть медленнее вставляет и удаляет. Берите её, когда порядок обхода важен: вывод в порядке ввода, JSON с предсказуемым порядком полей, кэш. В остальных случаях достаточно HashMap.
Как отсортировать LinkedHashMap по значению?
Саму карту отсортировать нельзя, но можно собрать новую: map.entrySet().stream().sorted(Map.Entry.comparingByValue()).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new)). Четвёртый аргумент LinkedHashMap::new обязателен: обычный Collectors.toMap() вернёт HashMap и потеряет порядок.
Можно ли получить элемент LinkedHashMap по индексу?
Метода get(int index) нет: LinkedHashMap — это хеш-таблица со связным списком, а не массив. Для разового доступа подойдёт new ArrayList<>(map.keySet()).get(i) или map.keySet().stream().skip(i).findFirst(), оба варианта работают за O(n). Если доступ по индексу нужен часто, храните ключи ещё и в ArrayList.
Как получить первый и последний элемент LinkedHashMap до Java 21?
Первый — через map.entrySet().iterator().next() (на пустой карте будет NoSuchElementException). Последний — только полным обходом, например map.entrySet().stream().reduce((a, b) -> b), за O(n). С Java 21 есть firstEntry() и lastEntry() за O(1) — см. раздел SequencedMap.
Как связаны LinkedHashSet и LinkedHashMap?
LinkedHashSet хранит элементы как ключи внутренней LinkedHashMap с фиктивным значением. Поэтому он тоже сохраняет порядок вставки, но режима порядка доступа у него нет: такого конструктора LinkedHashSet не предоставляет.
Комментарии