中秋月圆之夜,我与协程的泄漏做斗争

前言

人有悲欢离合,月有阴晴圆缺,此事古难全——苏东坡
人有悲欢离合,月有阴晴圆缺,你的协程是否泄漏了?——小鱼人
通过本篇文章,你将了解到:

  1. 如何检测Kotlin协程的内存泄漏?
  2. Kotlin协程为啥会内存泄漏?
  3. 如何避免Kotlin协程的内存泄漏?
  4. 协程挂起和线程挂起的终极混用
  5. 关注内存泄漏到底有没有现实意义?

1. 如何检测Kotlin协程的内存泄漏?

内存泄漏检测方式

Profiler抓取

Android官方给我们提供了profiler功能,可以实时观测线程、内存的情况:
选择内存分析,先dump文件:

中秋月圆之夜,我与协程的泄漏做斗争_第1张图片

dump成功后,解析文件:

中秋月圆之夜,我与协程的泄漏做斗争_第2张图片

如此一来就可以看到有泄漏了。

dumpsys meminfo抓取

Profiler功能很强,但步骤比较多也比较费时,如果只是想查看内存泄漏,可以使用更简单的方式。

第一步
在adb里输入如下命令查看进程的进程号:

ps -A | grep perform //perform为我自己包名的简称

image.png

24148 即为进程号。

第二步
拿到进程号后再使用如下命令:

dumpsys meminfo 24148

结果如下:

中秋月圆之夜,我与协程的泄漏做斗争_第3张图片

我们只需要关注Activities的值即可。
理论上打开一个Activity这个值就会加1,关闭一个Activity这个值就会减1。

如果只是打开了n个Activity,而此处的值>n,那么就可以判定发生了内存泄漏

一个会泄漏的协程

class SecondActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySecondBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivitySecondBinding.inflate(layoutInflater)
        setContentView(binding.root)


        GlobalScope.launch(Dispatchers.Main) {
            delay(10000)
            Toast.makeText(this@SecondActivity, "toast", Toast.LENGTH_LONG).show()
        }
    }
}

在Activity的onCreate()里开启一个协程,并延时10s弹出toast。
当从MainActivity点击进入SecondActivity,然后快速退出 SecondActivity回到MainActivity。

此时通过adb查看是否发生内存泄漏:

中秋月圆之夜,我与协程的泄漏做斗争_第4张图片

很显然,此时只有MainActivity展示了,但此处却显示还有2个Activity对象,SecondActivity 发生了泄漏。

2. Kotlin协程为啥会内存泄漏?

持有外部类对象

前面有分析过内存泄漏的本质:匿名内部类/Lambda Java和Kotlin谁会导致内存泄漏?

在该例子里,因为在协程的闭包里持有了SecondActivity对象,而协程的闭包本质上是匿名内部类对象。
Dispatchers.Main表示该闭包将会在主线程执行,而在Android主线程执行势必要通过Looper,因此闭包最终被MessageQueue持有,最终它会被主线程持有,而线程属于一种GC Root,最终的持有关系:

主线程持有了SecondActivity对象,当SecondActivity退出时,由于还被主线程持有,因此无法释放,最终导致内存泄漏

不持有外部类对象

当然,如果协程的闭包里不持有外部类对象,那么无论如何都不会泄漏Activity,如下代码:

class SecondActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySecondBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivitySecondBinding.inflate(layoutInflater)
        setContentView(binding.root)


        GlobalScope.launch(Dispatchers.Main) {
            delay(10000)
            println("我会泄漏吗?不会呀")
        }
    }
}

3. 如何避免Kotlin协程的内存泄漏?

青铜级避免

最简单的方式是协程闭包里不持有外部类的对象。
我们更多的时候需要在闭包里操作UI,因此需要关注协程的泄漏问题。

从最直观的方式思考:

能否在页面退出的时候关闭协程?

override fun onDestroy() {
    super.onDestroy()
    GlobalScope.cancel("我要取消协程")
}

遗憾的是发生了crash:

image.png

意思是该协程作用域(GlobalScope)底下没有任何的Job。

仔细想想也是如此,若是GlobalScope.cancel()能够取消协程的执行,那么其它也用了GlobalScope开启的协程不就被我们cancel掉了吗?

既然如此,尝试cancel指定的Job。

job = GlobalScope.launch(Dispatchers.Main) {
    delay(10000)
    Toast.makeText(this@SecondActivity, "toast", Toast.LENGTH_LONG).show()
}


override fun onDestroy() {
    super.onDestroy()
    job.cancel("")
} 

这次程序没有Crash,退出Activity后也没有弹出Toast,说明协程被cancel掉了。

王者级避免

