Java Коллекции и дженерики: устройство, выбор структуры, вариантность
0%

Коллекции и дженерики: устройство, выбор структуры, вариантность

Коллекции и дженерики: устройство, выбор структуры, вариантность

В прикладном 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     <-- каст вставил компилятор

Из стирания напрямую следуют все ограничения, о которые спотыкаются новички:

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[]). Расплата — проверка типа при каждой записи в массив и исключение в рантайме:

Вариантность в Java: решётка подтипов wildcards и ковариантность массивов

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-приложения, и её устройство надо знать наизусть.

Устройство HashMap: путь ключа от hashCode до ячейки таблицы

Ключевые числа из исходников 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.

Дерево решений: какую структуру брать

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.utilfail-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 — они хранят примитивы в плоских массивах и убирают боксинг полностью. Не тащите их в проект заранее: сначала измерьте (см. производительность), потом оптимизируйте. Но знать про них надо.

Эволюция: что менялось и почему

Типичные ошибки: чек-лист

  1. Объявлять поля конкретными классами (ArrayList вместо List) — теряется гибкость.
  2. LinkedList «потому что вставка O(1)» — на практике почти всегда медленнее ArrayList.
  3. Мутабельные ключи в HashMap/HashSet — элементы теряются и утекают.
  4. equals без hashCode (или наоборот) — тихо ломает все хеш-структуры. Генерируйте их IDE или используйте record.
  5. Сравнение обёрток через == — работает до 127, ломается после.
  6. Изменение коллекции в for-eachConcurrentModificationException или, хуже, молча пропущенный элемент.
  7. Collections.unmodifiableList как «иммутабельность» — это view, оригинал всё ещё меняется.
  8. Arrays.asList вместо List.of — фиксированный размер, изменяемые элементы, ловушка с массивом примитивов.
  9. Игнорирование unchecked-предупреждений — heap pollution и ClassCastException вдалеке от причины.
  10. Wildcard в возвращаемом типе — заражает весь вызывающий код.
  11. HashMap без начальной ёмкости при известном объёме — лишние resize и мусор.
  12. Optional или null как ключ TreeMapNullPointerException при первом же сравнении (HashMap один null-ключ допускает, TreeMap и List.of — нет).
  13. Итерация keySet() с последующим get() — двойное хеширование; обходите entrySet().
  14. 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 решает это бесплатно.

Источники

Что дальше

Мы разобрали, где живут данные и как язык защищает их типы. Следующий шаг — как писать код, который корректно ведёт себя при отклонениях от счастливого пути: исключения проверяемые и непроверяемые, Optional без злоупотреблений, иммутабельность как инструмент проектирования.

Идиоматика и обработка ошибок: исключения, Optional, immutability

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов