← назад к разделу

Массив в Java фиксированного размера: создал на 10 элементов — больше не положишь. В реальном коде заранее размер обычно неизвестен: пользователей может быть три, а может три тысячи. Для этого есть коллекции — набор готовых структур данных, которые сами растут, ищут и хранят элементы. Разберём три главных вида и поймём, когда какой брать.

одни и те же значения кладём в три коллекции кладём «Москва» кладём «Казань» кладём «Москва» второй раз List Москва Казань Москва size() → 3порядок и дубликаты Set Москва Казань повтор отброшен size() → 2только уникальные Map Москва→1 Казань→1 Москва→2 значение заменено size() → 2ключ хранится один раз

Три коллекции получают одни и те же три добавления. List кладёт всё подряд и помнит порядок; Set видит повтор и молча его отбрасывает; Map по тому же ключу заменяет значение. Поэтому в конце размеры разные: 3, 2 и 2.

Обязательно

Зачем нужны коллекции

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

Все коллекции живут в пакете java.util. В основе — три интерфейса: List (список), Set (множество) и Map (словарь). Интерфейс описывает, что коллекция умеет, а конкретные классы (ArrayList, HashMap и т. д.) — как это сделано внутри.

Короткая формула: упорядоченная последовательность с возможными повторами — List; набор уникальных значений — Set; пары «ключ → значение» — Map.

Вот вся семья на одной картинке: сверху интерфейсы — то, что коллекция умеет, снизу классы — как это сделано внутри. Map стоит особняком: он не наследует Collection, потому что хранит не элементы, а пары.

Iterable Collection List Set Queue ArrayListLinkedList HashSetLinkedHashSetTreeSet ArrayDequePriorityQueue Map HashMapLinkedHashMapTreeMap Collection не наследует:хранит пары, а не элементы List — порядок и повторы: «позиции заказа по порядку». Берём ArrayList Set — только уникальные: «теги товара». Берём HashSet Map — пары «ключ → значение»: «логин → покупатель». Берём HashMap

Интерфейсы сверху, реализации снизу. По умолчанию берут ArrayList, HashSet и HashMap — остальные нужны, когда важен порядок обхода или сортировка.

List — упорядоченный список

List хранит элементы в том порядке, в каком вы их добавили, и допускает дубликаты. К любому элементу можно обратиться по индексу (как в массиве), начиная с нуля.

живой пример

import java.util.ArrayList;
import java.util.List;

