Connect with us

Разработка

Одна строчка, которая пожирала нашу память

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

Опубликовано

/

     
     

Недавно в проект JetBrains Jewel поступило сообщение о предполагаемой утечке памяти в компоненте LazyTree. К счастью, автор приложил минимальный воспроизводимый пример, который позволил нам исследовать это странное поведение.

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

Для начала разберёмся, что такое утечка памяти и что с ней можно сделать.

Контекст

В информатике утечкой памяти называют разновидность утечки ресурсов, которая возникает, когда программа неправильно управляет выделенной памятью и не освобождает участки, которые ей больше не нужны, — Википедия.

Поскольку Jewel — это библиотека Compose Multiplatform, предназначенная для десктопных приложений, нам нужны инструменты, рассчитанные на JVM.

Для поиска утечек памяти можно применять разные методы: создавать снимки кучи JVM, анализировать подробные логи сборщика мусора, наблюдать за приложением во время выполнения и сравнивать результаты нескольких запусков. Подробнее об этих подходах можно прочитать в статье Элефтерии, посвящённой этой теме.

Для анализа доступно множество профилировщиков. У JetBrains есть собственный профилировщик, встроенный в среду разработки, однако он доступен только подписчикам Ultimate. При этом существуют и бесплатные альтернативы.

Для исследования этой утечки я использовал проверенный временем инструмент Memory Analyzer Tool от Eclipse. Он не настолько прост в использовании, как профилировщик JetBrains, но успешно справляется с задачей.

Memory Analyzer Tool предоставляет мощный набор возможностей для анализа снимков кучи. Самой полезной для нашего случая является проверка объёма удерживаемой памяти для каждого класса.

Вызов

Воспроизводимый пример выглядел примерно так:

data class TestTreeNode(
    val foo: String,
    val bar: Int,
)

fun rebuildTree(elems: List<TestTreeNode>) =
    buildTree {
        elems.forEach {
            addNode(TestTreeNode("foo${it.foo}", it.bar), it)
        }
    }

fun expandElems(elems: List<TestTreeNode>): List<TestTreeNode> {
    val lastBar = elems.lastOrNull()?.bar ?: 1
    var newElems = listOf<TestTreeNode>()

    repeat(1024) {
        newElems += TestTreeNode(
            bar = lastBar + it,
            foo = "foo${lastBar + it}",
        )
    }

    if (elems.size > 1024) {
        newElems = newElems.drop(elems.size - 1024)
    }

    return newElems
}

@OptIn(ExperimentalJewelApi::class)
@Composable
internal fun TestTree() {
    Column(modifier = Modifier.fillMaxSize()) {
        var tree by remember { mutableStateOf(buildTree<TestTreeNode> { }) }
        val treeState = rememberTreeState()
        var elems by remember { mutableStateOf(listOf<TestTreeNode>()) }

        Column {
            OutlinedButton(
                onClick = {
                    elems = expandElems(elems)
                    tree = rebuildTree(elems)
                },
            ) {
                Text("Add many items")
            }

            LazyTree(
                tree = tree,
                treeState = treeState,
            ) {
                Row {
                    Text("bar ${it.data.bar}; foo ${it.data.foo}")
                }
            }
        }
    }
}

Каждый раз, когда мы нажимали кнопку «Add many items», код добавлял в LazyTree 1024 новых элемента и одновременно удалял из дерева последние 1024 элемента. Это означало, что в дереве никогда не должно было находиться больше 1024 элементов, поэтому объём выделенной памяти должен был оставаться примерно одинаковым независимо от того, сколько раз мы «добавляли» новые элементы. Код явно очищал список элементов как из дерева компонентов Compose, так и из изменяемого списка, в котором они изначально хранились.

После запуска воспроизводимого примера и примерно семи нажатий на кнопку «Add many items» настало время проверить, что происходит на самом деле. В полученном отчёте говорилось, что приложение продолжает ссылаться на старые данные, поэтому самым прямым подходом было проверить, нет ли у нас подозрительно большого количества объектов TestTreeNode или элементов LazyTree.

