Компания
199,71
рейтинг
20 октября 2014 в 16:09

Разработка → Борьба с утечками памяти в Android. Часть 1

Этой статьей мы открываем цикл статей на Хабре о нашей разработке под Android.
Согласно докладу компании Crittercism от 2012 года, OutOfMemoryError — вторая по распространенности причина «крашей» мобильных приложений.
Честно говоря, и в Badoo эта ошибка была в топе всех крашей (что неудивительно при том объеме фотографий, которые просматривают наши пользователи). Борьба с OutOfMemory — занятие кропотливое. Мы взяли в руки Allocation Tracker и начали играться с приложением. Наблюдая за данными зарезервированной памяти, мы выявили несколько сценариев, при которых выделение памяти росло с подозрительной стремительностью, забывая при этом уменьшаться. Вооружившись несколькими дампами памяти после этих сценариев, мы проанализировали их в MAT (http://www.eclipse.org/mat/).
Результат был занимательный и позволил нам в течение нескольких недель снизить количество крашей в разы. Что-то было специфично для нашего кода, но также выявились типичные проблемы, присущие большинству Android приложений.
Сегодня поговорим о конкретном случае утечки памяти. О нем многие знают, но часто закрывают на это глаза (а зря).

Речь пойдет об утечках памяти, связанных с неправильным использованием android.os.Handler. Не совсем очевидно, но все, что вы помещаете в Handler, находится в памяти и не может быть очищено сборщиком мусора в течении некоторого времени. Иногда довольно длительного.
Чуть позже мы покажем на примерах, что происходит и почему память не может быть освобождена. Если вы не любопытный, но хотите знать, как бороться с проблемой, то перейдите к выводам в конце статьи. Или сразу отправляйтесь на страничку маленькой библиотеки, которую мы выложили в открытый доступ: https://github.com/badoo/android-weak-handler.

Итак, что же там «течет»? Давайте разберемся.

Простой пример




Это очень простой класс Activity. Предположим, что нам нужно поменять текст по прошествии 800 секунд. Пример, конечно, нелепый, но зато хорошо продемонстрирует нам, как текут ручьи нашей памяти.
Обратите внимание на анонимный Runnable, который мы постим в Handler. Так же важно обратить внимание на длительный тайм-аут.
Для теста мы запустили этот пример и повернули телефон 7 раз, тем самым вызвав смену ориентации экрана и пересоздание Activity. Затем сняли дамп памяти и открыли его в MAT (http://www.eclipse.org/mat/).

С помощью OQL запускаем простой запрос, который выводит все инстансы класса Activity:

select * from instanceof android.app.Activity

Мы очень рекомендуем почитать про OQL — он ощутимо поможет вам в анализе памяти.
Почитать можно тут visualvm.java.net/oqlhelp.html или тут help.eclipse.org/luna/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Freference%2Foqlsyntax.html.



В памяти висит 7 инстансов Activity. Это в 7 раз больше, чем нужно. Давайте разберемся, почему сборщик мусора не смог удалить отработавшие объекты из памяти. Откроем кратчайший граф ссылок на один из Activity:



На скриншоте видно, что на Activity ссылается this$0. Это неявная ссылка из анонимного класса на внешний класс. В Java любой анонимный класс всегда имеет неявную ссылку на внешний класс, даже если вы никогда не обращаетесь к внешним полям или методам. Java не идеальна, а жизнь — это боль. Такие дела, котаны.

Далее, ссылка на this$0 хранится в callback, который хранится в связанном списке сообщений. В конце цепочки — локальная ссылка в стеке главного потока. По всей видимости, это локальная переменная в главном цикле UI потока, которая освободится, когда цикл отработает. В нашем случае это произойдет после того, как приложение завершит свою работу.

Итак, после того как мы поместили Runnable или Message в Handler, он будет хранится в списке сообщений в LooperThread до тех пор, пока сообщение не отработает. Вполне очевидно, что если мы поместим отложенное сообщение, то оно будет лежать в памяти до тех пор, пока не настанет его время. Вместе с сообщением в памяти будут лежать все объекты, на которые ссылается сообщение, явно и неявно.
И с этим нужно что-то делать.

Решение с использованием статического класса


Давайте попробуем решить нашу проблему, избавившись от ссылки this$0. Для этого переделаем анонимный класс в статический:



Запускаем, пару раз поворачиваем телефон и собираем дамп памяти.



Снова больше одной Activity? Давайте посмотрим, почему сборщик мусора не смог их удалить.



Обратите внимание на самый низ графа ссылок: Activity сохранен в ссылке mContext из mTextView внутри класса DoneRunnable. Очевидно, что использование статического класса самого по себе недостаточно, чтобы избежать утечки памяти. Нам нужно сделать кое-что еще.

Решение с использованием статического класса и WeakReference


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



Обратите внимание, что мы сохраняем ссылку на TextView в WeakReference. Использование WeakReference требует особой аккуратности: такая ссылка в любой момент может обнулиться. Поэтому сначала сохраняем ссылку в локальную переменную и работаем только с последней, проверив ее на null.

Запускаем, поворачиваем и собираем дамп памяти.



Мы добились желаемого! Только один Activity в памяти. Проблема решена.

Для использования данного подхода нам необходимо:

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

Хорош ли данный метод?
Если сравнивать оригинальный код и «безопасный» код, то в глаза бросается большое количество «шума». Он отвлекает от понимания кода и усложняет его поддержку. Написание такого кода — то еще удовольствие, не говоря уж о том, что можно что-то забыть или забить.

Хорошо, что есть решения получше.

Очистка всех сообщений в onDestroy


У класса Handler есть занимательный и очень полезный метод — removeCallbacksAndMessages, который принимает null в качестве аргумента. Он удаляет все сообщения, находящиеся в очереди данного Handler'а. Давайте используем его в onDestroy.



Запустим, повернем и снимем дамп памяти.



Прекрасно! Только один класс Activity.

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

У нас в запасе есть еще один метод, который, возможно, понравится вам намного больше.

Решение с использованием WeakHandler



Команда Badoo написала свой Handler — WeakHandler. Это класс, который ведет себя совершенно как Handler, но исключает утечки памяти.

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



Очень похоже на оригинальный код, не так ли? Лишь одна маленькая деталь: вместо использования android.os.Handler мы использовали WeakHandler. Давайте запустим, повернем телефон несколько раз и снимем дамп памяти.



Наконец-то! Код чист как слеза и память не течет.

Если вам понравился этот метод, то вот хорошая новость: использовать WeakHandler очень просто.

Добавьте maven-зависимость в ваш проект:
repositories {
    maven {
        repositories {
            url 'https://oss.sonatype.org/content/repositories/releases/'
        }
    }
}

dependencies {
    compile 'com.badoo.mobile:android-weak-handler:1.0'
}


Импортируйте WeakHandler в вашем коде:

import com.badoo.mobile.util.WeakHandler

Исходный код выложен на github: github.com/badoo/android-weak-handler.

Принцип работы WeakHandler


Главная идея — держать жесткую ссылку на сообщения или Runnable до тех пор, пока существует жесткая ссылка на WeakHandler. Как только WeakHandler может быть удален из памяти, все остальное должно быть удалено вместе с ним.

Для простоты объяснения мы покажем простенькую диаграмму, демонстрирующую разницу между помещением анонимного Runnable в простой Handler и в WeakHandler:



Обратите внимание на верхнюю диаграмму: Activity ссылается на Handler, который постит Runnable (помещает его в очередь сообщений, на которые ссылается Thread). Все неплохо, за исключением неявной обратной ссылки из Runnable на Activity. Пока Message лежит в очереди, которая живет, пока жив Thread, весь граф не может быть собран сборщиком мусора. В том числе и толстая Activity.

В нижней диаграмме Activity ссылается на WeakHandler, который держит Handler внутри. Когда мы просим его поместить Runnable, он заворачивает его в WeakRunnable и постит в очередь. Таким образом, очередь сообщений ссылается только на WeakRunnable. WeakRunnable содержит WeakReference на изначальный Runnable, т.е. сборщик мусора может его очистить в любой момент. Что бы он его не очистил раньше времени, WeakHandler держит жесткую ссылку на Runnable. Но как только сам WeakHandler может быть удален, Runnable так же может быть удален.

Нужно быть аккуратным и не забывать, что на WeakHandler должна быть ссылка извне, иначе все сообщения будут очищены вместе с ним сборщиком мусора.

Выводы


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

  • использовать статический внутренний класс Runnable/Handler с WeakReferences на внешний класс;
  • чистить все сообщения в классе Handler из метода onDestroy;
  • использовать WeakHandler от Badoo (https://github.com/badoo/android-weak-handler).

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

Удачной вам борьбы! Оставайтесь с нами — у этой темы будет продолжение.

Статья в нашем англоязычном блоге: bit.ly/AndroidHandlerMemoryLeaks

Дмитрий Воронкевич, ведущий разработчик
Автор: @mutable
Badoo
рейтинг 199,71

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

  • 0
    del
  • +1
    По этой теме помню был доклад на Yac. tech.yandex.ru/events/yac/2012/talks/398/
    За WeakHandler спасибо.
  • +7
    Про OQL наверное мало кто знает.
    Всегда приятно читать эдакие поиски истины.
    Но ещё приятнее их находить. Это называется опыт.
    Делитесь с нами своим опытом ещё!
  • +2
    Интересно, но я так и не понял, чем же плох вариант removeCallbacksAndMessages(null)?
    • +3
      Вариант с removeCallbacksAndMessages(null) очень даже хорош. Но программируя на языке со сборщиком мусора хотелось бы меньше уделять внимания таким мелочам как освобождение памяти.
      Дело в предпочтениях.
  • 0
    Еще одна причина ваших утечек памяти — onDestroy. Вызов этого метода не гарантируется и там не стоит освобождать критические ресурсы и отсоединять листенеры. Последний гарантированный метод, который вызовется при уничтожении activity это onPause.
    • +7
      В общем случае система не может гарантировать вызов onDestroy потому что она может убить весь процесс целиком в случае нехватки памяти или большой загруженности.
      В таком случае все критические ресурсы закроет ядро линукса. А листенеры и проч. уже не будут иметь значения, ведь они жили в памяти процесса и вместе с его смертью исчезли.

      Если вы знаете о других случаях, когда onDestroy не вызывается, пожалуйста сообщите.
      • НЛО прилетело и опубликовало эту надпись здесь
      • 0
        Удалено, фигню сказал, не подумав.
      • 0
        Из документации:
        When your activity receives a call to the onStop() method, it's no longer visible and should release almost all resources that aren't needed while the user is not using it. Once your activity is stopped, the system might destroy the instance if it needs to recover system memory. In extreme cases, the system might simply kill your app process without calling the activity's final onDestroy() callback, so it's important you use onStop() to release resources that might leak memory.

        Не всегда уничтожается процесс при нехватке памяти, могут просто освобождаться activity
        • 0
          Только и postDelayed() надо запускать в onStart(), потому что onCreate() после onStop() может и не вызваться.
        • 0
          In extreme cases, the system might simply kill your app process without calling the activity's final onDestroy() callback


          Другого пути «освободить activity» без вызова onDestroy, насколько мне всегда казалось, не существует.
          • +1
            И всё-таки ресурсы настоятельно рекомендуют освобождать именно в onStop(). Вообще, этому следовало бы уделить больше внимания в документации, расписать подробнее, а то как-то по крупицам собирать приходится.
            • 0
              Ресурсы действительно лучше освобождать как можно раньше. Если был вызван onStop, значит Activity не видна на экране, и незачем занимать память/диск/сеть.
  • 0
    А может кто-нить об'яснить зачем при перевороте каждый раз создаётся новая Activity?
    • 0
      Если я не ошибаюсь, то для того, чтобы можно было другой layout подсунуть для landscape ориентации. В добавок к этому можно ещё как-нибудь обработать смену ориентации. Также можно запретить вызов onCreate для активити при различных сменах конфигурации в манифесте.
      • 0
        Ну так и дали б событие на смену ориентации, а там уж реализуй как хочешь: подсовывай другой layout, создавая новый или используя имеющийся (например, с предыдущего переворота) или вообще ничего не делай;
        А то непонятны эти свистопляски с постоянным созданием нового, будто перезапустил приложение, но с другим разрешением экрана;
        • 0
          Так пропишите в манифесте android:configChanges=«orientation|screenSize» и ловите onConfigurationChanged()

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

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

Самое читаемое Разработка