public class ListDemo {
    public static void main(String[] args) {
        List<String> cities = new ArrayList<>();
        cities.add("Москва");
        cities.add("Казань");
        cities.add("Москва");                // дубликаты разрешены
        System.out.println(cities.get(0));   // Москва
        System.out.println(cities.size());   // 3
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Здесь List<String> — это «список строк». Угловые скобки — обобщённый тип (generics), он фиксирует, что внутри лежат именно String; подробнее — в отдельной статье про дженерики.

Реализации две. ArrayList внутри обычный массив, который сам расширяется: быстрый доступ по индексу, быстрое добавление в конец, и это выбор по умолчанию. LinkedList связный список, где каждый элемент знает соседей. Вставка в начало у него дешёвая, а привычное «зато быстрее вставляет в середину» миф: до места сначала надо дойти по цепочке ссылок, а ArrayList сдвигает хвост массива одной операцией копирования и почти всегда оказывается быстрее. LinkedList берут, только когда нужна очередь с обоих концов, и даже тогда обычно лучше ArrayDeque.

Короткая формула: сомневаешься — бери ArrayList.

ArrayList сразу на место сдвиг хвоста запись LinkedList от головы шаг за шагом дошли до места правка ссылок

Вставка в середину по шагам: ArrayList попадает на место сразу и сдвигает хвост, а LinkedList сначала идёт по ссылкам, и этот путь тем длиннее, чем больше список.

Как ArrayList растёт, стоит знать, потому что от этого зависят две его цены. Внутри лежит обычный массив, и при первом add под него выделяют десять ячеек. Когда место кончается, создаётся новый массив в полтора раза больше, и все элементы копируются в него: на миллионе элементов таких переездов около тридцати, и каждый копирует всё, что накопилось. Если размер известен заранее, его называют сразу, new ArrayList<>(100_000), и переездов не будет. Вторая цена в удалении и вставке не в конец: remove(0) сдвигает весь хвост массива на одну ячейку влево, и цикл, который так выкидывает элементы из начала большого списка, работает квадратично. Для очереди, где берут с начала, есть ArrayDeque, о ней ниже.

Set — множество уникальных значений

Set хранит только уникальные элементы: повторное добавление того же значения ничего не меняет. Это удобно, когда нужно убрать дубликаты или проверить «а есть ли уже такой». И проверка эта почти бесплатна: list.contains(x) идёт по списку до конца, а set.contains(x) считает хеш и заглядывает в одну ячейку — на тысяче элементов разница уже видна глазом.

живой пример

import java.util.HashSet;
import java.util.Set;

public class SetDemo {
    public static void main(String[] args) {
        Set<String> tags = new HashSet<>();
        tags.add("java");
        tags.add("backend");
        tags.add("java");                          // повтор проигнорирован
        System.out.println(tags.size());           // 2
        System.out.println(tags.contains("java")); // true
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Реализации различаются одним: что происходит с порядком. HashSet порядок не гарантирует и поэтому самый быстрый, его берут по умолчанию. TreeSet держит элементы отсортированными, строки по алфавиту, числа по возрастанию, и нужен, когда обход должен идти в этом порядке. LinkedHashSet помнит порядок добавления.

Map — словарь «ключ → значение»

Map хранит пары: по ключу мгновенно достаётся значение. Это как телефонная книжка: по имени находите номер. Ключи уникальны, значения могут повторяться.

живой пример

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

public class MapDemo {
    public static void main(String[] args) {
        Map<String, Integer> ages = new HashMap<>();
        ages.put("Анна", 30);
        ages.put("Иван", 25);
        ages.put("Анна", 31);                             // тот же ключ — значение перезаписано
        System.out.println(ages.get("Анна"));             // 31
        System.out.println(ages.getOrDefault("Пётр", 0)); // 0 — ключа нет
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Тот же выбор по порядку. HashMap порядок не гарантирует, это выбор по умолчанию. TreeMap держит ключи отсортированными. LinkedHashMap помнит порядок добавления, а если создать его третьим конструктором с accessOrder = true, порядок станет порядком обращений: только что прочитанный ключ уезжает в конец, а самый давно не нужный оказывается первым. На этом делают кэш с вытеснением, переопределив removeEldestEntry.

getOrDefault удобен, чтобы не получить null, когда ключа нет, — про обработку отсутствующих значений и Optional есть отдельные статьи. Сам null при этом не запрещён: HashMap спокойно принимает один null-ключ и сколько угодно null-значений. А TreeMap на null-ключе падает с NullPointerException — ему надо сравнивать ключи между собой, а null сравнивать не с чем.

Queue и Deque: очередь и стек

На схеме иерархии выше есть ветка, которую легко пропустить: Queue и Deque. Они нужны, когда порядок обработки важнее доступа по индексу. Обработать задачи в порядке поступления, откатить последние действия, обойти дерево в ширину — всё это очередь или стек, и ArrayList для них плохой выбор: брать с начала списка дорого, о чём сказано выше.

Queue это очередь «первым пришёл, первым вышел»: offer кладёт в конец, poll забирает с начала (и возвращает null, если пусто), peek подглядывает без изъятия. Deque двусторонняя: класть и брать можно с обоих концов, и та же структура работает как стек через push и pop. Реализация по умолчанию для обоих — ArrayDeque: кольцевой массив, быстрее LinkedList и без лишних объектов на элемент, но null в него класть нельзя. Отдельно стоит PriorityQueue: она отдаёт не первый добавленный, а наименьший по естественному порядку или компаратору, и на ней строят планировщики и обход «сначала самое срочное».

живой пример

import java.util.*;

public class QueueDemo {
    public static void main(String[] args) {
        Queue<String> tasks = new ArrayDeque<>();
        tasks.offer("собрать");
        tasks.offer("протестировать");
        tasks.offer("выкатить");
        System.out.println(tasks.poll() + " → " + tasks.peek() + ", осталось " + tasks.size());

        Deque<String> undo = new ArrayDeque<>();
        undo.push("ввод текста");
        undo.push("выделение жирным");
        System.out.println("отменяем: " + undo.pop() + ", следующим будет: " + undo.peek());

        Queue<Integer> urgent = new PriorityQueue<>(List.of(5, 1, 3));
        System.out.println("самое срочное: " + urgent.poll() + ", в очереди: " + urgent);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Последняя строка показывает ловушку PriorityQueue: poll честно отдал единицу, а печать очереди даёт [3, 5] не потому, что она отсортирована, а потому, что так лёг внутренний массив; обход PriorityQueue итератором порядка не гарантирует, порядок даёт только poll. И для многопоточной работы эти классы не годятся: очереди, из которых один поток берёт, а другой кладёт, живут в java.util.concurrent и разобраны в фазе про многопоточность.

Как перебирать коллекции

Самый частый способ — цикл for-each: читается просто, индексы не нужны.

живой пример

import java.util.List;

public class ForEachDemo {
    public static void main(String[] args) {
        List<String> cities = List.of("Москва", "Казань", "Сочи");
        for (String city : cities) {       // «для каждого city из cities»
            System.out.println(city);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Для Map перебирают пары через entrySet():

живой пример

import java.util.Map;

public class EntriesDemo {
    public static void main(String[] args) {
        Map<String, Integer> ages = Map.of("Анна", 30, "Иван", 25);
        for (Map.Entry<String, Integer> entry : ages.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Под капотом for-each использует итератор (Iterator) — объект, который «проходит» по коллекции элемент за элементом. Напрямую он нужен в одном важном случае: если надо удалять элементы прямо во время обхода. Удалять через cities.remove(...) внутри for-each нельзя — будет ошибка ConcurrentModificationException. Правильно так:

живой пример

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class IteratorDemo {
    public static void main(String[] args) {
        List<String> cities = new ArrayList<>(List.of("Москва", "Казань", "Сочи"));
        Iterator<String> it = cities.iterator();
        while (it.hasNext()) {
            String city = it.next();
            if (city.startsWith("К")) {
                it.remove();            // безопасное удаление через итератор
            }
        }
        System.out.println(cities);     // [Москва, Сочи]
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Почему важны equals и hashCode

Положили свой объект в HashSet, тут же спросили contains с таким же по полям объектом и получили false. Так ведёт себя любой класс без двух методов: equals (равны ли два объекта по смыслу) и hashCode (число-«отпечаток», по которому коллекция находит нужную ячейку). HashSet и HashMap сравнивают элементы и ключи только через них, а не по ссылке в памяти; у String и чисел оба метода уже реализованы, поэтому примеры выше работают. Запись record получает их даром:

живой пример

import java.util.HashSet;
import java.util.Set;

public class PointDemo {
    record Point(int x, int y) {}      // record сам генерирует equals и hashCode

    public static void main(String[] args) {
        Set<Point> points = new HashSet<>();
        points.add(new Point(1, 2));
        System.out.println(points.contains(new Point(1, 2)));  // true
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Если бы Point был обычным class без переопределённых equals/hashCode, результат был бы false: два разных объекта с одинаковыми координатами считались бы разными. Правило: если объект кладётся в Set или становится ключом Map, у него должны быть согласованные equals и hashCode. Самый простой способ получить их даром — сделать тип record; как он устроен — в статьях про ООП и современные возможности Java.

EnumMap и EnumSet: коллекции для enum-ключей

Когда ключ — это enum, обычная HashMap избыточна: хешировать нечего, множество ключей известно заранее. EnumMap хранит значения в обычном массиве по порядковому номеру константы — это быстрее HashMap, компактнее по памяти, а обход всегда идёт в порядке объявления констант. EnumSet устроен ещё экономнее: множество enum-значений упаковано в битовую маску. Правило простое: ключ или элемент — enum, значит EnumMap/EnumSet, а не HashMap/HashSet.

Вот обе коллекции на статусах заказа. Запустите и посмотрите на порядок вывода: он не случайный, а такой, в каком объявлены константы.

живой пример

import java.util.*;

public class EnumDemo {
    enum Status { PENDING_PAYMENT, PAID, SHIPPED, DELIVERED, CANCELLED }

    public static void main(String[] args) {
        // сколько заказов в каждом статусе
        Map<Status, Integer> byStatus = new EnumMap<>(Status.class);
        for (Status s : List.of(Status.PAID, Status.PENDING_PAYMENT, Status.PAID, Status.DELIVERED)) {
            byStatus.merge(s, 1, Integer::sum);
        }
        System.out.println(byStatus);

        // какие статусы считаем «заказ ещё в работе»
        Set<Status> active = EnumSet.of(Status.PENDING_PAYMENT, Status.PAID, Status.SHIPPED);
        System.out.println("PAID в работе? " + active.contains(Status.PAID));
        System.out.println("остальные: " + EnumSet.complementOf(EnumSet.copyOf(active)));

        try {
            byStatus.put(null, 1);                 // а вот так нельзя
        } catch (NullPointerException e) {
            System.out.println("null ключом в EnumMap не положить");
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Три вещи, которые видно по выводу. Порядок в EnumMap — это порядок объявления констант, а не порядок вставки: PENDING_PAYMENT напечатался первым, хотя добавляли его вторым. EnumSet.of перечисляет нужные значения, а complementOf даёт «все остальные» — удобно, когда правило описывают через исключения. И обе коллекции отказываются принимать null в качестве ключа или элемента: у HashMap это разрешено, здесь — NullPointerException, последняя строка вывода про это.

Неизменяемые коллекции

Иногда коллекцию нужно защитить от изменений — например, вернуть из метода так, чтобы вызывающий код её не испортил. Для этого есть фабричные методы List.of, Set.of, Map.of (с Java 9):

живой пример

import java.util.List;

public class ImmutableDemo {
    public static void main(String[] args) {
        List<String> roles = List.of("admin", "user");   // неизменяемый список
        try {
            roles.add("guest");
        } catch (UnsupportedOperationException e) {
            System.out.println("UnsupportedOperationException: менять нельзя");
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

У этих фабрик есть ещё два свойства, о которые спотыкаются чаще, чем о запрет менять. null в них класть нельзя вообще: List.of(null) бросит NullPointerException на месте, а не вернёт список с дыркой. И у Map.of ровно десять пар потолком — дальше нужен Map.ofEntries(Map.entry(k, v), ...).

Такие коллекции иммутабельны: попытка что-то добавить или удалить бросит исключение. Это безопаснее и нагляднее — сразу видно, что данные менять не предполагается. Если позже нужна изменяемая копия — оберните: new ArrayList<>(List.of(...)).

Рядом с List.of живут два старших способа, и их путают с ним чаще всего. Arrays.asList("a", "b") даёт список фиксированного размера поверх массива: set работает и меняет исходный массив, а add и remove бросают UnsupportedOperationException. Это не неизменяемый список, это массив в одежде списка, и null в нём разрешён. Collections.unmodifiableList(list) даёт представление: менять через него нельзя, но исходный список остался у того, кто его передал, и его правки видны через обёртку. Так возвращают из метода коллекцию, которую вызывающий не должен править, не копируя её. А List.of и List.copyOf создают настоящую копию, которую не изменить ни через обёртку, ни через исходник.

живой пример

import java.util.*;

public class ThreeListsDemo {
    public static void main(String[] args) {
        List<String> fixed = Arrays.asList("a", "b");
        fixed.set(0, "z");
        try {
            fixed.add("c");
        } catch (UnsupportedOperationException e) {
            System.out.println("asList: set можно, add нельзя: " + fixed);
        }

        List<String> source = new ArrayList<>(List.of("x"));
        List<String> view = Collections.unmodifiableList(source);
        source.add("y");
        System.out.println("unmodifiableList видит правки исходника: " + view);

        List<String> copy = List.copyOf(source);
        source.add("w");
        System.out.println("List.copyOf не видит: " + copy);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Правило выбора: нужен снимок, который никто не поменяет, — List.of или List.copyOf; нужно отдать наружу живой список только для чтения — unmodifiableList; Arrays.asList оставьте для случаев, когда у вас уже есть массив и его надо передать туда, где ждут список.

Дополнительно: при первом чтении можно пропустить

Глубже: сортировка: Comparable, Comparator, thenComparing и nullsLastрасширенное

sorted упомянут в перечне операций, а в статье про дженерики мелькает Comparable; порядок элементов стоит разобрать целиком, потому что на нём держатся TreeMap, sort и половина задач тренажёра.

Два способа сказать, что больше. Comparable это естественный порядок, встроенный в сам класс: compareTo возвращает отрицательное, ноль или положительное; так устроены числа, строки (лексикографически, по кодам символов, поэтому «Яблоко» после «apple»), даты. Свой класс реализует Comparable<Money>, если у него есть один очевидный порядок. Comparator это порядок снаружи, объект, который сравнивает двоих: их может быть сколько угодно, по цене, по имени, по дате, и они не трогают класс.

живой пример

import java.util.*;

public class Sorting {
    record Product(String name, Integer rating, double price) {}

    public static void main(String[] args) {
        List<Product> items = new ArrayList<>(List.of(
            new Product("чай", 5, 120.0), new Product("кофе", null, 350.0),
            new Product("какао", 5, 90.0), new Product("сок", 4, 120.0)));

        items.sort(Comparator.comparing(Product::rating, Comparator.nullsLast(Comparator.reverseOrder()))
                             .thenComparing(Product::price)
                             .thenComparing(Product::name));
        items.forEach(System.out::println);

        System.out.println(Collections.max(items, Comparator.comparingDouble(Product::price)).name());
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Comparator.comparing строит компаратор по ключу; thenComparing добавляет следующий критерий для равных; reversed() переворачивает, но с ловушкой: comparing(Product::price).reversed() переворачивает всё, что до него, поэтому обратный порядок для одного ключа задают вторым аргументом comparing, как в примере. nullsLast и nullsFirst оборачивают компаратор ключа и решают, куда деть null, иначе первый же null уронит сортировку с NullPointerException. TreeMap и TreeSet принимают компаратор в конструкторе, и для них равенство определяется им, а не equals: два товара с одинаковой ценой в TreeSet по цене это один элемент.

Контракт компаратора тот же, что у compareTo: согласованность и транзитивность; компаратор, который для a и b отвечает по-разному в зависимости от порядка аргументов, даёт IllegalArgumentException: Comparison method violates its general contract из недр sort, и это одна из самых непонятных ошибок для новичка. Сортировка List.sort и Collections.sort устойчива: равные по компаратору элементы сохраняют исходный порядок, и на этом строят сортировку по нескольким полям в несколько проходов, хотя thenComparing читается лучше.

Коротко

  • Три базовых интерфейса: List (порядок + дубликаты), Set (уникальные значения), Map (пары «ключ → значение»).
  • По умолчанию берите ArrayList, HashSet, HashMap — они быстрые и покрывают большинство задач.
  • LinkedList почти никогда не нужен: ArrayList быстрее даже там, где в теории должен проигрывать. TreeSet/TreeMap — когда нужна сортировка.
  • Перебор — через for-each; удаление во время обхода — только через Iterator.remove().
  • Для элементов Set и ключей Map нужны согласованные equals и hashCode; проще всего — record.
  • List.of, Set.of, Map.of создают неизменяемые коллекции — удобно для защиты данных; null в них не кладут, а у Map.of предел — десять пар.
  • Естественный порядок задаёт Comparable, внешний Comparator через comparing, thenComparing, nullsLast и reverseOrder вторым аргументом; TreeSet считает равенство по компаратору, а нарушенный контракт роняет sort.
  • ArrayList растёт в полтора раза с копированием всего массива (размер известен — задайте сразу), а remove(0) сдвигает весь хвост.
  • Queue (offer/poll/peek) и Deque (плюс push/pop как стек) по умолчанию это ArrayDeque без null; PriorityQueue отдаёт наименьший только через poll, обход её не сортирует.
  • Arrays.asList это массив в одежде списка (set да, add нет), Collections.unmodifiableList это представление живого списка, List.of/List.copyOf это снимок.

Что почитать дальше