Коллекции и дженерики: устройство, выбор структуры, вариантность
В прикладном Java-коде примерно 80% операций с данными проходят через java.util.
Выбор между ArrayList и LinkedList, между HashMap и TreeMap кажется мелочью —
до момента, когда сервис начинает тратить половину CPU на HashMap.resize(), а heap-дамп
показывает 4 миллиона объектов Integer. Дженерики же выглядят простым синтаксическим
сахаром — пока компилятор не откажется собирать метод, который «очевидно правильный»,
или пока ClassCastException не прилетит из строки, где нет ни одного каста.
Эта статья разбирает и то, и другое через модель исполнения: что реально лежит в памяти, какие проверки делает компилятор, какие — JVM, и почему граница между ними проведена именно так.
Карта: интерфейсы против реализаций
Главная идея Java Collections Framework (JCF), заложенная Джошуа Блохом ещё в JDK 1.2:
контракт отделён от устройства. List — это обещание про упорядоченность и доступ по индексу,
ArrayList — конкретный способ это обещание выполнить. Отсюда базовое правило кодстайла:
поля, параметры и возвращаемые типы объявляйте интерфейсом, реализацию выбирайте в точке создания.
// правильно: вызывающий не привязан к устройству
public List<Order> findRecent(int limit) { ... }
// плохо: сигнатура фиксирует реализацию и мешает её поменять
public ArrayList<Order> findRecent(int limit) { ... }
Обратите внимание на SequencedCollection — это новый интерфейс из
JEP 431, появившийся в Java 21. До него получить последний
элемент LinkedHashSet можно было только итерацией по всей коллекции, а «развернуть» список —
через Collections.reverse с копированием. Теперь у всех коллекций с определённым порядком
единый набор getFirst(), getLast(), addFirst(), addLast(), reversed():
var seen = new LinkedHashSet<String>(List.of("a", "b", "c"));
System.out.println(seen.getLast()); // c — раньше требовался цикл
System.out.println(seen.reversed()); // [c, b, a] — это view, не копия
var map = new LinkedHashMap<String, Integer>();
map.put("x", 1); map.put("y", 2);
System.out.println(map.firstEntry()); // x=1 (SequencedMap)
Это типичный пример того, чем современная Java отличается от учебников десятилетней давности: дыры в API закрываются, но старый код продолжает работать. Совместимость здесь — религия.
Дженерики: что делает компилятор и чего не знает JVM
Дженерики появились в Java 5 с жёстким требованием: новый код должен работать со старыми
библиотеками, а старый — с новыми. Решение — erasure (стирание типов). Компилятор проверяет
типы, вставляет касты и выбрасывает параметры типа. В байткоде List<String> и List<Integer> —
это один и тот же java.util.List.
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
System.out.println(strings.getClass() == ints.getClass()); // true
Посмотреть глазами на результат стирания проще всего через javap:
javac Demo.java && javap -c -p Demo
# в теле метода вы увидите:
# invokeinterface java/util/List.get:(I)Ljava/lang/Object;
# checkcast class java/lang/String <-- каст вставил компилятор
List<String> l = ...
String s = l.get(0)"] --> B["Проверка типов
javac"] B -->|ошибка| E["Компиляция падает"] B -->|ок| C["Стирание:
List<String> → List
<T extends Number> → Number
<T> → Object"] C --> D["Вставка checkcast
и bridge-методов"] D --> F["Байткод: параметров типа нет,
остались только сигнатуры
в атрибуте Signature"] F --> G["JVM исполняет:
про дженерики не знает ничего"]
Из стирания напрямую следуют все ограничения, о которые спотыкаются новички:
class Box<T> {
// T[] items = new T[10]; // нельзя: JVM не знает, какой класс массива создавать
@SuppressWarnings("unchecked")
private T[] items = (T[]) new Object[10]; // канонический обходной путь
// static T shared; // нельзя: статика одна на все параметризации
// void f(List<String> l) {}
// void f(List<Integer> l) {} // нельзя: после стирания обе — f(List)
boolean isList(Object o) {
// return o instanceof List<String>; // нельзя: информации нет в рантайме
return o instanceof List<?>; // можно: проверяем только «сырой» тип
}
}
Обходной приём, который стоит знать: type token. Раз тип нельзя восстановить из объекта,
его передают явно как Class<T> — так устроены Optional.orElseThrow-подобные фабрики,
Spring-овский getBean(Class<T>) и Jackson. Для параметризованных типов используется трюк
с анонимным подклассом (super type token), потому что сигнатуры классов в байткоде
как раз сохраняются — в атрибуте Signature:
// Jackson: тип элементов списка не стирается, потому что зашит в объявление подкласса
List<Order> orders = mapper.readValue(json, new TypeReference<List<Order>>() {});
Heap pollution: почему ClassCastException падает не там
Компилятор гарантирует типобезопасность, только если вы не обманываете его сырыми типами. Стоит один раз «протечь» — и ошибка проявится далеко от места преступления:
static void unsafeAdd(List<String> list, Object o) {
List raw = list; // сырой тип: компилятор выдаст unchecked-предупреждение
raw.add(o); // heap pollution — в List<String> лежит Integer
}
public static void main(String[] args) {
List<String> names = new ArrayList<>();
unsafeAdd(names, 42);
System.out.println(names.size()); // 1 — всё ещё «работает»
String s = names.get(0); // ClassCastException: Integer cannot be cast to String
}
Вывод практический: никогда не игнорируйте unchecked-предупреждения молча. Собирайте
проект с -Xlint:all (а в строгих проектах — с -Werror), а если каст действительно безопасен,
локализуйте @SuppressWarnings("unchecked") на минимально возможной области, желательно
на отдельной переменной, и рядом напишите комментарий, почему он безопасен. Это Item 27
в Effective Java.
Отдельная ловушка — обобщённые varargs. T... компилируется в T[], то есть в Object[],
и такой массив можно «отравить». Если метод только читает аргументы, пометьте его
@SafeVarargs:
@SafeVarargs
static <T> List<T> listOf(T... items) { // читаем — значит безопасно
return new ArrayList<>(Arrays.asList(items));
}
Честно о цене стирания: сравнение с C#
На портале есть трек C#/.NET, и здесь различие
принципиальное. В .NET дженерики реифицированы: CLR генерирует специализированный код для
каждого типа-значения, поэтому List<int> хранит настоящие int без упаковки, а typeof(T)
доступен в рантайме. В Java List<Integer> — это массив ссылок на объекты Integer,
каждый из которых стоит отдельного заголовка в куче.
| Аспект | Java (erasure) | C# (reified) |
|---|---|---|
T в рантайме |
недоступен | доступен через typeof(T) |
new T[] |
запрещено | разрешено |
| Коллекция примитивов | боксинг: List<Integer> |
без боксинга: List<int> |
| Совместимость со старым кодом | полная, бинарная | потребовался новый рантайм |
| Раздувание кода | нет | есть (специализация под value-типы) |
Это осознанный размен: Java купила бесшовную миграцию всей экосистемы ценой боксинга. Работы по устранению этой цены идут в Project Valhalla (value-классы и универсальные дженерики), но на момент написания это ещё не стабильная часть языка — планировать архитектуру под неё рано.
Вариантность: почему List<String> не является List<Object>
Самый частый вопрос новичка: «String же наследник Object, почему тогда
List<Object> l = new ArrayList<String>() не компилируется?» Потому что иначе через l
можно было бы положить в список строк любой объект. Дженерики инвариантны — и это
защита, а не придирка.
А вот массивы в Java ковариантны — историческая ошибка дизайна, допущенная до появления
дженериков, чтобы можно было писать Arrays.sort(Object[]). Расплата — проверка типа
при каждой записи в массив и исключение в рантайме:
Object[] arr = new String[2]; // компилируется: массивы ковариантны
arr[0] = 42; // ArrayStoreException в рантайме
// List<Object> list = new ArrayList<String>(); // не компилируется — ошибка поймана раньше
PECS: Producer Extends, Consumer Super
Инвариантность строгая, но иногда нужна гибкость — тогда в точке использования пишут wildcard. Мнемоника Блоха (Effective Java, Item 31): Producer — Extends, Consumer — Super.
// src только отдаёт элементы (producer) → ? extends
// dst только принимает элементы (consumer) → ? super
public static <T> void copy(List<? super T> dst, List<? extends T> src) {
for (T item : src) { // читаем как T — безопасно
dst.add(item); // пишем T в контейнер супертипа — безопасно
}
}
List<Integer> ints = List.of(1, 2, 3);
List<Number> numbers = new ArrayList<>();
copy(numbers, ints); // работает: Number — супертип Integer
Почему из ? extends нельзя писать? Потому что List<? extends Number> может оказаться
List<Integer>, и добавление Double сломало бы его. Компилятор не знает конкретный тип,
поэтому запрещает всё, кроме null:
List<? extends Number> producer = List.of(1, 2, 3);
Number n = producer.get(0); // ок: что бы там ни было, это Number
// producer.add(3.14); // ошибка компиляции
// producer.add(1); // тоже ошибка — даже «правильный» тип запрещён
List<? super Integer> consumer = new ArrayList<Number>();
consumer.add(42); // ок: Integer влезет в любой супертип
Object o = consumer.get(0); // читать можно только как Object
Практическое правило API-дизайна: если параметр метода — коллекция, из которой вы только
читаете, пишите Collection<? extends T>; если только пишете — Collection<? super T>;
если и то и другое — оставляйте Collection<T>. Для возвращаемых значений wildcard не
используйте — он заставит вызывающего писать wildcard у себя, и зараза расползётся по коду.
Именно поэтому сигнатуры в JDK выглядят так, как выглядят:
// Comparator сравнивает — значит потребляет, отсюда ? super
static <T> void sort(List<T> list, Comparator<? super T> c);
// Функция производит R и потребляет T
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
// Рекурсивная граница: T должен уметь сравниваться сам с собой или со своим предком
static <T extends Comparable<? super T>> T max(Collection<? extends T> coll);
Последняя строка — самая пугающая конструкция в JDK, но читается она просто:
«T сравним с T или с любым его супертипом». ? super нужен, чтобы max работал
для класса, унаследовавшего Comparable от родителя. Полный разбор всех тонкостей —
в Java Generics FAQ
Ангелики Лангер, это до сих пор самый детальный источник по теме.
Списки: ArrayList против LinkedList
ArrayList — это массив Object[] плюс поле size. При переполнении создаётся новый массив
в 1.5 раза больше (newCapacity = oldCapacity + (oldCapacity >> 1)), и данные копируются
через System.arraycopy — интринсик, который JIT превращает в векторизованный memcpy.
Амортизированная стоимость add — O(1), но каждое расширение это разовая аллокация и копия.
LinkedList — двусвязный список: на каждый элемент отдельный объект Node с тремя полями
(item, prev, next), то есть ~40 байт накладных расходов на элемент и полное отсутствие
локальности в памяти.
| Операция | ArrayList | LinkedList | Комментарий |
|---|---|---|---|
get(i) |
O(1) | O(n) | у списка — обход с ближайшего конца |
add(e) в конец |
O(1)* | O(1) | * амортизированно |
add(0, e) |
O(n) | O(1) | но у ArrayList это быстрый arraycopy |
remove(i) в середине |
O(n) | O(n) | у LinkedList поиск позиции тоже O(n) |
| Память на элемент | 4–8 байт ссылки | ~40 байт | плюс сам объект |
| Итерация | быстро | медленно | кэш-промах на каждом шаге |
Главный вывод, который противоречит учебникам: LinkedList в реальном коде почти всегда
проигрывает, даже там, где асимптотика на его стороне. Современный процессор читает память
кэш-линиями по 64 байта; последовательный массив он префетчит, а прыжки по указателям — нет.
На типичных размерах (сотни–тысячи элементов) вставка в середину ArrayList через arraycopy
быстрее, чем обход LinkedList до нужной позиции. Подробнее про этот эффект —
в статье про кэш и локальность.
Если нужна очередь или дек — берите ArrayDeque: кольцевой буфер на массиве, без аллокации
узлов, быстрее LinkedList в обеих ролях. LinkedList остаётся оправдан лишь в редком случае:
у вас уже есть ListIterator в нужной позиции и вы делаете много вставок/удалений именно через него.
Deque<Task> queue = new ArrayDeque<>(); // очередь и стек — это он
queue.addLast(task); // enqueue
Task next = queue.pollFirst(); // dequeue, null если пусто
queue.push(task); // как стек: addFirst
HashMap изнутри
HashMap — рабочая лошадь любого Java-приложения, и её устройство надо знать наизусть.
Ключевые числа из исходников OpenJDK:
- начальная ёмкость 16, всегда степень двойки — индекс считается как
hash & (n - 1), это одна инструкция вместо деления; loadFactor = 0.75— приsize > capacity * 0.75таблица удваивается и все записи перераспределяются (resize);- функция «размешивания»
h ^ (h >>> 16)— подмешивает старшие биты хеша в младшие, потому что маска отбрасывает старшие; без неё ключи с одинаковыми младшими битами (например, объекты с хешем, кратным степени двойки) собрались бы в один бакет; - при 8 узлах в одном бакете и таблице длиной ≥ 64 связный список превращается в
красно-чёрное дерево (
TREEIFY_THRESHOLD), и худший случай поиска становится O(log k) вместо O(k) — это защита от hash-collision DoS, добавленная в Java 8.
Практическое следствие: если вы знаете размер заранее — задайте его. Наполнение
HashMap на миллион записей с дефолтной ёмкостью — это ~16 полных пересозданий таблицы.
В Java 19 появились удобные фабрики, которые сами учитывают load factor:
// до Java 19 приходилось считать вручную:
Map<String, User> old = new HashMap<>((int) (expected / 0.75f) + 1);
// с Java 19 — понятнее и без ошибок:
Map<String, User> byId = HashMap.newHashMap(expected);
Set<String> seen = HashSet.newHashSet(expected);
Контракт equals/hashCode — источник самых злых багов
Правило простое: равные объекты обязаны иметь равный хеш. Обратное не требуется. Нарушение приводит к тому, что объект «теряется» в коллекции.
// records генерируют корректные equals/hashCode автоматически — предпочитайте их
record CacheKey(String tenant, long userId) {}
Map<CacheKey, Profile> cache = new HashMap<>();
cache.put(new CacheKey("acme", 42), profile);
System.out.println(cache.get(new CacheKey("acme", 42))); // найдено: равенство по значению
Про record и его семантику подробно — в статье про
ООП в Java. А вот классическая ловушка — мутабельный ключ:
class MutableKey {
String name;
MutableKey(String name) { this.name = name; }
@Override public boolean equals(Object o) {
return o instanceof MutableKey k && Objects.equals(name, k.name);
}
@Override public int hashCode() { return Objects.hashCode(name); }
}
var key = new MutableKey("a");
Set<MutableKey> set = new HashSet<>();
set.add(key);
key.name = "b"; // хеш изменился, а объект лежит в старом бакете
System.out.println(set.contains(key)); // false — объект есть, но не находится
System.out.println(set.size()); // 1 — и удалить его штатно уже нельзя
Это утечка памяти в чистом виде: элемент недостижим логически, но жив для GC.
Правило: ключи хеш-коллекций должны быть иммутабельны. record с иммутабельными
компонентами, String, обёртки примитивов, enum — да; JPA-сущность с изменяемым
id — категорически нет (об этом ещё поговорим в статье про
работу с данными).
Идиомы Map, которые экономят строки и баги
Map<String, List<Order>> byCustomer = new HashMap<>();
// вместо get + null-проверка + put
byCustomer.computeIfAbsent(order.customer(), k -> new ArrayList<>()).add(order);
// счётчики
Map<String, Integer> counts = new HashMap<>();
counts.merge(word, 1, Integer::sum);
// значение по умолчанию без записи в карту
int c = counts.getOrDefault(word, 0);
// атомарное «положи, если нет» — возвращает СТАРОЕ значение или null
Session prev = sessions.putIfAbsent(id, session);
Грабли computeIfAbsent: нельзя изменять ту же карту внутри переданной функции.
До Java 9 это тихо портило структуру, начиная с Java 9 — честно кидает
ConcurrentModificationException. Если mapping-функция рекурсивно обращается к той же
карте, вынесите вычисление наружу.
Set, отсортированные структуры и специализированные карты
HashSet — это HashMap с фиктивным значением, ровно те же характеристики.
LinkedHashSet добавляет двусвязный список поверх бакетов и сохраняет порядок вставки —
идеальный выбор, когда нужны и уникальность, и предсказуемый порядок (например, для
детерминированных ответов API). Стоимость — две дополнительные ссылки на элемент.
TreeMap/TreeSet — красно-чёрное дерево, все операции O(log n), зато доступен
навигационный API, который у хеш-структур принципиально невозможен:
NavigableMap<Instant, Event> timeline = new TreeMap<>();
timeline.put(t1, e1); timeline.put(t2, e2);
timeline.firstEntry(); // самое раннее событие
timeline.floorEntry(now); // последнее событие не позже now
timeline.subMap(from, true, to, false); // диапазон — это view, не копия
timeline.descendingMap(); // обратный порядок, тоже view
Важный нюанс: TreeMap определяет равенство через compareTo/compare, а не через
equals. Если компаратор не согласован с equals, коллекция начинает вести себя странно
с точки зрения контракта Set:
Set<String> ci = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
ci.add("Java"); ci.add("JAVA");
System.out.println(ci.size()); // 1 — для TreeSet это один элемент
System.out.println(ci.contains("java")); // true, хотя equals сказал бы false
Для перечислений всегда используйте специализированные реализации — они на порядок эффективнее общих:
// EnumSet — это битовая маска в long (или массив long при >64 константах)
EnumSet<Permission> perms = EnumSet.of(Permission.READ, Permission.WRITE);
perms.contains(Permission.READ); // проверка бита, без хеширования
// EnumMap — обычный массив, индексируемый ordinal(); нет коллизий вообще
EnumMap<Status, Integer> counters = new EnumMap<>(Status.class);
Ещё две редкие, но полезные карты: IdentityHashMap (сравнение по ==, нужен при обходе
графов объектов) и WeakHashMap (ключи держатся слабыми ссылками, запись исчезает после GC).
С WeakHashMap есть классическая ловушка: если значение ссылается на свой ключ,
запись никогда не будет собрана — получается утечка вместо кэша. Для настоящих кэшей
берите специализированные библиотеки вроде Caffeine.
Дерево решений: какую структуру брать
или диапазонные запросы?"} K2 -->|"сортировка, floor/ceiling"| TM["TreeMap"] K2 -->|"порядок вставки или LRU"| LHM["LinkedHashMap"] K2 -->|"порядок не важен"| K3{"Доступ из нескольких потоков?"} K3 -->|да| CHM["ConcurrentHashMap"] K3 -->|нет| HM["HashMap"] KV -->|нет| U{"Нужна уникальность?"} U -->|да| S1{"Элемент — enum?"} S1 -->|да| ES["EnumSet"] S1 -->|нет| S2{"Нужен порядок?"} S2 -->|"сортированный"| TS["TreeSet"] S2 -->|"вставки"| LHS["LinkedHashSet"] S2 -->|"не нужен"| HS["HashSet"] U -->|нет| L1{"Как обращаетесь?"} L1 -->|"по индексу, итерация"| AL["ArrayList"] L1 -->|"с двух концов, FIFO/LIFO"| AD["ArrayDeque"] L1 -->|"всегда минимум/максимум"| PQ["PriorityQueue"] L1 -->|"обмен между потоками"| BQ["BlockingQueue"]
PriorityQueue стоит упомянуть отдельно: это бинарная куча на массиве, offer/poll за
O(log n), peek за O(1). Частая ошибка — ожидать, что итерация по ней даст отсортированный
порядок. Это не так: упорядочен только корень. Чтобы получить отсортированный вывод,
нужно последовательно вызывать poll(). Про устройство кучи —
в статье о heap и очередях с приоритетом.
Иммутабельность, views и копии
В Java есть три разных вещи, которые легко перепутать:
// 1. Настоящая иммутабельная коллекция (Java 9+)
List<String> immutable = List.of("a", "b");
// immutable.add("c"); // UnsupportedOperationException
// List.of(null); // NullPointerException — null-hostile!
// 2. Немодифицируемое ПРЕДСТАВЛЕНИЕ изменяемой коллекции
List<String> backing = new ArrayList<>(List.of("a"));
List<String> view = Collections.unmodifiableList(backing);
backing.add("b");
System.out.println(view); // [a, b] — «неизменяемый» список изменился!
// 3. Иммутабельная КОПИЯ — снимок на момент вызова
List<String> snapshot = List.copyOf(backing);
backing.add("c");
System.out.println(snapshot); // [a, b] — не зависит от источника
Возвращая коллекцию из геттера доменного объекта, отдавайте List.copyOf(...), а не
внутренний список и не его unmodifiable-view: иначе вызывающий увидит гонки и незапланированные
мутации. Тема иммутабельности продолжится в следующей статье трека.
Отдельная классическая ловушка — Arrays.asList:
List<String> l = Arrays.asList("a", "b");
l.set(0, "z"); // ок: это обёртка над массивом фиксированного размера
l.add("c"); // UnsupportedOperationException — размер менять нельзя
// и совсем коварно:
int[] primitives = {1, 2, 3};
System.out.println(Arrays.asList(primitives).size()); // 1! Это List<int[]>
Последняя строка — прямое следствие стирания: примитив не может быть параметром типа,
поэтому int[] целиком становится единственным элементом списка. Для примитивов
используйте Arrays.stream(primitives).boxed().toList().
Ещё один вид представлений — subList. Он не копирует данные, а смотрит в исходный список,
и структурное изменение оригинала делает его невалидным:
List<Integer> base = new ArrayList<>(List.of(1, 2, 3, 4, 5));
List<Integer> mid = base.subList(1, 4);
base.add(6);
// mid.get(0); // ConcurrentModificationException
Зато это же свойство даёт красивую идиому удаления диапазона за один вызов:
base.subList(1, 4).clear() удаляет элементы 1..3 из оригинала.
Итерация, fail-fast и ConcurrentModificationException
Все коллекции из java.util — fail-fast: итератор запоминает счётчик модификаций
modCount и при расхождении бросает ConcurrentModificationException. Это не гарантия
корректности, а диагностика — best-effort попытка не дать вам молча получить мусор.
List<String> names = new ArrayList<>(List.of("ann", "bob", "eve"));
for (String n : names) {
if (n.startsWith("b")) names.remove(n); // ConcurrentModificationException
}
// правильно, вариант 1 — итератор
for (Iterator<String> it = names.iterator(); it.hasNext(); ) {
if (it.next().startsWith("b")) it.remove();
}
// правильно, вариант 2 — идиоматично и коротко (Java 8+)
names.removeIf(n -> n.startsWith("b"));
// для карт — обход entrySet с изменением значений
for (Map.Entry<String, Integer> e : counts.entrySet()) {
e.setValue(e.getValue() + 1); // менять значение можно, структуру — нет
}
Хитрость, которую любят на собеседованиях: удаление предпоследнего элемента в цикле
for-each по ArrayList не бросит исключение — из-за особенностей проверки
hasNext() цикл просто завершится раньше, молча пропустив элемент. Ещё один аргумент
за removeIf.
Конкурентные коллекции ведут себя иначе: итераторы ConcurrentHashMap weakly consistent —
они не бросают исключение, но и не гарантируют, что увидят изменения, сделанные после
создания итератора. CopyOnWriteArrayList итерируется по снимку и вообще не видит изменений.
Подробно — в статье про конкурентность.
Память и производительность: где Java платит
Главная плата за стирание — боксинг. List<Integer> на миллион элементов — это массив
на ~4 МБ (сжатые указатели) плюс миллион объектов Integer по 16 байт = ещё 16 МБ,
разбросанных по куче. Эквивалентный int[] занимает 4 МБ одним непрерывным куском.
Разница в скорости обхода — в разы, и вся она в кэш-промахах и работе GC.
// демонстрация кэша обёрток: -128..127 кэшируются, остальные — новые объекты
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true — тот же объект из кэша
System.out.println(c == d); // false — два разных объекта!
System.out.println(c.equals(d)); // true — сравнивать обёртки надо только equals
Это одна из самых известных ловушек Java: код с == работает на маленьких числах
и ломается на больших. Размер кэша управляется флагом -XX:AutoBoxCacheMax, но полагаться
на это нельзя.
Оценить реальный размер структуры помогает JOL:
java -jar jol-cli.jar internals java.util.HashMap
# покажет layout полей, выравнивание и суммарный footprint
Ориентировочные накладные расходы на элемент (64-битная JVM, compressed oops):
| Структура | Overhead на элемент | Комментарий |
|---|---|---|
int[] |
4 байта | ничего лишнего |
ArrayList<Integer> |
~20 байт | ссылка + объект Integer |
HashMap<Integer, Integer> |
~48–56 байт | Node + два бокса |
LinkedList<Integer> |
~40+ байт | Node с тремя полями |
TreeMap<Integer, Integer> |
~56–64 байта | Entry с left/right/parent/color |
Когда счёт идёт на десятки миллионов записей, стандартные коллекции становятся дорогими.
Тогда берут примитивные коллекции: Eclipse Collections
(IntIntHashMap, IntArrayList) или fastutil —
они хранят примитивы в плоских массивах и убирают боксинг полностью. Не тащите их в проект
заранее: сначала измерьте (см. производительность),
потом оптимизируйте. Но знать про них надо.
Эволюция: что менялось и почему
Типичные ошибки: чек-лист
- Объявлять поля конкретными классами (
ArrayListвместоList) — теряется гибкость. LinkedList«потому что вставка O(1)» — на практике почти всегда медленнееArrayList.- Мутабельные ключи в
HashMap/HashSet— элементы теряются и утекают. equalsбезhashCode(или наоборот) — тихо ломает все хеш-структуры. Генерируйте их IDE или используйтеrecord.- Сравнение обёрток через
==— работает до 127, ломается после. - Изменение коллекции в for-each —
ConcurrentModificationExceptionили, хуже, молча пропущенный элемент. Collections.unmodifiableListкак «иммутабельность» — это view, оригинал всё ещё меняется.Arrays.asListвместоList.of— фиксированный размер, изменяемые элементы, ловушка с массивом примитивов.- Игнорирование
unchecked-предупреждений — heap pollution иClassCastExceptionвдалеке от причины. - Wildcard в возвращаемом типе — заражает весь вызывающий код.
HashMapбез начальной ёмкости при известном объёме — лишниеresizeи мусор.Optionalилиnullкак ключTreeMap—NullPointerExceptionпри первом же сравнении (HashMapодинnull-ключ допускает,TreeMapиList.of— нет).- Итерация
keySet()с последующимget()— двойное хеширование; обходитеentrySet(). ConcurrentHashMapкак гарантия атомарности составных операций —get+putне атомарны, нужныcompute/merge.
Мини-итог
- Коллекции выбирают по паттерну доступа, а не по асимптотике из учебника: локальность памяти на реальных размерах часто важнее степени в O(…).
ArrayList+HashMap+ArrayDequeпокрывают 90% задач. Отклоняйтесь от них осознанно: нужен порядок —LinkedHashMap/TreeMap, нужен enum —EnumMap/EnumSet.HashMap— это массив бакетов, маска вместо деления, размешивание хеша, resize при 75% заполнения и treeification при 8 коллизиях. Знание этих чисел напрямую конвертируется в умение читать профайлер.- Дженерики стираются: типы проверяет компилятор, JVM про них не знает. Отсюда и запрет
new T[], и боксинг, и обязанность не игнорироватьunchecked. - Дженерики инвариантны, массивы ковариантны. Вариантность включается wildcard-ами
по правилу PECS: producer —
extends, consumer —super. - Ключи хеш-структур обязаны быть иммутабельны и иметь согласованные
equals/hashCode.recordрешает это бесплатно.
Источники
- Java Collections Framework overview — официальный обзор.
- Javadoc java.util — читайте контракты интерфейсов, а не только методов.
- The Java Tutorials: Generics — пошаговое введение.
- JLS §4.6 Type Erasure — формальные правила стирания.
- Angelika Langer, Java Generics FAQ — исчерпывающий справочник по дженерикам.
- Naftalin, Wadler, Java Generics and Collections — книга авторов реализации дженериков.
- Bloch, Effective Java, 3rd ed. — главы 5 и 6 обязательны.
- OpenJDK: исходники HashMap — лучший учебник по хеш-таблицам.
- JEP 431: Sequenced Collections — мотивация и дизайн нового API.
- Guava: New Collection Types —
Multimap,Multiset,BiMap, которых нет в JDK.
Что дальше
Мы разобрали, где живут данные и как язык защищает их типы. Следующий шаг — как писать код,
который корректно ведёт себя при отклонениях от счастливого пути: исключения проверяемые
и непроверяемые, Optional без злоупотреблений, иммутабельность как инструмент проектирования.
Идиоматика и обработка ошибок: исключения, Optional, immutability