四、线程与切线程:从回调到续体

从 Java 主线程到 Android 消息循环,从 Retrofit 回调到协程续体 —— 切线程的两种方式,本质都是回调

线程 Handler 回调 续体 CPS 协程

目录导航(点击跳转)

一、线程的本质:从 main 方法说起

1main 是程序的入口,线程是指令流

每个 Java 程序都从 main 方法开始执行,它是程序的入口。线程的本质,就是一条顺序执行的指令流main 方法从第一行执行到最后一行,就是一条连续的指令流,它跑在一个线程里——这个线程就是主线程

当你创建并启动一个新线程时:

  • start() 这个调用本身,是在主线程上执行的。
  • 线程真正被操作系统调度起来之后,run() 方法里的代码,才是在新线程上执行的,和主线程并行运行。

用一个最简例子验证:

public class Demo {
    public static void main(String[] args) {
        System.out.println("Hello, Java!");
        new Thread() {
            @Override
            public void run() {
                System.out.println("Hello, Java Thread!");
            }
        }.start();
        System.out.println("Bye, Java!");
    }
}

"Bye, Java!" 几乎总是比 "Hello, Java Thread!" 先打印。因为 start() 调用之后紧接着就是 System.out.println("Bye, Java!"),而线程从启动到真正开始执行 run() 需要一定的初始化时间(操作系统分配线程资源、调度等)。

main 线程
println("Hello")
main 线程
thread.start()
main 线程
println("Bye")
start() 调用后
OS 初始化新线程
新线程
run(): println("Hello Thread")

main 线程不会等待新线程完成。它继续向下执行,新线程独立启动。这就是多线程最基本的并行行为。

2Java 不能直接"切线程"

一个关键认知:Java 没有"从子线程直接切换回主线程去执行某段代码"的功能。反过来,想从主线程直接切换到某个已经在运行的子线程去插入一段代码,也是做不到的。

线程之间不能随意"跳来跳去"。一条线程在执行时,只能老老实实跑自己的指令流。你要想让某个线程执行某个任务,必须通过某种机制把任务"投递"给目标线程

Java 没有的

  • 从子线程"跳回"主线程执行代码
  • 从主线程"插入"子线程执行代码
  • 任意线程间的直接切换

Java 有的

  • 线程间通过共享数据结构通信
  • 通过线程池提交任务(ExecutorService)
  • 通过 wait/notify 协同等待
  • 通过 Future 获取异步结果

那么 Android 中经常说的"切回主线程"是怎么做到的?答案在下一节。

二、Android 的主线程:永不结束的 main

1Android 应用也是从 main 开始的

Android 应用也是从 main 开始的,只不过它是一个永不结束的 main。大致分为两步:

1
初始化(init()):准备 Looper、MessageQueue 等基础设施
2
进入无限循环(while(true)):不断刷新界面,不断从消息队列里取出任务来执行

伪代码大概是这样:

public static void main(String[] args) {
    // 1. 初始化
    Looper.prepareMainLooper();   // 创建主线程 Looper + MessageQueue
    // ... 其他初始化 ...

    // 2. 启动无限循环
    Looper.loop();                // while(true) { 取消息 → 执行消息 }
}

在这个循环里,主线程不断地做两件事:

  • 刷新界面(VSYNC 信号驱动的绘制流程)
  • 从消息队列里取出任务(通过 Handler 投递的 Message/Runnable)来执行

所以 Android 中"切回主线程"的本质:通过 Handler 向主线程的 MessageQueue 里插入一段代码(Runnable 或 Message),然后主线程在循环中取出这段代码来执行。这不是从子线程"跳回"主线程——子线程还在继续跑自己的——而是让主线程在自己合适的时机执行你投递的任务。

2HandlerThread:自带消息循环的后台线程

Android 中还有一个特殊的存在——HandlerThread。它本质上是一个自带 Looper 的 Thread:

Thread 启动
Looper.prepare()
创建消息队列
Looper.loop()
while(true) 循环
有新任务
就执行

HandlerThread 一旦运行就会进入无限循环,一旦有新任务(通过 Handler 投递)就会去执行。它和主线程的结构一模一样——都是"消息循环 + 任务队列"模式。

理解 Handler + Looper + MessageQueue 这套机制是理解 Android 线程切换的基础。详见Android 消息机制全面解析

三、传统切线程:Retrofit 的回调链路

1enqueue 的完整链路

