Освой программирование играючи

Сайт Александра Климова

Шкодим

/* Моя кошка замечательно разбирается в программировании. Стоит мне объяснить проблему ей - и все становится ясно. */
John Robbins, Debugging Applications, Microsoft Press, 2000

Утечка памяти

Кот затыкает воду

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

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

Память условно имеет две области для хранения данных - стек (stack) и куча (heap).

Стек работает в порядке LIFO (Last In, First Out), то есть последний добавленный в стек фрагмент данных будет первым в очереди на вывод из стека. Каждый раз, когда функция объявляет новую переменную, переменная добавляется в стек, а когда эта переменная пропадает из области видимости, она автоматически удаляется из стека. Когда стековая переменная освобождается, эта область памяти становится доступной для других стековых переменных. Размер стека — это фиксированная величина, и превышение лимита выделенной на стеке памяти приведёт к переполнению стека. Размер задаётся при создании потока, и у каждой переменной есть максимальный размер, зависящий от типа данных.

Куча — это хранилище памяти, также расположенное в ОЗУ, которое допускает динамическое выделение памяти. Куча не имеет упорядоченного набора данных, это просто склад для ваших переменных. По завершении приложения все выделенные участки памяти освобождаются. Размер кучи задаётся при запуске приложения, но, в отличие от стека, он ограничен лишь физически, и это позволяет создавать динамические переменные.

Чтобы наглядно преддставить способ хранения объектов в памяти, напишем простую программу на Java.

public class Memory {
	public static void main(String[] args) {
		int i = 1;
		Object obj = new Object();
		Memory mem = new Memory();
		mem.foo(obj);
	}
	
	private void foo(Object param) {
		String str = param.toString();
		System.out.println(str);
	}
}

Размещение в памяти при запуске выглядит следующим образом.

Stack & Heap

По рисунку видно, что в стек попали функция main(), переменная с примитивным типом int.

Также в стек попадает объект obj, когда он создаётся из класса Object, при этом в куче создаётся ссылка на класс (указатель).

Аналогично, в стеке появляется объект mem с ссылкой на класс в куче.

Для функции foo() в стеке создаётся отдельный блок. В этом блоке создаётся объект param с ссылкой в куче на класс Object и строковый объект с ссылкой в куче на отдельный блок String Pool.

Когда в программе выполнение доходит до закрывающей фигурной скобкой метода foo(), метод прекращает работу и объекты в стеке, относящиеся к блоку функции, освобождаются. Память выглядит следующим образом.

Stack & Heap

Последняя закрывающая фигурная скобка от функции main() закрывает эту функцию, освобождая свой блок данных.

Stack & Heap

Стек успешно очистился, когда все функции отработали. Но данные в куче ведут себя немного иначе. Они сами по себе не уходят. В Java имеется специальный помощник - сборщик мусора, который следит за порядком и если он заметит неиспользуемые объекты, то убирает их. Суть его работы состоит в том, чтобы смотреть, есть ли связь между данными в стеке и куче. Если у объекта нет ссылки на класс в куче, значит класс можно удалить из памяти. Идеальный порядок выглядит следующим образом.

Stack & Heap

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

Сначала ответим на вопрос: а зачем исправлять эти ошибки, чем это грозит? Даже с утечками памяти приложение может работать.

Можно провести эксперимент, намеренно создав утечку памяти - активность при каждом повороте будет создавать новый экземпляр. На современном устройстве таким образом можно создать несколько десятков новых экранов, прежде чем приложение закроется с ошибкой. Но в среднем, пользователь открывает 3-5 экранов, поэтому вероятность появление ошибки маловероятно. Но данный пример не должен успокаивать вас. Не все телефоны выделяют много памяти приложению.

Основные проблемные источники: Context и его производные (Activity), внутренние классы (Inner Classes), анонимные классы (Anonymous Classes), Handlers c Runnable, Threads, TimerTask, SensorManager и другие менеджеры.

Самая главная рекомендация - никогда не сохраняйте ссылки на Context, Activity, View, Fragment, Service в статических переменных.


private static TextView textView; // никогда так не делайте
private static Context context; // никогда так не делайте

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

Почему же утечка активности такая большая проблема? Дело в том, что если сборщик мусора не соберёт Activity, то он не соберёт и все View и Fragment, а вместе с ними и все прочие объекты, расположенные в Activity. В том числе не будут высвобождены картинки. Поэтому утечка любой активности — это, как правило, самая большая утечка памяти, которая может быть в вашем приложении.

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

Этот пункт также относится к любым объектам, временем жизни которых напрямую или косвенно управляет Android, т.е. View, Fragment, Service и т.д.

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

Будьте аккуратны при передаче ссылки на Activity (View, Fragment, Service) в другие объекты.

