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

前言

协程系列文章:

人有悲欢离合,月有阴晴圆缺,此事古难全------苏东坡

人有悲欢离合,月有阴晴圆缺,你的协程是否泄漏了?------小鱼人

通过本篇文章,你将了解到:

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

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

内存泄漏检测方式

Profiler抓取

Android官方给我们提供了profiler功能,可以实时观测线程、内存的情况:

选择内存分析,先dump文件:

dump成功后,解析文件:

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

dumpsys meminfo抓取

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

第一步

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

kotlin 复制代码
ps -A | grep perform //perform为我自己包名的简称

24148 即为进程号。

第二步

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

shell 复制代码
dumpsys meminfo 24148

结果如下:

我们只需要关注Activities的值即可。

理论上打开一个Activity这个值就会加1,关闭一个Activity这个值就会减1。

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

一个会泄漏的协程

kotlin 复制代码
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查看是否发生内存泄漏:

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

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

持有外部类对象

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

在该例子里,因为在协程的闭包里持有了SecondActivity对象,而协程的闭包本质上是匿名内部类对象。

Dispatchers.Main表示该闭包将会在主线程执行,而在Android主线程执行势必要通过Looper,因此闭包最终被MessageQueue持有,最终它会被主线程持有,而线程属于一种GC Root,最终的持有关系:

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

不持有外部类对象

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

kotlin 复制代码
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,因此需要关注协程的泄漏问题。

从最直观的方式思考:

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

kotlin 复制代码
override fun onDestroy() {
    super.onDestroy()
    GlobalScope.cancel("我要取消协程")
}

遗憾的是发生了crash:

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

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

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

kotlin 复制代码
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,猜猜会有内存泄漏吗?

kotlin 复制代码
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()
        }
    }
}

答案是:没有内存泄漏。

你可能会问了:此处咱们也没有显式地取消协程,为啥没泄漏呢?

真相只有一个:那就是从源码里寻找答案。

kotlin 复制代码
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本来就是设计为能够取消协程正在执行的动作,没什么那么多为什么。

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

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

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

再换个写法:

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

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

对比前后两者差异可知:

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

取消的原理

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

将以上代码转为Java查看:

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:

kotlin 复制代码
lifecycleScope.launch(Dispatchers.IO) {
    while (true) {
        delay(1000) 
        println ("协程还在运行中...")
    }
}
java 复制代码
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。

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

退出Activity时,打印如下:

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

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

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

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

用一张图解释:

对于有挂起函数的协程,将会完全执行上图流程。

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

kotlin 复制代码
ResultKt.throwOnFailure(var1);

最终也不会抛出异常。

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

答案是:

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

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

协程取消能否中断线程?

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

同样的步骤,进入到Activity后就退出,此时协程将会被取消。

问:2处的语句还会执行吗?

答案是:能

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

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

  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 协程系列全面解读

相关推荐
爱学习的大牛1231 小时前
MVVM 架构 android
android·mvvm
alexhilton3 小时前
理解retain{}的内部机制:Jetpack Compose中基于作用域的状态保存
android·kotlin·android jetpack
꒰ঌ 安卓开发໒꒱4 小时前
Mysql 坏表修复
android·mysql·adb
_李小白4 小时前
【Android Gradle学习笔记】第八天:NDK的使用
android·笔记·学习
袁震4 小时前
Android-Compose 列表组件详解
android·recyclerview·compose
Sky#boy5 小时前
Kotion 常见用法注意事项(持续更新...)
kotlin
沐怡旸5 小时前
【穿越Effective C++】条款02:尽量以const, enum, inline替换#define
c++·面试
CptW5 小时前
第1篇(Ref):搞定 Vue3 Reactivity 响应式源码
前端·面试
2501_916007476 小时前
提升 iOS 26 系统流畅度的实战指南,多工具组合监控
android·macos·ios·小程序·uni-app·cocoa·iphone
zh_xuan6 小时前
android 利用反射和注解绑定控件id和点击事件
android·注解·反射·控件绑定