Введение

При разработке Android-приложений скорее всего каждый сталкивался с такой ошибкой, как OutOfMemoryError.

Что же она означает?

Согласно официальной Android-документации:

java.lang.OutOfMemoryError генерируется, когда виртуальная машина Java (JVM) не может выделить объект из-за нехватки памяти, и сборщик мусора (Garbage Collector) больше не может освободить память для новых объектов.

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

Что такое утечка памяти?

Если рассматривать утечку памяти в контексте объектов, то утёкшими являются живущие в памяти, но никогда неиспользуемые объекты. При каждом выделении памяти для хранения данных, программа также обязана освободить эту память, когда она перестаёт быть необходимой. Если память не освобождается, она остаётся недоступной для других частей программы, что может привести к исчерпанию ресурсов, снижению производительности системы и, в конечном итоге, всё сводится к сбою приложения, чаще всего это Application Not Responding (ANR).

Цель этой статьи — изучить эффективные и комплексные решения  по нахождению и устранению утечек памяти в контексте Android-разработки. Стоит понимать, что утечка памяти чаще всего возникает из-за незнания технологии или собственного кода на подкапотном уровне, поэтому основной целью является научиться правильно писать код, учитывая специфику работы Java Memory Model, Garbage Collector и File descriptor.


Основная часть

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

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

  1. Короткое время использования (менее 10 минут). Примеры таких приложений — подсчёт шагов, конвертация валют и сканирование QR-кодов. Пользователь заходит в подобные приложения для конкретной цели. В этом случае не так важно следить за утечками памяти, потому что мелкие утечки не сильно влияют на работу приложения, а более крупные можно легко обнаружить;

  2. Длительное время использования (более 10 минут). К таким приложениям относятся видеоплатформы, музыкальные стриминговые сервисы и мессенджеры, которыми пользователь может пользоваться часами. В этом случае необходимо следить за утечками памяти и обеспечивать стабильную работу приложения. Даже небольшие утечки памяти в течение 30-минутной сессии могут негативно сказаться на работе приложения.

Как уже упоминалось ранее, для того, чтобы не только устранять, но и избегать утечек памяти, необходимо понимать основные принципы работы Java Memory Model (JMM), Garbage Collector (GC) и File descriptor (FD).

Составим классификацию и разделим утечки памяти на виды с учётом места их возникновения:

  1. Однопоточные;

  2. Многопоточные;

  3. Файловые дескрипторы.

Однопоточные утечки памяти

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

Что такое GC?

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

Почему важно понимать работу GC?

Чем больший объём памяти занимают утекаемые объекты в приложении, тем чаще Android-система будет вызывать сборку мусора, что является довольно трудоёмким процессом и соответственно тормозит работу приложения (особенно это актуально для старых Android версий), подробнее можно почитать здесь.

Какие знания о GC помогут в нахождении и устранении утечек памяти?

Современные Android GC строят структуру Heap на основе системы поколений:

Структура Heap с точки зрения GC
Структура Heap с точки зрения GC

Есть 3 вида поколений: Young, Old, Permanent. Рассмотрим подробнее первые два:

  • Young Generation содержит новые и короткоживущие объекты. Minor GC происходит в этой области, освобождая память от объектов, которые быстро становятся недостижимыми, и создавая место для новых объектов.

  • Old Generation содержит старые объекты с более длительным жизненным циклом. Major GC происходит в этой области, освобождая память от устаревших или больших объектов, которые больше не используются.

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

Утечки памяти происходят, когда вы сохраняете ссылку на объект, который существует недолго, в объекте, который существует дольше (стоит отметить, что это правило применимо ко всем языкам программирования со сборщиком мусора).

Иначе говоря, когда в Old Generation объектах сохраняются ссылки на объекты из Young Generation, то с большой вероятностью может возникнуть утечка.

Помимо знаний о работе поколений, также стоить учитывать достижимость объекта (Object Reachability).

Но перед этим рассмотрим, что такое Root-объекты? Это объекты, которые в основном хранят в памяти другие объекты.

Критерий Object Reachability позволяет GC определить, подлежит ли объект сборке мусора. Для определения достижимости объекта, GC придерживается следующей схемы:

Object Reachability
Object Reachability

В этой блок-схеме можно увидеть, что объекты, которые недостижимы (отмечены красным цветом), подлежат сборке мусора.

Многопоточные утечки памяти

Помимо однопоточных, существуют и многопоточные утечки памяти, которые по моим наблюдениям не просто устранить, поэтому подойдем к решению фундаментально. Рассмотрим Java Memory Model (JMM).

JMM позволяет:

  • выполнять различные оптимизации компилятором, JVM или процессором;

  • быть неким компромиссом между возможными оптимизациями и строгостью исполнения кода;

  • строго закрепить условия, при которых программа считается правильно синхронизированной, и закрепить поведение правильно синхронизированных программ;

  • описывать отношение между написанным кодом и самой памятью.

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

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

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

Для борьбы с многопоточными утечками памяти есть несколько советов по тому, как правильно загружать и выгружать объекты:

  1. Понимать и уметь применять на практике атомарные операции. К примеру использовать Atomic-классы из пакета java.util.concurrent.atomic;

  2. Придерживаться happens-before подхода. К примеру использовать в критических секциях примитивы синхронизации volatile, synchronized и ReentrantLock, либо в контексте корутинMutex;

  3. Придерживаться Safe Publication. К примеру использовать Semaphore. Картинка ниже раскрывает суть данного подхода.

    Safe Publication
    Safe Publication

