Files
obsidian/Kotlin/Коллекции/Коллекции под капотом.md
T

17 KiB
Raw Blame History

title, description, tags
title description tags
Коллекции под капотом Что лежит в байт-коде, сложность операций, расход памяти и упаковка примитивов
kotlin
collections
performance

Отдельных классов коллекций у Kotlin нет. List, MutableList, Map - это интерфейсы, которые при компиляции отображаются в типы java.util.

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 компилируется.

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)

* амортизированная: при переполнении массив копируется в новый, вдвое больший.

Практические следствия:

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
}

Упаковка примитивов

Главный источник неожиданных расходов памяти:

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 смягчает это для малых чисел:

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. Правило: для чисел всегда ==, никогда ===. Подробнее: Числа и переполнение

Специализированные массивы без упаковки:

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

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 копирований.

Что делает цепочка операций

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

Измерить разницу можно так:

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, поэтому лямбда не создаёт объект:

fun main() {
    val sum = listOf(1, 2, 3).filter { it > 1 }.sumOf { it * 2 }
    println(sum)       // 10
}

Компилятор подставляет тело лямбды прямо в цикл - в байт-коде нет ни объекта Function1, ни виртуального вызова. Это то, чего не даёт Java-стрим: там каждая лямбда - объект, а цепочка строит конвейер из Spliterator-ов.

Исключение - Sequence: там операции не могут быть полностью inline, потому что элементы обрабатываются лениво через итератор. Это и есть источник накладных расходов Sequence на малых данных. Подробнее: Inline-функции

Как выбирают структуру

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
}

Потокобезопасность

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

Подробнее: Разделяемое состояние

[!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, JIT

[!warning] Подводные камни listOf возвращает разные классы - полагаться на конкретный тип нельзя:

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²). Самая частая проблема производительности:

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

Измерения производительности на JVM без прогрева врут. Пример с System.nanoTime выше показывает порядок величины, но JIT компилирует горячий код только после тысяч вызовов. Для настоящих измерений нужен JMH.

Далее: Параметрический полиморфизм