Утечка через слушателей

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


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

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


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

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

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

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

Другой пример с использованием системных слушателей. Например, есть слушатель определения местоположения.

public class LoginActivity extends Activity implements LocationListener { @Override public void onLocationUpdated(Location location){ // do something } @Override protected void onStart(){ LocationManager.getInstance().register(this); } @Override protected void onStop(){ LocationManager.getInstance().unregister(this); } }

Если забудем снять регистрацию слушателя в onStop(), то пользователь может закрыть приложение, но сборщик мусора не сможет освободить память, так как LocationManager будет по-прежнему выполнять свою работу.

Пример утечки с внутренним классом

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


import ...

public class MainActivity extends AppCompatActivity {

    private TextView mTextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mTextView = findViewById(R.id.textView);

        new BackgroundTask().execute();
    }

    private class BackgroundTask extends AsyncTask<Void, Void, String> {

        @Override
        protected String doInBackground(Void... params) {
            // Что-то делаем в фоне
            return "some string";
        }

        @Override
        protected void onPostExecute(String result) {
            mTextView.setText(result);
        }
    }
}

// Kotlin
class MyActivity : AppCompatActivity() {
    ...
    inner class MyTask : AsyncTask<Void, Void, String>() {
        override fun doInBackground(vararg params: Void): String {
          // Что-то делаем в фоне
        }
        override fun onPostExecute(result: String) {
          [email protected](result)
        }
    }
}

Сам пример вполне нормальный. Но нужно помнить, что класс BackgroundTask хранит ссылку на активность. Если задача выполняется очень долго (плохое соединение, большая картинка на сервере), то сложная активность со всеми своими ресурсами остаётся в памяти, пока задача не будет отработана.

Есть разные варианты решения задачи. Часто рекомендуют подход с WeakReference.


package ru.alexanderklimov.as21;

import ...

public class MainActivity extends AppCompatActivity {

    private TextView mTextView;
    private AsyncTask mAsyncTask;

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mTextView = findViewById(R.id.textView);

        mAsyncTask = new BackgroundTask(mTextView).execute();
    }

    @Override
    protected void onDestroy() {
        mAsyncTask.cancel(true);
        super.onDestroy();
    }

    private static class BackgroundTask extends AsyncTask<Void, Void, String> {

        private final WeakReference<TextView> textViewReference;

        public BackgroundTask(TextView resultTextView) {
            this.textViewReference = new WeakReference<>(resultTextView);
        }

        @Override
        protected void onCancelled() {
            // код для отмены задачи
        }

        @Override
        protected String doInBackground(Void... params) {
            // задача в фоне
            return "some string";
        }

        @Override
        protected void onPostExecute(String result) {
            TextView view = textViewReference.get();
            if (view != null) {
                view.setText(result);
            }
        }
    }

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

Для примера на Kotlin можно убрать модификатор inner.


class MyActivity : AppCompatActivity() {
    ...
    class MyTask(activity: MainActivity) : AsyncTask<Void, Void, String>() {
        private val weakRef = WeakReference<MyActivity>(activity)
        override fun doInBackground(vararg params: Void): String {
          // Perform heavy operation and return a result
        }
		
        override fun onPostExecute(result: String) {
          weakRef.get()?.showResult(result)
        }
    }
}

Если в качестве внутреннего класса использовать Handler, то студия будет выводить подсказку This Handler class should be static or leaks might occur (anonymous android.os.Handler). Код, чтобы увидеть подсказку.


private final Handler mLeakyHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        // ...
    }
};

@Override
protected void onCreate(Bundle savedInstanceState) {

    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
}

Более подробное описание подсказки в студии:


Since this Handler is declared as an inner class, it may prevent the outer class from being garbage collected. If the Handler is using a Looper or MessageQueue for a thread other than the main thread, then there is no issue. If the Handler is using the Looper or MessageQueue of the main thread, you need to fix your Handler declaration, as follows: Declare the Handler as a static class; In the outer class, instantiate a WeakReference to the outer class and pass this object to your Handler when you instantiate the Handler; Make all references to members of the outer class using the WeakReference object.

Пример утечки с анонимным классом

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

Утечка через потоки

Случай первый - потоки. Создадим внутренний класс внутри активности. Внутренний класс будет иметь ссылку на активность.


package ru.alexanderklimov.leak;

import android.app.Activity;
import android.os.Bundle;
import android.os.SystemClock;

import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_main);

        new CatFeedTask(this).start();
    }

    private class CatFeedTask extends Thread {

        Activity activity;

        public CatFeedTask(Activity activity) {
            this.activity = activity;
        }

        @Override
        public void run() {
            SystemClock.sleep(20 * 1000);
        }
    }
}

