从 Java 主线程到 Android 消息循环,从 Retrofit 回调到协程续体 —— 切线程的两种方式,本质都是回调
每个 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 线程不会等待新线程完成。它继续向下执行,新线程独立启动。这就是多线程最基本的并行行为。
一个关键认知:Java 没有"从子线程直接切换回主线程去执行某段代码"的功能。反过来,想从主线程直接切换到某个已经在运行的子线程去插入一段代码,也是做不到的。
线程之间不能随意"跳来跳去"。一条线程在执行时,只能老老实实跑自己的指令流。你要想让某个线程执行某个任务,必须通过某种机制把任务"投递"给目标线程。
那么 Android 中经常说的"切回主线程"是怎么做到的?答案在下一节。
Android 应用也是从 main 开始的,只不过它是一个永不结束的 main。大致分为两步:
伪代码大概是这样:
public static void main(String[] args) {
// 1. 初始化
Looper.prepareMainLooper(); // 创建主线程 Looper + MessageQueue
// ... 其他初始化 ...
// 2. 启动无限循环
Looper.loop(); // while(true) { 取消息 → 执行消息 }
}
在这个循环里,主线程不断地做两件事:
所以 Android 中"切回主线程"的本质:通过 Handler 向主线程的 MessageQueue 里插入一段代码(Runnable 或 Message),然后主线程在循环中取出这段代码来执行。这不是从子线程"跳回"主线程——子线程还在继续跑自己的——而是让主线程在自己合适的时机执行你投递的任务。
Android 中还有一个特殊的存在——HandlerThread。它本质上是一个自带 Looper 的 Thread:
HandlerThread 一旦运行就会进入无限循环,一旦有新任务(通过 Handler 投递)就会去执行。它和主线程的结构一模一样——都是"消息循环 + 任务队列"模式。
理解 Handler + Looper + MessageQueue 这套机制是理解 Android 线程切换的基础。详见Android 消息机制全面解析。
传统的切线程方式——以 Retrofit 为例——enqueue 内部会进行一次切线程。经过一系列调用,最终把网络请求丢入线程池中去执行。这个线程池实际上是由 Retrofit 底层的 OkHttp 来管理的。
完整链路大概是这样的:
具体代码:
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()
}
}
)
执行时,onResponse 和 onFailure 已经在主线程上了——因为 OkHttp 内部通过 Handler(Looper.getMainLooper()).post 把回调投递给了主线程的消息队列。
整个链路可以用一张时序图概括:
传统的切线程方式,本质是基于回调的。你把"做完之后要干什么"包装成一个 Callback 对象,扔给框架。框架在后台线程完成任务后,通过 Handler 把 Callback 投递到主线程的消息队列,主线程再取出执行。
挂起函数的实现本质上是编译器对代码的 CPS(续体传递风格)变换。在编译期,编译器会以每个挂起点为分界,将协程体拆分成若干状态机片段,每个片段对应一个回调续体。
协程执行时,在挂起点之前只是正常执行并返回挂起标记,不会触发任何回调;只有在挂起任务完成、通过续体的 resume 恢复执行时,才会触发一次回调,进入下一个状态继续执行。
关于 CPS 变换和状态机的完整原理,详见续体与状态机 —— suspend 函数的编译秘密。
把传统方式和协程方式放在一起对比:
传统的切线程是基于回调的,协程中切线程还是基于回调的。区别只在于:传统方式是你手动写回调,协程方式是编译器帮你生成回调(Continuation)。续体本质上就是一个精心设计的回调对象——它持有状态机的 label、result 和 completion,在合适的时机被调用,推进状态机到下一个状态。
协程没有发明新的切线程机制。它只是把"回调"这件事从开发者手里接过来,交给编译器自动完成。你写 suspend 函数,编译器生成 Continuation。你写 withContext,框架在合适的线程上 resume 你的续体。底层的线程切换依然走 Dispatcher → Handler → 消息队列这条路,但你不需要看见任何回调代码。
线程切换的本质从未改变——都是把"接下来要做什么"包装成回调,投递给目标线程去执行。传统方式你手动写 Callback,协程方式编译器生成 Continuation。协程没有发明新的切线程机制,只是把回调从开发者手里接过来,藏在了 CPS 变换和状态机背后。