17 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| Коллекции под капотом | Что лежит в байт-коде, сложность операций, расход памяти и упаковка примитивов |
|
Отдельных классов коллекций у 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()->ArrayListnew HashMap<>()mutableMapOf()->LinkedHashMapIntStreamбез упаковки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)создаёт объект лямбды и конвейер стрима. В Kotlinlist.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.
Далее: Параметрический полиморфизм