人有悲欢离合,月有阴晴圆缺,此事古难全——苏东坡
人有悲欢离合,月有阴晴圆缺,你的协程是否泄漏了?——小鱼人
通过本篇文章,你将了解到:
- 如何检测Kotlin协程的内存泄漏?
- Kotlin协程为啥会内存泄漏?
- 如何避免Kotlin协程的内存泄漏?
- 协程挂起和线程挂起的终极混用
- 关注内存泄漏到底有没有现实意义?
Android官方给我们提供了profiler功能,可以实时观测线程、内存的情况:
选择内存分析,先dump文件:
dump成功后,解析文件:
如此一来就可以看到有泄漏了。
Profiler功能很强,但步骤比较多也比较费时,如果只是想查看内存泄漏,可以使用更简单的方式。
第一步
在adb里输入如下命令查看进程的进程号:
ps -A | grep perform //perform为我自己包名的简称
24148 即为进程号。
第二步
拿到进程号后再使用如下命令:
dumpsys meminfo 24148
结果如下:
我们只需要关注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查看是否发生内存泄漏:
很显然,此时只有MainActivity展示了,但此处却显示还有2个Activity对象,SecondActivity 发生了泄漏。
前面有分析过内存泄漏的本质:匿名内部类/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("我会泄漏吗?不会呀")
}
}
}
最简单的方式是协程闭包里不持有外部类的对象。
我们更多的时候需要在闭包里操作UI,因此需要关注协程的泄漏问题。
从最直观的方式思考:
能否在页面退出的时候关闭协程?
override fun onDestroy() {
super.onDestroy()
GlobalScope.cancel("我要取消协程")
}
遗憾的是发生了crash:
意思是该协程作用域(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()
}
}
由上可知:
- lifecycleScope 监听了Activity生命周期,在Activity销毁时会取消协程,因此不会发生泄漏
- 同样的,在实际的应用中不推荐使用GlobalScope,而是使用与Activity/Fragment/ViewMode相关联的scope开启协程,如此一来我们只专注于协程实现业务逻辑
各个组件的协程作用域请参考:狂飙吧,Lifecycle与协程、Flow的化学反应
scope.cancel()/job.cancel()为什么就能够取消协程呢?
你可能会说:cancel本来就是设计为能够取消协程正在执行的动作,没什么那么多为什么。
阁下说的很有道理,倘若我代码写成以下这样子,阁下将如何应对呢?
lifecycleScope.launch(Dispatchers.IO) {
while (true) {
println("协程还在运行中...")
}
}
现实是:即使Activity退出了,协程也没法取消,打印一直持续到天荒地老。
再换个写法:
lifecycleScope.launch(Dispatchers.IO) {
while (true) {
delay(1000)
println("协程还在运行中...")
}
}
当Activity退出时,协程被取消了,打印没了。
对比前后两者差异可知:
- 协程的取消能够打断挂起的函数,对不是挂起的函数不生效
- 协程取消后,挂起函数后面的代码将无法得到执行
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时,打印如下:
确实发生了异常,由此我们得出结论:
当协程里有挂起的函数时并且当前协程被挂起,若此时调用了协程的cancel方法,那么协程会终止挂起函数的执行,并抛出异常阻断后续代码的执行
此时依旧还有两个问题没有解决:
- 为什么非挂起函数不能取消?
- 协程是如何监听到取消指令的?
用一张图解释:
对于有挂起函数的协程,将会完全执行上图流程。
而对于没有挂起函数的协程,那么第5步将不会执行,也就是协程将不会切换状态机的状态(case的值),当然也不会触发到如下语句:
ResultKt.throwOnFailure(var1);
最终也不会抛出异常。
最后一个问题:为啥抛出了异常,协程就没泄漏了?
答案是:
协程体的本质是一个Runnable,提交给了线程执行,当Runnable里的逻辑抛出了异常,那么这个Runnable就执行结束了,也就不会被线程持有,既然没被GC Root持有,那么在GC的时候就有机会被回收
lifecycleScope.launch(Dispatchers.IO) {
Thread.sleep(10000)
println("协程还在运行中...")//2
}
同样的步骤,进入到Activity后就退出,此时协程将会被取消。
问:2处的语句还会执行吗?
答案是:能
可以看出,此时的协程状态机里只有一个状态,并没有挂起函数,依据前面的分析可知协程并不会被成功取消。
这里涉及到线程的挂起和协程挂起的差异:
- 线程的挂起表示当前线程不会占用CPU的执行时间,也就是线程休息了,暂时不干活了
- 协程的挂起表示当前线程执行到挂起函数后就不会往下执行了,当前线程继续去做别的事(执行其它Runnable)
因此,若是在协程里想要延迟一段时间请使用协程相关的挂起函数如Delay等。
建议以下几个步骤:
- 协程里若是没有持有外部类对象(Activity/Fragment/Dialog等),那么此时协程并不会泄漏UI对象
- 若是步骤1不满足,那么需要使用生命周期关联的协程作用域(LifecycleScope/viewModelScope等),当UI组件销毁时自动取消协程
- 协程体里尽量不使用线程相关的API,如Thread.sleep 等
小明说:“现在应用的内存都比较大,最常见的是UI对象的泄漏,不过呢泄漏几个Activity最多浪费了几K的内存,无伤大雅,不需要花费太多的时间在上面”
小刚说:“事虽然小,但有可能是压死骆驼的最后一棵稻草,勿以善小而不为,勿以恶小而为之”
小明继续道:“我们更多的需要关注频繁分配对象与突然间分配大对象的场景"
小刚说:“对于程序员来说,代码洁癖是一种美德,关注内存泄漏对己对人都有裨益”
小码说:“你倆都快领毕业大礼包了,先关心自己能不能抵御这大环境的寒冰真气吧"
小刚:“…"
小码:“…”
阁下意下如何?请把你的想法写在评论上吧。
本篇基于kotlin 1.7.0
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 协程系列全面解读