Коллекции в Java: как устроен Collections Framework и как выбрать реализацию

Коллекции в Java: как устроен Collections Framework и как выбрать реализацию Полезное

Коллекция в Java — это объект, который хранит группу других объектов (элементов) и дает единый набор методов работы с ними: добавить, удалить, найти, перебрать. Набор таких интерфейсов и классов из пакета java.util называют Java Collections Framework (JCF); он появился в JDK 1.2 и остается основой стандартной библиотеки в Java 25 LTS.

Разберу иерархию интерфейсов, ключевые реализации, сравню их по сложности операций и покажу, как безопасно обходить коллекцию и зачем нужны обобщения (generics) — все ради выбора структуры под задачу.

Иерархия интерфейсов

В основе JCF лежит интерфейс Iterable<E> — все, что его реализует, можно перебрать циклом for-each. От него наследуется Collection<E> — контракт «группа элементов» с методами add, remove, size, contains, iterator. От него расходятся три ветви:

  • List — упорядоченный список с доступом по индексу, дубликаты разрешены.
  • Set — множество без дубликатов, индекса нет.
  • Queue и Deque — очереди, доступ к краям, а не по индексу.

Map<K, V> стоит отдельно: это отображение «ключ -> значение», и оно не наследует Collection. У карты нет метода add(E), вместо него put(K, V). Map — часть фреймворка, но не подтип коллекции; это частая путаница у новичков.

Минимальный рабочий пример

Пример ниже показывает разницу List и Set.

import java.util.*;

public class CollectionsDemo {
    public static void main(String[] args) {
        List<String> languages = new ArrayList<>();
        languages.add("Java");
        languages.add("Python");
        languages.add("Java");       // дубликат разрешен
        System.out.println(languages);     // [Java, Python, Java]

        Set<String> unique = new HashSet<>(languages);
        System.out.println(unique.size()); // 2 (дубликат отброшен)
    }
}

Список сохранил оба «Java», а HashSet схлопнул дубликаты до двух элементов. Порядок в HashSet не гарантирован, поэтому я печатаю только size(), а не сам набор.

List: ArrayList или LinkedList

List — самый ходовой интерфейс. Две основные реализации решают одну задачу по-разному.

ArrayList — массив с автоматическим ростом. get(i) читает ячейку массива — O(1). Но вставка или удаление в середине сдвигает все последующие элементы — O(n).

LinkedList — двусвязный список узлов. Вставка и удаление в известной позиции — O(1) (перецепить ссылки), но get(i) требует пройти список от края — O(n). Также реализует Deque.

Правило: чаще читаете по индексу и добавляете в конец — берите ArrayList (он экономнее по памяти). LinkedList выигрывает лишь при частых вставках и удалениях с краев без обращений по индексу.

Set: убрать дубликаты

Set хранит уникальные элементы. Уникальность для HashSet задают методы hashCode() и equals() — для объектов своих классов их нужно переопределить, иначе дубликаты не распознаются. Три реализации отличаются порядком обхода:

  • HashSet — порядок не определен, операции O(1).
  • LinkedHashSet — сохраняет порядок добавления.
  • TreeSet — элементы отсортированы (реализует SortedSet), операции O(log n).
Set<String> tree = new TreeSet<>(List.of("banana", "apple", "cherry"));
tree.add("apple");                 // повтор игнорируется
System.out.println(tree);          // [apple, banana, cherry]

TreeSet вывел элементы в алфавитном порядке независимо от вставки и отбросил повтор. Уточнение: TreeSet не принимает null (бросит NullPointerException), а HashSet допускает один null.

Map: пары ключ-значение

Map связывает уникальный ключ со значением. Повторный put с тем же ключом перезаписывает значение, а не добавляет пару.

  • HashMap — порядок ключей не определен, доступ по ключу O(1); один null-ключ допустим.
  • LinkedHashMap — сохраняет порядок вставки ключей.
  • TreeMap — ключи отсортированы (красно-черное дерево, реализует SortedMap), доступ O(log n); null-ключ не допускается.
Map<String, Integer> count = new TreeMap<>();
for (String w : new String[]{"java", "map", "java", "set", "java"}) {
    count.merge(w, 1, Integer::sum); // +1 или инициализация единицей
}
System.out.println(count);            // {java=3, map=1, set=1}

Метод merge удобен для подсчета: нет ключа — кладет 1, иначе прибавляет к текущему. TreeMap выдал ключи по алфавиту; с HashMap порядок был бы не определен.

Queue и Deque

Queue — очередь с дисциплиной FIFO (первым пришел — первым вышел). Deque (double-ended queue, «дэк») — двусторонняя очередь: добавлять и извлекать можно с обоих концов, поэтому она заменяет и очередь, и стек. Для стека и очереди рекомендуют ArrayDeque (push/pop/peek для стека, offer/poll для очереди), а не устаревший Stack. PriorityQueue выдает элементы по приоритету, а не по порядку прихода. Граница: ArrayDeque не хранит null.

