Что такое утечки памяти в android, как проверить программу на их отсутствие и как предотвратить их появление

в 13:10, , рубрики: android, memory management, Блог компании Sebbia, память, Разработка под android, руководство для новичков, утечки памяти

В этой статье для начинающих android-разработчиков я постараюсь рассказать о том, что такое «утечки памяти» в android, почему о них стоит думать на современных устройствах, выделяющих по 192МБ на приложение, как быстро найти и устранить эти утечки в малознакомом приложении и на что нужно обращать особое внимание при разработке любого приложения.

Что такое утечки памяти в android, как проверить программу на их отсутствие и как предотвратить их появление - 1

Конечная цель это статьи — ответ на простой вопрос:
Куда нажать, чтобы узнать, какую строчку в приложении поправить?

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

Начнем с того, что же называется «утечкой памяти». В строгом понимании объект можно назвать утечкой памяти, если он продолжает существовать в памяти даже после того, как на него потеряны все ссылки. С этим определением сразу же возникает проблема: память для всех объектов, которые вы создаете, выделяется при участии сборщика мусора, и все созданные объекты сборщик мусора помнит, независимо от того, есть у вас ссылка на объект, или нет.

На самом деле сборщик мусора устроен крайне примитивно (на самом деле нет — но принцип работы действительно простой): есть граф, в котором каждый существующий объект — это вершина, а ссылка от любого объекта на любой другой объект — ребро. Некоторые вершины на этом графе — особые. Это корни сборщика мусора (garbage collection roots) — те сущности, которые созданы системой и продолжают свое существование независимо от того, ссылаются на них другие объекты или нет. Если и только если на графе существует любой путь от данного объекта до любого корня, объект не будет уничтожен сборщиком мусора.

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

Попытки получить в java «чистую» утечку памяти предпринимались неоднократно и, безусловно, продолжают предприниматься, однако ни один из способов не способен заставить сборщика мусора забыть ссылку на объект, не освободив память. Существуют утечки памяти, связанные с выделением памяти нативным кодом (JNI), однако в этой статье мы их не будем рассматривать.

Вывод: вы можете потерять все ссылки на интересующий вас объект, но сборщик мусора помнит.

Итак, определение «утечки памяти» в строгом смысле нам не подходит. Поэтому далее будем понимать утечку памяти как объект, который продолжает существовать после того, как он должен быть уничтожен.

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

Но, прежде чем перейти к описанию этих частых ошибок, нужно ответить на главный вопрос: а нужно ли вообще исправлять эти ошибки? Приложение-то работает...

Почему нужно тратить время на устранение утечек памяти?

Что такое утечки памяти в android, как проверить программу на их отсутствие и как предотвратить их появление - 2

Приложения уже давно не падают из-за того, что вы забыли пережать ресурсы в папку drawable-ldpi. Готовясь к написанию этой статьи, я провел простой эксперимент: я взял одно из работающих приложений, и добавил в него утечку памяти таким образом, что ни одно создаваемое activity никогда не выгружалось из памяти (стал добавлять их в статический список). Я открыл приложение и начал прокликивать экраны, ожидая, когда же приложение наконец упадет на моем Nexus 5. Наконец, через 5 минут и 55 экранов, приложение упало. Ирония в том, что, по данным Google Analytics, обычно пользователь за сессию посещает 3 экрана.

Так нужно ли волноваться по поводу утечек памяти, если пользователь может их просто не заметить? Да, и есть три причины почему.

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

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

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

Во-вторых, не все приложения потребляют мало памяти, и не все телефоны выделяют много памяти.

Помните про приложение, которое упало только после 5 минут и 55 не выгруженных экранов? Так вот для этого же приложения мне каждую неделю приходит 1-2 отчета о падении с OutOfMemoryException (в основном с устройств до 4.0; у приложения 50.000 установок). И это при том, что утечек памяти в приложении нет. Поэтому даже сейчас вы можете изрядно подпортить себе карму, выложив приложение с утечками памяти, особенно если ваше приложение потребляет много памяти. Как обычно в мире android, от блестящего будущего нас отделяет суровое настоящее.