В большинстве ситуаций предпочтительным вариантом является безопасная публикация, которая обеспечивает видимость всех внесённых изменений. Только в немногих случаях вам потребуется использовать порядок синхронизации (synchronization order) для связывания всех действий. В любом случае, оба подхода гарантируют отсутствие гонок (race condition) и последовательно согласованные (sequentially consistent) результаты.

Утечки файловых дескрипторов

И наконец последнее, но не по значению, вид утечек при работе с файловыми дескрипторами. На мой субъективный взгляд, утечки памяти, связанные с FileDescriptor (FD) являются недооценёнными в мире Android-разработки, поэтому рассмотрим их с практической точки зрения.

Что такое файловый дескриптор?

Это абстрактный индикатор, используемый для доступа к файлу или другому ресурсу ввода/вывода (I/O). Любое I/O соединение проходит через FileDescriptor объект.

Какую роль играют файловые дескрипторы в контексте Android?

В Android все I/O операции, такие как database/network подключения, Resources, и даже Handler/Looper открывают FileDescriptor и держат его открытым до тех пор, пока это соединение не будет явно или неявно закрыто. Как раз в этом процессе может возникнуть утечка памяти.

Как возникает утечка памяти при работе с файловыми дескрипторами?

Давайте рассмотрим приложения, которые мы разрабатываем, с точки зрения процессов. Каждое приложение является отдельным процессом. Приложение может порождать несколько процессов, но они также изолированы, то есть 2 процесса, порождённые одним и тем же приложением, должны использовать межпроцессное взаимодействие (IPC) для связи друг с другом.

Для одного процесса Android позволяет открывать максимум 1024 FD. Некоторые устройства могут даже понизить этот порог до 512 (либо ещё меньше, всё зависит от вендорских прошивок). Любой процесс, превышающий этот предел, немедленно уничтожается с помощью:

java.lang.RuntimeException: Too many open files

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

За весь мой опыт в разработке Android-приложений, я не встречал такие случаи, когда нахождение и устранение утечек файловых дескрипторов происходило так быстро, как этого хотелось бы. Часто разработчики, включая меня, из-за неопытности в определённой технологии могут не осознавать подобные "тяжёлые" утечки памяти. Поэтому рекомендуется активно бороться с ними на этапе разработки, чтобы избежать проблем и ошибок в продакшене.

Так как же с практической точки зрения решить проблему?

Самый главный совет — всегда закрывать любой FileDescriptor, сразу же после его эксплуатации.

Что имеется в виду:

  • всегда отключайте объект HttpURLConnection после выполнения сетевых вызовов;

  • закрывайте WebSockets после использования;

  • используйте ограниченное количество Handler/Looper. Каждый вызов Looper.prepare() открывает FileDescriptor;

  • убедитесь, что соединения SQLiteDatabaseHelper являются Singleton, т.к. каждый новый объект будет содержать открытый FD для базового файла sqlite db;

  • используйте extension-функцию use, дабы обеспечить закрытие потока данных после использования.

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

  1. Если для приложения написаны тест-кейсы, то пройтись по ним, либо воспроизвести основные флоу приложения, дабы проверить количество открытых FD;

  2. После воспроизведения сценариев приложения выполнить терминальную команду для просмотра количества открытых FileDescriptor:
    adb shell su ls -l /proc/${APP_PID}/fd | wc -l
    Вместо APP_PID указываем идентификатор процесса приложения;

  3. Если счётчик насчитывает недопустимое количество открытых FD для процесса (к примеру 200 FD), то скорее всего в кодовой базе есть утечки файловых дескрипторов.


Заключение

Утечки памяти в приложении можно сравнить с болезнью, которая замедляет его работу и потребляет важные ресурсы памяти. Если мы не устраним эту проблему, то она может привести к серьезным сбоям и ошибкам в работе приложения.

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

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


  1. Rusrst
    21.07.2023 12:13

    Да у самого андроида есть утечки внутри - тот же тост или soundpool захватывают ссылки на контекст внутри.

    Looper.prepare() это сильно, я не сразу вспомнил что оно в Handler Tread использовалось при переопределении.

    Как совет более полезны были бы leek canary, meminfo и mem dump profiler для поиска. Знать про gc при этом много не надо.


    1. yerdaulet_nurkeyev Автор
      21.07.2023 12:13

      Добрый день. Данная статья запланирована как первая часть из двух, и поэтому в ней рассмотрена в основном теоретическая составляющая большой темы. Я посчитал написать о GC, JMM и FD, т.к. при поиске и устранении утечек памяти в проектах различных уровней сложности мне потребовалось углубиться в данные темы для лучшего понимания того кода, который мы ежедневно пишем в своих проектах. Для более подробного изучения тех терминов, которые в статье не раскрыты, я приложил ссылки, которые по сути являются документацией и с научной точки зрения рассмотрены до меня.

      В следующей статье запланировано рассказать и на практике показать работу с leak canary, android studio profiler и т.п.

      В любом случае, спасибо за фидбек и рад, что Вы для себя нашли что-то новое!