传统的切线程方式——以 Retrofit 为例——enqueue 内部会进行一次切线程。经过一系列调用,最终把网络请求丢入线程池中去执行。这个线程池实际上是由 Retrofit 底层的 OkHttp 来管理的。

完整链路大概是这样的:

1
用户定义 Callback
onResponse / onFailure
2
enqueue 提交
Retrofit 将请求交给 OkHttp
3
OkHttp 线程池执行
后台线程处理网络请求
4
后台线程解析 JSON
将响应体转为 Java/Kotlin 对象
5
Handler.post 切回主线程
通过 Looper.getMainLooper() 投递回调
6
主线程调用回调
onResponse / onFailure 执行

具体代码:

api.testCallStyle()
    .enqueue(
        object : Callback<BaseResponse<List<User>>> {

            override fun onResponse(
                p0: Call<BaseResponse<List<User>>>,
                p1: Response<BaseResponse<List<User>>>
            ) {
                println("Retrofit Result: ${p1.body()}")
            }

            override fun onFailure(
                p0: Call<BaseResponse<List<User>>>,
                p1: Throwable
            ) {
                p1.printStackTrace()
            }
        }
    )

执行时,onResponseonFailure 已经在主线程上了——因为 OkHttp 内部通过 Handler(Looper.getMainLooper()).post 把回调投递给了主线程的消息队列。

2传统切线程的核心特征:回调

整个链路可以用一张时序图概括:

主线程
1. 调用 enqueue()
6. Handler.post 把回调投递到消息队列
7. Looper 取出回调 → onResponse() 执行
OkHttp 线程池
2. 收到请求任务
3. 执行网络请求(DNS + TCP + HTTP)
4. 解析 JSON 响应体
5. Handler(Looper.getMainLooper()).post(回调)

传统的切线程方式,本质是基于回调的。你把"做完之后要干什么"包装成一个 Callback 对象,扔给框架。框架在后台线程完成任务后,通过 Handler 把 Callback 投递到主线程的消息队列,主线程再取出执行。

四、协程切线程:续体也是回调

1协程的切线程方式:CPS 变换

挂起函数的实现本质上是编译器对代码的 CPS(续体传递风格)变换。在编译期,编译器会以每个挂起点为分界,将协程体拆分成若干状态机片段,每个片段对应一个回调续体。

协程执行时,在挂起点之前只是正常执行并返回挂起标记,不会触发任何回调;只有在挂起任务完成、通过续体的 resume 恢复执行时,才会触发一次回调,进入下一个状态继续执行。

主线程:执行协程代码
遇到挂起点
CPS 变换
label 更新
Continuation 保存状态
返回 COROUTINE_SUSPENDED
线程释放
任务完成 → resume
恢复执行下一段

关于 CPS 变换和状态机的完整原理,详见续体与状态机 —— suspend 函数的编译秘密

2关键结论:两种切线程,本质都是回调

把传统方式和协程方式放在一起对比:

传统切线程(Retrofit Callback)

  • 你手动写 Callback 对象
  • 框架在后台线程完成后调用你的回调
  • 切回主线程靠 Handler.post
  • 本质:显式回调

协程切线程(suspend + Continuation)

  • 你写的是"看起来同步"的代码
  • 编译器自动生成 Continuation 回调
  • 切回主线程靠 Dispatcher(底层也是 Handler)
  • 本质:编译器帮你写回调

传统的切线程是基于回调的,协程中切线程还是基于回调的。区别只在于:传统方式是你手动写回调,协程方式是编译器帮你生成回调(Continuation)。续体本质上就是一个精心设计的回调对象——它持有状态机的 label、result 和 completion,在合适的时机被调用,推进状态机到下一个状态。

传统方式
Callback 接口
(你写的)
Handler.post
切回主线程
协程方式
Continuation 续体
(编译器生成的)
Dispatcher 调度
切回主线程

协程没有发明新的切线程机制。它只是把"回调"这件事从开发者手里接过来,交给编译器自动完成。你写 suspend 函数,编译器生成 Continuation。你写 withContext,框架在合适的线程上 resume 你的续体。底层的线程切换依然走 Dispatcher → Handler → 消息队列这条路,但你不需要看见任何回调代码。

五、核心思想总结

核心要点

一句话总结

线程切换的本质从未改变——都是把"接下来要做什么"包装成回调,投递给目标线程去执行。传统方式你手动写 Callback,协程方式编译器生成 Continuation。协程没有发明新的切线程机制,只是把回调从开发者手里接过来,藏在了 CPS 变换和状态机背后。

延伸阅读