Android Small Talks: Wycieki pamięci w Androidzie

Android Small Talks: Wycieki pamięci w Androidzie

Wyciek pamięci następuje wtedy, kiedy nie zwolnimy zaalokowanej wcześniej pamięci, a nie potrzebujemy już obiektów, dla których ta pamięć została zarezerwowana. Wiąże się to z utratą kontroli nad pewnym obszarem pamięci, aż do końca życia procesu. W niektórych językach sami musimy martwić się o zwalnianie pamięci. W języku C należy użyć instrukcji free, w C++ instrukcji delete, a w Javie?

Wycieki pamięci z aplikacji na Androida

Wirtualna maszyna Javy pomaga programiście nie myśleć ciągle o pamięci i usypia naszą czujność. W tle działa bowiem zarządca pamięci, czyli Garbage Collector. Zlicza on istniejące referencje do każdego obiektu i jeśli wykryje, że do któregoś z nich nie odnosi się już żadna część programu, zwalnia to miejsce. W tym przypadku jednak ciągle bardzo łatwo o wyciek. Wystarczy, że zapomnimy o jakiejś istniejącej referencji, a Garbage Collector nie będzie mógł już nic na to poradzić.

Memory leaks diagram - blind spots
Źródło zdjęcia: https://youtu.be/BkbHeFHn8JY?t=43s

Skutki

Jak powiedział Benjamin Franklin, mały wyciek może spowodować zatonięcie wielkiego statku. Pamięć przydzielona dla programu jest ograniczona. Dane dynamicznie alokowane odkładają się na stercie, która może się przepełnić.

Wyciek jednego obiektu wiąże się zawsze z wyciekiem całej masy obiektów, z którymi jest on powiązany. Kilka małych wycieków może doprowadzić do spowolnienia programu oraz zwiększenia ryzyka wystąpienia błędu braku pamięci, co całkowicie zawiesi nasz program.

Należy też pamiętać, że system Android działa na urządzeniach mobilnych, gdzie do dyspozycji jest często niewiele pamięci. Warto wyjść też z założenia, że pamięć RAM jest nie tylko dla nas, ale również innych programów działających w tle oraz samego systemu.

Typowe błędy i zagrożenia

W Androidzie bardzo kosztowna, a jednocześnie najłatwiejsza do przeoczenia jest utrata kontroli nad widokiem. Czasem zdarza się, że ma on w sobie bitmapę, a ta zajmuje sporo pamięci. Jeszcze bardziej kosztowna może być utrata kontroli nad całą Aktywnością. Dlaczego? Ponieważ powiązana jest ona z wieloma wewnętrznymi widokami, a być może też bitmapami, które są przez te widoki przetrzymywane lub nawet całymi kolekcjami innych danych. One z kolei mogą posiadać referencje do następnych obiektów.

Łatwo stworzyć łańcuch, który pociągnie nasz program na dno. Marnujemy całe drzewo powiązanych danych zapisanych na stercie. Co więcej, działa to w dwie strony, czyli jeśli stracimy kontrolę nad Fragmentem, wyciekną nam jego wewnętrzne obiekty, ale również Aktywność, w której się znajduje i wszystkie jej pola. Dojdziemy tak aż do samego korzenia, czyli w tym przypadku Contextu.

Cykl życia zależności

Wbrew pozorom łatwo o niektórych zależnościach zapomnieć lub w ogóle ich nie zauważyć. Musimy pamiętać o tym, że Aktywności i Fragmenty w Androidzie mają swój cykl życia. Należy zachować szczególną ostrożność, kiedy tworzymy nowy wątek wewnątrz Aktywności np. po to, aby po zakończeniu jego pracy uaktualnić jej stan. Jeśli nie zadbamy o to, aby zakończyć wątek lub zignorować jego wyniki kiedy aktywność została zabita, otrzymamy wyjątek NullPointerException.

Pracujemy w aplikacji, która żyje, więc użytkownik w każdej chwili może zamknąć dany ekran lub chociażby go obrócić. Jeśli przykładowo podpięliśmy się pod zdarzenie onCreate i tam uruchomiliśmy wątek, jego praca rozpocznie się ponownie, nie przerywając poprzedniej. Jeśli wewnątrz jest jakieś twarde powiązanie z Aktywnością, nie będzie ona mogła zostać normalnie zniszczona, a będzie przechowywana w pamięci, co spowoduje wyciek i żadne sprawdzenie, czy obiekt nie stał się w między czasie nullem nie ustrzeże nas przed takim wyciekiem. Co więcej, nie musimy nawet mieć w naszym wątku żadnej referencji do jakiegokolwiek pola Aktywności. Jeśli będzie on zadeklarowany w sposób przedstawiony poniżej, jako klasa wewnętrzna Aktywności, żaden obiekt tej Aktywności nie będzie mógł zostać usunięty, jeśli praca wątku wciąż będzie trwać.

