美文网首页android
【译】Kotlin如何帮助避免内存泄漏

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

作者: meStronger | 来源:发表于2020-01-18 11:33 被阅读0次

    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

    相关文章

      网友评论

        本文标题:【译】Kotlin如何帮助避免内存泄漏

        本文链接:https://www.haomeiwen.com/subject/dwhkzctx.html