Логотип Workflow

Article

Collections Framework

Тема 7. Collections Framework (List, Set, Map, Queue)

Collections Framework в Java — это не «набор контейнеров», а язык проектирования in-memory модели данных. В реальном backend-коде почти каждая операция проходит через коллекции: мы читаем строки из БД в List, строим индекс по ключу в Map, убираем дубликаты через Set, планируем обработку задач в Queue. Поэтому выбор структуры влияет не только на скорость, но и на корректность логики. Если выбрать структуру по привычке, можно получить скрытые ошибки: потерю порядка, дубли, неустойчивую производительность и сложные race-condition при масштабировании.

В этой теме мы разберем фундаментальные интерфейсы и внутренние модели хранения по слоям абстракции: сначала Iterable, затем Collection, дальше специализации List, Set, Queue, и отдельно Map, который не наследует Collection, но является центральной частью framework. Цель — чтобы ты мог смотреть на бизнес-задачу и сразу понимать, какая структура данных выражает ее семантику наиболее точно.

Iterable: контракт обхода, на котором держится for-each

Iterable<T> — минимальный интерфейс, который говорит: «эту структуру можно последовательно обходить». Он содержит ключевой метод iterator(), возвращающий объект Iterator<T>. Когда ты пишешь for-each, компилятор под капотом использует именно этот контракт и вызывает hasNext()/next().

Iterable traversal flow

Важно понимать, что Iterable не обещает ни уникальности, ни сортировки, ни доступа по индексу. Он обещает только валидный traversal. Это делает интерфейс универсальным: один и тот же цикл for-each работает и для списка, и для множества, и для очереди, пока они предоставляют Iterator.

Iterable<String> names = List.of("Alice", "Bob", "Carol");

for (String item : names) {
    System.out.println(item);
}

Этот фрагмент выглядит простым, но за ним есть важная идея архитектуры Java: алгоритмы можно писать к самому слабому контракту, который достаточен для задачи. Если тебе нужен только последовательный обход, используй абстракцию Iterable, а не более «тяжелые» типы. Это упрощает API методов и делает код менее связанным с конкретной реализацией.

public static void printAll(Iterable<String> data) {
    for (String value : data) {
        System.out.println(value);
    }
}

Еще один практический момент: iterator обычно fail-fast для большинства стандартных коллекций. Если структура модифицируется во время обхода нечерез сам iterator, получишь ConcurrentModificationException. Это защита от неочевидных ошибок, а не потокобезопасность. Для многопоточного сценария нужны отдельные concurrent-структуры или синхронизация.

Collection: общий поведенческий контракт коллекций элементов

Collection<E> расширяет Iterable<E> и добавляет базовые операции работы с группой элементов: add, remove, contains, size, isEmpty, clear, iterator. Это слой, где описывается, что вообще значит «коллекция элементов», до разделения на порядок, уникальность и очередь.

Collection core contract

Ключевая инженерная идея: Collection задает минимальный доменный язык операций, но не гарантирует конкретную асимптотику и не навязывает модель хранения. Например, contains есть везде, но в ArrayList это линейный проход, а в HashSet обычно почти O(1). Одинаковый метод — разная стоимость из-за разной внутренней структуры.

Collection<String> tags = new ArrayList<>();
tags.add("java");
tags.add("spring");

boolean hasJava = tags.contains("java");
int count = tags.size();

Полезно помнить, что не все операции обязательны для любой реализации. В некоторых коллекциях часть операций может быть unsupported и бросать UnsupportedOperationException, особенно если это immutable-объекты (List.of(...), Set.of(...)). Поэтому при проектировании API нужно четко понимать, ожидаешь ли ты изменяемую коллекцию или только read-only контракт.

С точки зрения архитектуры Collection дает правильную степень абстракции для сервисных методов, где не важна конкретика структуры. Если метод «получает набор элементов и фильтрует», часто достаточно сигнатуры через Collection или Iterable, а выбор ArrayList/LinkedHashSet/... должен оставаться на стороне вызывающего кода.

List: упорядоченная последовательность с индексом и дубликатами

List<E> выбирают, когда порядок элементов семантически важен и когда нужен доступ по позиции. List допускает дубликаты, и это принципиально: одинаковые значения могут иметь разный смысл из-за позиции или контекста обработки.

List internal storage

Внутри List-модели чаще всего используются две реализации: ArrayList и LinkedList. ArrayList хранит данные в динамическом массиве. Это дает очень быстрый доступ get(i), потому что индекс переводится в адрес почти напрямую. Цена — дорогие вставки в середину: элементы приходится сдвигать. Также при росте массива периодически происходит resize и копирование.

List<String> users = new ArrayList<>();
users.add("u1");
users.add("u2");
users.add(1, "u1.5"); // сдвиг части массива вправо
System.out.println(users.get(2)); // u2

LinkedList хранит цепочку узлов, где каждый узел знает соседей. Это дешево для вставок и удалений вблизи найденной позиции или на концах, но плохо для случайного доступа по индексу, потому что нужен пошаговый проход по ссылкам. На практике это означает: если у тебя много get(i) в цикле, LinkedList обычно будет заметно хуже.

List<String> events = new LinkedList<>();
events.add("A");
events.add("B");
events.remove(0); // структурная операция без сдвига массива

Нельзя выбирать LinkedList только по аргументу «вставки O(1)». В реальных системах основная нагрузка часто приходится на чтение и итерацию с CPU-кеш-локальностью, где ArrayList выигрывает за счет непрерывной памяти. Поэтому ArrayList — дефолт почти для всех обычных сценариев, если нет доказанной причины выбрать LinkedList.