В-третьих, мужик должен всё уметь! (я же обещал, что все 3 причины будут серьёзные)

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

Никогда не сохраняйте ссылки на activity (view, fragment, service) в статических переменных

Один из первых вопросов, с которым сталкивается каждый начинающий разработчик, это как передать объект из одного activity в следующий. Самое простое и самое неправильное решение, которое мне периодически приходится видеть, это запись первого activity в статическую переменную и обращение к этой переменной из второго activity. Это крайне неудачный подход. Не только потому, что он моментально приводит к утечке памяти (статическая переменная продолжит существовать пока существует приложение, и activity, на который она ссылается, никогда не будет выгружен). Этот подход также может привести к ситуации, когда вы будете обмениваться информацией не с тем экраном, ведь экран, невидимый пользователю, может в любой момент быть уничтожен и пересоздан лишь когда пользователь к нему вернется.

Почему же утечка activity — такая большая проблема? Дело в том, что если сборщик мусора не соберет activity, то он не соберет и все view и fragment, а вместе с ними и все прочие объекты, расположенные на activity. В том числе не будут высвобождены картинки. Поэтому утечка любого activity — это, как правило, самая большая утечка памяти, которая может быть в вашем приложении.

Никогда не записывайте ссылки на activity в статические переменные. Используйте передачу объектов через Intent, либо вообще передавайте не объект, а id объекта (если у вас есть база данных, из которой этот id потом можно достать).

Этот пункт также относится к любым объектам, временем жизни которых напрямую или косвенно управляет android. Т.е. к view, fragment, service и т.д..

View и fragment объекты содержат ссылку на activity, в котором они расположены, поэтому, если утечет один единственный view, утечет сразу всё — activity и все view в нём, а, вместе с ними, и все drawable и всё, на что у любого элемента из экрана есть ссылка!

Будьте аккуратны при передаче ссылки на activity (view, fragment, service) в другие объекты

Рассмотрим простой пример: ваше приложение для социальной сети отображает фамилию, имя и рейтинг текущего пользователя на каждом экране приложения. Объект с профилем текущего пользователя существует с момента входа в аккаунт до момента выхода из него, и все экраны вашего приложения обращаются за информацией к одному и тому же объекту. Этот объект также периодически обновляет данные с сервера, так как рейтинг может часто меняться. Необходимо, чтобы объект с профилем уведомлял текущее activity об обновлении рейтинга. Как этого добиться? Очень просто:

@Override
protected void onResume() {
	super.onResume();
	currentUser.addOnUserUpdateListener(this);
}

Как добиться в этой ситуации утечки памяти? Тоже очень несложно! Просто забудьте отписаться от уведомлений в методе onPause:

@Override
protected void onPause() {
	super.onPause();
	/* Забудьте про следующую строчку и вы получите серьёзную утечку памяти */
	currentUser.removeOnUserUpdateListener(this);
}

Из-за такой утечки памяти activity будет продолжать обновлять интерфейс каждый раз, когда профиль будет обновляться даже после того, как экран перестанет быть видим пользователю. Хуже того, таким образом экран может подписать 2, 3 или больше раза на одно и то же уведомление. Это может привести к видимым тормозам интерфейса в момент обновления профиля — и не только на этом экране.

Что делать, чтобы избежать этой ошибки?

Во-первых, конечно нужно всегда внимательно следить за тем, что вы отписались от всех уведомлений в момент ухода activity в фон.

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

В-третьих, есть и альтернативный подход к проблеме: вы можете сохранять не ссылки на объекты, а слабые ссылки. Это особенно полезно для наследников класса View — ведь у них нет метода onPause и не совсем понятно, в какой момент они должны отписываться от уведомления. Слабые ссылки не считаются сборщиком мусора как связи между объектами, поэтому объект, на который существуют только слабые ссылки, будет уничтожен, а ссылка перестанет ссылаться на объект и примет значение null. Чтобы не возиться каждый раз с не очень удобными в использовании слабыми ссылками, вы можете воспользоваться примерно следующим шаблонным классом:

public class Observer<I> {
	
	private ArrayList<I> strongListeners = new ArrayList<I>();
	private ArrayList<WeakReference<I>> weakListeners = new ArrayList<WeakReference<I>>();
	
	public void addStrongListener(I listener) {
		strongListeners.add(listener);
	}

	public void addWeakListener(I listener) {
		weakListeners.add(new WeakReference<I>(listener));
	}
	
	public void removeListener(I listener) {
		strongListeners.remove(listener);
		for (int i = 0; i < weakListeners.size(); ++i) {
			WeakReference<I> ref = weakListeners.get(i);
			if (ref.get() == null || ref.get() == listener) {
				weakListeners.remove(i--);
			}
		}
	}
	
	public List<I> getListeners() {
		ArrayList<I> activeListeners = new ArrayList<I>();
		activeListeners.addAll(strongListeners);
		for (int i = 0; i < weakListeners.size(); ++i) {
			WeakReference<I> ref = weakListeners.get(i);
			I listener = ref.get();
			if (listener == null) {
				weakListeners.remove(i--);
				continue;
			}
			
			activeListeners.add(listener);
		}
		return activeListeners;
	}
	
}

Который будет работать примерно вот так:

public class User {
	
	...
	
	public interface OnUserUpdateListener {
		public void onUserUpdate();
	}
	
	private Observer<OnUserUpdateListener> updateObserver = new Observer<OnUserUpdateListener>();
	
	public Observer<OnUserUpdateListener> getUpdateObserver() {
		return updateObserver;
	}
	
}

...

@Override
protected void onFinishInflate() {
	super.onFinishInflate();
	/* Мы подписываемся на уведомления при создании объекта */
	currentUser.getUpdateObserver().addWeakListener(this);
}

/* ... и никогда от этих уведомлений не отписываемся */
...

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

Есть только одна тонкость при использовании метода addWeakListener: на объект, который вы добавляете, должен кто-то ссылаться. Иначе сборщик мусора уничтожит этот объект до того, как он получит свое первое уведомление:

/* Не делайте так! */
currentUser.getUpdateObserver().addWeakListener(new OnUserUpdateListener() {
	@Override
	public void onUserUpdate() {
		/* Этот код не будет вызван */
	}
});

Таймеры и потоки, которые не отменяются при выходе с экрана

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

public class HandlerActivity extends Activity {

	private Handler mainLoopHandler = new Handler(Looper.getMainLooper());
	private Runnable queryServerRunnable = new Runnable() {
		@Override
		public void run() {
			new QueryServerTask().execute();
			mainLoopHandler.postDelayed(queryServerRunnable, 10000);
		}
	};
	
	@Override
	protected void onResume() {
		super.onResume();
		mainLoopHandler.post(queryServerRunnable);
	}
	
	@Override
	protected void onPause() {
		super.onPause();
		/* Вы забыли написать строчку ниже и в вашем приложении появилась утечка памяти */
		/* mainLoopHandler.removeCallbacks(queryServerRunnable); */
	}
	
	...
	
}

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

Никогда не сохраняйте ссылки на fragment в activity или другом fragment

Я очень много раз видел эту ошибку. Activity хранит ссылки на 5-6 запущенных фрагментов даже не смотря на то, что на экране всегда виден только 1. Один фрагмент хранит ссылку на другой фрагмент. Фрагменты, видимые на экране в разное время, общаются друг с другом по прямым закешированным ссылкам. FragmentManager в таких приложениях выполняет чаще всего рудиментарную роль — в нужный момент он подменяет содержимое контейнера нужным фрагментом, а сами фрагменты в back stack не добавляются (добавление фрагмента, на который у вас есть прямая ссылка, в back stack рано или поздно приведет к тому, что фрагмент будет выгружен из памяти; после возврата к этому фрагменту будет создан новый, а ваша ссылка продолжит ссылаться на существующий, но невидимый пользователю фрагмент).

