Kotlin 协程生命周期和内存管理

1. 为什么要关闭或者取消一个协程

协程是一种轻量级的线程,可以在一个线程中并发执行多个任务。但是,并不是所有的协程都需要一直运行,有些协程可能会在某些条件下失去执行的必要或者意义。例如,协程可能会因为超时、错误、用户请求、业务逻辑等原因需要终止。如果不及时关闭或者取消这些协程,可能会导致内存泄漏、资源浪费、程序崩溃等问题。因此,我们需要在合适的时机关闭或者取消不需要的协程,以保证程序的健壮性和性能。

1.1 协程的生命周期

协程的生命周期是指协程从创建到销毁的过程,它可以分为以下几个阶段:

  • 新建:协程被创建,但还没有开始执行
  • 活动:协程开始执行,并在遇到 suspend 函数时暂停和恢复
  • 完成:协程正常结束或者异常终止,并返回结果或者抛出异常
  • 取消:协程收到取消信号,并停止执行

下图是一个协程生命周期的示意图:

协程生命周期的示意图

1.2 协程的状态

协程的状态是指协程在生命周期中的具体状态,它可以用一个 Job 对象来表示。Job 对象是一个接口,它定义了一些属性和方法来控制和监视协程的状态。Job 对象可以分为以下几种类型:

  • Job:最基本的 Job 类型,它只有一个 isActive 属性,表示协程是否处于活动状态
  • CancellableJob:一个可取消的 Job 类型,它有一个 isCancelled 属性,表示协程是否已经被取消,以及一个 cancel 方法,用于取消协程
  • CompletableJob:一个可完成的 Job 类型,它有一个 isCompleted 属性,表示协程是否已经完成,以及一个 complete 方法,用于完成协程
  • Deferred:一个可延迟的 Job 类型,它有一个 await 方法,用于等待协程返回结果

下图是一个 Job 类型层次结构的示意图:

Job 类型层次结构的示意图

2. 如何关闭或者取消一个协程

关闭或者取消一个协程主要有两个步骤:发送取消信号和捕捉取消信号。发送取消信号是指向协程发送一个终止执行的通知,让协程知道需要停止运行。捕捉取消信号是指协程在接收到取消信号后,执行相应的操作,如释放资源、清理状态、返回结果等。发送和捕捉取消信号的方式有多种,常见的有以下几种:

  • 使用全局变量:如果全局变量为真就退出
  • 使用通道:协程在通道里面取到 true 或者 nil 就退出
  • 使用 context:通过调用 ctx.Done () 方法通知所有的协程退出

2.1 使用全局变量

使用全局变量是一种最简单的取消协程的方法,它只需要定义一个全局的布尔变量,然后在协程中检查这个变量的值,如果为真就退出。这种方法的优点是简单易用,缺点是不够灵活,不能针对单个协程进行取消,也不能传递取消的原因。下面是一个使用全局变量取消协程的示例:

import kotlinx.coroutines.*

// 定义一个全局的布尔变量
var isCancelled = false

fun main() {
    // 创建一个 CoroutineScope 对象
    val scope = CoroutineScope(Dispatchers.Default)
    // 在 scope 中启动一个协程
    scope.launch {
        // 在协程中循环打印信息
        while (true) {
            // 检查全局变量的值,如果为真就退出
            if (isCancelled) {
                println("Coroutine is cancelled")
                break
            }
            println("Coroutine is running")
            // 暂停一段时间
            delay(1000L)
        }
    }
    // 主线程等待一段时间
    Thread.sleep(3000L)
    // 修改全局变量的值为真
    isCancelled = true
    // 主线程等待一段时间
    Thread.sleep(1000L)
}

输出结果:

Coroutine is running
Coroutine is running
Coroutine is running
Coroutine is cancelled

2.2 使用通道

