Jak Kotlin pomaga uniknąć wycieków pamięci
W styczniu wygłosiłem w MobOS wykład na temat pisania i automatyzacji testów wydajności w systemie Android. W ramach wykładu chciałem zademonstrować, w jaki sposób można wykryć wycieki pamięci podczas testów integracyjnych. Aby to udowodnić, stworzyłem Activity z wykorzystaniem Kotlina, które miało spowodować wyciek pamięci, ale z jakiegoś powodu tak się nie stało. Czyżby Kotlin pomagał mi bez mojej wiedzy?
Zanim zacznę, kod z tego artykułu jest dostępny w gałęzi kotlin-mem-leak
na moim repozytorium testów wydajności.
Zamysł był prosty - chciałem napisać Activity, która spowoduje wyciek pamięci, aby móc to wykryć podczas testu integracyjnego. Ponieważ już używałem leakcanary, skopiowałem ich testowe Activity, aby odtworzyć wyciek pamięci. Usunąłem trochę kodu z próbki i skończyłem z następującą klasą Java.
public class LeakActivity extends Activity {
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_leak);
View button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startAsyncWork();
}
});
}
@SuppressLint("StaticFieldLeak")
void startAsyncWork() {
Runnable work = new Runnable() {
@Override public void run() {
SystemClock.sleep(20000);
}
};
new Thread(work).start();
}
}
LeakActivity
posiada przycisk, który po naciśnięciu tworzy nowy Runnable, który działa przez 20 sekund. Ponieważ Runnable jest klasą anonimową, ma ukrytą referencję do zewnętrznej LeakActivity
i jeśli aktywność zostanie zniszczona przed zakończeniem wątku (20 sekund po naciśnięciu przycisku), wtedy aktywność będzie przeciekać. Nie będzie jednak przeciekać wiecznie - po 20 sekundach garbage collector może ją sprzątnąć.
Zważając, że pisałem swój kod w Kotlinie, przekonwertowałem tę klasę Java na kod Kotlina, który wyglądał tak:
class KLeakActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_leak)
button.setOnClickListener { startAsyncWork() }
}
private fun startAsyncWork() {
val work = Runnable { SystemClock.sleep(20000) }
Thread(work).start()
}
}
Nie ma tu nic szczególnego. Wykorzystuję lambdy do usunięcia boilerplate z "Runnable", więc teoretycznie wszystko powinno wyglądać tak samo, prawda? Następnie napisałem taki oto test, używając adnotacji @LeakTest do uruchomienia analizatora pamięci tylko w tym teście.
class LeakTest {
@get:Rule
var mainActivityActivityTestRule = ActivityTestRule(KLeakActivity::class.java)
@Test
@LeakTest
fun testLeaks() {
onView(withId(R.id.button)).perform(click())
}
}
Test wykonuje kliknięcie przycisku i ponieważ jest to jedyna rzecz, którą robimy, aktywność zostanie zniszczona natychmiast po wykonaniu testu i spowoduje wyciek, ponieważ nie trwała przez 20 sekund.
Jeśli spróbujemy wykonać testLeaks
wewnątrz MyKLeakTest
, testy przechodzą pomyślnie, co oznacza, że nie wykryliśmy żadnych wycieków pamięci.
Ten wynik bardzo mnie zdezorientował, tak więc pod koniec dnia zastępowałem już tę anonimową klasę Java anonimową klasą wewnętrzną, a ponieważ była to instancja funkcyjnego interfejsu Java, mogłem zamiast tego użyć wyrażenia lambd (więcej o pojedynczych abstrakcyjnych metodach lub konwersjach SAM tutaj).
Byłam tak zdezorientowany i czułem się tak głupio, że aż napisałem takiego tweeta:
I dostałem odpowiedź, która mnie rozśmieszyła. Chciałbym, żeby moje umiejętności były na tym poziomie :D
Owszem, łatwo jest utknąć w kręgu "wszystko działa prawidowo, ale wciąż nie wiem o co chodzi", dlatego wróciłem do podstaw.
Napisałem nowe activity, ten sam kod, ale tym razem w Javie. Zmieniłem test, by wskazywał na nowe activity, uruchomiłem je i tym razem.... test się wywalił. Sprawy zaczęły nabierać większego sensu. Kod Kotlina musiał być inny niż kod Java, coś się tam wydarzyło i pozostało mi już tylko jedno miejsce do sprawdzenia. Bytecode.
Analizowanie LeakActivity.java
Na początek przeanalizowałem bytecode dalvika dla aktywności w Javie. Aby to zrobić, musisz przeanalizować swój apk poprzez Build/Analyze APK
...., a następnie wybrać z pliku classes.dex
klasę, którą chcesz przeanalizować.
Kliknij prawym przyciskiem myszy na klasę i wybierz Show Bytecode
, aby uzyskać kod bajtowy klasy. Skupię się na metodzie startAsyncWork
, ponieważ wiemy, że jest to miejsce, gdzie dochodzi do wycieku pamięci.
.method startAsyncWork()V
.registers 3
.annotation build Landroid/annotation/SuppressLint;
value = {
"StaticFieldLeak"
}
.end annotation
.line 29
new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;
invoke-direct {v0, p0}, Lcom/marcosholgado/performancetest/LeakActivity$2;-><init>
(Lcom/marcosholgado/performancetest/LeakActivity;)V
.line 34
.local v0, "work":Ljava/lang/Runnable;
new-instance v1, Ljava/lang/Thread;
invoke-direct {v1, v0}, Ljava/lang/Thread;-><init>(Ljava/lang/Runnable;)V
invoke-virtual {v1}, Ljava/lang/Thread;->start()V
.line 35
return-void
.end method
Wiemy, że klasa anonimowa posiada referencję do klasy zewnętrznej, więc będziemy jej szukać. W powyższym bytecode tworzymy nową instancję LeakActivity$2
i przechowujemy ją w v0
(linia 10).
new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;
Ale czym jest LeakActivity $2
? Jeśli nadal będziemy przeglądać nasz plik classes.dex, znajdziemy go tam.
Zobaczmy więc dalszy kod bajtowy dla tej klasy. Usunąłem z wyników kod, który nas nie obchodzi.
.class Lcom/marcosholgado/performancetest/LeakActivity$2;
.super Ljava/lang/Object;
.source "LeakActivity.java"
# interfaces
.implements Ljava/lang/Runnable;
# instance fields
.field final synthetic this$0:Lcom/marcosholgado/performancetest/LeakActivity;
# direct methods
.method constructor <init>(Lcom/marcosholgado/performancetest/LeakActivity;)V
.registers 2
.param p1, "this$0" # Lcom/marcosholgado/performancetest/LeakActivity;
.line 29
iput-object p1, p0, Lcom/marcosholgado/performancetest/LeakActivity$2;
->this$0:Lcom/marcosholgado/performancetest/LeakActivity;
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
Pierwszą interesującą rzeczą, jest implementacja Runnable przez tę klasę.
# interfaces
.implements Ljava/lang/Runnable;
Jak pisałem wcześniej, ta klasa powinna mieć referencję do klasy zewnętrznej, więc gdzie ona jest? Tuż pod interfejsem znajduje się pole instancji typu LeakActivity
.
# instance fields
.field final synthetic
this$0:Lcom/marcosholgado/performancetest/LeakActivity;
A jeśli spojrzymy na konstruktor naszego Runnable, zobaczymy, że pobiera jeden parametr, czyli LeakActivity
.
.method constructor
<init>(Lcom/marcosholgado/performancetest/LeakActivity;)V
Wracając do bytecode LeakActivity
, gdzie tworzyliśmy instancję LeakActivity$2
, można zobaczyć jak wykorzystuje instancję (przechowywaną w v0) do wywołania konstruktora, który właśnie widzieliśmy, aby przejść przez instancję LeakActivity
.
new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;
invoke-direct {v0, p0},
Lcom/marcosholgado/performancetest/LeakActivity$2;-><init>
(Lcom/marcosholgado/performancetest/LeakActivity;)V
Tak więc nasza klasa LeakActivity.java rzeczywiście by wyciekła, gdyby została zatrzymana przed zakończeniem Runnable, ponieważ ma referencję do aktywności i nie zostałaby sprzątnięta w tym momencie.
Analizowanie KLeakActivity.kt
Jeśli teraz spojrzymy na kod bajtowy KLeakActivity
i ponownie skupimy się na metodzie startAsyncWork
, otrzymamy następujący bytecode.
.method private final startAsyncWork()V
.registers 3
.line 20
sget-object v0,
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
->INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
check-cast v0, Ljava/lang/Runnable;
.line 24
.local v0, "work":Ljava/lang/Runnable;
new-instance v1, Ljava/lang/Thread;
invoke-direct {v1, v0}, Ljava/lang/Thread;-><init>(Ljava/lang/Runnable;)V
invoke-virtual {v1}, Ljava/lang/Thread;->start()V
.line 25
return-void
.end method
W tym przypadku, zamiast tworzyć nową instancję, bytecode wykonuje operację sget-obiekt
.
sget-object v0,
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1; -> INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
Wchodząc nieco głębiej w bytecode KLeakActivity$startAsyncWork$work$1
widzimy, że tak jak wcześniej, ta klasa implementuje Runnable, ale teraz ma statyczną metodę, która nie wymaga instancji klasy zewnętrznej.
.class final Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
.super Ljava/lang/Object;
.source "KLeakActivity.kt"
# interfaces
.implements Ljava/lang/Runnable;
.method static constructor <clinit>()V
.registers 1
new-instance v0,
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
invoke-direct {v0},
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;-><init>()V
sput-object v0,
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
->INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
return-void
.end method
.method constructor <init>()V
.registers 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
return-void
.end method
I dlatego moja KLeakActivity
tak naprawdę w ogóle nie przeciekała, używając lambdy (właściwie SAM), a nie anonimowej klasy wewnętrznej, nie miałem referencji do mojego zewnętrznego activity. Jednak nie możemy powiedzieć, że jest to wyjątkowa cecha Kotlina - jeśli używasz lambdy Java8, wynik jest dokładnie taki sam.
Jeśli chcesz wiedzieć więcej na ten temat, gorąco polecam przeczytanie tego artykułu na temat tłumaczeń wyrażeń lambda, a ja pozwolę sobie na zacytowanie ważnego fragmentu.
Lambdy takie jak te w powyższej sekcji mogą być tłumaczone na metody statyczne, ponieważ nie używają instancji obiektu zamykającego (enclosing instance) w żaden sposób (nie odwołuj się do
this
,super
, czy składowych instancji domknięcia). Będziemy odnosić się do lambd, które używająthis
,super
, lub przechwytują składową z instancji zamykającej, jako lambdy przechwytujące instancje.
Lambdy nie przechwytujące instancji są tłumaczone na prywatne, statyczne metody. Lambdy przechwytujące instancje są tłumaczone na prywatne metody instancji.
Więc o co chodzi? Nasza lambda Kotlina jest lambdą nieprzechwytującą, ponieważ nie wykorzystuje instancji obiektu zamykającego. Gdybyśmy jednak używali, powiedzmy, pola z klasy zewnętrznej, nasza lambda miałaby wtedy referencję do klasy zewnętrznej i powodowała wyciek.
class KLeakActivity : Activity() {
private var test: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_leak)
button.setOnClickListener { startAsyncWork() }
}
private fun startAsyncWork() {
val work = Runnable {
test = 1 // comment this line to pass the test
SystemClock.sleep(20000)
}
Thread(work).start()
}
}
W powyższym przykładzie wykorzystujemy pole test
wewnątrz naszego Runnable, dzięki czemu mamy referencję do aktywności zewnętrznej i powodujemy wyciek pamięci. Patrząc ponownie na bytecode widzimy jak teraz musi przekazać instancję KLeakActivity
do naszego Runnable (linia 9), ponieważ używamy lambdę przechwytującą instancję.
.method private final startAsyncWork()V
.registers 3
.line 20
new-instance v0, Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
invoke-direct {v0, p0},
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
-><init>(Lcom/marcosholgado/performancetest/KLeakActivity;)V
check-cast v0, Ljava/lang/Runnable;
.line 24
.local v0, "work":Ljava/lang/Runnable;
new-instance v1, Ljava/lang/Thread;
invoke-direct {v1, v0}, Ljava/lang/Thread;-><init>(Ljava/lang/Runnable;)V
invoke-virtual {v1}, Ljava/lang/Thread;->start()V
.line 25
return-void
.end method
Podsumowanie
Mam nadzieję, że ten artykuł pomoże Ci zrozumieć nieco więcej na temat SAM, tłumaczeń lambd i tego, jak możesz bezpiecznie używać lambd, nie martwiąc się o wycieki pamięci.
Pamiętaj, że jeśli chcesz sam to wypróbować, cały kod tego z artykułu jest dostępny w tym repozytorium.
Oryginał tekstu w języku angielskim przeczytasz tutaj.