在onDestroy()里进行资源的回收是比较古老的操作了,自从有了Lifecycle组件,生命周期的监听变得简单易上手,并且Lifecycle还扩展了协程作用域,因此我们可以只关注使用协程来实现业务逻辑,而无需关心它的生命周期。

同样的测试方式,只是使用lifecycleScope替换了GlobalScope,猜猜会有内存泄漏吗?

class SecondActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySecondBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivitySecondBinding.inflate(layoutInflater)
        setContentView(binding.root)

        lifecycleScope.launch {
            delay(4000)
            Toast.makeText(this@SecondActivity, "toast", Toast.LENGTH_LONG).show()
        }
    }
}

答案是:没有内存泄漏。

你可能会问了:此处咱们也没有显式地取消协程,为啥没泄漏呢?
真相只有一个:那就是从源码里寻找答案。

public val Lifecycle.coroutineScope: LifecycleCoroutineScope
    get() {
        while (true) {
            val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
            if (existing != null) {
                return existing
            }
            //构造协程作用域
            val newScope = LifecycleCoroutineScopeImpl(
                this,
                SupervisorJob() + Dispatchers.Main.immediate
            )
            if (mInternalScopeRef.compareAndSet(null, newScope)) {
                //注册监听Activity生命周期
                newScope.register()
                return newScope
            }
        }
    }
    
fun register() {
    launch(Dispatchers.Main.immediate) {
        if (lifecycle.currentState >= Lifecycle.State.INITIALIZED) {
           //监听生命周期
           lifecycle.addObserver(this@LifecycleCoroutineScopeImpl)
        } else {
            //如果Activity已经关闭,则取消协程
            coroutineContext.cancel()
        }
    }
}

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
    if (lifecycle.currentState <= Lifecycle.State.DESTROYED) {
        lifecycle.removeObserver(this)
        //监听到Activity关闭,则取消协程
        coroutineContext.cancel()
    }
}

由上可知:

  1. lifecycleScope 监听了Activity生命周期,在Activity销毁时会取消协程,因此不会发生泄漏
  2. 同样的,在实际的应用中不推荐使用GlobalScope,而是使用与Activity/Fragment/ViewMode相关联的scope开启协程,如此一来我们只专注于协程实现业务逻辑

各个组件的协程作用域请参考:狂飙吧,Lifecycle与协程、Flow的化学反应

协程取消的原理

什么场景下能够取消协程

scope.cancel()/job.cancel()为什么就能够取消协程呢?
你可能会说:cancel本来就是设计为能够取消协程正在执行的动作,没什么那么多为什么。

阁下说的很有道理,倘若我代码写成以下这样子,阁下将如何应对呢?

lifecycleScope.launch(Dispatchers.IO) {
    while (true) {
        println("协程还在运行中...")
    }
}

中秋月圆之夜,我与协程的泄漏做斗争_第5张图片

现实是:即使Activity退出了,协程也没法取消,打印一直持续到天荒地老。

再换个写法:

lifecycleScope.launch(Dispatchers.IO) {
    while (true) {
        delay(1000)
        println("协程还在运行中...")
    }
}

当Activity退出时,协程被取消了,打印没了。

对比前后两者差异可知:

  1. 协程的取消能够打断挂起的函数,对不是挂起的函数不生效
  2. 协程取消后,挂起函数后面的代码将无法得到执行

取消的原理

lifecycleScope.launch(Dispatchers.IO) {
    while (true) {
        println("协程还在运行中...")
    }
}

将以上代码转为Java查看:

public final Object invokeSuspend(@NotNull Object var1) {
   Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
   switch (this.label) {
      case 0:
         //检测是否有异常
         ResultKt.throwOnFailure(var1);

         while(true) {
            String var2 = "协程还在运行中...";
            System.out.println(var2);
         }
      default:
         throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
   }
}

同样的,也将如下代码转为Java:

lifecycleScope.launch(Dispatchers.IO) {
    while (true) {
        delay(1000) 
        println ("协程还在运行中...")
    }
}
public final Object invokeSuspend(@NotNull Object $result) {
   Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
   String var2;
   switch (this.label) {
      case 0:
         //检测异常,若是则抛出
         ResultKt.throwOnFailure($result);
         break;
      case 1:
         //检测异常,若是则抛出
         ResultKt.throwOnFailure($result);
         var2 = "协程还在运行中...";
         System.out.println(var2);
         break;
      default:
         throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
   }

   while(true) {
      this.label = 1;
      if (DelayKt.delay(1000L, this) == var3) {
         return var3;
      }

      var2 = "协程还在运行中...";
      System.out.println(var2);
   }
}

可以看出,两者的相同点是:

协程闭包执行每一个分支前都判断是否有异常,若是则抛出

