[譯]Kotlin是如何幫助你避免記憶體洩漏的?

reply-1988發表於2019-03-09

首先,本文的程式碼位置在github.com/marcosholga…中的kotlin-mem-leak分支上。

令人困惑的現象

我是通過建立一個會導致記憶體洩漏的Activity,然後觀察其使用JavaKotlin編寫時的表現來進行測試的。 其中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();
  }
}
複製程式碼

如上述程式碼所示,我們的button點選之後,執行了一個耗時任務。這樣如果我們在20s之內關閉LeakActivity的話就會產生記憶體洩漏,因為這個新開的執行緒持有對LeakActivity的引用。如果我們是在20s之後再關閉這個Activity的話,就不會導致記憶體洩漏。

然後我們把這段程式碼改成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()
    }
}
複製程式碼

咋一看,好像就只是在Runable中使用lambda表示式替換了原來的樣板程式碼。然後我使用leakcanary和我自己的@LeakTest註釋寫了一個記憶體洩漏測試用例。

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

    @Test
    @LeakTest
    fun testLeaks() {
        onView(withId(R.id.button)).perform(click())
    }
}
複製程式碼

我們使用這個用例分別對Java寫的LeakActivityKotlin寫的KLeakActivity進行測試。測試結果是Java寫的出現記憶體洩漏,而Kotlin寫的則沒有出現記憶體洩漏。 這個問題困擾了我很長時間,一度接近自閉。。

[譯]Kotlin是如何幫助你避免記憶體洩漏的?
然後某天,我突然靈光一現,感覺應該和編譯後位元組碼有關係。

分析LeakActivity.java的位元組碼

Java類產生的位元組碼如下:

.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
複製程式碼

我們知道匿名內部類持有對外部類的引用,正是這個引用導致了記憶體洩漏的產生,接下來我們就在位元組碼中找出這個引用。

new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;
複製程式碼

上述位元組碼的含義是: 首先我們建立了一個LeakActivity$2的例項。。

奇怪的是我們沒有建立這個類啊,那這個類應該是系統自動生成的,那它的作用是什麼啊? 我們開啟LeakActivity$2的位元組碼看下

.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
複製程式碼

第一個有意思的事是這個LeakActivity$2實現了Runnable介面。

# interfaces
.implements Ljava/lang/Runnable;
複製程式碼

這就說明LeakActivity$2就是那個持有LeakActivity物件引用的匿名內部類的物件。

就像我們前面說的,這個LeakActivity$2應該持有LeakActivity的引用,那我們繼續找。

# instance fields
.field final synthetic        
    this$0:Lcom/marcosholgado/performancetest/LeakActivity;
複製程式碼

果然,我們發現了外部類LeakActivity的物件的引用。 那這個引用是什麼時候傳入的呢?只有可能是在構造器中傳入的,那我們繼續找它的構造器。

.method constructor 
    <init>(Lcom/marcosholgado/performancetest/LeakActivity;)V
複製程式碼

果然,在構造器中傳入了LeakActivity物件的引用。 讓我們回到LeakActivity的位元組碼中,看看這個LeakActivity$2被初始化的時候。

new-instance v0, Lcom/marcosholgado/performancetest/LeakActivity$2;
invoke-direct {v0, p0},   
    Lcom/marcosholgado/performancetest/LeakActivity$2;-><init>    
    (Lcom/marcosholgado/performancetest/LeakActivity;)V
複製程式碼

可以看到,我們使用LeakActivity物件來初始化LeakActivity$2物件,這樣就解釋了為什麼LeakActivity.java會出現記憶體洩漏的現象。

分析 KLeakActivity.kt的位元組碼

KLeakActivity.kt中我們關注startAsyncWork這個方法的位元組碼,因為其他部分和Java寫法是一樣的,只有這部分不一樣。 該方法的位元組碼如下所示:

.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
複製程式碼

可以看出,與Java位元組碼中初始化一個包含Activity引用的實現Runnable介面物件不同的是,這個位元組碼使用了靜態變數來執行靜態方法。

sget-object v0,         
Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1; -> 
INSTANCE:Lcom/marcosholgado/performancetest/KLeakActivity$startAsyncWork$work$1;
複製程式碼

我們深入KLeakActivity\$startAsyncWork\$work$1的位元組碼看下:

.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\$startAsyncWork\$work$1實現了Runnable介面,但是其擁有的是靜態方法,因此不需要外部類物件的引用。 所以Kotlin不出現記憶體洩漏的原因出來了,在Kotlin中,我們使用lambda(實際上是一個 SAM)來代替Java中的匿名內部類。沒有Activity物件的引用就不會發生記憶體洩漏。 當然並不是說只有Kotlin才有這個功能,如果你使用Java8中的lambda的話,一樣不會發生記憶體洩漏。 如果你想對這部分做更深入的瞭解,可以參看這篇文章Translation of Lambda Expressions

如果有需要翻譯的同學可以在評論裡面說就行啦。

[譯]Kotlin是如何幫助你避免記憶體洩漏的?
現在把其中比較重要的一部分說下:

上述段落中的Lamdba表示式可以被認為是靜態方法。因為它們沒有使用類中的例項屬性,例如使用super、this或者該類中的成員變數。 我們把這種Lambda稱為Non-instance-capturing lambdas(這裡我感覺還是不翻譯為好,英文原文更原汁原味些)。而那些需要例項屬性的Lambda則稱為instance-capturing lambdas

Non-instance-capturing lambdas可以被認為是private、static方法。instance-capturing lambdas可以被認為是普通的private、instance方法。

這段話放在我們這篇文章中是什麼意思呢?

因為我們Kotlin中的lambda沒有使用例項屬性,所以其是一個non-instance-capturing lambda,可以被當成靜態方法來看待,就不會產生記憶體洩漏。

如果我們在其中新增一個外部類物件屬性的引用的話,這個lambda就轉變成instance-capturing lambdas,就會產生記憶體洩漏。

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這個例項屬性,就會導致記憶體洩漏。 startAsyncWork方法的位元組碼如下所示:

.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
複製程式碼

很明顯,我們傳入了KLeakActivity的物件,因此就會導致記憶體洩漏。

啊,終於翻譯完了,可以去睡覺了!!

[譯]Kotlin是如何幫助你避免記憶體洩漏的?

原文地址

How Kotlin helps you avoid memory leaks

相關文章