Интерфейс Map в Java: контракт, реализации и ключевые методы

Интерфейс Map в Java: контракт, реализации и ключевые методы Полезное

Map в Java — это интерфейс из пакета java.util, который описывает контракт коллекции пар «ключ — значение», где каждый ключ уникален и связан ровно с одним значением. Появился в JDK 1.2 как часть Collections Framework. Ниже разберем, чем контракт Map отличается от Collection, какие реализации выбирать (HashMap, LinkedHashMap, TreeMap, Hashtable, ConcurrentHashMap), ключевые методы и почему для ключей критичны equals и hashCode.

Map — это не Collection

Важно сразу развести два интерфейса. Collection (и его наследники List, Set, Queue) работает с отдельными элементами. Map работает с парами и в иерархию Collection не входит — это отдельная ветка Collections Framework.

Поэтому у Map нет методов add(element) или iterator(). Вместо этого он дает три представления (view), каждое из которых уже является коллекцией: keySet() (ключи), values() (значения) и entrySet() (пары). Именно их и перебирают.

Минимальный рабочий пример — создать карту, положить пары и прочитать их:

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

public class MapDemo {
    public static void main(String[] args) {
        Map<Integer, String> map = new HashMap<>();
        map.put(1, "Petyr");
        map.put(2, "Sansa");
        map.put(3, "Jon");

        System.out.println(map.get(2));         // Sansa
        System.out.println(map.containsKey(4)); // false
        System.out.println(map.size());         // 3
    }
}

Ключи и значения — только ссылочные типы. Примитив напрямую параметром обобщения (generics) быть не может, поэтому пишут Map<Integer, String>, а не Map<int, String>; автоупаковка (autoboxing) превращает int в Integer при вызове put.

Реализации: когда что брать

Все реализации подчиняются одному контракту Map, поэтому методы у них одинаковые. Различаются они порядком элементов, поведением с null, потокобезопасностью и скоростью. Выбор идет от задачи.

Реализация Порядок ключей null-ключ Потокобезопасна get/put (в среднем) Когда брать
HashMap не гарантирован 1 разрешен нет O(1) вариант по умолчанию
LinkedHashMap порядок вставки или доступа 1 разрешен нет O(1) нужен предсказуемый обход, кеш по типу LRU
TreeMap сортировка по ключу запрещен (NPE) нет O(log n) нужен отсортированный обход и запросы по диапазону
Hashtable не гарантирован запрещен да (весь объект) O(1) не брать в новом коде (legacy)
ConcurrentHashMap не гарантирован запрещен да O(1) доступ из нескольких потоков

Пояснения к таблице:

  • HashMap разрешает один null-ключ и любое число null-значений, порядок не хранит. Начиная с Java 8 длинная цепочка коллизий в одной корзине превращается из связного списка в сбалансированное дерево (это ускоряет поиск при плохом распределении хешей), но полагаться на конкретный порядок обхода все равно нельзя.
  • LinkedHashMap наследует HashMap и дополнительно хранит порядок: по умолчанию — порядок вставки, а с конструктором accessOrder = true — порядок обращения, на чем удобно строить кеш с вытеснением.
  • TreeMap держит ключи отсортированными (по натуральному порядку или через переданный Comparator) и реализует NavigableMap, поэтому умеет firstKey, floorKey, headMap и подобные запросы. null-ключ он не принимает.
  • Hashtable синхронизирует каждый метод целиком и не допускает null ни в ключе, ни в значении. Это класс из JDK 1.0, в новом коде его заменяют на HashMap (однопоток) или ConcurrentHashMap (многопоток).
  • ConcurrentHashMap дает потокобезопасность без блокировки всей карты и тоже запрещает null (иначе нельзя отличить «нет ключа» от «значение null» при конкурентном доступе).

Разница в порядке видна на одном наборе ключей:

import java.util.*;

public class OrderDemo {
    public static void main(String[] args) {
        List<Map<String, Integer>> maps = List.of(
                new HashMap<>(), new LinkedHashMap<>(), new TreeMap<>());
        for (Map<String, Integer> m : maps) {
            m.put("banana", 1);
            m.put("apple", 2);
            m.put("cherry", 3);
        }
        System.out.println(maps.get(0).keySet()); // HashMap: порядок не гарантирован
        System.out.println(maps.get(1).keySet()); // LinkedHashMap: [banana, apple, cherry]
        System.out.println(maps.get(2).keySet()); // TreeMap: [apple, banana, cherry]
    }
}

Ключевые методы

put(key, value) кладет пару и возвращает прежнее значение для этого ключа или null, если ключа не было. Повторный put с тем же ключом перезаписывает значение — дубликатов ключей в карте не бывает.

Map<Integer, String> map = new HashMap<>();
System.out.println(map.put(1, "Jon"));   // null  - ключа еще не было
System.out.println(map.put(1, "Sansa")); // Jon   - вернул прежнее значение
System.out.println(map.get(1));          // Sansa - значение перезаписано

Часто нужнее методы, которые аккуратнее работают с отсутствием ключа: getOrDefault возвращает запасное значение вместо null, putIfAbsent не перетирает существующее, а merge удобен для счетчиков и агрегации.

Map<String, Integer> counts = new HashMap<>();
System.out.println(counts.getOrDefault("a", 0)); // 0 - ключа нет, вернул default
counts.merge("a", 1, Integer::sum);              // ключа нет -> кладет 1
counts.merge("a", 1, Integer::sum);              // ключ есть -> 1 + 1
System.out.println(counts.get("a"));             // 2

