写Android这几年,只要涉及到多线程访问共享数据,synchronized几乎就是默认选项。面试被问“synchronized底层原理”的人很多,但真正在项目里把synchronized用得干净利落、不留下暗坑的人,反而没那么多。这篇文章我想从实际开发的角度,把synchronized在Android线程安全里怎么理解、怎么用、怎么排查问题讲透。它适合那些已经被并发问题坑过、或者正在写多线程业务但心里没底的Android开发者,当然也适合准备面试时想把synchronized彻底搞明白的人。
我不是来讲教科书概念的,就按我日常debug和写代码时真实的思考路径来聊。synchronized说到底就是一把内置锁,它的核心价值,是让一段临界区代码在同一时刻只有一个线程能进去执行。听起来简单,但它在JVM和ART虚拟机里玩出的花样、在Android业务代码里能触发的各种问题,绝对值得花十分钟系统梳理一遍。
1. 线程安全问题的本质与synchronized的设计思路
1.1 竞态条件、原子性、可见性、有序性,到底指的是什么
先看一个最常见的错误示例。单线程里写int count = 0; count++;,不会有任何问题,但两个线程同时执行count++,最终结果很可能不是2而是1。原因在于count++从来都不是一个原子操作,它在字节码层面至少包含三条指令:读取count的当前值、执行加1、写回新值。两个线程可能同时读到0,然后各自加1再写回,这个过程中有一次更新被另一个线程覆盖了。这就叫竞态条件。
除了原子性,还有两个比原子性更隐蔽的问题。第一个是可见性,线程A修改了一个共享变量,线程B不一定能立刻看到这个变化。因为每个线程在工作时会把变量拷贝到自己的工作内存(对应到CPU的寄存器、L1/L2缓存),如果一个线程改了缓存里的副本但没有同步到主存,另一个线程从主存里读到的仍然是旧值。第二个是有序性,编译器在保证单线程语义不变的前提下,可能会对指令做重排。单线程下重排不会影响最终结果,但多线程下,另一个线程可能看到的是重排后的执行顺序,结果就很容易出问题。
这三个问题——“原子性、可见性、有序性”——是并发编程里所有幺蛾子的根源。Java的内存模型(JMM)就是为了在语言层面规范这三个问题的规则,而synchronized是其中最能打的一件兵器。
1.2 synchronized是如何同时解决三大并发问题的
从JMM的角度看,synchronized其实做了三件配套动作。
第一是互斥执行。这是它最直观的能力:一个线程进入同步代码块前必须先获得锁,锁被持有时其他线程只能在入口处阻塞等待,直到锁被释放。这保证了临界区代码的原子性,count++在同步块里执行时,读、加、写三步不可分割。
第二是可见性。JMM规定锁的获取和释放会建立happens-before关系:解锁线程对共享变量的所有修改,对后续加锁线程是完全可见的。换句话说,线程A在释放锁之前写下的所有变量值,一旦线程B获取同一把锁,它读到的必然是A写完后的最新值。这就把“缓存副本不可见”的问题解决了。
第三是有序性。在同步代码块内部,JVM不会做可能破坏同步语义的指令重排,这意味着临界区内的代码是严格按照逻辑顺序来执行的,不会出现诡异的乱序。
所以synchronized不是只解决了一个问题,它是把并发编程中最基本的三个问题一揽子打包处理了。这也是为什么它比volatile更全面——volatile只保证可见性和有序性,不能保证原子性。
1.3 为什么不是volatile、Lock或原子类
Android里解决线程安全的工具不止一种:volatile、AtomicInteger、ReentrantLock、ConcurrentHashMap,新项目里还有协程。但在我个人的判断里,synchronized依然是大多数业务场景的第一选择,原因非常现实。
第一,它由JVM/ART虚拟机层面支持和持续优化。JDK 1.6之后引入的锁升级机制,让synchronized在竞争不那么激烈的时候开销非常小,甚至接近于无锁。很多人对“synchronized性能差”的印象还停留在远古时代,这个观点早就过时了。
第二,语法上足够简洁,异常自动释放锁。用ReentrantLock时如果在临界区抛了异常又忘了在finally里unlock,这个锁可能永远不被释放。synchronized没有这个问题,不管正常退出还是异常退出,锁都会被自动释放,少了一个让代码review抓狂的隐患。
第三,语义清晰、可读性好。一行synchronized (lock) { ... }就能让所有人明白这里是临界区,不需要额外解释。在团队协作里,简单直接的代码往往比花哨的方案更容易维护。
当然也不是说synchronized就万能。高竞争场景下它的性能不如Lock,需要公平锁、超时中断等高级语义时也得换成Lock,但这些都是少数场景。日常业务里,用synchronized已经覆盖了90%以上的线程安全问题。
2. synchronized的三种用法与锁的底层机制
2.1 实例方法、静态方法、同步代码块,锁的到底是什么
synchronized有三种基本用法,很多人背过但实际写起来容易混淆。
第一种,修饰实例方法,锁住的是当前对象this:
public synchronized void increase() { count++; }第二种,修饰静态方法,锁住的是当前类的Class对象。这一点很关键,它和实例方法锁的不是同一个对象,所以一个静态同步方法和一个实例同步方法之间并不互相阻塞:
public static synchronized void doSomething() { // 锁住的是Xxx.class对象 }第三种,同步代码块,锁对象可以自己在代码里指定,写法最灵活:
public void increase() { synchronized (lock) { count++; } }我见过不止一次同事把静态方法和实例方法搞混,觉得两个方法都被synchronized修饰了就一定互斥。实际上完全不是这样——锁的是对象,而不是方法本身。如果两个同步方法分别锁在this和Class对象上,它们各走各的锁,同时执行一点问题都没有。
所以在设计的时候,如果你希望同一份数据的所有操作都互斥,就一定要确保所有的入口都用同一个锁对象。这是自查代码时最该先确认的一点。
2.2 锁升级机制:从偏向锁到重量级锁,性能开销怎么变化
synchronized的底层实现在JDK 1.6以后做了巨大优化,引入了锁升级的路径:偏向锁 -> 轻量级锁 -> 重量级锁。
偏向锁的思路是:如果同一个线程反复获取同一把锁,那没必要每次都做同步操作,直接在对象头的Mark Word里记录持有锁的线程ID,后续这个线程再次进入时只需要比对一下ID就行,开销几乎为零。当第二个线程也来竞争时,偏向锁被撤销,升级为轻量级锁。
轻量级锁通过CAS自旋来获取锁,不涉及线程阻塞和操作系统内核态切换。如果自旋一段时间还拿不到锁,说明竞争真的激烈了,就升级为重量级锁,未获得锁的线程会被挂起阻塞,此时涉及用户态和内核态的切换,成本相对较高。
对Android开发者来说,有一点需要额外注意:Android上老版本是Dalvik,新版本是ART虚拟机,它们的锁实现和HotSpot并不完全一样。但总体的思路类似,锁在竞争不激烈时开销是可以忽略的。我实测过,在比较新的Android设备上,单线程反复进出同一个synchronized块,几乎没有性能感知。哪怕是轻量级的短临界区竞争,也就是微秒级别的阻塞。真正的性能杀手是重量级锁导致的线程阻塞和唤醒,而这种情况通常是锁粒度设计不合理,不是synchronized本身的锅。
2.3 锁对象的选择与wait/notify的配合
锁对象是synchronized最容易踩坑的地方。我见过一个线上问题,两段完全无关的业务代码,用了内容相同的String字符串常量做锁对象,结果无端产生了锁竞争,两个功能互相拖慢。Java里String有常量池机制,内容相同的字符串很可能指向同一个对象,你以为自己锁的是局部变量,实际锁住了全JVM共享的一个字符串常量池对象。
所以我的原则是:锁对象用private final Object lock = new Object()是最安全、最可控的。能不用this就不用this,因为this可能在你不知道的地方被别人拿去当锁用了;能用同一把锁就尽量集中在一个地方,避免锁对象满天飞。
wait()和notify()和synchronized是强绑定的。wait()会释放当前持有的锁,notify()会唤醒一个在条件变量上等待的线程,这两个方法都必须在同步块或同步方法内调用,否则会抛IllegalMonitorStateException。一个经典的生产者-消费者模式可以这样写:
private final Object lock = new Object(); private final Queue<String> queue = new LinkedList<>(); private static final int MAX_SIZE = 16; public void produce(String message) throws InterruptedException { synchronized (lock) { while (queue.size() >= MAX_SIZE) { lock.wait(); } queue.offer(message); lock.notifyAll(); } } public String consume() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } String message = queue.poll(); lock.notifyAll(); return message; } }注意判断条件用的是while而不是if。这是因为存在“虚假唤醒”的可能,用while可以让线程被唤醒后重新检查条件,只有条件真正满足才继续往下走。这是官方文档明确建议的写法,理解之后就会明白它的必要性。
3. Android实战:单例、内存缓存、线程通信
3.1 双重检查锁单例的正确写法,volatile一个都不能少
Android里synchronized最常见的应用场景就是写单例。很多人刚入门时写的懒汉式单例是方法级同步:
public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }这个方法本身没问题,但每次调用都要经过同步入口,虽然竞争不激烈时有偏向锁兜底,性能还可以,但代码观感上不够漂亮。所以更常见的写法是双重检查锁(Double Check Locking):
private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; }这里有一个特别容易被忽略的细节:instance必须用volatile修饰。原因在于instance = new Singleton()不是原子操作,它在底层大致分三步:分配内存、调用构造函数初始化对象、把引用指向这块内存。如果没有volatile,在高并发下第二步和第三步可能被重排,也就是引用先指向了一块尚未初始化完成的内存,另一个线程在第一个if (instance == null)判断时发现instance不为null,就直接返回了这个没有初始化完成的对象。这个问题的标准解法就是用volatile禁止指令重排。理解了原理你就明白,网上有些去掉volatile的写法是有隐患的,哪怕测试跑一万次不出问题,也不能保证线上不会偶发。
如果想彻底绕开锁,也可以用静态内部类方式实现单例,借助类加载机制天然保证线程安全。但在日常代码里,volatile + double check依然是很高效、很直观的方案。
3.2 一个线程安全的内存缓存封装,锁粒度怎么设计
实际项目里经常遇到一个需求:手写一个简单的内存KV缓存,要求多线程读写安全。最直觉的方案是给put和get都加上synchronized:
public class MemoryCache<K, V> { private final LinkedHashMap<K, V> map = new LinkedHashMap<>(); private final int maxSize; public MemoryCache(int maxSize) { this.maxSize = maxSize; } public synchronized V put(K key, V value) { V old = map.put(key, value); if (map.size() > maxSize) { K eldest = map.keySet().iterator().next(); map.remove(eldest); } return old; } public synchronized V get(K key) { return map.get(key); } }这是最简单可靠的方案,适合缓存操作不频繁的场景。但如果get是高频操作、put是低频操作,这种全量同步的方案就不太划算了。更好的方案是底层用ConcurrentHashMap,再配合淘汰策略。注意这里不能用ConcurrentHashMap.size()来精确判断淘汰时机,因为它的size在并发下是近似值。这种情况下要么接受近似淘汰,要么给淘汰逻辑单独加一把轻量级锁。这也是一个典型的设计取舍:你得先搞清楚自己的业务到底更看重什么。
另外,AndroidX自带的LruCache其实本身已经是线程安全的,它的内部实现就是LinkedHashMap加synchronized。所以很多时候不用自己造轮子,但读懂LruCache的源码,理解它是怎么用锁的,对排查线上问题非常有帮助。
3.3 synchronized与Handler、线程池、主线程的配合
Android开发里绕不开的一个场景是子线程算完数据回主线程更新UI。通常的写法是new Handler(Looper.getMainLooper()).post(...)或者用协程的withContext(Dispatchers.Main)。但我发现不少人对这里的线程安全边界是模糊的——数据在子线程算完了,不等于发到主线程用的时候就不用操心同步了。
我踩过的一个典型坑是:子线程在临界区里持锁做了耗时操作,比如网络请求或数据库查询,此时主线程也在等待同一把锁。主线程一旦被锁阻塞时间过长,就会直接触发ANR。日志里能看到主线程阻塞在锁上的堆栈,下一步就是用户感受到卡死。
这个问题的解决办法不是让锁自动变得高效,而是要从设计上规避:synchronized临界区里千万不要做耗时操作,尤其是网络IO、磁盘IO这类可能几十毫秒甚至更久的操作。锁只用来保护内存数据和轻量计算,这是铁律。如果确实需要在临界区里等待某个异步结果,那一定是设计有问题,需要重构,而不是靠调大超时时间糊弄过去。
4. 锁粒度、性能分析与死锁排查
4.1 锁粒度怎么划最合理:一致性边界优先于并发度
锁粒度是synchronized使用中两极分化最严重的一环。有的人图省事把整个方法锁住,导致并发度极低;有的人为了追求并发性能把锁拆得七零八碎,结果数据一致性出了问题。
我的判断标准是:先画清楚哪些变量属于同一组不可分割的业务状态,这一组状态的所有读写必须使用同一把锁。比如一个电商订单,订单状态和库存扣减是关联数据,如果你把库存的锁和订单状态的锁分开,就可能出现:余额扣了但订单状态还没更新,另一个线程查到的是中间状态,产生脏读。
确定了一致性边界之后,再在这个边界内尽量缩小临界区。同步块应该尽量小,但至少要覆盖需要保持一致性的最小操作集合。我见到的很多bug,都是先把锁拆小了,然后发现数据对不上了,最后又回去把锁恢复大。如果一开始就按一致性边界来设计,往往能省掉不少返工。
4.2 synchronized与Lock、并发容器、原子类的取舍
这是经常被问到的选择题:什么场景用synchronized,什么场景换ReentrantLock?我的实践经验是这样:
| 诉求 | 推荐方案 | 原因 |
|---|---|---|
| 简单互斥、临界区短、竞争不激烈 | synchronized | 语法简单、异常自动释放锁、有锁升级优化 |
| 需要超时获取锁、可中断、公平锁 | ReentrantLock | 提供tryLock、lockInterruptibly、公平策略 |
| 读多写少 | ReadWriteLock或StampedLock | 读锁可以共享,吞吐量更高 |
| 计数器、数值累加 | AtomicInteger / AtomicLong | CAS自旋,无阻塞,性能远优于锁 |
| 并发Map | ConcurrentHashMap | 内部CAS + synchronized锁桶,分段互不阻塞 |
| 并发List,读多写少 | CopyOnWriteArrayList | 写时复制,读操作无锁 |
| 生产者-消费者队列 | ArrayBlockingQueue / LinkedBlockingQueue | 内部已封装好锁和条件队列 |
值得多说一句的是ConcurrentHashMap在JDK 8里已经大量使用了synchronized锁桶的思路,它没有排斥synchronized,反而是把锁的粒度拆小到了单个桶。这种“尽量缩小锁覆盖范围”的思路才是并发编程的精髓,而不是纠结选哪个锁工具。
4.3 死锁与ANR的定位思路:从Thread Dump开始
死锁是线程安全问题里最头痛的一种。四个必要条件:互斥、持有并等待、不可剥夺、循环等待。在Android上,死锁往往最终表现为ANR或App彻底卡住。
定位手段有三板斧。第一板斧是抓线程堆栈:连接设备后执行adb shell kill -3 <pid>,系统会生成ANR trace文件,也可以用Android Studio的Profiler直接看线程状态。第二板斧是看关键字:在堆栈中搜索"held by thread"和"waiting to lock",一般能直接看到两个线程互相等待的完整链路。第三板斧是复盘锁顺序:如果两个线程各自持有一把锁,又要去获取对方手里的锁,就形成了循环等待,解决办法是统一锁获取顺序,让所有线程都严格按照同样的顺序获取多把锁。
我处理过一个真实案例:线程A持锁后调用了一个方法,这个方法内部又需要获取线程B持有的锁,而线程B的临界区反过来又要等线程A的锁,两个线程直接锁死。后来把两层锁合并成一把锁,问题立刻消失。有时候最简单的解法就是把锁减少,而不是增加。
5. 常见问题速查表与我的几点心得
5.1 一份synchronized相关问题的排查速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| IllegalMonitorStateException | wait/notify在synchronized块外调用 | 把wait/notify放到同步块或同步方法内 |
| 数据偶尔不一致 | 静态同步方法和实例同步方法用了不同锁 | 统一锁对象,或用同一个private final锁 |
| 性能卡顿、CPU飙高 | 临界区内做了耗时操作,或锁竞争激烈 | 缩小锁粒度,把耗时操作移出临界区 |
| ANR,trace显示waiting to lock | 主线程等锁,且锁被其他线程长期持有 | 避免主线程竞争锁,精简锁内代码 |
| 两个线程卡死,互相等待 | 死锁 | 统一锁获取顺序,用tryLock做兜底 |
| 拿String或Integer做锁对象 | 常量池缓存导致意外的全局锁竞争 | 改成private final Object锁 |
| 用Lock忘记unlock | 没有配套try-finally | 换synchronized,或严格用try-finally包裹 |
5.2 我对synchronized的三个使用原则
第一条,锁的对象要少而精。一个模块内尽量只用一个锁,或者一组关联操作使用同一个锁。锁对象越分散,越容易出现不一致和死锁的隐患。
第二条,同步代码块越小越好。锁保护的是代码,不是整个方法。能保护局部变量就不要锁整个类,能让锁覆盖的代码短一点,就不要把无关逻辑包进去。
第三条,能不上锁就不上锁。多线程场景下优先考虑用并发安全的容器和原子类,把可变状态封装到一个比较小的范围内,然后集中给这个范围上锁。现代并发编程的思路是减少共享可变状态,而不是把所有操作都包在锁里。
5.3 一个日常用到的线程安全工具类模板
最后分享一个在Android项目里我经常用来做兜底的线程安全计数器模板,既能当参考也能直接改:
public class ThreadSafeCounter { private final Object lock = new Object(); private int count = 0; public void increment() { synchronized (lock) { count++; } } public int get() { synchronized (lock) { return count; } } }看起来很基础,但真正在项目里用的时候,我会再加一层设计:把count相关的读写全部封装在类内部,外部线程只能调用increment和get方法,根本拿不到count这个变量本身。这就是“可变状态最小化”的落地——让锁去保护的范围尽可能小,同时也让外部代码没有机会绕过锁直接访问数据。
我在实际项目中调试过不少偶发性的数据错乱问题,绕来绕去,最后多半都是回到synchronized的基本功上:锁对象选对了没有,临界区切得准不准,有没有在锁里做不该做的事。把这三点想清楚,Android开发里大部分线程安全问题都能迎刃而解。