В 2018 году в androidx появился новый пакет collection, который содержал несколько специфичных структур данных, переписанных на Kotlin, таких как LongSparseArray, SimpleArrayMap и SparseArrayCompat.

На тот период Kotlin только начинал набирать обороты в Android разработке и добавление новых более эффективных коллекций, полностью написанных на нём было одним из шагов по внедрению языка.

С тех пор прошло более 6 лет и в январе текущего года был выпущен новый релиз с мощной заменой HashMap, о которой я расскажу чуть позже:

dependencies {
    implementation("androidx.collection:collection:1.4.0")
}

Список релизов и изменений можете глянуть на официальном сайте.

Зачем вообще нужно было строгать новые коллекции и переписывать старые?

На это есть как минимум три причины:

  1. Эффективный расход памяти - думаю не секрет что даже при наличии 8Gb ОЗУ на вашем телефоне память не бесконечна, поэтому новые коллекции были написаны, придерживаясь принципа "минимум объектов".

  2. Эффективная реализация алгоритмов - старые реализации могут содержать не очень эффективные алгоритмы и устаревшие решения, требующие рефакторинга.

  3. Kotlin Multiplatform - при написании общего кода на Kotlin под разные платформы требуется минимальное количество зависимостей от платформенных структур данных, например таких как android.util.SparseArray.

А теперь перейдём к самой вкусной части статьи, разберёмся что за магические штуки наколдовали Google кодеры и самое главное как они работают под капотом.

Полетели!

Списки или динамические массивы

Было добавлено несколько реализаций:

// обобщённая реализация
val games = objectListOf("Atomic Heart", "BioShock", "Mafia II")
val movies = objectListOf("Dune: Part Two", "1 + 1")

val moviesAndGames = mutableObjectListOf<String>()
// переопределённые операторы plusAsssign
moviesAndGames += movies
moviesAndGames += games
moviesAndGames += "Far Cry 3"
moviesAndGames += "The Shawshank Redemption"

// изменяемая версия mutableIntListOf()
val fibonacciIntegerNumbers = intListOf(1, 1, 2, 3, 5)

// изменяемая версия mutableLongListOf()
val fibonacciLongNumbers = longListOf(1, 1, 2, 3, 5)

// изменяемая версия mutableFloatListOf()
val fibonacciFloatingNumbers = floatListOf(1f, 1f, 2f, 3f, 5f)

/* P.S. реализации для примитивных типов были созданы под копирку 
на основе обобщённой objectListOf() */

Под капотом обычный массив, который увеличивается в полтора раза при превышении размера, а при удалении значений из середины происходит сдвиг массива, можно сказать что это аналогия ArrayList из Java мира.

Основные преимущества:

  1. Методы forEach, forEachIndexed и аналогичные реализованы без использования итераторов + они помечены ключевым словом inline для уменьшения загрузки стэка вызовов.

  2. Есть отдельные реализации для примитивных типов Int, Long и Float без autoboxing / unboxing механизма, который характерен для ArrayList<Integer> например.

  3. Перегрузка операторов += и -= для более лаконичного кода, также классы коллекций помечены ключевым словом sealed, поэтому за пределами androidx.collection библиотеки не удастся создать новых наследников и нарушить четко определённую логику.

Сводка классов, если захотите залезть в исходники:

  1. ObjectList / MutableObjectList для обобщенных списков

  2. IntList / MutableIntList для примитивного типа Int

  3. FloatList / MutableFloatList для примитивного типа Float

  4. LongList / MutableLongList для примитивного типа Long

Пары значений

Библиотека androidx.collection включает три реализации пар значений для примитивных типов, возможно на момент чтения этой статьи что-то изменилось, но базовые идеи реализации редко меняются.

1. IntIntPair хранит два значения типа Int в виде одного числа Long благодаря простейшей битовой математики: первое число хранится в старших 32 битах Long, а второе в младших:

@JvmInline
value class IntIntPair(val packedValue: Long) {
  
    constructor(first: Int, second: Int) : this(packInts(first, second))
    
    // первое значение хранится в старших битах числа Long
    val first: Int
        get() = (packedValue shr 32).toInt()

    // второе значение хранится в младших битах числа Long
    val second: Int
        get() = (packedValue and 0xFFFFFFFF).toInt()
}