Доказательства

Сначала необходимо создать снимок кучи. К счастью, в состав JDK входит удобный инструмент jcmd, который умеет это делать. Однако сначала нужно узнать идентификатор процесса приложения, чтобы jcmd мог обратиться к нему и сохранить снимок кучи. Интересно, что при запуске jcmd без аргументов он сам выводит список всех процессов JVM, поэтому отдельная команда или дополнительный инструмент для поиска идентификатора процесса не нужны.

После получения идентификатора процесса достаточно выполнить:

jcmd <PID> GC.heap_dump <PATH>

Готово — снимок кучи будет сохранён по пути <PATH>. При использовании команды GC.heap_dump сборщик мусора запускается непосредственно перед созданием снимка. Поэтому можно быть уверенным, что все оставшиеся в куче объекты находятся там потому, что сборщик мусора не смог их удалить.

Теперь остаётся открыть Memory Analyzer Tool и загрузить в него файл снимка.

Факты

Как я уже говорил, сначала нужно проверить, нет ли подозрительно больших значений, связанных с нашим кодом. Изучив список классов и занимаемую ими память на вкладке Histogram, мы обнаружили следующее:

Одна строчка, которая пожирала нашу память

Имя класса Объекты Собственная память, байты Удерживаемая память, байты
TestTreeNode 14 336 344 064 ≥ 1 088 904
Tree$Element$Node 7 168 344 064 ≥ 1 194 256

Уже сами по себе эти цифры поражают. Для списка, в котором должно находиться всего 1024 элемента, удерживается чуть больше одного мегабайта объектов. Кроме того, в памяти находится более семи тысяч объектов дерева, хотя их количество должно было остановиться на отметке 1024. Здесь явно что-то не так, но давайте разберёмся глубже.

Кстати, разница между собственной и удерживаемой памятью заключается в следующем. Собственная память показывает объём памяти, занимаемый самим объектом отдельно от всего остального. Удерживаемая память включает собственную память объекта и собственную память всех остальных объектов, которые были бы удалены сборщиком мусора, если бы этот конкретный объект перестал существовать.

Когда нужно понять, почему сборщик мусора не может освободить больше не используемую память, в Memory Analyzer Tool можно воспользоваться функцией Path to GC Roots. Она показывает, какие объекты в цепочке удерживают наибольший объём памяти. При этом не забудьте исключить слабые и мягкие ссылки, поскольку они не препятствуют сборке мусора.

Если посмотреть путь до корня сборщика мусора для объекта Tree$Element$Node, можно увидеть следующую цепочку:

Одна строчка, которая пожирала нашу память

Имя класса Удерживаемая память, байты
…jewel.foundation.lazy.tree.Tree$Element$Node 168
(arg$2) …jewel.foundation.lazy.tree.BasicLazyTreeKt$Lambda 24
(onSelectedIndexesChange) …jewel.foundation.lazy.tree.SelectableLazyColumnKt$notifyingPointerEventActions 63 000
(layoutNodeLayoutDelegate) androidx.compose.ui.node.MeasurePassDelegate 21 240
(measurePassDelegate) androidx.compose.ui.node.LayoutNodeLayoutDelegate 21 696
(layoutDelegate) androidx.compose.ui.node.LayoutNode 22 624

Не пугайтесь количества классов в цепочке. Как видно, два подозрительных объекта здесь — onSelectedIndexesChange из Jewel и layoutDelegate из Compose вместе с его родительскими объектами.

Внутри происходит следующее:

  1. RootNodeOwner — корень дерева интерфейса Compose
  2. … удерживает LayoutNode — универсальный элемент интерфейса Compose
  3. … который захватывает лямбду BasicLazyTreeKt$Lambda
  4. … которая ссылается на Tree$Element$Node

Можно исключить проблему на стороне Compose, поскольку такой объём удерживаемой памяти у его классов является ожидаемым. Каждый элемент интерфейса Compose — от Text до Row — внутри представлен объектом LayoutNode. Эти объекты выполняют значительный объём работы: рассчитывают точные координаты X и Y для отрисовки, удерживают дочерние элементы интерфейса и всю цепочку модификаторов.