Это очень плохой подход по целому ряду причин.

Во-первых, если вы храните в acitvity прямые ссылки на 5-6 фрагментов, то это тоже самое, как если бы вы хранили ссылки на 5-6 activity. Весь интерфейс, все картинки и вся логика 5 неиспользуемых фрагментов не могут быть выгружены из памяти, пока запущено activity.

Во-вторых, эти фрагменты становится крайне сложно переиспользовать. Попробуйте перенести фрагмент в другое место программы при условии, что он должен быть обязательно запущен в одном activity с фрагментами, x, y и z, которые переносить не надо.

Относитесь к фрагментам как к activity. Делайте их максимально модульными, общайтесь между фрагментами только через activity и fragmentManager. Это может казаться излишне сложной системой: зачем так стараться, когда можно просто передать ссылку? Но, на самом деле, такой подход сделает вашу программу лучше и проще.

По этой теме есть отличная официальная статья от Google: «Communicating with Other Fragments». Перечитайте эту статью и никогда больше не сохраняйте указатели на фрагменты.

Обобщённое правило

После прочтения четырех предыдущих пунктов вы могли заметить, что они практически ничем не отличаются. Все это — частные случаи одного общего правила.

Все утечки памяти появляются тогда и только тогда, когда вы сохраняете ссылку на объект с коротким жизненным циклом (short-lived object) в объекте с длинным жизненным циклом (long-lived object).

Помните об этом и всегда внимательно относитесь к таким ситуациям.

У этого правила нет красивого короткого названия, такого как KISS, YAGNI или RTFM, но оно применимо ко всем языкам со сборщиком мусора и ко всем объектам, а не только к activity в android.

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

Куда нажать, чтобы узнать, какую строчку в приложении поправить?

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

Для того, чтобы определить наличие и источник утечек памяти в приложении вам потребуется немного времени и MAT. Если вы никогда раньше не пользовались MAT, установите его как plugin к eclipse, откройте DDMS perspective и найдите кнопку «Dump HPROF file». Нажатие на эту кнопку откроет дамп памяти выбранного приложения. Если вы используете Android Studio, то процесс будет немного сложнее, так как на данный момент MAT все ещё не существует как плагин к Android Studio. Поставьте MAT как отдельную программу и воспользуйтесь инструкцией со stackoverflow.