Set: модель уникальности и проверка принадлежности

Set<E> выражает семантику «элемент либо есть, либо его нет». Дубликаты не разрешены. Если попытаться добавить то же значение повторно, размер множества не изменится. Это не просто удобство, это способ зафиксировать бизнес-инвариант данных в типе.

Set storage and uniqueness

Самая частая реализация — HashSet. Под капотом хеш-таблица распределяет элементы по bucket-ам по результату hashCode. При совпадении hash работает сравнение через equals. Поэтому корректность HashSet напрямую зависит от корректно реализованных equals/hashCode у пользовательских классов. Если контракт сломан, множество начинает «терять» элементы логически: они вроде добавлены, но поиск ведет себя непредсказуемо.

Set<String> roles = new HashSet<>();
roles.add("ADMIN");
roles.add("ADMIN");
roles.add("USER");

System.out.println(roles.size()); // 2
System.out.println(roles.contains("USER")); // true

LinkedHashSet сохраняет порядок вставки, что важно для стабильного вывода или детерминированных тестов. TreeSet хранит элементы отсортированными (натуральный порядок или Comparator) и поддерживает range-операции, но за это платит O(log n) на основные операции.

Set<Integer> sorted = new TreeSet<>();
sorted.add(40);
sorted.add(10);
sorted.add(25);
System.out.println(sorted); // [10, 25, 40]

Практический вывод: выбирай Set, когда уникальность — часть бизнес-правила, а не постобработка «потом уберем дубли». Тогда сама структура защищает инвариант в каждом месте кода.

Map: индексирование по ключу и модель key-value

Map<K, V> не наследует Collection, потому что хранит не отдельные элементы, а пары ключ-значение. Ключевая роль Map в backend — быстрая адресация данных по ключу: id пользователя, код заказа, composite key, имя фича-флага, кэш-ключ.

Map internal storage

HashMap — базовый выбор по умолчанию. Он дает быстрые put/get/remove в среднем и хорошо подходит для индексации. Но «быстрый в среднем» означает зависимость от качества хеширования ключей и распределения bucket-ов. Если много коллизий, производительность деградирует.

Map<Long, String> userById = new HashMap<>();
userById.put(101L, "Alice");
userById.put(102L, "Bob");

String name = userById.get(102L); // Bob

LinkedHashMap полезен, когда важен предсказуемый порядок итерации. В режиме access-order его часто используют как основу простых LRU-подобных кэшей. TreeMap держит ключи отсортированными и дает операции навигации (firstKey, lastKey, ceilingKey, диапазоны), что удобно для временных шкал и range-запросов в памяти.

Map нужно воспринимать как in-memory индекс. Если у тебя есть частый поиск по атрибуту, построение Map может радикально снизить сложность с O(n) на каждый поиск до почти O(1) среднего доступа. Это один из самых сильных приемов оптимизации бизнес-кода без преждевременной микротюнинга.

Queue: очередность обработки, FIFO и приоритет

Queue<E> выражает не просто хранение, а порядок потребления элементов. Для стандартной очереди это FIFO: первый пришел — первый обработан. Это напрямую маппится на реальные сценарии: обработка задач, событий, команд, сообщений.

Queue processing models

Важные методы: offer (добавить), poll (забрать и удалить голову), peek (посмотреть голову без удаления). В отличие от add/remove/element, методы offer/poll/peek лучше для надежного кода, потому что возвращают «мягкие» значения в пограничных случаях вместо исключений.

Queue<String> tasks = new ArrayDeque<>();
tasks.offer("import-users");
tasks.offer("send-report");

String first = tasks.poll(); // import-users
String next = tasks.peek();  // send-report

ArrayDeque обычно лучший выбор для очереди/стека в однопоточном коде: компактная память и быстрые операции на концах. PriorityQueue работает иначе: она возвращает элемент с лучшим приоритетом, а не с самым ранним временем вставки. Внутри — бинарная куча, поэтому полная итерация не дает «глобально отсортированный список».

Queue<Integer> pq = new PriorityQueue<>();
pq.offer(50);
pq.offer(10);
pq.offer(30);

System.out.println(pq.poll()); // 10 (минимум по умолчанию)

Это критичный момент для корректности: если нужна строгая FIFO-обработка, PriorityQueue не подходит. Если нужна обработка по весу/сроку/приоритету — подходит идеально.

Что выбрать в реальной задаче

Хороший выбор структуры начинается не с Big-O таблицы, а с вопроса «какую семантику нужно выразить». Если нужна последовательность с позициями — это List. Если нужен инвариант уникальности — Set. Если нужен быстрый доступ по ключу — Map. Если важен порядок обработки задач — Queue.

Когда семантика выбрана верно, производительность чаще всего «приходит бесплатно», потому что структура уже оптимизирована под нужный тип операций. Когда семантика выбрана неверно, даже быстрый код начинает усложняться костылями: ручное удаление дублей, дополнительные сортировки, линейные поиски внутри циклов.

Практический инженерный паттерн: проектируй API к интерфейсу (List, Set, Map, Queue), а реализацию выбирай по профилю нагрузки и инвариантам. Не делай оптимизацию заранее «на глаз», но и не игнорируй внутренние модели хранения. Понимание того, как структура держит данные в памяти, дает тебе контроль над поведением системы в продакшене.

Quiz

Проверьте, что вы усвоили

Авторизуйтесь чтоб пройти тесты