Странность заключается в том, что onSelectedIndexesChange удерживает 63 килобайта. Именно здесь заканчивается обычная работа Compose и начинается наш код. Наша лямбда BasicLazyTree незаметно удерживает огромный объём памяти. Это наблюдение можно сопоставить с тем, что в памяти остаётся чуть больше семи тысяч объектов Tree$Element$Node.

Это означает, что Compose не может повторно использовать соответствующие объекты LayoutNode. Иными словами, LazyColumn в Compose фактически сохраняет каждый отдельный элемент дерева как уникальный макет интерфейса в своём пуле повторного использования. Из-за этого механизм повторного использования перестаёт приносить какую-либо пользу.

Почему?

Виновник

Теперь можно начать выяснять, что именно приводит к росту количества узлов в нашем дереве:

@Composable
public fun <T> BasicLazyTree(
    tree: Tree<T>,
    treeState: TreeState = rememberTreeState(),
    // ... a bunch of parameters
) {
    val flattenedTree: List<Tree.Element<T>> = remember(tree, treeState.openNodes, treeState.allNodes) {
        tree.roots.flatMap { it.flattenTree(treeState) }
    }
    
    SelectableLazyColumn(
        // ... other parameters
        onSelectedIndexesChange = { 
            onSelectionChange(
                it.map { element -> flattenedTree[element] as Tree.Element<T> }
            )
         },
    ) {
        itemsIndexed(
            items = flattenedTree, key = { _, item -> item.id }, contentType = { _, item -> item.data },
        ) { index, element ->
            // Tree node layout
        }
    }
}

Причину утечки памяти, вероятно, можно заметить уже по приведённому выше фрагменту кода, хотя она не так очевидна, как может показаться.

Настоящая проблема находится здесь: contentType = { _, item -> item.data }

В документации Compose для функции items параметр contentType описывается так:

Фабрика типов содержимого для элемента. Компоновки элементов одного типа могут использоваться повторно более эффективно. Значение null также является допустимым типом, и элементы с таким типом считаются совместимыми.

По сути, Compose использует contentType как ключ в своей системе кеширования.

В нашем воспроизводимом примере каждый элемент содержит уникальную строку:

newElems += TestTreeNode(
    bar = lastBar + it,
    foo = "foo${lastBar + it}", // <--- here!
)

Неоспоримое доказательство

Передавая item.data в качестве contentType, мы назначали каждому элементу полностью уникальный тип содержимого. Из-за этого LazyColumn создавала новое хранилище повторного использования для каждого когда-либо удалённого элемента. В каждом таком хранилище Compose кешировал ровно один узел. Поскольку со временем появлялись сотни тысяч уникальных хранилищ, Compose продолжал удерживать каждый старый объект Tree.Element бесконечно. Вот это да.

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

contentType = { _, item -> item.data?.let { it::class } }

Соучастник

Отлично. Теперь понятно, почему Compose удерживал огромное количество узлов дерева. Но как это объясняет тот факт, что лямбда onSelectedIndexesChange удерживала 63 КБ памяти?

Рассмотрим её подробнее:

onSelectedIndexesChange = { 
    onSelectionChange(
        it.map { element -> flattenedTree[element] as Tree.Element<T> }
    )
}

Эта лямбда неявно захватывает переменную flattenedTree из внешнего скоупа. flattenedTree — это список List<Tree.Element<T>>, содержащий снимок всего раскрытого дерева. В JVM объект ArrayList, который хранит около 7000 ссылок на объекты, занимает примерно 60–65 КБ. В 64-разрядной JVM один указатель занимает 8 байт. Вот и ответ: те самые 63 КБ — это весь плоский список узлов.