inline fun packInts(val1: Int, val2: Int): Long {
    /* чтобы положить два 32-битных числа в одно 64-битное 
    первое число преобразуем к 64-битному и делаем сдвиг влево на 32 бита, 
    второе также расширяем до 64 бит с занулением старших 32 бит, 
    если такие случайно там оказались, после этого совмещаем биты обеих чисел 
    с помощью логического OR */
    return (val1.toLong() shl 32) or (val2.toLong() and 0xFFFFFFFF)
}

Обратите внимание, что IntIntPair является inline value классом, а это значит что поле packedValue будет встраиваться в место вызова без создания объекта класса, что избавляет GC от ненужной работы.

2. FloatFloatPair аналогично IntIntPair хранит два значения типа Float в одном значении типа Long, отличие заключается только в дополнительной трансформации числа Float в битовое представление типа Int:

/* сначала извлекается битовое представление типа Int из старших 32 бит числа, 
затем трансформируется во Float тип */
inline val first: Float
    get() = floatFromBits((packedValue shr 32).toInt())

/* алгоритм аналогичен извлечению первого значения, 
только битовое представление извлекается из младших 32 бит */
inline val second: Float
    get() = floatFromBits((packedValue and 0xFFFFFFFF).toInt())

inline fun packFloats(val1: Float, val2: Float): Long {
    /* превращаем каждое значение Float в битовое представление типа Int, 
    для дальнейших битовых операций переводим в Long */
    val v1 = val1.toRawBits().toLong()
    val v2 = val2.toRawBits().toLong()
    // также как в IntIntPair запаковываем два числа типа Int в один Long
    return (v1 shl 32) or (v2 and 0xFFFFFFFF)
}

inline fun floatFromBits(bits: Int): Float = java.lang.Float.intBitsToFloat(bits)
inline fun Float.toRawBits(): Int = java.lang.Float.floatToRawIntBits(this)

Более детальное описание алгоритма превращения числа Float в битовое представление Int и обратно содержится в документации по методам Float.intBitsToFloat и Float.floatToRawIntBits.

3. LongLongPair - простейшая реализация для двух чисел типа Long:

class LongLongPair(val first: Long, val second: Long) { ... }

Выигрывает перед Pair<Long, Long> в памяти, так как для всех обобщенных типов используются классы обертки, а не примитивные типы.

Во всех вышеперечисленных реализациях пар значений есть переопределение операторов component1 и component2 для конструкций деструктуризации:

val (firstInt, secondInt) = IntIntPair(3, 7)
val (firstLong, secondLong) = LongLongPair(2021, 2023)
val (firstFloat, secondFloat) = FloatFloatPair(0.33f, 0.45f)

Хэш-таблицы

Настал момент заглянуть в самую сложную алгоритмическую часть этой статьи, готов представить вам, новая реализация HashMap:

// обобщённый вариант хэш-таблицы
val scatterMap: ScatterMap<String, String> = mutableScatterMapOf()

// реализации хэш-таблиц для примитивных типов, аналогичные варианты есть для Long и Float
intIntMapOf()
intFloatMapOf()
intLongMapOf()
intObjectMapOf<String>()

// варианты для случаев, когда в качестве ключа один из примитивных типов: Int, Long или Float
intObjectMapOf<String>()
longObjectMapOf<String>()
floatObjectMapOf<String>()

/* P.S. похожие штуки на intIntMapOf(), intLongMapOf() и intObjectMapOf() 
есть в android.util пакете - SparseIntArray, SparseLongArray 
и SparseArray соответственно */

Полагаю вы уже догадались, что все эти реализации были созданы, также как и ранее рассмотренные списки, под копирку на основе обобщённой версии:

sealed class ScatterMap<K, V> {
    // меняются буквально только типы для массивов ключей и значений
    var keys: Array<Any?> = EMPTY_OBJECTS
    var values: Array<Any?> = EMPTY_OBJECTS
    // остальная логика остаётся неизменной
}

