android

【译】Kotlin如何帮助避免内存泄漏

2020-01-18  本文已影响0人  meStronger

Kotlin如何避免内存泄漏

本文翻译自马科斯·霍尔加多(Marcos Holgado)发表的《How Kotlin helps you avoid memory leaks》, 感兴趣的可以查看原文,链接可能打不开,但对于会魔法的终极魔法师应该不是什么问题。

下面是正文:

上周,我在MobOS上发表了有关在Android中编写和自动化性能测试的演讲作为演讲的一部分,我想演示如何在集成测试期间检测内存泄漏。为了证明这一点,我使用Kotlin创建了一个Activity,该Activity应该会泄漏内存,但是由于某种原因却没有。Kotlin是在不知不觉中帮助我吗?

在开始之前,本文的代码可在kotlin-mem-leak我的性能测试存储库的分支中找到:

https://github.com/marcosholgado/performance-test/tree/kotlin-mem-leak

整个前提很简单,我想编写一个会泄漏内存的Activity,以便在集成测试中可以检测到该Activity。因为我已经在使用leacanary,所以我复制了他们的示例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的匿名引用,如果LeakActivity在线程完成之前(按钮按下后20秒内)被销毁,则LeakActivity将泄漏。不过,它不会永远泄漏,在那20秒之后,将可以再次进行垃圾收集。

然后我用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()
    }
}

这里的代码并没有特别之处,我利用了lambda的优点优化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秒再关闭Activity。

如果我们执行testLeaks的测试,将会看到MyKLeakTest的测试通过,这意味着我们未检测到任何内存泄漏。

这个结果使我很困惑。

我感觉自己如此愚蠢,甚至于我在推特上写道:

How Kotlin helps you avoid memory leaks

并得到了让我笑的答复。我希望我的技能达到那个水平:D

How Kotlin helps you avoid memory leaks

人们很容易陷入“总觉得哪里不对劲,但就是不知道哪里有问题”的死循环中,于是我决定从头再来。

我编写了一个新Activity,使用相同的代码,但是这次我将其保存在Java中。我将测试更改为指向此新Activity,然后运行它,这次…测试用例没通过。现在事情开始变得更有意义了。Kotlin代码肯定与Java代码不同,想知道有什么不同,只有一个地方可以找到它,那就是字节码

分析LeakActivity.java

首先,我分析了Java Activity的Dalvik字节码。为此,您可以通过分析apk Build/Analyze APK...,然后从classes.dex文件中选择要分析的类。

image.png

我们右键单击该类,然后选择Show Bytecode以获取该类的Dalvik字节码。我将只关注该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(第10行)中。

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

LeakActivity$2是什么呢?如果我们继续查看我们的classes.dex文件,您将在此处找到它。

image.png

因此,让我们看看该类的Dalvik字节码。我从结果中删除了一些我们不太关心的代码。

.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 构造函数
<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完成之前被杀死,则确实会泄漏,因为它持有LeakActivity的引用,并且此时不会被垃圾回收。

分析KLeakActivity.kt

如果现在查看KLeakActivity.kt的Dalvik字节码,然后只看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

可以看到,这里的字节码没有在创建新实例时传入Activity的引用,而是在sget-object执行操作,该操作使用static标识把Runable标记成静态字段。

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

更深入地查看KLeakActivity$startAsyncWork$work$1字节码,我们可以看到,像以前一样,该类实现了Runnable,但是现在它具有一个静态方法,不需要外部类的实例。

.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没有真正泄漏任何东西的原因,通过使用lambda(实际上是SAM)而不是匿名内部类,我没有保留对我外部Activity的引用。但是,这不能地说这是Kotlin特有的,如果您使用的是Java8 lambda,则结果是完全相同的。

如果您想了解更多有关此的内容,我强烈建议您阅读有关lambda翻译的本文,但我将为您重点介绍。

像那些在上面的部分lambda表达式可以转换为静态方法,因为它们不以任何方式使用封闭对象实例( enclosing object instance)(不是指this,super或封闭实例的成员。)总之,我们将把lambda表达式是使用this,super或将封闭实例的成员捕获为实例捕获lambdas。非实例捕获(non-instance-capturing)的lambda转换为私有的静态方法。捕获实例(instance-capturing)的lambda转换为私有实例方法

那是什么意思呢?我们的Kotlin Lambda是一个非实例捕获的Lambda,因为未使用封闭对象实例。但是,如果我们使用来自外部类的字段,那么我们的lambda将持有对外部类的引用和造成泄漏。

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

在上面的示例中,我们看到Runnable引用了test字段,因此它持有了外部类Activity的引用并造成了内存泄漏。再次查看字节码,您会发现它如何将KLeakActivity实例传递给我们的Runnable(第9行),我们现在使用的是实例捕获lambda。

.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,lambda转换以及如何安全地使用非捕获的lambda,而不必担心内存泄漏。

请记住,如果您想尝试此操作,可以在此github代码仓库获得本文的所有代码。

我意识到这不是一个非常简单的话题,因此如果您有任何疑问或认为我在某个地方搞砸了,请在Twitter上发表评论或联系。(作者的Twitter:orbycius

上一篇下一篇

猜你喜欢

热点阅读