283 lines
17 KiB
Markdown
283 lines
17 KiB
Markdown
---
|
||
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<Int>().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<T>` | `java.util.List` | тот же интерфейс |
|
||
| `MutableList<T>` | `java.util.List` | **тот же**, различия только в компиляторе |
|
||
| `Map<K, V>` | `java.util.Map` | |
|
||
| `Array<T>` | `T[]` | настоящий массив |
|
||
| `IntArray` | `int[]` | без упаковки |
|
||
| `Array<Int>` | `Integer[]` | с упаковкой |
|
||
|
||
`List` и `MutableList` - это один и тот же `java.util.List` в байт-коде. Разделение существует только при компиляции Kotlin, что объясняет две вещи: из Java read-only коллекция изменяема, и каст `List` в `MutableList` компилируется.
|
||
|
||
```kotlin
|
||
fun main() {
|
||
val ro: List<Int> = 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<Int> = 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<Int>(1_000_000)` | ~20 МБ (ссылки + объекты) |
|
||
| `List<Int>` из 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<Int>() // начальная ёмкость 10
|
||
repeat(1000) { withoutCapacity.add(it) } // ~8 перевыделений массива
|
||
|
||
val withCapacity = ArrayList<Int>(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<Int>()
|
||
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<String, Int>()
|
||
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<T>` из 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<Int>` упаковывает. Для числовых расчётов на больших объёмах используйте `IntArray` с обычным циклом или вызывайте Java-стримы.
|
||
>
|
||
> Сравнить: [HashMap в Java](../../Программирование%20на%20Java/Коллекции/HashMap.md), [JIT](../../Программирование%20на%20Java/JVM/JIT.md)
|
||
|
||
> [!warning] Подводные камни
|
||
> **`listOf` возвращает разные классы** - полагаться на конкретный тип нельзя:
|
||
> ```kotlin
|
||
> fun main() {
|
||
> println(listOf<Int>().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<Int>` упаковывает каждое число.** Для миллиона элементов это ~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)
|