Каждый раз при нажатии кнопки «Add many items» запускалась пугающая цепная реакция:

  1. Наш код создавал новый список flattenedTree размером около 63 КБ.
  2. Затем создавалась лямбда, захватывающая именно этот список.
  3. Compose создавал новые объекты LayoutNode для экрана и прикреплял к ним лямбду через модификатор нажатия.
  4. Старые элементы уходили за пределы экрана.
  5. Из-за ошибки в contentType их старые объекты LayoutNode помещались в фактически бесконечный кеш повторного использования и никогда не уничтожались.
  6. Поскольку старые узлы интерфейса продолжали существовать, вместе с ними сохранялись старые модификаторы.
  7. Модификаторы удерживали старые лямбды.
  8. Старые лямбды навсегда удерживали в памяти предыдущие снимки flattenedTree размером по 63 КБ каждый.

Исправление contentType решает и эту проблему. Теперь, когда старые элементы уходят за пределы экрана, Compose удаляет прежние объекты LayoutNode вместе с их модификаторами. После этого сборщик мусора наконец может удалить старые лямбды и удерживаемые ими списки flattenedTree.

Вердикт

Простая замена значения contentType на класс данных устранила утечку. Теперь Compose может ограничить количество хранилищ повторного использования двумя (Tree.Element.Node::class и Tree.Element.Leaf::class). Это единственные два типа Tree.Element<T>, используемые в нашем файле Tree.kt. Compose теперь повторно использует старый макет узла только для нового узла, а старый макет листа — только для нового листа.

После применения исправления мы снова создали снимок кучи и проверили вкладку Histogram.

Одна строчка, которая пожирала нашу память

Имя класса Объекты Собственная память, байты Удерживаемая память, байты
TestTreeNode 10 240 245 760 ≥ 753 824
Tree$Element$Node 2048 98 304 ≥ 344 272

Сравнение показателей до и после исправления:

Имя класса Удерживаемая память до Удерживаемая память после Сокращение
TestTreeNode ≈ 1 088 904 ≈ 753 824 ≈ 31%
Tree$Element$Node ≈ 1 194 256 ≈ 344 272 ≈ 71%

Таким образом, потребление памяти деревом удалось сократить примерно на 71%, что является значительным улучшением производительности. Больше нет переполненного хранилища повторного использования. В памяти остаётся ровно столько элементов, сколько необходимо Compose для плавной отрисовки списка.

Если проверить кратчайший путь до корня сборщика мусора, можно увидеть следующее.

Одна строчка, которая пожирала нашу память

Обратите внимание, что в цепочке больше нет ссылки на BasicLazyTree. Теперь кратчайший путь до корня проходит через состояние Compose — SlotTable.

Ещё одно отличие заключается в появлении SelectableLazyListScopeKt$$Lambda. Это лямбда, создаваемая блоком itemsIndexed внутри предметно-ориентированного языка SelectableLazyListScopeSlotTable удерживает эту лямбду, потому что она нужна для построения структуры списка. Но теперь сохраняется только активное состояние, а не тысячи устаревших копий.

Что касается сокращения удерживаемой памяти TestTreeNode примерно на 31%, мы внесли ещё несколько изменений, которые в итоге позволили уменьшить соответствующие расходы примерно на 71%. Некоторые участки кода создавали временные объекты ArrayList при каждой итерации flattenTree(). Кроме того, в функции получения всех дочерних узлов мы заменили filterIsInstance на forEach, благодаря чему функция перестала создавать дополнительные объекты.

Дело закрыто

Утечки памяти могут быть очень незаметными, а их поиск иногда кажется пугающе сложным. Но не стоит их бояться. Да, сначала непросто сопоставить данные гистограммы или кратчайший путь до корня сборщика мусора с кодом, который вы написали. Однако после того, как вы поймёте принцип, анализ помогает не только устранить конкретную проблему, но и обнаружить другие, менее очевидные недостатки.

Эта история служит важным напоминанием:

Если contentType формируется из значения, уникального для каждого элемента на протяжении всего срока жизни списка, он засорит хранилище повторного использования Compose.

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

И это хороший вывод:

Анализ кучи нужен не только для подтверждения уже предполагаемой ошибки. Его главная ценность — в обнаружении проблем, о которых вы даже не знали.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: