Я выступал на MobOS, где рассказывал о написании и автоматизации тестирования производительности на Android. Часть своей речи я посвятил обнаружению утечек памяти в процессе интеграционного тестирования. В качестве доказательства я решил создать Activity с помощью Kotlin, который будет приводить к утечкам памяти, однако по неизвестной причине он не выполнил эту функцию. Значит ли это, что Kotlin помогал мне без моего ведома?

Моей целью было написать activity, вызывающий утечку памяти, которую я мог бы обнаружить во время интеграционного тестирования. Поскольку я уже использовал leakcanary, я скопировал образец Activity для воссоздания утечки памяти. Удалив часть кода из образца, я получил следующий класс 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 создается новый Runnable, работающий в течении 20-ти секунд. Поскольку Runnable — это анонимный класс, он скрывает ссылку на внешний LeakActivity. Если activity разрушается до завершения потока (20 секунд после нажатия на кнопку), то происходит утечка памяти. По прошествии 20-ти секунд она прекращается, а activity становится доступен для сбора мусора.

Поскольку я писал код на Kotlin, то преобразовал класс Java в код Kotlin, и получил следующее:

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()
}
}

Ничего особенного, лямбда используется для удаления шаблона из Runnable, и в теории все должно быть в порядке, верно? Затем я написал следующее тестирование с помощью leakcanary и своей аннотации @LeakTest, чтобы запустить анализатор памяти только в этом тестировании.

class LeakTest {
@get:Rule
var mainActivityActivityTestRule = ActivityTestRule(KLeakActivity::class.java)

@Test
@LeakTest
fun testLeaks() {
onView(withId(R.id.button)).perform(click())
}
}

Тестирование выполняет нажатие кнопки, и activity немедленно разрушается, вызвав утечку, поскольку не прошло 20-ти секунд.

Результаты тестирования testLeaks внутри MyKLeakTest показывают отсутствие утечек памяти.

Я решил заменить анонимный класс Java анонимным внутренним классом. Поскольку он является экземпляром функционального интерфейса, то я использовал лямбда-выражение.

Я написал новый activity и использовал тот же самый код, но на этот раз сохранил его в Java. Изменил тестирование по направлению к новому Activity, запустил и… оно дало сбой. Ситуация начала проясняться. Код Kotlin должен отличаться от кода Java. Есть лишь одно место, где можно узнать, что произошло. Байт-код.

Анализ LeakActivity.java

Я начал с анализа Dalvik Bytecode для activity в Java. Для этого нужно проанализировать apk с помощью Build/Analyze APK..., а затем выбрать класс для анализа из файла classes.dex.

Нажимаем на класс и выбираем Show Bytecode для получения Dalvik Bytecode класса. Рассмотрим метод startAsyncWork, поскольку там происходят утечки памяти.

.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

Анонимный класс содержит ссылку на внешний класс, который нам и нужен. В байт-коде выше мы создали новый экземпляр LeakActivity$2 и сохранили его в v0.

new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;

Но что такое LeakActivity$2? Его можно обнаружить в файле classes.dex.

Рассмотрим Dalvik bytecode для этого класса. Я удалил ненужную часть кода из результата.

.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

Можно заметить, что этот класс реализует Runnable.

# interfaces
.implements Ljava/lang/Runnable;

Как было сказано ранее, он должен содержать ссылку на внешний класс. Так где же она? Ниже приведен интерфейс с экземпляром поля типа LeakActivity.

# instance fields
.field final synthetic
this$0:Lcom/marcosholgado/performancetest/LeakActivity;

Конструктор Runnable обладает лишь одним параметром — LeakActivity.

.method constructor 
<init>(Lcom/marcosholgado/performancetest/LeakActivity;)V

Байт-код LeakActivity, в котором был создан экземпляр LeakActivity$2, использует его (он сохранен в v0) для вызова конструктора, чтобы передать экземпляр LeakActivity.

new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;

invoke-direct {v0, p0},
Lcom/marcosholgado/performancetest/LeakActivity$2;-><init>
(Lcom/marcosholgado/performancetest/LeakActivity;)V

Поэтому при разрушении класса LeakActivity.java до окончания работы Runnable происходит утечка. Дело в том, что он ссылается на activity и не будет доступен для сбора мусора.

Анализ KLeakActivity.kt

Переходим к Dalvik bytecode для KLeakActivity, берем метод startAsyncWork и получаем следующий байт-код.

.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

В этом случае, вместо создания нового экземпляра, байт-код выполняет операцию sget-object для статического поля объекта с указанным статическим полем.

sget-object v0,         
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1; ->

INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;

Заглянув внутрь байт-кода KLeakActivity$startAsyncWork$work$1 , можно обнаружить, что, как и прежде, этот класс реализует Runnable, но теперь он обладает методом static, которому не нужен экземпляр внешнего класса.

.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

По этой причине KLeakActivity не вызывал утечки, поскольку, используя лямбду (по сути, SAM), а не анонимный внутренний класс, я не оставлял ссылку на внешний activity. Однако дело не в Kotlin, поскольку при использовании лямбды в Java8 получается тот же результат.

Подобные лямбды можно перевести в методы static, поскольку они не используют экземпляр закрывающего объекта (не ссылаются на this, super или члены закрывающего экземпляра.) В целом, мы ссылаемся на лямбды, которые используют this, super или членов закрывающего экземпляра в качестве лямбды instance-capturing.

Лямбды non-instance-capturing переводятся в методы private и static. Лямбды instance-capturing переводятся в частные методы экземпляра

К чему все это? Лямбда Kotlin не является non-instance-capturing, поскольку не использует экземпляр закрывающего объекта. Однако при использовании поля из внешнего класса, лямбда ссылается на внешний класс и вызывает утечку.

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()
}
}

В приведенном выше примере мы используем поле test внутри Runnable, следовательно, ссылаемся на внешний activity и создаем утечку памяти. Экземпляр KLeakActivity нужно передать в Runnable, поскольку мы используем лямбду instance-capture.

.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

Это все. Надеюсь, вы разобрались в том, что такое SAM, перевод лямбда-выражений, и как использовать лямбды non-capturing, чтобы не беспокоиться об утечках памяти.

Код из статьи доступен в репозитории.


Перевод статьи Marcos Holgado: How Kotlin helps you avoid memory leaks

Предыдущая статьяПрозрачность: иллюзия единой системы. Часть 1
Следующая статьяКак организовать Express-контроллеры для крупных баз кода