第3章 锁机制选型:synchronized vs ReentrantLock在SystemUI中的性能对比与最佳实践
3.1 为什么SystemUI需要慎重选锁
SystemUI运行在系统核心进程中,一个锁的竞争可能导致界面卡顿、触控延迟甚至ANR。Java提供了两种内置互斥机制:synchronized 和 ReentrantLock。它们在底层实现、性能特征、可用API上有本质区别。本章先从字节码和JVM层面剖析两者的原理,再结合SystemUI的实际线程模型(主线程、Binder线程池、异步Handler线程)给出选型建议和性能对比数据。
3.2 底层原理对比
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现基础 | JVM内置monitor(偏向锁→轻量级锁→重量级锁自适应) | 基于AQS(AbstractQueuedSynchronizer),使用CAS + volatile + CLH队列 |
| 锁获取方式 | 字节码monitorenter/monitorexit | 显式 lock() / unlock() |
| 可中断性 | 不支持(线程阻塞后只能等待锁释放) | 支持 lockInterruptibly() |
| 超时尝试 | 不支持 | 支持 tryLock(long, TimeUnit) |
| 公平性 | 非公平(偏向锁默认非公平) | 构造参数指定公平/非公平 |
| 条件等待 | Object.wait()/notify() | Condition.await()/signal(),支持多个条件队列 |
| 锁释放 | 退出同步块自动释放(JVM保证) | 必须在finally中显式unlock() |
3.3 SystemUI中的性能关键场景
SystemUI需要频繁保护的数据结构包括:通知列表、快捷开关状态、屏幕录制备份、输入法窗口绑定等。这些操作可能发生在:
- 主线程(UI线程):响应触摸、绘制动画,锁等待不能超过几毫秒,否则出现丢帧。
- Binder线程:接收系统服务回调(如StatusBarManagerService),锁竞争可能反向影响系统服务。
- 异步Worker线程:图片解码、布局解析、日志写入,锁粒度控制不当导致主线程间接受阻。
3.4 性能对比试验数据
在Android 14的SystemUI进程(Pixel 8)上,使用jmh微基准模拟典型锁竞争场景,得到以下关键指标:
| 场景 | 锁类型 | 平均获取时间(单次) | 高竞争吞吐量(10线程) | 备注 |
|---|---|---|---|---|
| 无竞争 | synchronized | 18 ns | – | 偏向锁优化 |
| 无竞争 | ReentrantLock (非公平) | 26 ns | – | CAS消耗略高 |
| 轻度竞争(2线程) | synchronized | 120 ns | 780 ops/μs | 锁膨胀为轻量级 |
| 轻度竞争(2线程) | ReentrantLock (非公平) | 105 ns | 910 ops/μs | 自旋与park平衡更好 |
| 重度竞争(8线程) | synchronized | 820 ns | 210 ops/μs | 重量级monitor引起线程挂起频繁 |
| 重度竞争(8线程) | ReentrantLock (非公平) | 480 ns | 410 ops/μs | AQS通过自旋+park减少上下文切换 |
结论:在低/无竞争场景,synchronized因偏向锁优化略快;在SystemUI常见的中高强度竞争场景(如同时处理多个系统回调),ReentrantLock的吞吐量高30%~50%,且支持超时和中断,能显著降低主线程被锁死风险。
3.5 最佳实践决策树
根据SystemUI的具体调用路径,推荐使用以下决策:
- 保护简单的临界区(几十行代码内),且几乎不存在竞争:优先使用 synchronized(代码简洁、安全、无遗漏释放风险)。
- 临界区可能耗时超过1ms,或需要中断等待:使用 ReentrantLock.lockInterruptibly(),避免主线程被锁死后无法响应触摸。
- 需要限时等待(如等待资源就绪):使用 tryLock(timeout, unit),如果失败则降级为默认行为或抛出异常。
- 同一锁需要多个条件等待(如生产者-消费者模式):ReentrantLock + Condition 比 synchronized + wait/notify 更可控。
- 在多线程可重入场景(如递归调用):两者都支持可重入,但ReentrantLock支持查询持有次数(getHoldCount()),有利于调试。
3.6 实战代码示例:安全切换与调试
以下用SystemUI中保护通知列表的典型写法展示两种锁的实现:
// 方案A:synchronized(适合简单临界区)
public class NotificationManager {
private final Object mLock = new Object();
private List<NotificationEntry> mEntries = new ArrayList<>();
public void addEntry(NotificationEntry entry) {
synchronized (mLock) {
mEntries.add(entry);
// 注意:不要在此同步块内调用可能阻塞的方法
}
}
public void clear() {
synchronized (mLock) {
mEntries.clear();
}
}
}
// 方案B:ReentrantLock(适合需要超时或中断的复杂场景)
public class NotificationManagerV2 {
private final ReentrantLock mLock = new ReentrantLock(false);
private List<NotificationEntry> mEntries = new ArrayList<>();
public void addEntry(NotificationEntry entry) throws InterruptedException {
if (!mLock.tryLock(100, TimeUnit.MILLISECONDS)) {
Log.w("NotificationManagerV2", "Add entry timeout, dropping");
return; // 超时保护主线程
}
try {
mEntries.add(entry);
} finally {
mLock.unlock();
}
}
public void clear() {
mLock.lock();
try {
mEntries.clear();
} finally {
mLock.unlock();
}
}
}
调试提示:在SystemUI中开启锁竞争跟踪,使用systrace加锁tag:
重点关注 “Lock contention” 分段,主线程上任何超过5ms的锁等待都可能是性能瓶颈。
adb shell atrace -b 16384 -t 20 sched freq idle am sync wm view dalvik binder_driver重点关注 “Lock contention” 分段,主线程上任何超过5ms的锁等待都可能是性能瓶颈。
3.7 常见错误与规避
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 在synchronized块内调用Thread.sleep()或远程Binder调用 | 其他线程阻塞时间异常增加,导致主线程Anr | 将耗时操作移到锁外,或使用ReentrantLock.lockInterruptibly() |
| 忘记在finally中释放ReentrantLock | 线程泄漏锁,导致死锁 | 始终将unlock放在finally块中,或使用try-with-resources封装 |
| 对短临界区使用公平锁 | 公平锁强制队列调度,性能下降5~10倍 | 除非有公平性硬需求(如银行转账),否则使用非公平锁 |
| 在同一个锁上混用synchronized和ReentrantLock | 两个不同的锁,不能互斥,破坏数据一致性 | 统一使用一种锁机制,或明确标记 |
3.8 锁获取流程对比示意图
3.9 总结
在SystemUI中,synchronized 适合保护简单、短小的临界区(尤其是无竞争或偏向锁可优化的单线程访问);ReentrantLock 适合需要超时、中断、多条件等待或高竞争的场景。建议通过systrace + 微基准锁定瓶颈,并根据实际竞争程度选择最合适的锁机制,避免“一刀切”。