使用通道是一种更灵活的取消协程的方法,它可以利用 Kotlin 的 Channel 类来实现协程之间的通信。Channel 类是一种类似于队列的数据结构,它可以让协程之间发送和接收数据。我们可以在 Channel 中发送一个特殊的值,如 true 或者 nil,来表示取消信号,然后在协程中从 Channel 中取出这个值,如果收到了就退出。这种方法的优点是可以针对单个或者多个协程进行取消,也可以传递取消的原因,缺点是需要额外创建和管理 Channel 对象。下面是一个使用通道取消协程的示例:

import kotlinx.coroutines.*

fun main() {
    // 创建一个 CoroutineScope 对象
    val scope = CoroutineScope(Dispatchers.Default)
    // 创建一个 Channel 对象
    val channel = Channel<Boolean>()
    // 在 scope 中启动一个协程
    scope.launch {
        // 在协程中循环打印信息
        while (true) {
            // 从 Channel 中取出一个值,如果为 true 或者 nil 就退出
            val value = channel.poll()
            if (value == true || value == null) {
                println("Coroutine is cancelled")
                break
            }
            println("Coroutine is running")
            // 暂停一段时间
            delay(1000L)
        }
    }
    // 主线程等待一段时间
    Thread.sleep(3000L)
    // 向 Channel 中发送一个 true 值
    channel.send(true)
    // 主线程等待一段时间
    Thread.sleep(1000L)
}

输出结果:

Coroutine is running
Coroutine is running
Coroutine is running
Coroutine is cancelled

2.3 使用 context

使用 context 是一种最推荐的取消协程的方法,它可以利用 Kotlin 的 CoroutineContext 类来实现协程之间的上下文传递。CoroutineContext 类是一种类似于字典的数据结构,它可以存储一些与协程相关的元素,如 Job、Dispatcher、Name 等。我们可以在 CoroutineContext 中获取一个 Job 对象,并调用它的 cancel 方法来发送取消信号,然后在协程中调用 isActive 属性或者 yield函数来捕捉取消信号,并执行相应的操作。这种方法的优点是可以利用协程的层次结构和作用域来实现协程的自动取消,也可以使用 cancelAndJoin 或者 await 等函数来等待协程的关闭,缺点是需要注意协程的取消异常和取消点的设置。下面是一个使用 context 取消协程的示例:

import kotlinx.coroutines.*

fun main() {
    // 创建一个 CoroutineScope 对象
    val scope = CoroutineScope(Dispatchers.Default)
    // 在 scope 中启动一个协程
    val job = scope.launch {
        // 在协程中启动另一个子协程
        launch {
            // 在子协程中循环打印信息
            while (true) {
                // 检查 isActive 属性,如果为假就退出
                if (!isActive) {
                    println("Child coroutine is cancelled")
                    break
                }
                println("Child coroutine is running")
                // 暂停一段时间
                delay(1000L)
            }
        }
        // 在协程中循环打印信息
        while (true) {
            // 调用 yield 函数,如果收到取消信号就退出
            yield()
            println("Parent coroutine is running")
            // 暂停一段时间
            delay(1000L)
        }
    }
    // 主线程等待一段时间
    Thread.sleep(3000L)
    // 调用 job 的 cancel 方法,向所有的子协程发送取消信号
    job.cancel()
    // 调用 job 的 join 方法,等待所有的子协程关闭
    job.join()
}

输出结果:

Child coroutine is running
Parent coroutine is running
Child coroutine is running
Parent coroutine is running
Child coroutine is running
Parent coroutine is running
Child coroutine is cancelled
Parent coroutine is cancelled

3. 如何管理协程的内存

