
1. 从一次诡异的线上崩溃说起去年帮一个朋友排查他们App的线上崩溃日志里出现了一个特别有意思的现象同一个订单对象在支付回调线程里读出来的金额是99.9在UI线程里读出来的却是0.0。更离谱的是这个对象明明在创建之后就没有任何地方修改过它。当时团队里有人怀疑是内存泄漏有人怀疑是序列化问题折腾了整整两天最后定位到的原因其实非常朴素——一个非线程安全的集合类被两个线程同时读写导致内部状态错乱。这件事让我意识到很多做Android开发的朋友对多线程的理解停留在new Thread().start()能跑起来就行的阶段一旦遇到线程安全问题排查起来完全没有章法。所以这一篇我想把Android里的线程安全和锁机制彻底讲透从为什么会出问题讲到怎么选锁再到实际项目里怎么用才不出事。这篇文章适合已经能写多线程代码、但一遇到并发问题就头大的Android开发者。如果你还在纠结Handler怎么用建议先补一下多线程基础如果你已经能熟练写线程池但说不清楚synchronized和ReentrantLock到底该选哪个那这篇正好对味。核心关键词就三个线程安全、锁机制、内存可见性我会围绕它们把Android并发编程里最容易踩的坑一个个拆开。2. 线程安全问题的三个真面目很多人以为线程安全问题就是两个线程同时改一个变量这个理解太窄了。实际上并发问题可以分成三类每一类的成因和表现都不一样排查手段也完全不同。搞清楚这三类你才能在看到诡异现象时快速缩小范围。2.1 原子性被破坏i为什么不是一步操作先看一段几乎每个人都写过的代码public class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }单线程跑一点问题没有。但只要两个线程同时调用increment()结果就会小于预期值。原因在于count在字节码层面其实是三步读取count的值、把值加1、把结果写回count。这三步之间随时可能被线程调度打断。假设count当前是5线程A读到5还没来得及写回线程B也读到5两个线程各自加1后都写回6。两次自增结果只增加了1。这就是原子性被破坏——一个操作被拆成了多个步骤中间状态被其他线程看到了。在Android里这种问题最常出现在计数器、状态标志位、缓存更新这些场景。比如你用一个int标记某个初始化任务是否完成多个线程去检查并设置就可能出现重复初始化。2.2 可见性缺失一个线程改了另一个线程看不见可见性问题比原子性更隐蔽因为它单线程测试永远测不出来。看这个例子public class VisibilityDemo { private boolean flag false; public void writer() { flag true; } public void reader() { while (!flag) { // 等待flag变为true } System.out.println(flag已变为true); } }一个线程调用writer把flag设为true另一个线程在reader里循环等待。按直觉flag一变truereader就该退出循环。但实际运行时reader可能永远卡在循环里。原因是每个CPU核心都有自己的缓存线程A修改了flag这个修改可能还停留在A所在核心的缓存里没有同步到主内存线程B读的仍然是自己缓存里的旧值false。这就是可见性问题——一个线程的修改另一个线程不一定能立刻看到。Android的UI线程和子线程之间共享变量时这个问题尤其常见。子线程更新了一个标志位UI线程却读不到最新值导致界面状态和实际数据不一致。2.3 有序性重排编译器和你开的玩笑有序性问题最少见但一旦出现最难排查。编译器和CPU为了优化性能会在不影响单线程语义的前提下调整指令执行顺序。单线程下这个调整没问题但多线程下就可能出乱子。经典的例子是双重检查锁定的单例写法public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }instance new Singleton()这行代码实际包含三步分配内存、初始化对象、把引用指向内存地址。编译器可能把第二步和第三步调换顺序。如果线程A执行到引用指向内存地址但对象还没初始化完线程B进来发现instance不为null直接返回了一个半成品对象用的时候就会出各种莫名其妙的空指针或错误数据。这三类问题经常交织在一起出现。原子性被破坏往往伴随可见性问题有序性问题又可能放大前两者的影响。所以排查并发bug时不要只盯着一个方向要把三个维度都过一遍。3. 锁机制的本质不只是加个锁那么简单理解了问题成因接下来看解决方案。锁是解决线程安全最直接的手段但很多人对锁的理解停留在加了锁就安全的层面结果要么锁得太重性能崩了要么锁得太轻根本没起作用。这一章把锁的底层逻辑讲清楚。3.1 锁到底锁的是什么对象头与监视器在Java和Android里每个对象都有一个对象头里面有一块区域叫Mark Word记录了对象的哈希码、GC分代年龄以及锁状态。当你对一个对象加synchronized锁时JVM会修改这个对象的Mark Word把它标记为被锁定并关联到一个**监视器Monitor**上。监视器可以理解成一个房间同一时刻只允许一个线程进入。线程要进入这个房间必须先拿到钥匙获取锁出来的时候要把钥匙还回去释放锁。其他想进入的线程只能在门外排队等待。这里有个关键点锁是加在对象上的不是加在代码上的。synchronized (obj)锁的是obj这个对象synchronized修饰实例方法锁的是当前实例对象修饰静态方法锁的是Class对象。搞清楚锁的是哪个对象才能判断两个线程会不会互斥。我见过一个典型的错误两个线程分别用不同的对象作为锁结果各自锁各自的完全没有互斥效果。代码看起来加了锁实际上等于没加。3.2 悲观锁与乐观锁两种截然不同的思路锁机制从思路上分两大类。悲观锁假设冲突一定会发生所以每次操作前先加锁操作完再释放。synchronized和ReentrantLock都是悲观锁。乐观锁假设冲突很少发生操作时不加锁只在最后提交时检查有没有被别人改过如果改过就重试。CASCompare And Swap就是乐观锁的典型实现。悲观锁的优点是简单可靠缺点是并发高时大量线程阻塞性能下降明显。乐观锁在低冲突场景下性能极好但高冲突时大量重试反而更慢。Android里有个很实际的例子AtomicInteger用的就是CAS乐观锁适合计数器这种低冲突场景而数据库事务、文件读写这种高冲突场景用悲观锁更合适。选哪种取决于你的实际并发情况没有绝对的好坏。3.3 锁的粒度为什么锁的范围越小越好锁的粒度指的是锁保护的代码范围。范围越大互斥越强但并发性能越差范围越小并发越好但可能保护不到位。看个例子// 粒度过大 public synchronized void process() { doSomethingSlow(); // 耗时操作不需要同步 updateSharedData(); // 需要同步 } // 粒度合理 public void process() { doSomethingSlow(); synchronized (this) { updateSharedData(); } }第一种写法把整个方法都锁住了耗时操作也串行执行白白浪费并发能力。第二种只锁真正需要同步的部分性能好得多。但粒度也不是越小越好。如果锁的范围太小把本该原子执行的操作拆开了反而会出问题。比如先检查再更新这种复合操作检查和更新必须在同一个锁里否则中间可能被其他线程插入。我的经验是先保证正确性再优化粒度。一开始可以锁大一点确保逻辑没问题然后再根据性能测试结果逐步缩小范围。不要一上来就追求极致粒度那样很容易引入难以发现的bug。4. Android里几种锁的实战选型理论讲完了这一章进入实操。Android里能用的锁有好几种每种都有自己的适用场景和坑。我会把最常用的几种拿出来对比告诉你什么情况下该用哪个。4.1 synchronized最基础也最容易用错的锁synchronized是Java内置的关键字使用简单不需要手动释放锁出现异常时JVM会自动释放。这是它最大的优势——不会因为忘记释放锁导致死锁。但它也有明显的局限。首先它不支持超时获取锁一个线程拿不到锁就只能一直等没法设置等待时间。其次它不支持中断等待中的线程没法被外部打断。第三它不支持公平锁无法保证等待最久的线程优先获得锁。在Android里synchronized还有一个性能考量。早期Android版本Dalvik时代synchronized性能较差很多老代码会刻意避免使用。但ART时代之后synchronized做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级机制性能已经不再是问题。新项目里可以放心用。一个常见的错误用法是在synchronized块里调用外部方法public synchronized void update() { listener.onUpdate(); // 外部回调可能耗时或再次加锁 }如果回调里又去获取同一把锁就会死锁。正确做法是把回调移到锁外面或者用ReentrantLock配合tryLock避免死锁。4.2 ReentrantLock需要高级功能时的选择ReentrantLock是JUC包提供的可重入锁功能比synchronized丰富得多。它支持超时获取、可中断、公平锁还能配合Condition实现更灵活的等待通知机制。private final ReentrantLock lock new ReentrantLock(); public void doWork() { if (lock.tryLock(100, TimeUnit.MILLISECONDS)) { try { // 临界区代码 } finally { lock.unlock(); } } else { // 获取锁超时走降级逻辑 } }注意那个finally块这是ReentrantLock最容易出错的地方。synchronized不需要手动释放但ReentrantLock必须手动unlock而且一定要放在finally里否则一旦临界区抛异常锁就永远释放不了其他线程全部卡死。tryLock的超时机制在Android里特别有用。比如主线程需要等待子线程的结果但又不能无限等待导致ANR就可以用tryLock设置一个合理的超时时间超时后走降级逻辑。4.3 读写锁读多写少场景的性能利器ReentrantReadWriteLock把锁分成读锁和写锁。多个线程可以同时持有读锁但写锁是独占的。这适合读多写少的场景比如缓存。private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final MapString, String cache new HashMap(); public String get(String key) { rwLock.readLock().lock(); try { return cache.get(key); } finally { rwLock.readLock().unlock(); } } public void put(String key, String value) { rwLock.writeLock().lock(); try { cache.put(key, value); } finally { rwLock.writeLock().unlock(); } }读锁之间不互斥所以多个读操作可以并行性能比普通互斥锁好很多。但要注意读写锁在写操作频繁时性能反而更差因为写锁要等待所有读锁释放而且写锁的优先级处理不当会导致写线程饥饿。Android里有个实际场景配置信息的读取。配置很少变但读取频繁用读写锁就很合适。但如果配置更新也很频繁那还不如直接用synchronized。4.4 各种锁的对比与选型建议锁类型可重入超时获取可中断公平锁适用场景synchronized是否否否简单同步代码简洁优先ReentrantLock是是是是需要高级功能复杂同步ReentrantReadWriteLock是是是是读多写少缓存场景Atomic系列----单一变量原子操作CAS自旋----低冲突计数器、状态标志选型的基本原则能用synchronized就用synchronized代码简单不易出错需要超时、中断、公平性时用ReentrantLock读多写少用读写锁单一变量用Atomic低冲突场景考虑CAS。不要为了炫技而用复杂的锁简单可靠永远是第一位的。5. 内存可见性volatile和happens-before锁解决了原子性和互斥问题但可见性问题还需要单独处理。这一章讲清楚volatile和happens-before规则这是理解Java内存模型的核心。5.1 volatile的两层含义可见性与禁止重排volatile关键字有两个作用。第一是保证可见性对一个volatile变量的写操作会立即刷新到主内存读操作会直接从主内存读取而不是从CPU缓存读。第二是禁止指令重排volatile变量前后的指令不会被编译器重排到volatile操作的另一侧。public class VolatileDemo { private volatile boolean flag false; public void writer() { flag true; // 写volatile立即对其他线程可见 } public void reader() { while (!flag) { } // 读volatile每次从主内存读 // flag为true时退出 } }加上volatile后reader线程就能及时看到flag的变化不会死循环。但volatile不保证原子性。volatile int count; count仍然不是线程安全的因为count是复合操作。volatile只保证单次读或单次写的原子性不保证复合操作的原子性。5.2 happens-before规则判断可见性的标尺happens-before是Java内存模型定义的一套规则用来判断一个操作的结果是否对另一个操作可见。如果A happens-before B那么A的结果对B一定可见。几条最常用的规则程序顺序规则同一个线程内前面的操作happens-before后面的操作。监视器锁规则对一个锁的解锁happens-before后续对这个锁的加锁。volatile规则对volatile变量的写happens-before后续对这个变量的读。线程启动规则Thread.start()调用happens-before新线程中的任何操作。线程终止规则线程中的所有操作happens-before其他线程检测到该线程终止。理解这些规则你就能判断代码里哪些共享变量需要同步哪些不需要。比如线程A启动线程B之前修改的变量线程B一定能看到因为线程启动规则保证了这一点。5.3 双重检查锁定的正确写法回到前面那个单例的例子加上volatile后就是正确的public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }volatile禁止了instance new Singleton()内部三步指令的重排保证其他线程看到的instance要么是null要么是完整初始化的对象不会出现半成品。这个写法在Android里很常见但很多人只是照抄不知道为什么要加volatile。理解了重排问题你就能明白这个volatile不是可有可无的装饰而是保证正确性的关键。6. 死锁并发编程里最凶险的坑死锁是并发编程里最棘手的问题之一因为一旦发生程序就彻底卡死没有任何恢复的可能。这一章讲死锁的成因、排查和预防。6.1 死锁的四个必要条件死锁的发生必须同时满足四个条件互斥资源同一时刻只能被一个线程持有。持有并等待线程持有至少一个资源同时等待其他资源。不可剥夺资源不能被强制从持有者手中夺走。循环等待存在一个线程等待环每个线程都在等待下一个线程持有的资源。四个条件缺一不可。破坏其中任何一个死锁就不会发生。实际工作中最容易破坏的是循环等待和持有并等待。6.2 一个真实的死锁排查过程之前遇到过一个死锁两个线程分别持有A锁和B锁然后互相等待对方释放。日志里没有任何异常就是程序卡住不动。排查过程是这样的第一步用jstack抓取线程堆栈找到处于BLOCKED状态的线程。堆栈里会明确显示waiting to lock和locked的信息能看出哪个线程持有什么锁、在等什么锁。第二步分析锁的获取顺序。发现线程1先拿A再拿B线程2先拿B再拿A顺序相反形成了循环等待。第三步统一锁的获取顺序。让所有线程都按A、B的顺序获取锁循环等待被破坏死锁消失。这个案例的教训是多个锁的获取顺序必须全局统一。只要顺序一致就不可能形成循环等待。6.3 预防死锁的实用手段除了统一锁顺序还有几个实用手段使用tryLock超时拿不到锁就放弃释放已持有的锁过一会儿重试。这样即使形成等待环也会因为超时而打破。减少锁的使用能用无锁数据结构就用无锁的能从设计上避免共享就避免共享。缩小锁范围锁的范围越小持有时间越短形成死锁的概率越低。避免嵌套锁尽量不要在一个锁里获取另一个锁如果必须嵌套确保顺序一致。在Android里死锁最常出现在主线程和子线程互相等待的场景。主线程等子线程结果子线程又要更新UI等主线程一不小心就死锁。解决办法是子线程不要直接操作UI用Handler或runOnUiThread把UI操作抛回主线程避免双向等待。7. 无锁编程CAS与原子类锁能解决问题但锁本身有开销。高并发场景下无锁编程往往能带来更好的性能。这一章讲CAS和原子类的使用。7.1 CAS的工作原理与ABA问题CAS是Compare And Swap的缩写包含三个值内存地址V、预期旧值A、新值B。只有当V的当前值等于A时才把V更新为B否则不做任何操作。整个比较和更新是一个原子操作。// AtomicInteger内部就是通过CAS实现的 AtomicInteger count new AtomicInteger(0); count.incrementAndGet(); // 原子自增CAS的优点是无需加锁不会阻塞线程性能好。但它有个经典问题叫ABA问题线程1读到值A线程2把值改成B又改回A线程1的CAS检查发现还是A就以为没人改过实际上已经被改过了。解决ABA问题可以用版本号每次修改版本号加1CAS时同时检查值和版本号。AtomicStampedReference就是干这个的。7.2 Atomic系列类的适用边界Android里常用的原子类有AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference。它们适合单一变量的原子操作比如计数器、状态标志、引用替换。但原子类不是万能的。多个变量需要一起原子更新时原子类就无能为力了这时候还是得用锁。另外高冲突场景下CAS会大量失败重试性能反而不如锁。所以原子类适合低冲突的简单场景复杂场景还是老老实实用锁。7.3 什么时候该放弃锁改用无锁判断标准很简单如果临界区只是对单一变量做简单操作且冲突不激烈用原子类如果临界区涉及多个变量或复杂逻辑用锁。不要为了追求无锁而把简单问题复杂化可读性和正确性永远优先于性能。8. 线程安全设计的几条实战心得讲了这么多机制最后分享几条我在实际项目里总结的经验。这些不是教科书上的理论而是踩过坑之后才明白的道理。第一条共享可变状态是万恶之源。能不让线程共享就不共享能用局部变量就用局部变量能用不可变对象就用不可变对象。很多并发问题从设计上避免共享就根本不会发生。第二条先保证正确再考虑性能。我见过太多人一上来就追求无锁、追求细粒度结果bug一堆。正确的做法是先用最简单的锁保证正确性然后通过性能测试找到瓶颈再针对性优化。第三条锁的文档要写清楚。一个类里用了哪些锁、保护哪些数据、获取顺序是什么这些必须写在注释里。否则过几个月自己都忘了更别说接手的人。第四条并发测试不能只靠跑一遍。并发bug往往在特定时序下才出现跑一遍没问题不代表真的没问题。要用压力测试、多线程反复跑必要时用工具模拟各种时序。第五条Android主线程绝对不能做耗时同步操作。主线程被锁阻塞超过5秒就是ANR这是硬性红线。所有可能阻塞的操作都要放到子线程主线程只做UI更新。线程安全和锁机制这个话题往深了讲能讲一本书。但核心就那么几点搞清楚原子性、可见性、有序性三类问题理解锁的本质和选型逻辑掌握volatile和happens-before警惕死锁合理使用无锁编程。把这些吃透日常开发里遇到的并发问题基本都能搞定。剩下的就是在实践中不断积累经验了。