Я выступал на 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