协程的内存管理主要涉及到堆栈帧和协程对象的创建和销毁。堆栈帧是用于保存协程局部变量和状态的数据结构,每个协程都有自己的堆栈帧。协程对象是用于保存协程元数据和引用的对象,每个协程都对应一个协程对象。协程的内存管理需要考虑以下几个方面:

  • 堆栈帧的大小和数量:堆栈帧越大或越多,占用的内存越多
  • 堆栈帧的复制和恢复:堆栈帧在暂停和恢复时需要进行复制和恢复,这会消耗一定的时间和空间
  • 协程对象的创建和销毁:协程对象在创建和销毁时需要分配和回收内存,这会产生一定的开销
  • 协程对象的引用:协程对象如果被其他对象引用,可能会导致内存泄漏或无法回收

3.1 堆栈帧的大小和数量

堆栈帧是用于保存协程局部变量和状态的数据结构,每个协程都有自己的堆栈帧。堆栈帧的大小取决于协程中定义的局部变量的数量和类型,以及调用的函数的数量和参数。堆栈帧的数量取决于协程中调用的函数的层次深度。堆栈帧越大或越多,占用的内存越多。

为了减少堆栈帧的大小和数量,我们可以采取以下一些措施:

  • 尽量避免在协程中定义过多或过大的局部变量,尤其是数组、集合、映射等容器类型,它们会消耗大量的内存空间。
  • 尽量避免在协程中调用过多或过深的函数,尤其是递归函数,它们会增加堆栈帧的数量和深度。
  • 尽量避免在协程中使用全局变量或者闭包,它们会导致协程持有外部对象的引用,从而增加内存的占用和泄漏的风险。

3.2 堆栈帧的复制和恢复

堆栈帧在暂停和恢复时需要进行复制和恢复,这会消耗一定的时间和空间。当一个协程遇到一个 suspend 函数时,它会将自己的堆栈帧复制到一个 Continuation 对象中,并将控制权交给其他协程或者调用者。当一个协程收到一个 resume 信号时,它会从 Continuation 对象中恢复自己的堆栈帧,并从暂停的地方继续执行。

为了减少堆栈帧的复制和恢复,我们可以采取以下一些措施:

  • 尽量避免在协程中频繁地调用 suspend 函数,尤其是在循环或者条件判断中,它们会导致协程不断地暂停和恢复,从而增加堆栈帧的复制和恢复的次数。
  • 尽量避免在协程中使用多个 suspend 函数,尤其是嵌套或者并发地使用,它们会导致协程创建多个 Continuation 对象,从而增加堆栈帧的复制和恢复的开销。
  • 尽量避免在协程中使用非局部返回或者异常抛出,它们会导致协程跳出当前的执行点,从而增加堆栈帧的复制和恢复的难度。

3.3 协程对象的创建和销毁

协程对象是用于保存协程元数据和引用的对象,每个协程都对应一个协程对象。协程对象在创建和销毁时需要分配和回收内存,这会产生一定的开销。协程对象的类型取决于协程构建器的选择,如 launch、async、coroutineScope 等。不同类型的协程对象有不同的功能和性能特点。

为了减少协程对象的创建和销毁,我们可以采取以下一些措施:

  • 根据业务需求合理选择协程构建器,尽量避免创建不必要或过多的协程对象。例如,如果不需要返回结果或等待子协程完成,就可以使用 launch 而不是 async;如果不需要创建新的作用域或上下文,就可以使用 runBlocking 而不是 coroutineScope。
  • 根据业务逻辑合理划分协程作用域,尽量避免创建超出生命周期范围的协程对象。例如,如果需要在 ViewModel 中使用协程,就可以使用 viewModelScope 而不是 GlobalScope;如果需要在 Activity 中使用协程,就可以使用 lifecycleScope 而不是 CoroutineScope。
  • 根据业务场景合理重用或回收协程对象,尽量避免频繁地创建或销毁协程对象。例如,如果需要在多个地方使用相同的上下文或调度器,就可以使用 withContext 而不是每次都创建新的 CoroutineScope;如果需要在多次调用之间保持协程状态或结果,就可以使用 CoroutineStart.LAZY 或者 flow 而不是每次都重新启动新的协程。