Обобщения (generics)

Угловые скобки <E> в List<String> — это параметр типа: он говорит компилятору, какого типа элементы в коллекции, и переносит проверку типов на этап компиляции. Без параметра (сырой тип, raw type) ошибка всплывет во время работы программы:

List raw = new ArrayList();       // сырой тип, без параметра
raw.add("text");
raw.add(42);
String s = (String) raw.get(1);   // берем Integer, приводим к String

Результат:

Exception in thread "main" java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String

С обобщением та же ошибка не доживет до запуска: в List<String> typed компилятор откажет добавить typed.add(42) с ошибкой incompatible types. Ромбовидный оператор <> справа позволяет не повторять тип — компилятор выводит его из левой части. В этом и смысл generics: безопасность типов без ручных приведений.

Обход коллекции

Перебрать коллекцию можно циклом for-each, явным Iterator или forEach с лямбдой. Типичная ловушка — удалять элементы внутри for-each. Так делать нельзя:

List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer n : numbers) {
    if (n % 2 == 0) {
        numbers.remove(n);   // изменяем список во время обхода
    }
}

Результат:

Exception in thread "main" java.util.ConcurrentModificationException

Правильно — удалять через removeIf или сам итератор:

List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5));
numbers.removeIf(n -> n % 2 == 0);
System.out.println(numbers);   // [1, 3, 5]

removeIf убрал четные числа и не бросил исключение. Тот же результат дает Iterator с it.remove() вместо list.remove().

Как выбрать реализацию: таблица

«Доступ» — по индексу для List и по ключу для Map (у Set индекса нет); сложности для среднего случая.

Реализация Интерфейс Доступ Вставка/удаление Порядок обхода null
ArrayList List O(1) по индексу O(n) в середину, O(1) в конец (амортизированно) порядок вставки да
LinkedList List, Deque O(n) по индексу O(1) в известной позиции у края порядок вставки да
HashSet Set O(1) в среднем не определен один null
LinkedHashSet Set O(1) в среднем порядок вставки один null
TreeSet SortedSet O(log n) по сортировке нет
HashMap Map O(1) по ключу O(1) в среднем не определен один null-ключ
LinkedHashMap Map O(1) по ключу O(1) в среднем порядок вставки один null-ключ
TreeMap SortedMap O(log n) по ключу O(log n) по сортировке ключей нет null-ключа

Оговорка: O(1) у HashMap/HashSet — это среднее при хорошем распределении хешей. При плохом hashCode() элементы скапливаются в одной корзине и операции деградируют, поэтому корректный hashCode() важен и для скорости.

Выводы

  • JCF строится на Collection (List, Set, Queue) и отдельно стоящем Map; выбирают сначала интерфейс под задачу, потом реализацию под нагрузку.
  • ArrayList быстр на доступе по индексу, LinkedList — на вставках у краев; по умолчанию берут ArrayList.
  • Set убирает дубликаты, Map хранит пары ключ-значение; у обоих есть варианты без порядка (Hash), с порядком вставки (LinkedHash) и отсортированные (Tree).
  • Обобщения переносят проверку типов на компиляцию; сырые типы оставляют ошибку до времени выполнения.
  • Удалять элементы во время обхода нужно через removeIf или Iterator.remove(), иначе получите ConcurrentModificationException.

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

Коллекции — ежедневный инструмент Java-разработчика: кэш на HashMap, список товаров на ArrayList, уникальные теги на HashSet, очередь задач на ArrayDeque. Умение выбрать структуру по сложности операций влияет на скорость и память сервиса, и его проверяют почти на каждом собеседовании на джуна.

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

Разобрать коллекции на практике, вместе с обобщениями и потоками (Stream API), помогает системный курс с обратной связью от инженеров: курс «Java-разработчик Basic». Посмотреть формат подачи можно на открытых уроках Otus.

FAQ

Чем отличается Collection от Collections?
Collection (без s) — интерфейс, вершина иерархии. Collections (с s) — служебный класс со статическими методами: Collections.sort(list), Collections.max(coll). Имена похожи, но это разные сущности.

Как получить неизменяемую коллекцию?
Для фиксированных наборов есть фабрики List.of(...), Set.of(...), Map.of(...) — попытка add к ним бросит UnsupportedOperationException. Обернуть существующую можно через Collections.unmodifiableList(list).

Что выбрать для многопоточного доступа?
Обычные HashMap/ArrayList не потокобезопасны. Для конкурентного доступа берут ConcurrentHashMap и CopyOnWriteArrayList из пакета java.util.concurrent — они масштабируются лучше, чем обертки Collections.synchronizedMap.

OTUS Журнал