Выполните следующие шаги:

  1. Установите приложение на устройство, подключенное к компьютеру и попользуйтесь им таким образом, чтобы оказаться на каждом экране как минимум однажды. Если один экран может быть открыт с разными параметрами, постарайтесь открыть его со всеми возможными комбинациями параметров. Вообщем — пройдитесь по всему приложению, как если бы вы проверяли его перед релизом. После того как вы прошли все экраны, нажимайте кнопку «назад» до тех пор, пока не выйдите из приложения. Не нажимайте кнопку home — ваша задача завершить все запущенные activity, а не просто скрыть их.
  2. Нажмите на кнопку Cause GC несколько раз. Если вы этого не сделаете, в дампе будут видны объекты, которые подлежат уничтожению сборщиком мусора, но ещё не были уничтожены.
  3. Сделайте дамп памяти приложения нажав на кнопку «Dump HPROF file».
  4. В открывшемся окне сделайте OQL запрос: «SELECT * FROM instanceof android.app.Activity»
    Что такое утечки памяти в android, как проверить программу на их отсутствие и как предотвратить их появление - 3

    Список результатов должен быть пустым. Если в списке есть хотя бы один элемент, значит этот элемент — это и есть ваша утечка памяти. На скриншоте вы видите именно такой элемент — HandlerActivity: это и есть утечка памяти. Выполните пункты 8-10 для каждого элемента из списка.

  5. Выполните аналогичные запросы для наследников Fragment: «SELECT * FROM instanceof android.app.Fragment». Как и в предыдущем случае, все, что попало в список результатов — это утечки памяти. Выполните для каждой из них пункты 8-10.
  6. Откройте histogram. Результаты, отображаемые в histogram, отличаются от результатов, отображаемых в OQL тем, что в histogram отображаются классы, а не объекты. В поле фильтра введите используемый для ваших классов package name (на скриншоте это com.examples.typicalleaks) и отсортируйте результаты по колонке objects (сколько объектов данного класса сейчас существует в системе). Обратите внимание, что в результатах отображаются в том числе и классы, 0 экземпляров которых существовало на момент получения дампа. Эти классы нас не интересуют. Если объектов действительно много — выделите всю таблицу, нажмите правой кнопкой и выберите пункт Calculate Precise Retained Size. Отсортируйте таблицу по полю Retained Heap и рассматривайте только объекты с большими значениями Retained Heap, например больше 10000.
    Что такое утечки памяти в android, как проверить программу на их отсутствие и как предотвратить их появление - 4

    На этот раз далеко не все объекты классов, которые вы видите в списке, являются утечками памяти. Однако все эти классы — это классы вашего приложения, и вы должны примерно понимать, сколько объектов каждого из этих классов должно существовать в данный момент. Например, на скриншоте мы видим 6 объектов класса Example и один массив Example[]. Это нормально — класс Example это enum, его объекты были созданы при первом обращении и будут существовать пока существует приложение. А вот HandlerActivity и HandlerActivity$1 (первый анонимный класс, объявленный внутри файла HandlerActivity.java) — это уже знакомые нам утечки памяти. Нажимаем правой кнопкой на подозрительный класс, выбираем пункт list objects, выполняем пункты 8-10 для одного из объектов из полученного списка.

  7. Если к этому шагу у вас не набралось ни одного подозрительного объекта — поздравляю! В вашем приложении нет значимых утечек памяти.
  8. Нажмите правой кнопкой на подозрительный объект и выберите пункт Merge Shortest Paths to GC Roots — exclude all phantom/weak/soft etc. references.
  9. Раскройте дерево. У вас должна получится примерно следующая картина:
    Что такое утечки памяти в android, как проверить программу на их отсутствие и как предотвратить их появление - 5

    В самом низу вы должны увидеть ваш подозрительный объект. В самом верху — корень сборщика мусора. Все, что посередине — это объекты, соединяющие ваш подозрительный объект с корнем сборщика мусора. Именно эта цепочка и не позволяет сборщику мусора уничтожить подозрительный объект. Читать эту цепочку следует следующим образом: жирным написана переменная объекта выше по списку, в которой содержится ссылка на объект справа от названия переменной. Т.е. на скриншоте мы видим, что переменная mMessages объекта MessageQueue содержит ссылку на объект Message, который содержит переменную callback, ссылающуюся на объект HandlerActivity$1, который содержит ссылку на объект HandlerActivity в переменной this$0. Иными словами, наш подозрительный объект HandlerActivity удерживает первый Runnable, объявленный в файле HandlerActivity.java, так как он добавлен в Handler с помощью метода post или postDelayed. Найдите последний снизу списка класс, который являются частью вашего приложения, нажмите на него правой кнопкой и выберите пункт Open Source File.

  10. Исправьте код приложения таким образом, чтобы разрушить цепочку между подозрительным объектом и корнем сборщика мусора в тот момент, когда подозрительный объект перестанет быть нужен. В нашем примере нам достаточно вызвать метод Handler.removeCallbacks(Runnable r) в методе onPause HandlerActivity.
  11. После того, как вы разобрались со всеми подозрительными объектами, повторите алгоритм с шага 1, чтобы проверить, что теперь все работает нормально.

Заключение

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

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

Автор: Grebenets

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js