3.4 协程对象的引用

协程对象如果被其他对象引用,可能会导致内存泄漏或无法回收。协程对象如果被其他对象引用,可能会导致内存泄漏或无法回收。内存泄漏是指协程对象占用的内存无法被垃圾回收器回收,从而导致内存资源的浪费。无法回收是指协程对象无法正常地完成或取消,从而导致协程状态的不一致。

为了避免协程对象的引用问题,我们可以采取以下一些措施:

  • 尽量避免在协程中使用全局变量或者闭包,它们会导致协程持有外部对象的引用,从而增加内存的占用和泄漏的风险。
  • 尽量避免在协程中使用强引用或者长生命周期的引用,它们会导致协程对象无法被垃圾回收器回收,从而导致内存资源的浪费。
  • 尽量使用弱引用或者短生命周期的引用,它们会在协程对象不再被使用时自动释放,从而避免内存泄漏或无法回收的问题。
  • 尽量使用协程作用域或者生命周期感知组件,它们会在合适的时机自动取消或完成协程,从而避免协程状态的不一致。

4. 协程取消和线程取消的思路对比

协程取消和线程取消都是一种终止执行的操作,但是它们有一些不同的思路和特点。下面我们来对比一下协程取消和线程取消的思路:

协程取消 线程取消
使用 CancellationException 异常 使用 InterruptedException 异常
可以传递取消的原因 不能传递任何信息
需要协程主动响应并执行操作 需要线程被强制终止并处理异常
可以利用协程作用域或生命周期感知组件自动管理 需要手动管理或使用 ExecutorService 自动管理

4.1 协程取消的思路

协程取消的思路是基于协作机制的,也就是说协程需要主动响应取消信号,并执行相应的操作。协程取消的特点有以下几个:

  • 协程取消是通过抛出一个特殊的异常 CancellationException 来实现的,这个异常可以携带取消的原因,并且不会被捕获或者打印
  • 协程取消是通过调用 Job 的 cancel 方法来发送取消信号的,这个方法可以传入一个 CancellationException 实例来提供更多关于本次取消的详细信息
  • 协程取消是通过检查 Job 的 isActive 属性或者调用 yield 函数来捕捉取消信号的,这些操作会在协程被取消时抛出 CancellationException 异常
  • 协程取消是通过使用协程作用域或者生命周期感知组件来自动管理的,它们会在合适的时机自动取消或完成协程

下面是一个协程取消的实例代码,它使用了 viewModelScope 来自动管理协程的生命周期,并在协程中调用了一个 suspend 函数来模拟耗时操作。当用户点击取消按钮时,协程会收到取消信号,并执行相应的操作。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*

class MyViewModel : ViewModel() {

    // 定义一个可延迟的 Job 对象
    private var job: Deferred<String>? = null

    // 定义一个 suspend 函数,用于模拟耗时操作
    private suspend fun doSomething(): String {
        // 暂停 5 秒钟
        delay(5000L)
        // 返回结果
        return "Done"
    }

    // 定义一个函数,用于启动协程
    fun startCoroutine() {
        // 在 viewModelScope 中启动一个协程,并将返回的 Job 对象赋值给 job
        job = viewModelScope.async {
            try {
                // 在协程中调用 suspend 函数,并捕获可能抛出的 CancellationException 异常
                val result = doSomething()
                // 在 UI 线程中更新视图
                withContext(Dispatchers.Main) {
                    textView.text = result
                }
            } catch (e: CancellationException) {
                // 在 UI 线程中显示取消的原因
                withContext(Dispatchers.Main) {
                    textView.text = e.message
                }
            }
        }
    }

    // 定义一个函数,用于取消协程
    fun cancelCoroutine() {
        // 调用 job 的 cancel 方法,并传入一个 CancellationException 实例,携带取消的原因
        job?.cancel(CancellationException("User cancelled"))
    }
}