异同点是:

一个有挂起函数,另一个没有

invokeSuspend()对应的就是协程的闭包逻辑。
既然可能会抛出异常,那我们尝试在协程的闭包里新增try…catch。

lifecycleScope.launch(Dispatchers.IO) {
    try {
        delay(30000)
        println("协程还在运行中...")
    } catch (e: Exception) {
        println("发生了异常:${e.localizedMessage}")
    }
}

退出Activity时,打印如下:

image.png

确实发生了异常,由此我们得出结论:

当协程里有挂起的函数时并且当前协程被挂起,若此时调用了协程的cancel方法,那么协程会终止挂起函数的执行,并抛出异常阻断后续代码的执行

此时依旧还有两个问题没有解决:

  1. 为什么非挂起函数不能取消?
  2. 协程是如何监听到取消指令的?

用一张图解释:

中秋月圆之夜,我与协程的泄漏做斗争_第6张图片

对于有挂起函数的协程,将会完全执行上图流程。
而对于没有挂起函数的协程,那么第5步将不会执行,也就是协程将不会切换状态机的状态(case的值),当然也不会触发到如下语句:

ResultKt.throwOnFailure(var1);

最终也不会抛出异常。

最后一个问题:为啥抛出了异常,协程就没泄漏了?

答案是:

协程体的本质是一个Runnable,提交给了线程执行,当Runnable里的逻辑抛出了异常,那么这个Runnable就执行结束了,也就不会被线程持有,既然没被GC Root持有,那么在GC的时候就有机会被回收

4. 协程挂起和线程挂起的终极混用

协程取消能否中断线程?

lifecycleScope.launch(Dispatchers.IO) {
    Thread.sleep(10000)
    println("协程还在运行中...")//2
}

同样的步骤,进入到Activity后就退出,此时协程将会被取消。
问:2处的语句还会执行吗?

答案是:能

中秋月圆之夜,我与协程的泄漏做斗争_第7张图片

可以看出,此时的协程状态机里只有一个状态,并没有挂起函数,依据前面的分析可知协程并不会被成功取消。

这里涉及到线程的挂起和协程挂起的差异:

  1. 线程的挂起表示当前线程不会占用CPU的执行时间,也就是线程休息了,暂时不干活了
  2. 协程的挂起表示当前线程执行到挂起函数后就不会往下执行了,当前线程继续去做别的事(执行其它Runnable)

因此,若是在协程里想要延迟一段时间请使用协程相关的挂起函数如Delay等。

如何编写没还有泄漏的协程代码?

建议以下几个步骤:

  1. 协程里若是没有持有外部类对象(Activity/Fragment/Dialog等),那么此时协程并不会泄漏UI对象
  2. 若是步骤1不满足,那么需要使用生命周期关联的协程作用域(LifecycleScope/viewModelScope等),当UI组件销毁时自动取消协程
  3. 协程体里尽量不使用线程相关的API,如Thread.sleep 等

5. 关注内存泄漏到底有没有现实意义?

小明说:“现在应用的内存都比较大,最常见的是UI对象的泄漏,不过呢泄漏几个Activity最多浪费了几K的内存,无伤大雅,不需要花费太多的时间在上面”

小刚说:“事虽然小,但有可能是压死骆驼的最后一棵稻草,勿以善小而不为,勿以恶小而为之”

小明继续道:“我们更多的需要关注频繁分配对象与突然间分配大对象的场景"

小刚说:“对于程序员来说,代码洁癖是一种美德,关注内存泄漏对己对人都有裨益”

小码说:“你倆都快领毕业大礼包了,先关心自己能不能抵御这大环境的寒冰真气吧"

小刚:“…"
小码:“…”

阁下意下如何?请把你的想法写在评论上吧。

本篇基于kotlin 1.7.0

您若喜欢,请点赞、关注、收藏,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android/Kotlin

1、Android各种Context的前世今生
2、Android DecorView 必知必会
3、Window/WindowManager 不可不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事件分发全套服务
6、Android invalidate/postInvalidate/requestLayout 彻底厘清
7、Android Window 如何确定大小/onMeasure()多次执行原因
8、Android事件驱动Handler-Message-Looper解析
9、Android 键盘一招搞定
10、Android 各种坐标彻底明了
11、Android Activity/Window/View 的background
12、Android Activity创建到View的显示过
13、Android IPC 系列
14、Android 存储系列
15、Java 并发系列不再疑惑
16、Java 线程池系列
17、Android Jetpack 前置基础系列
18、Android Jetpack 易学易懂系列
19、Kotlin 轻松入门系列
20、Kotlin 协程系列全面解读

你可能感兴趣的:(android,kotlin,开发语言)