Android 的消息机制,从第一个版本到 Android 16,核心实现都没怎么变过。核心原理:一个 synchronized 锁,守着一条单链表,所有操作(入队、出队、移除)都抢同一个 synchronized 锁,主线程(Looper 取消息)和子线程(发消息)频繁竞争锁。跑了将近二十年,终于在 Android 17 被重写了。
以下是官方关于Android 17的MessageQueue的相关介绍:
从 Android 17 开始,以 Android 17 或更高版本为目标平台的应用会收到 android.os.MessageQueue 的新无锁实现。新实现可提升性能并减少丢帧,但可能会破坏依赖于 MessageQueue 私有字段和方法的客户端。
Android 17 通过重写底层 MessageQueue 类,对 Looper 和 Handler 的工作方式进行了重大改进。自 Android 操作系统首次发布以来,MessageQueue 一直依赖于单个锁来管理主线程的任务队列。此设计经常导致锁争用;主线程可能会被后台线程阻塞,从而导致丢帧和界面卡顿。
一、为什么要重写MessageQueue
解决并发安全问题
Android 17 之前的 MessageQueue 基于 synchronized 关键字 +Object.wait()/notify() 实现线程同步,但存在两个致命问题:
- 锁竞争严重:主线程和子线程频繁操作消息队列(如 postMessage、removeMessage)时,synchronized 会导致线程阻塞,尤其在高并发场景下(如频繁更新 UI),性能下降明显;
- 唤醒丢失风险:Object.notify() 是「随机唤醒」,可能出现「线程唤醒后发现无消息,再次阻塞」的无效唤醒,甚至极端情况下出现「消息已入队但线程未被唤醒」的漏唤醒问题。
具体来说,当两个或多个线程竞争使用同一个锁时,这称为锁争用。这种争用会导致 优先级反转,进而造成用户界面卡顿和其他性能问题。请考虑以下序列:
- 低优先级后台线程获取MessageQueue锁,以便发布其完成的工作结果。
- 中等优先级线程变为可运行状态,内核调度器为其分配 CPU 时间,抢占低优先级线程。
- 高优先级UI 线程完成当前任务后尝试从队列中读取数据,但由于低优先级线程持有锁而被阻塞。
Android 17 后,MessageQueue 改用 Linux 底层的 epoll 机制 + native 层的 Mutex(互斥锁)+ ConditionVariable(条件变量)重构:
- epoll 是 Linux 高性能 I/O 多路复用机制,支持「事件驱动」的消息唤醒,避免无效阻塞;
- native 层的锁机制比 Java 层 synchronized 更轻量,且支持「精准唤醒」,彻底解决唤醒丢失问题。
适配新的系统特性
Android 17 引入了一些新特性,要求MessageQueue提供更灵活的能力:
- Choreographer 同步渲染:Android 17 完善了 Choreographer(负责 UI 渲染同步),需要 MessageQueue 支持「帧率对齐的消息调度」(如垂直同步 VSYNC 信号触发),旧版 MessageQueue 无法高效响应这种「定时 + 事件」混合的调度需求;
- 严格模式(StrictMode)增强:Android 17 强化了主线程耗时操作检测,要求 MessageQueue 能追踪消息的执行耗时,旧版架构无法精准统计每个消息的执行时间。
性能优化与内存控制
- 减少内存抖动:旧版 MessageQueue 的消息节点(Message)复用机制不完善,频繁创建 / 销毁 Message 对象导致内存抖动;新版重构了 Message 池的管理逻辑,结合 native 层的内存分配,降低 GC 频率;
- 简化唤醒逻辑:旧版需要区分「消息入队唤醒」「IdleHandler 唤醒」等多种场景,逻辑混乱;新版通过 epoll 统一管理所有唤醒源(消息入队、VSYNC 信号、文件描述符事件),逻辑更清晰,执行效率更高。
二、案例分析:启动器卡顿
为了更直观地说明,让我们分析一段 Trace 记录。该记录捕捉到了用户在相机应用中拍摄照片后,立即返回 Pixel 手机主屏幕时发生的卡顿。下面是 Perfetto 的截图,展示了导致掉帧的一系列事件:
- 现象:Launcher(启动器)主线程错过了帧截止时间(Frame Deadline)。它被阻塞了 18ms,超过了 60Hz 渲染所需的 16ms 限制。
- 诊断:Perfetto 显示主线程在 MessageQueue 锁上发生了阻塞。一个名为 “BackgroundExecutor” 的线程持有该锁。
根本原因:BackgroundExecutor 运行在 Process.THREAD_PRIORITY_BACKGROUND 级别(优先级极低)。它当时正在执行一项非紧急任务(检查应用使用限制)。与此同时,中等优先级的线程正在消耗 CPU 时间处理来自相机的图像数据。操作系统调度器于是抢占(Preempted)了 BackgroundExecutor 线程,转而运行相机线程。
这一序列导致了 Launcher 的 UI 线程(高优先级) 被 相机工作线程(中等优先级) 间接阻塞,因为后者导致了 Launcher 的 后台线程(低优先级) 无法及时释放锁。
使用 PerfettoSQL 查询追踪数据
你可以使用 PerfettoSQL 对追踪数据(Trace Data)进行特定模式的查询。如果你拥有来自用户设备或测试的大量追踪样本库,并希望从中搜索表现出特定问题的记录,这一功能将非常有用。
例如,下面这个查询语句可以找出那些与掉帧(Jank同时发生的 MessageQueue 锁争用事件:
INCLUDE PERFETTO MODULE android.monitor_contention;
INCLUDE PERFETTO MODULE android.frames.jank_type;
SELECT
process_name,
-- Convert duration from nanoseconds to milliseconds
SUM(dur) / 1000000 AS sum_dur_ms,
COUNT(*) AS count_contention
FROM android_monitor_contention
WHERE is_blocked_thread_main
AND short_blocked_method LIKE "%MessageQueue%"
-- Only look at app processes that had jank
AND upid IN (
SELECT DISTINCT(upid)
FROM actual_frame_timeline_slice
WHERE android_is_app_jank_type(jank_type) = TRUE
)
GROUP BY process_name
ORDER BY SUM(dur) DESC;
这是一个更复杂的示例,它通过连接(Join)跨越多个表的追踪数据,来识别应用启动过程(App Startup)期间发生的 MessageQueue锁争用:
INCLUDE PERFETTO MODULE android.monitor_contention;
INCLUDE PERFETTO MODULE android.startup.startups;
-- Join package and process information for startups
DROP VIEW IF EXISTS startups;
CREATE VIEW startups AS
SELECT startup_id, ts, dur, upid
FROM android_startups
JOIN android_startup_processes USING(startup_id);
-- Intersect monitor contention with startups in the same process.
DROP TABLE IF EXISTS monitor_contention_during_startup;
CREATE VIRTUAL TABLE monitor_contention_during_startup
USING SPAN_JOIN(android_monitor_contention PARTITIONED upid, startups PARTITIONED upid);
SELECT
process_name,
SUM(dur) / 1000000 AS sum_dur_ms,
COUNT(*) AS count_contention
FROM monitor_contention_during_startup
WHERE is_blocked_thread_main
AND short_blocked_method LIKE "%MessageQueue%"
GROUP BY process_name
ORDER BY SUM(dur) DESC;
你可以利用你最喜欢的 LLM(大语言模型) 来编写 PerfettoSQL 查询语句,以寻找其他模式。
在 Google 内部,我们使用 BigTrace 在数百万份追踪数据中运行 PerfettoSQL 查询。通过这种方式,我们证实了之前偶然观察到的现象实际上是一个系统性问题。数据表明,MessageQueue 的锁争用影响了整个生态系统中的用户,这充分证明了进行根本性架构变革的必要性。
三、解决方案:无锁并发
我们通过实现一种无锁数据结构解决了 MessageQueue 的争用问题。该结构使用原子内存操作而非排他锁来同步对共享状态的访问。如果一个数据结构或算法能够保证:无论其他线程的调度行为如何,至少有一个线程始终能取得进展,那么它就是“无锁”的。这种特性通常很难实现,对于大多数代码来说,往往不值得去追求。
Android 17系统MessageQueue 架构的核心变化是从「纯 Java 层同步」转向「Java 层封装 + Native 层核心实现」,引入 Linux 底层的 epoll 机制和 Native 锁,彻底解决了 Android 16 版本的并发、唤醒、性能问题。
原子原语
无锁软件通常依赖于硬件提供的原子“读-改-写”(Read-Modify-Write)原语。
在旧一代的 ARM64 CPU 上,原子操作使用的是 Load-Link/Store-Conditional (LL/SC) 循环。CPU 加载一个值并标记该地址;如果另一个线程向该地址写入了内容,则存储操作失败,循环重新尝试。由于各线程可以不断尝试并成功,而无需等待另一个线程(释放锁),因此这种操作是无锁的。
RM64 LL/SC loop example
retry:
ldxr x0, [x1] // Load exclusive from address x1 to x0
add x0, x0, #1 // Increment value by 1
stxr w2, x0, [x1] // Store exclusive.
// w2 gets 0 on success, 1 on failure
cbnz w2, retry // If w2 is non-zero (failed), branch to retr
较新的 ARM 架构(ARMv8.1)支持 大系统扩展(Large System Extensions, LSE),其中包含 比较并交换(Compare-And-Swap, CAS) 或 加载并相加(Load-And-Add) 形式的指令(如下所示)。在 Android 17 中,我们在 Android Runtime (ART) 编译器中增加了支持,能够检测硬件是否支持 LSE 并生成优化后的指令:
// ARMv8.1 LSE atomic example
ldadd x0, x1, [x2] // Atomic load-add.
// Faster, no loop required.
在我们的基准测试中,使用 CAS 指令的高竞争代码相比 LL/SC 变体实现了约 3 倍的性能提升。
Java 编程语言通过 java.util.concurrent.atomic 包提供了原子原语,这些原语正是依赖上述及其他特定的 CPU 指令实现的。
四、数据结构:DeliQueue
为了消除 MessageQueue 中的锁争用,我们的工程师设计了一种名为 DeliQueue 的新型数据结构。DeliQueue 将消息的插入(Insertion)与消息的处理(Processing)解耦:
- 消息列表(Treiber 栈): 一个无锁栈。任何线程都可以向此处推送(Push)新消息,且不会产生争用。
- 优先级队列(最小堆): 一个存放待处理消息的堆结构,由 Looper 线程独占(因此访问时无需任何同步或锁机制)。
入队(Enqueue):推送到 Treiber 栈
消息列表维护在一个 Treiber 栈中,这是一种利用 CAS 循环来更新头指针(Head Pointer)的无锁栈。
public class TreiberStack <E> {
AtomicReference<Node<E>> top =
new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null) return null;
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
}
任何生产者(线程)都可以在任何时间将新消息推送到栈中。这就像在熟食店(Deli)柜台取号一样——你的号码取决于你到达的时间,但你拿到食物的顺序并不一定非要按这个号来。由于它是一个链式栈,每个消息本身就是一个“子栈”——通过追踪头指针(Head)并向前遍历,你可以看到消息队列在任何时间点的状态;即便在你遍历的过程中有新消息压入栈顶,你也无法看到它们。
出队(Dequeue):批量转移至最小堆
为了找到下一个要处理的消息,Looper 会从 Treiber 栈的顶部开始向下遍历,直到找到它上一次处理过的最后一个消息,从而处理所有新消息。当 Looper 向下遍历栈时,它会将消息插入到一个按截止时间(Deadline)排序的最小堆(Min-heap)中。
由于这个堆由 Looper 线程独占,因此它在对消息进行排序和处理时,完全不需要锁或原子操作。
在向下遍历栈的过程中,Looper 还会建立从栈内消息指向其前驱节点的链接,从而形成一个双向链表。创建该链表是安全的,因为指向栈底的链接是通过 Treiber 栈算法配合 CAS 添加的,而指向栈顶的反向链接则仅由 Looper 线程读取和修改。这些反向链接随后被用于在 O(1) 时间内从栈中的任意位置移除消息。
这种设计为生产者(发送任务到队列的线程)提供了 O(1) 的插入复杂度,并为消费者(Looper)提供了均摊(Amortized) O(log N) 的处理复杂度。
使用最小堆(Min-heap)对消息进行排序,还解决了传统 MessageQueue 的一个根本缺陷:在旧实现中,消息被存储在一个单向链表中(以头部为根节点)。虽然在旧实现中从头部移除消息是 O(1),但插入消息在最坏情况下的复杂度为 O(N) —— 这在队列负载过高时扩展性极差!相比之下,最小堆的插入和移除操作均呈对数级(Logarithmic)增长,不仅提供了极具竞争力的平均性能,在尾部延迟(Tail Latencies)方面的表现更是出类拔萃。
| Legacy (locked) MessageQueue | DeliQueue | |
|---|---|---|
| Insert | O(N) | O(1) for calling threadO(logN) for Looper thread |
| Remove from head | O(1) | O(logN) |
在旧版的队列实现中,生产者和消费者使用同一个锁来协调对底层单向链表的排他性访问。而在 DeliQueue 中,由 Treiber 栈处理并发访问,由唯一的消费者(Looper)处理其工作队列的排序。
移除操作:通过“墓碑(Tombstones)”实现一致性
DeliQueue 是一种混合数据结构,它将无锁的 Treiber 栈与单线程的最小堆结合在一起。在没有全局锁的情况下保持这两个结构的同步面临着一个独特的挑战:一条消息可能在物理上仍存在于栈中,但在逻辑上已被从队列中移除。
为了解决这个问题,DeliQueue 使用了一种名为“墓碑机制(Tombstoning)”的技术:
- 每个 Message 都会追踪其在栈中的位置(通过前向和后向指针)、在堆数组中的索引,以及一个表示其是否已被移除的布尔标记。
- 当一条消息准备执行时,Looper 线程会通过 CAS 操作修改其“移除标记”,然后将其从堆和栈中彻底剥离。
当另一个线程需要(提前)移除一条消息时,它不会立即将其从数据结构中取出,而是执行以下步骤:
- 逻辑移除: 该线程使用 CAS 原子地将消息的“移除标记”从 false 设为 true。此时,该消息作为其待处理移除状态的证据留在数据结构中,即所谓的“墓碑”。一旦消息被标记为移除,DeliQueue 只要发现它,就会视其为已不存在。
- 延迟清理: 从数据结构中真正移除的职责交给了 Looper 线程,并推迟执行。移除线程并不会修改栈或堆,而是将该消息添加至另一个无锁的**空闲列表栈(freelist stack)**中。
- 结构化移除: 只有 Looper 可以操作堆或从栈中移除元素。当 Looper 唤醒时,它会清空空闲列表并处理其中包含的消息,将每条消息从栈中断开链接并从堆中移除。
这种方法确保了堆的所有管理操作都是单线程的。它最大限度地减少了并发操作和内存屏障(Memory Barriers)的数量,使关键路径运行得更快、更简洁。
五、遍历:Java 内存模型下的良性数据竞争
大多数并发 API(如 Java 的 Future,或 Kotlin 的 Job 和 Deferred)都包含在工作完成前取消任务的机制。这些类的一个实例与一个底层工作单元一一对应,调用 cancel 即可取消与其关联的特定操作。
如今的 Android 设备拥有多核 CPU 和并发的分代垃圾回收(GC)。但在 Android 开发之初,为每个工作单元都分配一个对象(指专门的取消控制器对象)成本太高。因此,Android 的 Handler 通过 removeMessages 的多个重载版本来支持取消操作——它不是移除某个特定的 Message 对象,而是移除所有符合特定条件的消息。在实践中,这需要遍历所有在调用 removeMessages 之前插入的消息,并移除匹配项。
当进行前向遍历时,线程只需要一次顺序原子操作来读取当前的栈顶(Head)。之后,使用普通的字段读取即可查找下一条消息。如果 Looper 线程在移除消息时修改了 next 字段,那么 Looper 的写操作与另一个线程的读操作就是不同步的——这在术语上叫作数据竞争(Data Race)。通常,数据竞争是会导致内存泄漏、死循环、崩溃或卡顿的严重 Bug。然而,在某些特定的狭窄条件下,数据竞争在 Java 内存模型(JMM) 中是可以被视为“良性(Benign)”的。
假设我们最初的栈结构如下:
我们对栈顶(Head)进行一次原子读取,看到消息 A。A 的 next 指针指向 B。就在我们处理 B 的同时,Looper 可能会通过更新 A 的指向,将其依次指向 C 然后指向 D,从而移除 B 和 C。
即便 B 和 C 在逻辑上已被移除,B 依然保留着指向 C 的 next 指针,而 C 也依然指向 D。正在执行读取操作的线程可以继续遍历这些已脱离主链的移除节点,并最终在 D 点重新回到活跃栈(Live stack)中。
通过这种将 DeliQueue 设计为能够兼容“遍历”与“移除”之间竞争的方案,我们实现了安全且无锁的迭代操作。
六、退出机制:原生引用计数(Native refcount)
Looper 的底层由一个原生内存分配(Native allocation)支持,当 Looper 退出后,必须手动释放该内存。如果当 Looper 正在退出时,仍有其他线程在尝试添加消息,则可能会发生“释放后使用(Use-After-Free)”的情况,这是一种严重的内存安全违规。我们通过使用标记引用计数(Tagged refcount)来防止这一点,其中原子变量的一个位(Bit)被用来标记 Looper 是否正在退出。
在访问原生内存之前,线程会先读取这个原子引用计数:
- 如果退出位(Quitting bit)已被设置,则返回 Looper 正在退出,且不得使用该原生内存。
- 如果未设置,则尝试通过 CAS 操作增加正在使用原生内存的活跃线程数。
- 在完成操作后,线程会减少计数值。如果它在增加计数后、减少计数前,退出位被设置了,且当前计数值减到了 0,那么该线程会负责唤醒 Looper 线程。
当 Looper 线程准备退出时,它会使用 CAS 操作在原子变量中设置退出位:
- 如果引用计数为 0,它可以直接释放原生内存。
- 否则,它会挂起(Park)自己,因为它知道当最后一个使用原生内存的线程减少引用计数时,自己会被唤醒。
这种方法确实意味着 Looper 线程在退出时需要等待其他线程的进度,但这种情况只会发生一次,且不属于性能敏感路径,同时它确保了其他调用原生内存的代码能够保持完全的无锁化。
在实现过程中还有许多其他的技巧和复杂机制。你可以通过审阅源代码来进一步了解 DeliQueue。
七、测试与验证
验证无锁算法的正确性是出了名的困难!
除了在开发过程中进行持续验证的标准单元测试外,我们还编写了严苛的压力测试,以验证队列的不变性(Invariants),并试图诱导可能存在的数据竞争。在我们的测试实验室中,我们可以在模拟设备和真实硬件上运行数百万次测试实例。
通过 Java ThreadSanitizer (JTSan) 检测工具,我们能够利用相同的测试来探测代码中的数据竞争。JTSan 没有在 DeliQueue 中发现任何存疑的数据竞争,但令人惊讶的是,它竟然检测到了 Robolectric 框架中的两个并发漏洞,我们随即对其进行了修复。
为了增强调试能力,我们开发了新的分析工具。下图展示了 Android 平台代码中的一个问题:一个线程发送的消息过载了另一个线程,导致了大量的任务积压。由于我们添加了 MessageQueue 检测功能,这一现象在 Perfetto 中清晰可见。
要在 system_server 进程中开启 MessageQueue 追踪,请在你的 Perfetto 配置中包含以下内容:
data_sources {
config {
name: "track_event"
target_buffer: 0 # Change this per your buffers configuration
track_event_config {
enabled_categories: "mq"
}
}
}
八、总结
DeliQueue 通过消除 MessageQueue 中的锁,提升了系统和应用的性能。
- 合成基准测试: 得益于并发能力的提升(Treiber 栈)和更快的插入速度(最小堆),向繁忙队列进行多线程插入的速度比传统 MessageQueue 快了高达 5000 倍。
- Perfetto 追踪分析: 在从内部测试人员处获取的 Perfetto 追踪记录中,我们看到应用主线程在锁竞争上消耗的时间减少了 15%。
- 用户体验提升: 在相同的测试设备上,锁竞争的减少带来了显著的用户体验提升,例如:
- 应用掉帧率降低 4%。
- 系统 UI(System UI)和启动器(Launcher)交互中的掉帧率降低 7.7%。
- 应用启动至首帧渲染时间(P95 分位数)缩短 9.1%。