sealed class IntIntMap {
    // типы были изменены на примитивные
    var keys: IntArray = EmptyIntArray
    var values: IntArray = EmptyIntArray
    // логика абсолютно не поменялась и была скопирована из ScatterMap
}

sealed class IntObjectMap<V> {
    // такая же история...
    var keys: IntArray = EmptyIntArray
    var values: Array<Any?> = EMPTY_OBJECTS
}

/* P.S. Такой подход может показаться избыточным так как 
одинаковая логика копируется прямо в лоб, на самом деле особого проигрыша 
в этом нет и любые неиспользуемые классы легко выпиливаются 
современными системами сборки. */

Следовательно мы можем взять реализацию ScatterMap / MutableScatterMap и рассмотреть её под капотом, логика остальных ничем не будет отличаться.

Приступим к изучению!

Как ScatterMap хранит информацию о парах ключ / значение

Механизм хранения информации о записях ключ / значение отличается от HashMap, где все лежит в одном массив


е Map.Entry объектов, в ScatterMap для этого используется отдельный массив метаданных:

internal var metadata: LongArray = EmptyGroup

где единицей хранимой информации является 8 бит, которыми можно сохранить одно состояние ровно для одной записи хэш-таблицы.

Список возможных состояний и их 8-ми битовая последовательность:

  1. Пустое значение - 10000000

  2. Конец таблицы - 11111111, используется при итерации по таблице, чтобы остановиться на последней записи

  3. Удалённое значение - 11111110

  4. Заполненное значение - хранит младшие 7 бит хэша ключа, вернёмся к этому чуть позже

Думаю не секрет, что Long является 64 битным числом и было бы печально хранить в нём только 8 бит, поэтому была введена концепция группы:

Группа - набор из 8-ми битных состояний или слотов, как это принято в терминологии ScatterMap, в данном случае это число Long, которое может хранить 8 слотов, а это значит что размер группы равен 8.

Есть интересный момент насчет групп в исходном коде ScatterMap:

// размер группы, который мы уже вычислили
const val GroupWidth = 8

/* при чтении кода сложно было абстрагироваться от числа Long из-за 
битовой арифметики, поэтому этот typealias ни раз меня сбивал с толку */
typealias Group = Long

При последовательном проходе по массиву метаданных за одну итерацию обычно извлекается одна группа, то есть число типа Long, которое не всегда может быть получено по единому индексу из-за некоторого смещения, о котором речь пойдёт ниже.

Метод поиска индекса ключа

Для более глубокого понимания как работает ScatterMap рассмотрим основной метод для поиска индекса:

/* метод возвращает индекс ключа, по которому также можно получить значение, 
так как массивы keys и values сопоставимы по индексам */
inline fun findKeyIndex(key: K): Int {
    // хэш-функция будет рассмотрена ниже
    val hash = hash(key)
    /* получаем младшие 7 бит хэша, которые используются 
    для поиска нужного слота в массиве метаданных */
    val hash2 = h2(hash)

    /* в качестве маски для вычисления смещения будет использоваться 
    размер хэш-таблицы */
     val probeMask = _capacity
    // берём старшие 25 бита хэша и рассчитываем смещение на основе маски
    var probeOffset = h1(hash) and probeMask
    var probeIndex = 0

    while (true) {
        /* получаем группу по смещению, это не совсем тривиальный алгоритм, 
        так как смещение не всегда кратно числу 8, в таком случае часть байтов берётся из одного числа Long, 
        другая из другого, например для массива metadata, который состоит из Long0 и Long1 
        группа по смещению 1 будет содержать 7 старших байтов из Long0 и 1 младший байт из Long1 */
        val g = group(metadata, probeOffset)
        /* ищем в группе нужный слот (состояние записи ключ / значение), 
        ранее я уже приводил 4 возможных состояния и там было указано 
        что в случае заполненного значения слот должен содержать младшие 7 бит хэша ключа */
        var m = g.match(hash2)
        // если нужный слот был найден прыгаем в цикл
        while (m.hasNext()) {
            /* в отличии от HashMap, где в случае совпадения хэшей 
            у нескольких ключей создаётся связанный список, в ScatterMap для этого 
            был введён специальный алгоритм (quadratic probing), суть которого состоит в том, 
            что при совпадении хэшей вычисляется новый индекс умножением текущего на степень двойки, 
            если там уже есть значение вычисление будет продолжаться до тех пор пока не найдется пустое местечко */
            val index = (probeOffset + m.get()) and probeMask
            // реальное совпадение ключа означает равенство двух методов hashCode() и equals(), никогда не забывайте об этом
            if (keys[index] == key) {
                return index
            }
            m = m.next()
        }
        // если такого хэша нет в группе, значит ключ не был добавлен в хэш-таблицу
        if (g.maskEmpty() != 0L) {
            break
        }

        probeIndex += GroupWidth
        probeOffset = (probeOffset + probeIndex) and probeMask
    }

    return -1
}