public class MyActivity extends Activity{
		//…
		private class SampleTask extends AsyncTask<Void, Void, Void> {
			//do work
		}
	}

Dzieje się tak, ponieważ stworzyliśmy domniemaną referencję (implicit reference). Ten sam problem wystąpi w przypadku użycia klasy anonimowej nowego wątku:

new AsyncTask<Void, Void, Void>(){
        	@Override
        	protected Void doInBackground(Void... params) {
            	//do work
        	}
    	}.execute();

lub jakiejkolwiek operacji wywołanej asynchronicznie z zewnątrz:

new AsyncTask<Void, Void, Void>(){
        	@Override
        	protected Void doInBackground(Void... params) {
            	//do work
        	}
    	}.execute();

Aby poradzić sobie z tym problemem, musimy pozbyć się tej ukrytej referencji. Najprostszym rozwiązaniem jest użycie wewnętrznej, statycznej klasy, która nie wiąże w żaden sposób powstałego obiektu z klasą zewnętrzną, w tym przypadku Aktywnością.

public class MyActivity extends Activity{
		//…
		private static class SampleTask extends AsyncTask<void, void="">{
    			@Override
    			protected Void doInBackground(Void... params) {
        			//do work
    			}
		};
	}

Bardzo często po wykonanej przez wątek pracy potrzebujemy uaktualnić stan naszych widoków oraz zapisać wyniki zakończonej pracy. Co w takim przypadku? Potrzebne są przecież odwołania do różnych pól klasy zewnętrznej. Pierwsze co powinniśmy zrobić to sprawdzić, czy obiekt przypisany do pola jeszcze istnieje, bowiem w dowolnej chwili mógł on stać się nullem.

Jak jednak ustrzec się wycieku pamięci, skoro wątek trzymając referencję do pola Aktywności powoduje, że nie może ona zostać usunięta przez Garbage Collector, pomimo że została zamknięta i jej cykl życia już się skończył? Z pomocą przychodzi nam WeakReference, czyli słaba referencja. Różni się ona od zwykłej, stworzonej przez znak przypisania ‘=’ , tym że Garbage Collector jej nie zlicza, co oznacza, że jeśli do danego obiektu nie istnieje już żadna twarda referencja, to może on zostać automatycznie usunięty. Nawet wtedy, kiedy trzymane są do niego słabe referencje.

Prawidłowy przykład bezpiecznej implementacji:

public class MyActivity extends Activity {
	private TextView label;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
    	super.onCreate(savedInstanceState);
    	// (...)
    	new SampleTask(this).execute();
	}

	public void setLabel(String text){
    		label.setText(text);
	}
    
	private static class SampleTask extends AsyncTask<Void, Void, Void>{
    	private WeakReference<MyActivity> mRef;

    	public SampleTask(MyActivity activity) {
        		mRef = new WeakReference<myactivity>(activity);
    	}

    	@Override
    	protected Void doInBackground(Void... params) {
        	//do work
        	return null;
    	}

    	@Override
		protected void onPostExecute(Void aVoid) {
        		MyActivity myActivity = mRef.get();
        		if (myActivity != null){
            			myActivity.setLabel("Gotowe");
        		}
    		}
	};
}

Tak jak wybawieniem może być wewnętrzna statyczna klasa, tak kolejną możliwością wycieku jest przechowywanie danych w zmiennych statycznych i nie usuwanie ich. Należy pamiętać, że dane przypisane do pola statycznego klasy będą pamiętane do końca życia procesu aplikacji.

Po pierwsze warto przemyśleć, czy na pewno potrzebujemy takiej zmiennej i czy nie jest to tylko droga na skróty. Jeśli koniecznie potrzebujemy takiego rozwiązania, zdecydowanie warto jest w odpowiednim momencie wyzerować to pole przez przypisanie null. Jeśli pole statyczne będzie typu prymitywnego, to zajmie co najwyżej kilka bajtów pamięci, wiec ten problem można pominąć. Należy jednak pamiętać, że nie będzie już można tej pamięci zwolnić w trakcie trwania procesu.

Najważniejsze zasady, które pomogą uniknąć wycieku pamięci

  • Tworzenie wewnętrznych klas statycznych w celu uniknięcia domniemanej referencji do klasy zewnętrznej.
  • Używanie WeakReference do połączenia z obiektem, którego stan mamy zamiar zmieniać asynchronicznie.
  • Unikanie przechowywania obiektów w zmiennych statycznych.
  • Odrejestrowywanie słuchaczy zawsze w parze do ich rejestracji. Na przykład rejestrując BroadcastReceiver w metodzie onResume() najlepiej go odrejestrować w onPause().
  • Zamykanie otwartej bazy danych bezpośrednio po wykonaniu pojedynczej lub serii transakcji.

Wykrywanie błędów z pamięcią