4.2 线程取消的思路

线程取消的思路是基于中断机制的,也就是说线程需要被外部强制终止,并处理中断异常。线程取消的特点有以下几个:

  • 线程取消是通过抛出一个普通的异常 InterruptedException 来实现的,这个异常需要被捕获或者打印,并且不能携带取消的原因
  • 线程取消是通过调用 Thread 的 interrupt 方法来发送中断信号的,这个方法不可以传入任何参数来提供更多关于本次中断的详细信息
  • 线程取消是通过检查 Thread 的 isInterrupted 方法或者调用可中断的方法(如 sleep、wait、join 等)来捕捉中断信号的,这些操作会在线程被中断时抛出 InterruptedException 异常
  • 线程取消是通过手动管理或者使用 ExecutorService 来自动管理的,它们需要在合适的时机手动调用 interrupt 方法或者 shutdown 方法来终止线程

下面是一个线程取消的实例代码,它使用了 ExecutorService 来自动管理线程的生命周期,并在线程中调用了一个可中断的方法来模拟耗时操作。当用户点击取消按钮时,线程会收到中断信号,并处理中断异常。

import java.util.concurrent.*;

public class MyActivity extends AppCompatActivity {

    // 定义一个 ExecutorService 对象,用于创建和管理线程池
    private ExecutorService executor;

    // 定义一个 Future 对象,用于接收线程的返回结果或异常
    private Future<String> future;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 初始化 ExecutorService 对象,创建一个单线程的线程池
        executor = Executors.newSingleThreadExecutor();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 调用 ExecutorService 的 shutdown 方法,关闭线程池并终止所有的线程
        executor.shutdown();
    }

    // 定义一个函数,用于启动线程
    public void startThread() {
        // 在 ExecutorService 中提交一个 Callable 任务,并将返回的 Future 对象赋值给 future
        future = executor.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                try {
                    // 在线程中调用可中断的方法,并捕获可能抛出的 InterruptedException 异常
                    Thread.sleep(5000L);
                    // 返回结果
                    return "Done";
                } catch (InterruptedException e) {
                    // 抛出异常,让 Future 对象接收
                    throw e;
                }
            }
        });
        // 在主线程中创建一个 Handler 对象,用于更新 UI
        Handler handler = new Handler(Looper.getMainLooper());
        // 在主线程中创建一个 Runnable 对象,用于获取 Future 对象的结果或异常
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    // 从 Future 对象中获取结果或异常,并在 UI 线程中更新视图
                    String result = future.get();
                    textView.setText(result);
                } catch (ExecutionException e) {
                    // 如果 Future 对象抛出了异常,获取其中的原因,并在 UI 线程中显示
                    Throwable cause = e.getCause();
                    textView.setText(cause.getMessage());
                } catch (InterruptedException e) {
                    // 如果主线程被中断,忽略异常
                }
            }
        };
        // 在 Handler 对象中延迟执行 Runnable 对象,等待 Future 对象完成
        handler.postDelayed(runnable, 5000L);
    }

    // 定义一个函数,用于取消线程
    public void cancelThread() {
        // 调用 Future 对象的 cancel 方法,并传入一个 true 值,表示发送中断信号
        future.cancel(true);
    }
}

4.3 思路对比

从上面的对比可以看出,协程取消和线程取消有以下几点不同:

  • 协程取消使用了一种特殊的异常 CancellationException ,而线程取消使用了一种普通的异常 InterruptedException 。
  • 协程取消可以传递更多关于本次取消的详细信息,而线程取消不能传递任何信息。
  • 协程取消需要协程主动响应并执行相应的操作,而线程取消需要线程被强制终止并处理异常。
  • 协程取消可以利用协程作用域或者生命周期感知组件来自动管理,而线程取消需要手动管理或者使用 ExecutorService 来自动管理。

总体来说,协程取消比线程取消更灵活、更优雅、更高效。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容