--- title: Коллекции под капотом description: Что лежит в байт-коде, сложность операций, расход памяти и упаковка примитивов tags: - kotlin - collections - performance --- Отдельных классов коллекций у Kotlin нет. `List`, `MutableList`, `Map` - это интерфейсы, которые при компиляции отображаются в типы `java.util`. ```kotlin fun main() { println(listOf(1, 2, 3).javaClass.name) // java.util.Arrays$ArrayList println(mutableListOf(1, 2).javaClass.name) // java.util.ArrayList println(setOf(1, 2).javaClass.name) // java.util.LinkedHashSet println(mapOf("a" to 1, "b" to 2).javaClass.name) // java.util.LinkedHashMap println(emptyList().javaClass.name) // kotlin.collections.EmptyList println(listOf(1).javaClass.name) // java.util.Collections$SingletonList println(mapOf("a" to 1).javaClass.name) // java.util.Collections$SingletonMap } ``` Обратите внимание: `listOf` возвращает **три разные** реализации в зависимости от числа аргументов, и `mapOf` ведёт себя так же. Это оптимизация под частые случаи - пустая коллекция и коллекция из одного элемента не требуют ни массива, ни хеш-таблицы. ## Соответствие типов | Kotlin | JVM | Примечание | |---|---|---| | `List` | `java.util.List` | тот же интерфейс | | `MutableList` | `java.util.List` | **тот же**, различия только в компиляторе | | `Map` | `java.util.Map` | | | `Array` | `T[]` | настоящий массив | | `IntArray` | `int[]` | без упаковки | | `Array` | `Integer[]` | с упаковкой | `List` и `MutableList` - это один и тот же `java.util.List` в байт-коде. Разделение существует только при компиляции Kotlin, что объясняет две вещи: из Java read-only коллекция изменяема, и каст `List` в `MutableList` компилируется. ```kotlin fun main() { val ro: List = mutableListOf(1, 2) println(ro is MutableList<*>) // true — в рантайме это один тип } ``` ## Сложность операций | Операция | `ArrayList` | `LinkedHashSet` | `LinkedHashMap` | `TreeMap` | |---|---|---|---|---| | доступ по индексу | O(1) | - | - | - | | поиск элемента | O(n) | O(1) | O(1) по ключу | O(log n) | | вставка в конец | O(1)* | O(1) | O(1) | O(log n) | | вставка в начало | O(n) | O(1) | O(1) | O(log n) | | удаление по индексу | O(n) | - | - | - | | удаление по значению | O(n) | O(1) | O(1) | O(log n) | \* амортизированная: при переполнении массив копируется в новый, вдвое больший. Практические следствия: ```kotlin fun main() { val list = List(10_000) { it } val set = list.toSet() // O(n) против O(1) println(9999 in list) // true, но обошёл весь список println(9999 in set) // true, один хеш // вставка в начало ArrayList — сдвиг всего массива val deque = ArrayDeque(list) deque.addFirst(-1) // O(1) println(deque.first()) // -1 } ``` ## Упаковка примитивов Главный источник неожиданных расходов памяти: ```kotlin fun main() { val boxed: List = List(5) { it } // каждый элемент — объект Integer val primitive: IntArray = IntArray(5) { it } // настоящий int[] println(boxed.sum()) // 10 println(primitive.sum()) // 10 println(primitive.javaClass.name) // [I — массив int } ``` Разница в памяти на 1 млн элементов: | Структура | Примерный размер | |---|---| | `IntArray(1_000_000)` | ~4 МБ | | `Array(1_000_000)` | ~20 МБ (ссылки + объекты) | | `List` из 1 млн | ~20 МБ | Кеш `Integer` смягчает это для малых чисел: ```kotlin fun main() { val a: Int? = 127 val b: Int? = 127 println(a === b) // true — из кеша -128..127 val c: Int? = 128 val d: Int? = 128 println(c === d) // false — разные объекты println(c == d) // true — значения равны } ``` Это ровно та же ловушка, что с `Integer` в Java. Правило: для чисел всегда `==`, никогда `===`. Подробнее: [Числа и переполнение](../Синтаксис%20и%20типы/Работа%20с%20данными/Числа%20и%20переполнение.md) Специализированные массивы без упаковки: ```kotlin fun main() { println(intArrayOf(1, 2, 3).sum()) // 6 println(doubleArrayOf(1.0, 2.0).average()) // 1.5 println(booleanArrayOf(true, false).count { it }) // 1 println(longArrayOf(1L).size) // 1 } ``` Их стоит использовать, когда элементов много и они числовые: матрицы, буферы, обработка сигналов. В обычном бизнес-коде разница несущественна. ## Рост ArrayList ```kotlin fun main() { val withoutCapacity = ArrayList() // начальная ёмкость 10 repeat(1000) { withoutCapacity.add(it) } // ~8 перевыделений массива val withCapacity = ArrayList(1000) // сразу нужный размер repeat(1000) { withCapacity.add(it) } // перевыделений нет println("${withoutCapacity.size} ${withCapacity.size}") // 1000 1000 } ``` При каждом переполнении создаётся массив в 1.5 раза больше и копируется содержимое. Для заранее известного размера задавайте ёмкость - это убирает лог n копирований. ## Что делает цепочка операций ```kotlin fun main() { val source = List(1000) { it } val result = source .map { it * 2 } // новый ArrayList на 1000 .filter { it > 100 } // новый ArrayList на ~950 .take(5) // новый ArrayList на 5 println(result) // [102, 104, 106, 108, 110] } ``` Три промежуточных списка, 1000 + 950 операций ради 5 результатов. Та же цепочка на `Sequence` не создаст ни одного промежуточного списка и выполнит ~56 операций. Подробнее: [Sequence](Sequence.md) Измерить разницу можно так: ```kotlin fun main() { val source = List(1_000_000) { it } val t1 = System.nanoTime() val a = source.map { it * 2 }.filter { it > 100 }.first() val eager = (System.nanoTime() - t1) / 1_000_000 val t2 = System.nanoTime() val b = source.asSequence().map { it * 2 }.filter { it > 100 }.first() val lazy = (System.nanoTime() - t2) / 1_000_000 println("$a == $b") // 102 == 102 println("list ${eager}мс, sequence ${lazy}мс — ленивый быстрее: ${lazy < eager}") } ``` ## Inline и отсутствие объектов-лямбд Операции над коллекциями объявлены `inline`, поэтому лямбда не создаёт объект: ```kotlin fun main() { val sum = listOf(1, 2, 3).filter { it > 1 }.sumOf { it * 2 } println(sum) // 10 } ``` Компилятор подставляет тело лямбды прямо в цикл - в байт-коде нет ни объекта `Function1`, ни виртуального вызова. Это то, чего не даёт Java-стрим: там каждая лямбда - объект, а цепочка строит конвейер из `Spliterator`-ов. Исключение - `Sequence`: там операции не могут быть полностью inline, потому что элементы обрабатываются лениво через итератор. Это и есть источник накладных расходов `Sequence` на малых данных. Подробнее: [Inline-функции](../Функциональный%20Kotlin/Inline-функции.md) ## Как выбирают структуру ```kotlin fun main() { // частый поиск -> Set val allowed = setOf("a", "b", "c") println(listOf("a", "x").count { it in allowed }) // 1 // частое добавление в начало и конец -> ArrayDeque val queue = ArrayDeque() queue.addLast(1); queue.addFirst(0) println(queue) // [0, 1] // нужен отсортированный обход и диапазоны -> TreeMap val sorted = sortedMapOf(3 to "c", 1 to "a", 2 to "b") println(sorted.headMap(3)) // {1=a, 2=b} // много числовых данных -> примитивный массив val buffer = IntArray(1000) println(buffer.size) // 1000 } ``` ## Потокобезопасность ```kotlin import java.util.concurrent.ConcurrentHashMap fun main() { val map = ConcurrentHashMap() map["a"] = 1 map.compute("a") { _, v -> (v ?: 0) + 1 } println(map) // {a=2} val sync = java.util.Collections.synchronizedMap(mutableMapOf("a" to 1)) println(sync.size) // 1 } ``` Обычные коллекции Kotlin не потокобезопасны - это те же `ArrayList` и `HashMap`. Варианты: - read-only копия на каждый поток (`toList()`) - проще всего - `ConcurrentHashMap` и `CopyOnWriteArrayList` из JDK - корутины с ограничением доступа одним потоком или `Mutex` Подробнее: [Разделяемое состояние](../Корутины/Разделяемое%20состояние.md) > [!info] Отличия от Java > | Java | Kotlin | > |---|---| > | `ArrayList` явно | `mutableListOf()` -> `ArrayList` | > | `new HashMap<>()` | `mutableMapOf()` -> `LinkedHashMap` | > | `IntStream` без упаковки | `IntArray` или Java-стрим | > | лямбда - объект `Function` | лямбда инлайнится | > | `Stream` ленив | `Iterable`-операции энергичны | > | `new ArrayList<>(100)` | `ArrayList(100)` | > > **Коллекции те же самые - это не «Kotlin-коллекции».** Любая `List` из Kotlin принимается Java-методом, принимающим `java.util.List`, без конвертации и накладных расходов. Это принципиальное отличие от языков со своей библиотекой коллекций (например, Scala), где на границе нужны обёртки. > > **Умолчание `LinkedHashMap` вместо `HashMap`.** `mutableMapOf()` создаёт `LinkedHashMap`, который на ~20% тяжелее по памяти (хранит ссылки на соседей), но даёт предсказуемый порядок обхода. Если порядок не нужен и записей миллионы, `hashMapOf()` экономнее. > > **Инлайн лямбд - преимущество перед стримами.** В Java `list.stream().filter(x -> x > 1)` создаёт объект лямбды и конвейер стрима. В Kotlin `list.filter { it > 1 }` компилируется в обычный цикл `for` с телом лямбды внутри - объектов не создаётся вовсе. Для коротких цепочек на небольших коллекциях Kotlin обычно быстрее Java-стримов именно поэтому. > > **Нет `IntStream`.** Java даёт специализации для `int`, `long`, `double`, избегающие упаковки в цепочках. В Kotlin эквивалента нет: `Sequence` упаковывает. Для числовых расчётов на больших объёмах используйте `IntArray` с обычным циклом или вызывайте Java-стримы. > > Сравнить: [HashMap в Java](../../Программирование%20на%20Java/Коллекции/HashMap.md), [JIT](../../Программирование%20на%20Java/JVM/JIT.md) > [!warning] Подводные камни > **`listOf` возвращает разные классы** - полагаться на конкретный тип нельзя: > ```kotlin > fun main() { > println(listOf().javaClass.simpleName) // EmptyList > println(listOf(1).javaClass.simpleName) // SingletonList > println(listOf(1, 2).javaClass.simpleName) // ArrayList (Arrays$ArrayList) > } > ``` > Ни один из них не поддерживает `add`, но по разным причинам, и сообщения об ошибках будут разными. > > **`Arrays$ArrayList` - не `java.util.ArrayList`.** Это обёртка над массивом с фиксированным размером: `set` работает, `add` бросает `UnsupportedOperationException`. > > **`List` упаковывает каждое число.** Для миллиона элементов это ~20 МБ вместо 4 МБ и нагрузка на сборщик мусора. В горячем численном коде используйте `IntArray`. > > **`===` на числах даёт разные результаты в зависимости от значения** - из-за кеша `Integer` в диапазоне -128..127. Сравнивайте только через `==`. > > **`list.contains` в цикле - O(n²).** Самая частая проблема производительности: > ```kotlin > fun main() { > val a = List(2000) { it } > val b = List(2000) { it * 2 } > println(a.count { it in b }) // 4 млн сравнений > val bSet = b.toSet() > println(a.count { it in bSet }) // 2000 операций, тот же результат > } > ``` > > **Каждая операция создаёт коллекцию.** Цепочка из пяти операций по большому списку - пять списков в памяти одновременно. Для больших данных нужен `Sequence`. > > **`ArrayList` без начальной ёмкости перевыделяет массив.** При известном размере задавайте его: `ArrayList(n)` или `buildList(n) { }`. > > **`subList` держит ссылку на исходный список** - он не будет собран сборщиком мусора, даже если нужен только маленький срез большого списка. Для среза, который переживёт оригинал, используйте `slice` или `toList`. > > **Коллекции Kotlin не потокобезопасны.** `mutableListOf()` - обычный `ArrayList`. Одновременное изменение из двух потоков повреждает структуру или бросает `ConcurrentModificationException`. > > **Изменяемый ключ ломает `HashMap`.** Запись становится недостижимой, хотя `size` её считает. Ключи должны быть неизменяемыми - `data class` с `val`-полями. Подробнее: [equals, hashCode и toString](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/equals,%20hashCode%20и%20toString.md) > > **Измерения производительности на JVM без прогрева врут.** Пример с `System.nanoTime` выше показывает порядок величины, но JIT компилирует горячий код только после тысяч вызовов. Для настоящих измерений нужен JMH. Далее: [Параметрический полиморфизм](../Дженерики/Параметрический%20полиморфизм.md)