Nie musimy czekać, aż zauważymy błąd braku pamięci. Warto zabezpieczyć się przed taką ewentualnością wcześniej. Zawsze możemy popełnić błąd. Ważne, żeby zorientować się w odpowiedniej chwili. Pomogą nam w tym narzędzia udostępnione wraz z Android Studio, jak i biblioteki napisane przez zapobiegawczych programistów.

Analiza użytej pamięci

Otwierając zakładkę Memory w Android Studio widzimy dynamiczny, aktualny wykres użytej pamięci przez naszą aplikację. Jeśli wzrasta ona wtedy kiedy nie powinna, trzeba prześwietlić swój kod.

Android monitor - Memory storage

Activity monitor

Do głębszej analizy warto jednak wykorzystać Android Device Monitor, który możemy otworzyć w oddzielnym oknie przez sekwencję Tools -> Android -> Android Device Monitor.

View from Android Device Monitor

Device monitor

Po otwarciu należy:

  1. Wybrać podpięte urządzenie.
  2. Wybrać działający na nim proces.
  3. Włączyć przycisk aktualizowania sterty.
  4. Przejść na zakładkę Heap.
  5. Kliknąć Cause GC, aby wymusić wywołanie Garbage Collector.

W ten sposób zaczniemy śledzić stertę. Uchronimy się też od fałszywego podejrzenia wycieku, bowiem na poprzednim wykresie pamięci możemy pomylić wyciek z chwilową bezczynnością GC, który wykonuje się w różnych momentach. Tutaj sami możemy nim sterować. Jeśli przykładowo wycieka nam jakaś Aktywność, możemy dowiedzieć się o tym w następujący sposób:

  1. Otwieramy podejrzany ekran
  2. Klikamy Cause GC i zapamiętujemy ile mamy zużytej pamięci
  3. Kilkukrotnie zamykamy ekran i szybko powracamy z powrotem lub obracamy ekran
  4. Klikamy Cause GC i porównujemy ilość użytej pamięci z zapamiętanym chwilę wcześniej zużyciem

Jeśli zużyta pamięć się zwiększyła i GC nie pomaga to znaczy, że niestety mamy jakiś wyciek.

Biblioteka Leak Canary

Czasem wzrost może być mało zauważalny, albo po prostu nie bardzo wiemy, gdzie dokładnie szukać. Możemy wtedy skorzystać z biblioteki Leak Canary. Została ona stworzona po to, aby śledzić wybrane obiekty i poinformować o tym, że zablokowana na niego pamięć nie została zwolniona ani przez GC ani przez samego programistę. Jej instalacja i użycie jest bardzo proste i jest przedstawione na Githubie. Należy jej używać tylko będąc w trybie debug naszej aplikacji, ponieważ na pewno użytkownik końcowy nie spodziewa się od niej żadnych powiadomień.

Kiedy wyciek zostanie wykryty pokaże się żółte logo z komunikatem, a w tle rozpocznie się proces zapisywania pliku z rozszerzeniem .hprof. Aplikacja musi mieć wtedy pozwolenie na zapis w pamięci zewnętrznej. Po kilku sekundach otrzymamy notyfikację w pasku powiadomień, która zaprowadzi nas do pełnoekranowego wykresu drzewa powiązań obiektów, które wyciekły. Łatwo wywnioskujemy z niego, co dokładnie się stało i będziemy mogli zastanowić się nad rozwiązaniem.

Muszę tutaj dodać, że napotkałem problem z działaniem biblioteki na Androidzie 6.0. Nie potrafiła ona wygenerować pliku z wynikiem własnej analizy. Pokazywała tylko komunikat o wycieku i wieszała aplikacje. Błąd ten jest znany twórcom i myślę, że będzie niedługo poprawiony (testowana wersja 1.3.1 oraz 1.4beta1). Na Androidzie 5.1 i niższym wszystko działa prawidłowo.

Pamiętaj o zużyciu pamięci

Lepiej zapobiegać niż leczyć. Warto co jakiś czas przeanalizować kod pod tym kątem oraz sprawdzić, ile pamięci zużywamy i czy zostaje ona prawidłowo zwalniana. Pisząc aplikację należy rozumieć, w jaki sposób współdzielimy pamięć i jak z niej korzystać, aby wystarczyło jej w newralgicznych momentach.

Wycena projektu

Opowiedz nam o swoim projekcie i napisz, jak możemy Ci pomóc.

Dlaczego warto rozwijać z nami projekty?

Logo Mobile Trends Awards

Mobile Trends Awards 2021

Wygrana w kategorii
ŻYCIE CODZIENNE

Nagroda Legalnych Bukmacherów

Nagroda Legalnych Bukmacherów 2019

Najlepsza aplikacja mobilna

Mobile Trends Awards logo

Mobile Trends Awards 2023

Wygrana w kategorii
MCOMMERCE ROZWÓJ

23

opinie klientów

Clutch logo