В обычной ситуации пользователь запустит активность, запустится задача на двадцать секунд.

Когда задача выполнится, стек освободит объекты.

Затем сборщик мусора освободит объекты в куче.

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

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

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

Когда метод run() выполнится, стек освободит объекты и сборщик мусора в порядке очереди почистит объекты в куче, так как они уже не будут иметь ссылок из стека.

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

Singleton (Одиночка)


public class SingletonManager {

    private static SingletonManager singleton;
    private Context context;

    private SingletonManager(Context context) {
        this.context = context;
    }

    public synchronized static SingletonManager getInstance(Context context) {
        if (singleton == null) {
            singleton = new SingletonManager(context);
        }
        return singleton;
    }
}

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


public class LoginActivity extends Activity {
    @Override
        protected void onCreate(Bundle savedInstanceState) {
            //...
            SingletonManager.getInstance(this);
        }
    }
}

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

Чтобы избежать утечку, используйте контекст приложения, а не активности.


SingletonManager.getInstance(getApplicationContext());

Или вы можете переписать класс одиночки.


public class SingletonManager {
    // ....
    public synchronized static SingletonManager getInstance(Context context) {
        if (singleton == null) {
            singleton = new SingletonManager(context.getApplicationContext());
        }
        return singleton;
    }
}

Утечка с таймерами

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

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


public class MainActivity 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 в активности или другом фрагменте.

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

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

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

Относитесь к фрагментам как к Activity. Делайте их максимально модульными, общайтесь между фрагментами только через Activity и FragmentManager.

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

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

Утечки памяти, связанные с неправильным использованием android.os.Handler. Не совсем очевидно, но все, что вы помещаете в Handler, находится в памяти и не может быть очищено сборщиком мусора в течении некоторого времени. Иногда довольно длительного. Читайте статью Борьба с утечками памяти в Android. Часть 1

Пример утечки с системными менеджерами

В Android есть много системных менеджеров (содержат слово "Manager" в именах классов), которые следует регистрировать. И часто программисты забывают снять регистрацию.

Возьмём для примера класс LocationManager, который помогает определить местоположение. Напишем минимальный код.


package ru.alexanderklimov.sample;

import ...

public class MainActivity extends AppCompatActivity implements LocationListener {

    private LocationManager locationManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);

        // Здесь студия просит вставить код для разрешения. Я его опустил
        
        locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER,
		        TimeUnit.MINUTES.toMillis(5), 100F, this);

    }

    // методы интерфейса LocationListener
    @Override
    public void onLocationChanged(Location location) {  }

    @Override
    public void onStatusChanged(String provider, int status, Bundle extras) {  }

    @Override
    public void onProviderEnabled(String provider) {  }

    @Override
    public void onProviderDisabled(String provider) {  }
}

Запустите пример. В студии внизу выберите вкладку 6: Android Monitor (Сейчас вместо него появился Profiler), а в ней вкладку Monitors. В верхней части окна будет блок Memory, который представляет для нас интерес.

Начинайте вращать устройство с запущенным приложением. Вы увидите, что ваше приложение начинает забирать память у устройства (тёмно-синий цвет).

Memory Leak

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

Нажмите на третью кнопку в этом окне Dump Java Heap. Данное действие сгенерирует hprof-файл, содержащий слепок памяти в заданный момент. Далее студия автоматически откроет созданный файл, который можно изучить.

Обратите внимание на вкладку Analyzer Tasks сбоку в верхнем правом углу. Откройте эту вкладку. В ней вы увидите строчку с флажком Detect Leaked Activities (Обнаружить утекающие активности). В окнеAnalysis Results щёлкните по строке Leaked Activities, чтобы увидеть дополнительную информацию.

Memory Leak

Видно, что при поворотах создавалось множество активностей MainActivity, а вместе с ней и объект LocationManager.

Добавим код в метод onDestroy(), как это предписано документацией.


@Override
protected void onDestroy() {
    // Здесь студия просит вставить код для разрешения. Я его опустил
    
    locationManager.removeUpdates(this);
    super.onDestroy();
}

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

В Android Studio есть специальный инструмент, который позволяет следить за памятью - Profiler, запускаемый из меню View | Tool Windows. Также имеется отдельный значок инструмента на панели в верхней части. Новый инструмент заменил Android Monitor в старых версиях студии.

Запустите профайлер, появится окно с четырьмя блоками: CPU, MEMORY, NETWORK, ENERGY. Нас интересует память. Щёлкаем в этой области, чтобы оставить слежение только за используемой памятью.

Нажмите кнопку Dump Java heap, чтобы получить дамп кучи. Рядом имеется кнопка очистки мусора Force garbage collection.

Дополнительные материалы

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

Android: LeakCanary — канарейка для поиска утечек памяти

Реклама