Обобщая всё вышесказанное можно сказать, что основной механизм работы ScatterMap основан на хранении состояния (слота) записи ключ / значение в отдельном массиве metadata типа Long, доступ к которому основан на битовой арифметике, а для решения коллизий используется алгоритм quadratic probing, основанный на вычислении следующего свободного индекса в массиве.

Хэш-функция и итоги

Теперь рассмотрим хэш-функцию:

inline fun hash(k: Any?): Int {
    // константа MurmurHashC1 была добавлена для сокращения коллизий
    val hash = k.hashCode() * MurmurHashC1
    /* к старшим битам подмешиваются младшие для более 
    эффективной работы алгоритма quadratic probing */
    return hash xor (hash shl 16)
}

// константа была взята из MurmurHash алгоритма: 
// https://en.wikipedia.org/wiki/MurmurHash#Algorithm
const val MurmurHashC1: Int = 0xcc9e2d51.toInt()

Хочу добавить, если не проговорил это явно, все данные: ключи и значения лежат в двух массивах keys и values без какой-либо вложенности как это было в HashMap, где Map.Entry мог быть узлом связанного списка или дерева.

Итоговые замечания:

  1. Все остальные операции: вставка и удаление работают практически также, как и метод поиска индекса ключа с небольшой лишь разницей: происходит не только чтение массивов metadata, keys и values но и запись нового значения либо его зануление + при превышении размера хэш-таблицы размеры массивов увеличиваются.

  2. ScatterMap не сохраняет порядок добавления новых значений, как это делает LinkedHashMap например.

  3. forEach, forEachIndexed также как и в предыдущих коллекциях являются inline функциями и работают без создания итераторов.

Множества

Реализация практически такая же как у хэш-таблиц, только вместо двух массивов keys и values под капотом лежит один:

sealed class ScatterSet<E> {

    // вся битовая арифметика состояний (слотов) абсолютна такая же как у ScatterMap
    var metadata: LongArray = EmptyGroup

     // меняется только количество массивов, так как множеству не нужны ключи
    var elements: Array<Any?> = EMPTY_OBJECTS

}

Данная реализация также как и ScatterMap не сохраняет порядка при добавлении новых значений.

Придерживаясь канону построения структур данных для примитивных типов получаем следующие реализации:

// обобщённая реализация
scatterSetOf<String>()
mutableScatterSetOf<String>()

/* аналогично спискам и хэш-таблицам реализации 
для примитивных типов сделаны под копирку */
intSetOf()
mutableIntSetOf()

longSetOf()
mutableLongSetOf()

floatSetOf()
mutableFloatSetOf()

Другие коллекции

Кратко пробегусь по оставшимся коллекциям из androidx.collection библиотеки:

  1. SparseArrayCompat, LongSparseArray, SimpleArrayMap, ArraySet и LruCache - переписанные версии из android.util пакета на Kotlin возможно с некоторыми оптимизациями.

  2. CircularArray и CircularIntArray - динамические массивы с возможностью добавить значение в начало и в конец за O(1) время, реализация основана на двух индексах head и tail, которые перемещаются по массиву в противоположных направлениях: первый уменьшается, а второй увеличивается, при совпадении индексов массив удваивается.

Замеры

Сравнение коллекций с аналогами из Java мира по объёму используемой памяти в байтах:

вверхняя строка: количество элементов в коллекции

