Kotlin guide done. (20.10.26)
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user