Остальной базовый набор: get(key) читает значение (или null), remove(key) удаляет пару и возвращает удаленное значение, containsKey и containsValue проверяют наличие, size и isEmpty дают размер, clear очищает карту.

Итерация через entrySet

Для перебора и ключа, и значения одновременно берут entrySet(): это один проход по парам, тогда как связка keySet() + get() внутри цикла делает лишний поиск на каждой итерации.

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

public class IterationDemo {
    public static void main(String[] args) {
        Map<Integer, String> map = new LinkedHashMap<>();
        map.put(1, "Jon");
        map.put(2, "Sansa");
        for (Map.Entry<Integer, String> e : map.entrySet()) {
            System.out.println(e.getKey() + " -> " + e.getValue());
        }
        // 1 -> Jon
        // 2 -> Sansa
    }
}

Тот же обход в стиле Java 8 — через forEach с лямбдой: map.forEach((k, v) -> System.out.println(k + " -> " + v));. Порядок вывода здесь предсказуем, потому что взят LinkedHashMap; у HashMap он был бы не гарантирован.

equals и hashCode для ключей

Это самая частая скрытая ошибка. HashMap сначала по hashCode() ключа выбирает корзину, а затем внутри нее сравнивает ключи через equals(). Если у вашего класса-ключа эти методы не переопределены, действуют версии из Object, которые сравнивают ссылки, а не содержимое. Тогда два «одинаковых» объекта окажутся разными ключами:

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

public class KeyContract {
    static class Point {
        final int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }
        // equals и hashCode НЕ переопределены
    }

    public static void main(String[] args) {
        Map<Point, String> map = new HashMap<>();
        map.put(new Point(1, 2), "A");
        System.out.println(map.get(new Point(1, 2))); // null - разные объекты
    }
}

Контракт таков: если a.equals(b), то обязательно a.hashCode() == b.hashCode(). Обратное не требуется (равные хеши у неравных объектов — это коллизия, она допустима). Переопределяем оба метода вместе, обычно через Objects:

static class Point {
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point p = (Point) o;
        return x == p.x && y == p.y;
    }

    @Override
    public int hashCode() {
        return java.util.Objects.hash(x, y);
    }
}

С таким классом map.get(new Point(1, 2)) вернет "A". Отдельная рекомендация — делать поля ключа неизменяемыми (final): если после вставки поменять поле, участвующее в hashCode, объект «потеряется» в своей корзине, и найти его по этому же ключу уже не получится.

Неизменяемые карты (Java 9+)

С Java 9 есть фабричные методы для готовых неизменяемых карт: Map.of(...) для нескольких пар и Map.ofEntries(...) для произвольного числа. Такую карту нельзя менять — любые put, remove, clear бросают исключение.

import java.util.Map;

public class ImmutableDemo {
    public static void main(String[] args) {
        Map<String, Integer> ages = Map.of("Jon", 24, "Sansa", 20);
        System.out.println(ages.get("Jon")); // 24
        ages.put("Arya", 18);                // UnsupportedOperationException
    }
}

Выводы

  • Map — интерфейс пар «ключ — значение» с уникальными ключами; он не наследует Collection, а дает представления keySet, values, entrySet.
  • Выбор реализации идет от задачи: HashMap по умолчанию, LinkedHashMap за предсказуемый порядок, TreeMap за сортировку и запросы по диапазону, ConcurrentHashMap за многопоточность; Hashtable в новом коде не нужен.
  • Поведение с null различается: HashMap/LinkedHashMap допускают null-ключ, а TreeMap, Hashtable и ConcurrentHashMap — нет.
  • Для перебора пар берите entrySet() — это один проход вместо keySet() + get().
  • Для класса-ключа переопределяйте equals и hashCode согласованно, а поля ключа держите неизменяемыми.

Где применяется / связь с практикой

Map — базовый инструмент почти в любом Java-приложении: кеши, подсчет частот, индексация по идентификатору, конфигурации, группировка данных. Умение выбрать реализацию под задачу и корректно оформить класс-ключ — то, что отличает рабочий код от кода, который «иногда теряет данные».

Освойте тему на практике

Если хочется собрать эти основы системно и с практикой, посмотрите курс Java Basic: там коллекции разбирают на задачах. Проверить формат и уровень перед стартом удобно на открытых уроках — это бесплатные занятия с разбором тем.

Смежные темы: Интерфейсы и коллекции в Java, Java: что это за язык и зачем он нужен.

FAQ

Чем HashMap отличается от HashSet?
HashSet хранит только элементы (множество без дубликатов) и внутри реализован поверх HashMap, где ваши элементы — это ключи, а значением служит один общий объект-пустышка. То есть Set — это про наличие, Map — про связь ключа со значением.

Можно ли в HashMap положить null как значение и как ключ?
Да: HashMap допускает один null-ключ и сколько угодно null-значений. Из-за этого get может вернуть null и когда ключа нет, и когда значение равно null; различить случаи помогает containsKey.

Что будет, если менять поля объекта-ключа после вставки?
Если изменить поле, участвующее в hashCode, объект окажется «не в той» корзине, и поиск по нему сломается: get/containsKey вернут так, будто ключа нет. Поэтому ключи делают неизменяемыми.

OTUS Журнал