левая колонка: список коллекций

1000

10000

100000

1000000

ArrayList<String>

61016

616296

6026920

60862032

ObjectList<String>

61520

601600

6073408

61391976

ArrayList<Int>

20976

216256

2026880

20861992

IntList

5520

41600

473408

5391976

HashMap<String, String>

144192

1497536

15440576

152380608

ScatterMap<String, String>

122464

1259488

12371680

130866400

HashMap<String, Int>

104264

1097608

11440648

112380680

ObjectIntMap<String>

66536

699560

6771752

74866472

HashMap<Int, Int>

70224

703568

7446608

72386640

IntIntMap

18528

147552

1179744

18874464

HashSet<String>

96288

945632

9848672

96388704

ScatterSet<String>

66312

641992

6255432

66485832

HashSet<Int>

56288

545632

5848672

56388704

IntSet

10312

81992

655432

10485832

Несколько замечаний:

  1. ObjectList имеет начальную ёмкость 16 в отличии от ArrayList, у которого она составляет 10, поэтому при каждом увеличении списка ObjectList может занимать немного больше места, но проигрыша в этом нет, так этот параметр можно изменить в конструкторе.

  2. HashMap не так сильно проигрывает ScatterMap (в 1.2 - 1.3 раза) как я полагал, зато ScatterSet выигрывает HashSet практически в 1.5 раза!

  3. Коллекции для примитивных типов в 2-7 раз превосходят обобщённые варианты, что позволяет сократить приличный кусок памяти.

Заключение

Надеюсь статья была полезна для вас и мне удалось насколько возможно описать механизмы работы новых структур данных из библиотеки androidx.collection, пишите своё мнение в комментариях и всем хорошего кода!

Полезные ссылки:

  1. Мой телеграм канал

  2. Мой Github репозиторий по алгоритмам и структурам данных

  3. Описание и история релизов библиотеки

  4. Inline value classes

  5. Inline functions

  6. Autoboxing / unboxing механизм

  7. Kotlin Multiplatform

  8. Динамический массив

  9. Хэш-таблица

  10. Алгоритм quadratic probing

  11. Алгоритм MurmurHash

Комментарии (7)


  1. ytowka
    30.04.2024 10:04

    Спасибо автору за интересную статью! раньше даже не знал, что существует отдельная библиотека для оптимизированных коллекций. Возник вопроc: если ли смысл использовать эти коллекции повсеместно, где это возможно, и будет ли от этого ощутимая разница в производительности?


    1. DmitryTsyvtsyn Автор
      30.04.2024 10:04

      Если это классическая прилка в стиле "получить список данных из инета, пропустить его через всякие фильтры и тд, а затем отобразить" наверно особо нет, но для сложных алгоритмов где колоссальное количество коллекций есть: это сократит в десятки раз обьем используемой ОЗУ


      1. dyadyaSerezha
        30.04.2024 10:04

        Насчет десятков раз можно поподробнее? Что-то я сомневаюсь.


        1. DmitryTsyvtsyn Автор
          30.04.2024 10:04

          Сравнение простое:

          1) HashSet и HashMap создают минимум в два раза больше обьектов чем ScatterSet и ScatterMap, так как ключ и значение содержится в Map.Entry, а это дополнительный обьект

          2) HashMap<String, Float> создаст еще и обертку над примитивным типом в отличии от objectFloatMapOf()

          3) Отсюда следует, что при большом количестве коллекций, особенно если это примитивные, можно достигнуть сокращение памяти в десятки раз

          P.S. Согласен, что надо бы сделать замеры, чтобы наверняка быть уверенным, возможно добавлю...


          1. dyadyaSerezha
            30.04.2024 10:04

            Сами посчитайте ваш случай - я в упор не вижу десятков раз. Ну в 2-3 раза.


  1. dyadyaSerezha
    30.04.2024 10:04
    +1

    Интересно, но жаль, что нет тестов или просто выводов из других источников по памяти/времени - насколько эти коллекции лучше стандартных.


    1. DmitryTsyvtsyn Автор
      30.04.2024 10:04

      Насчет тестов хорошая идея, возможно сделаю...