Sytuacja kobiet w IT w 2024 roku
8.07.20198 min
Marcos Holgado

Marcos HolgadoSenior Android DeveloperSky

Jak Kotlin pomaga uniknąć wycieków pamięci

Dowiedz się więcej na temat SAM, tłumaczeń lambd i tego, jak możesz bezpiecznie używać ich w Kotlinie, nie martwiąc się o wycieki pamięci.

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.

<p>Loading...</p>