☰
Android多线程编程:线程安全与锁机制深度解析
2026/10/13 1:20:45 网站建设 项目流程

1. 多线程编程里绕不开的那道坎

做Android开发有些年头的人,几乎都经历过这样的场景:代码在测试机上跑得好好的,一到线上就偶发崩溃;或者列表滑动时数据莫名其妙错乱,日志里翻来翻去也找不到原因。最后定位下来,十有八九是多个线程同时读写同一块数据惹的祸。这就是多线程编程里最核心也最容易被忽视的问题——线程安全。

线程安全和锁机制,说白了就是解决“多个执行流同时操作共享资源时如何不打架”的问题。Android的主线程负责UI渲染,子线程负责网络请求、数据库读写、图片处理这些耗时操作,两者之间必然存在数据交换。如果交换过程没有做好同步,轻则数据错乱,重则直接崩溃。这套机制适合所有涉及并发操作的Android开发者,不管你是刚接触多线程的新手,还是写过几年业务代码但没系统梳理过并发模型的老手,把这块吃透都能实实在在减少线上问题。

我见过太多项目,业务逻辑写得漂漂亮亮,唯独在并发这一块埋了雷。等到用户量上来、设备性能差异变大,这些雷就一个个炸开。所以这篇内容不讲虚的,直接从内存模型讲到锁的选型,再到实际项目里怎么排查并发问题,把线程安全这件事掰开揉碎说清楚。

2. 线程安全问题的根源到底在哪

2.1 从Java内存模型说起

要理解线程安全,得先搞清楚数据在内存里是怎么流转的。Java内存模型(JMM)定义了一套规则,规定了多线程环境下变量的读写行为。简单来说,每个线程都有自己的工作内存,变量从主内存拷贝到工作内存,线程操作的是工作内存里的副本,操作完再写回主内存。这个“拷贝-操作-写回”的过程,就是并发问题的温床。

打个比方,主内存像是一个公共仓库,每个线程像是一个工人。工人要修改仓库里的货物,不能直接改,得先搬到自己的工位上,改完了再搬回去。如果两个工人同时搬了同一件货物,各自改完再搬回去,后搬的那个就会覆盖前一个的修改。这就是典型的竞态条件。

JMM规定了八种原子操作:lock、unlock、read、load、use、assign、store、write。这些操作之间可以交错执行,交错的方式决定了最终结果。理解这一点,就能明白为什么加了volatile的变量能保证可见性——因为它强制每次读都从主内存读,每次写都立刻刷回主内存,跳过了工作内存的缓存。

2.2 三个核心概念:原子性、可见性、有序性

线程安全问题归根结底是这三个特性被破坏导致的。

原子性指的是一个操作要么全部执行完,要么完全不执行,中间不能被中断。比如count++这个看似简单的操作,实际上包含了读取、加一、写回三个步骤,多线程环境下就可能出问题。Java里只有基本类型的简单赋值(比如int a = 1)和引用赋值是原子操作,其他复合操作都需要额外同步。

可见性指的是一个线程修改了共享变量,其他线程能立刻看到。没有同步措施的话,一个线程改了值放在自己工作内存里,另一个线程还在用旧值,就会出大问题。volatile、synchronized、final都能保证可见性,但机制不同。

有序性指的是程序执行的顺序按照代码的先后顺序来。编译器和处理器为了优化性能,可能会对指令重排序。单线程下重排序不影响结果,但多线程下就可能导致意想不到的问题。经典的例子就是单例模式的双重检查锁定,如果不加volatile,就可能拿到一个构造未完成的对象。

注意:这三个特性不是孤立的,往往一个同步手段能同时保证多个特性。比如synchronized既能保证原子性,也能保证可见性和有序性,这也是它被称为“万能锁”的原因。

2.3 Android里哪些场景最容易踩坑

结合我自己的项目经验,Android开发中线程安全问题高发的场景主要有这么几类。

第一类是集合类的并发访问。ArrayList、HashMap这些都不是线程安全的,多个线程同时add或者put,可能导致数据丢失甚至死循环。我遇到过一个线上崩溃,就是子线程在遍历HashMap的同时主线程在put,直接抛了ConcurrentModificationException。

第二类是单例对象的初始化。如果单例的构造过程比较重,又没有做好同步,多线程同时调用getInstance就可能创建出多个实例,或者拿到半成品对象。

第三类是UI更新与后台数据的交互。子线程请求完网络数据,直接修改了某个全局变量,主线程同时在读这个变量渲染UI,时序不对就会显示错乱。

第四类是数据库和文件操作。多个线程同时写同一个文件或者操作同一个数据库连接,不加锁的话数据完整性根本无法保证。

这些场景的共同点就是:共享资源 + 多线程访问 + 缺乏同步。只要满足这三个条件,问题迟早会出现。

3. 锁机制的全景拆解与选型思路

3.1 synchronized的底层原理与使用姿势

synchronized是Java最基础的同步手段,用起来简单,但底层实现并不简单。它在JVM层面通过monitorenter和monitorexit指令实现,每个对象都有一个监视器锁(Monitor)。线程进入同步块时尝试获取监视器,获取不到就阻塞等待。

在Android的ART虚拟机里,synchronized的实现做了不少优化。早期是重量级锁,直接依赖系统互斥量,性能开销大。后来引入了锁升级机制:偏向锁、轻量级锁、重量级锁。偏向锁针对的是同一个线程反复进入同步块的场景,轻量级锁针对的是竞争不激烈的场景,只有真正发生激烈竞争时才升级为重量级锁。这个优化让synchronized在大多数情况下的性能已经和ReentrantLock差不多了。

使用synchronized有几个关键点要注意。锁对象的选择很重要,锁this、锁Class、锁某个特定对象,效果完全不同。锁的粒度也要控制好,锁太大影响并发性能,锁太小又起不到保护作用。另外,synchronized是可重入的,同一个线程可以重复获取同一把锁,这点和ReentrantLock一样。

// 锁实例对象,不同实例之间互不影响 public synchronized void methodA() { ... } // 锁Class对象,所有实例共用一把锁 public static synchronized void methodB() { ... } // 锁特定对象,粒度更细 private final Object lock = new Object(); public void methodC() { synchronized (lock) { // 临界区 } }

3.2 ReentrantLock与Condition的进阶用法

ReentrantLock是JUC包提供的可重入锁,功能比synchronized更丰富。它支持公平锁和非公平锁,支持可中断的锁获取,支持超时获取锁,还支持多个条件变量。这些特性在复杂并发场景下非常有用。

公平锁保证先到先得,避免线程饥饿,但吞吐量会下降。非公平锁允许插队,性能更好,但可能导致某些线程长时间获取不到锁。默认是非公平锁,大多数场景下够用。如果你确实需要严格的顺序保证,再考虑公平锁。

Condition是ReentrantLock的好搭档,用来实现线程间的等待/通知机制。相比Object的wait/notify,Condition可以创建多个等待队列,精确控制唤醒哪些线程。比如实现一个生产者消费者模型,可以用两个Condition分别管理生产者和消费者的等待队列,避免无效唤醒。

ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); }

提示:使用ReentrantLock一定要把unlock放在finally块里,否则一旦临界区抛异常,锁就永远释放不了,其他线程全部卡死。这个坑我见过不止一次。

3.3 volatile、原子类与CAS的配合

volatile是最轻量的同步机制,只保证可见性和有序性,不保证原子性。它适合用在状态标志位这种场景,一个线程改了,其他线程立刻能看到。但如果你用它来做计数器,那就大错特错了。

原子类(AtomicInteger、AtomicLong、AtomicReference等)底层依赖CAS(Compare-And-Swap)操作。CAS是一种乐观锁策略,每次更新前先比较当前值是否等于预期值,相等才更新,否则重试。这个过程是CPU指令级别的原子操作,不需要加锁,性能很好。

但CAS也有自己的问题。ABA问题是其中之一:一个值从A变成B又变回A,CAS检查时以为没变过,实际上已经变了。解决方法是加版本号,AtomicStampedReference就是干这个的。另外,高并发下CAS可能大量自旋,消耗CPU,这时候反而不如直接加锁。

// 原子类的基本用法 AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet(); // 原子自增 counter.compareAndSet(1, 2); // CAS操作 // 解决ABA问题 AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(0, 0); int stamp = ref.getStamp(); ref.compareAndSet(0, 1, stamp, stamp + 1);

3.4 各种锁的对比与选型建议

锁类型适用场景优点缺点
synchronized简单同步、代码块保护使用简单、JVM优化好、自动释放功能单一、不可中断
ReentrantLock复杂同步、需要条件变量功能丰富、可中断、可超时需手动释放、代码稍复杂
volatile状态标志、一写多读轻量、无锁开销不保证原子性
原子类计数器、累加器无锁、性能好ABA问题、高并发自旋
ReadWriteLock读多写少读并发高写饥饿风险
StampedLock读多写少、乐观读性能最优API复杂、不可重入

选型的时候,我一般遵循这个原则:能用synchronized就用synchronized,简单可靠;需要高级功能再上ReentrantLock;只是状态标志用volatile;计数器用原子类;读多写少考虑ReadWriteLock。不要为了炫技而用复杂的锁,简单方案能解决问题就是最好的方案。

4. 实战中的线程安全方案与代码落地

4.1 单例模式的线程安全写法

单例模式是线程安全问题的高发区,也是面试常客。我见过各种写法,有的看似正确实则暗藏隐患。

饿汉式最简单,类加载时就创建实例,天生线程安全,但如果构造过程耗时或者依赖其他资源,可能影响启动速度。静态内部类方式是推荐做法,既实现了懒加载,又保证了线程安全,代码也简洁。

public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }

双重检查锁定(DCL)是另一个常见写法,但必须给instance加volatile,否则可能拿到未初始化完成的对象。这个问题的根源是指令重排序:创建对象分为分配内存、初始化、赋值引用三步,编译器可能把赋值引用排到初始化之前,导致另一个线程看到一个非null但未初始化的对象。

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; } }

枚举方式是《Effective Java》推荐的写法,天然线程安全,还能防止反射攻击和序列化破坏。如果业务允许,优先考虑枚举。

4.2 生产者消费者模型的完整实现

生产者消费者模型是并发编程的经典场景,Android里的消息队列、线程池的任务队列都是这个模型的变体。用ReentrantLock和Condition实现一版,能覆盖大部分实际需求。

public class BlockingQueue<T> { private final Queue<T> queue = new LinkedList<>(); private final int capacity; private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public BlockingQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.offer(item); notEmpty.signalAll(); } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } T item = queue.poll(); notFull.signalAll(); return item; } finally { lock.unlock(); } } }

这段代码有几个细节值得说。wait条件用while而不是if,是为了防止虚假唤醒。signalAll而不是signal,是因为可能有多个线程在等待,唤醒一个可能不够。锁的获取和释放必须配对,放在try-finally里保证异常时也能释放。

4.3 读写锁在缓存场景的应用

Android里经常需要缓存一些数据,比如图片、网络响应、配置信息。缓存的特点是读多写少,用ReadWriteLock能显著提升并发性能。

public class Cache<K, V> { private final Map<K, V> map = new HashMap<>(); private final ReadWriteLock lock = new ReentrantReadWriteLock(); public V get(K key) { lock.readLock().lock(); try { return map.get(key); } finally { lock.readLock().unlock(); } } public void put(K key, V value) { lock.writeLock().lock(); try { map.put(key, value); } finally { lock.writeLock().unlock(); } } }

读锁之间不互斥,多个线程可以同时读。读锁和写锁互斥,写的时候不能读,读的时候不能写。写锁和写锁也互斥。这样就保证了数据一致性,同时读操作的并发度大大提高。

但ReadWriteLock有个潜在问题:如果读操作非常频繁,写操作可能一直获取不到锁,造成写饥饿。StampedLock通过乐观读策略缓解了这个问题,但API更复杂,需要根据实际场景权衡。

4.4 线程池中的并发控制

线程池本身就是并发控制的利器,但线程池内部也有线程安全问题。比如ThreadPoolExecutor的worker集合、任务队列、状态变量,都需要同步保护。理解这些内部机制,有助于我们更好地使用线程池。

自定义线程池的时候,核心线程数、最大线程数、队列容量、拒绝策略这几个参数需要仔细考虑。核心线程数一般设为CPU核心数加一,最大线程数根据任务类型调整,IO密集型可以设大一些,CPU密集型设小一些。队列容量太小容易触发拒绝策略,太大又可能导致内存问题。

ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(100), // 任务队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );

注意:线程池的参数没有万能公式,必须结合业务特点压测调优。我见过直接抄网上配置导致OOM的案例,队列设了无界,任务堆积到内存耗尽。

5. 并发问题排查与避坑经验实录

5.1 常见并发问题的排查思路

并发问题最难的地方在于复现。同样的代码,可能跑一万次才出一次问题,日志里也看不出明显异常。我总结了一套排查思路,按这个顺序走,大部分问题都能定位。

第一步,确认是不是并发问题。看崩溃日志里有没有ConcurrentModificationException、NullPointerException(对象被其他线程置空了)、ArrayIndexOutOfBoundsException(集合大小被其他线程改了)这些典型异常。看问题是不是偶发,是不是和特定时序相关。

第二步,定位共享资源。找出被多个线程访问的变量、集合、对象。重点关注静态变量、单例对象、全局缓存这些。

第三步,分析访问路径。画出每个线程的操作序列,看哪些操作可能交错执行导致问题。特别关注“检查后执行”这种复合操作。

第四步,加日志验证。在关键位置打日志,记录线程名、时间戳、变量值。通过日志还原出问题发生时的执行顺序。

第五步,修复并验证。根据分析结果加锁或者改用线程安全的数据结构,然后写压力测试验证修复效果。

5.2 死锁的成因与规避

死锁是并发编程里最棘手的问题之一。两个或多个线程互相持有对方需要的锁,谁也不释放,程序就卡死了。死锁的发生需要满足四个条件:互斥、持有并等待、不可剥夺、循环等待。破坏其中任何一个条件,死锁就不会发生。

最常见的死锁场景是锁顺序不一致。线程A先锁X再锁Y,线程B先锁Y再锁X,两者同时执行就可能死锁。解决办法是统一锁的获取顺序,所有线程都按同样的顺序获取锁。

// 错误示范:锁顺序不一致 // 线程1 synchronized(lockA) { synchronized(lockB) { ... } } // 线程2 synchronized(lockB) { synchronized(lockA) { ... } } // 正确做法:统一按hash值排序获取 int hashA = System.identityHashCode(lockA); int hashB = System.identityHashCode(lockB); if (hashA < hashB) { synchronized(lockA) { synchronized(lockB) { ... } } } else { synchronized(lockB) { synchronized(lockA) { ... } } }

另外,使用tryLock带超时的方式也能避免死锁。获取不到锁就放弃并释放已持有的锁,过一段时间重试。这样即使发生循环等待,也能自动解开。

5.3 性能与安全的平衡技巧

加锁能保证安全,但会牺牲性能。怎么在两者之间找平衡,是每个开发者都要面对的问题。

第一个技巧是减小锁粒度。把大锁拆成小锁,只锁真正需要保护的部分。比如ConcurrentHashMap的分段锁设计,把整个表分成多个段,不同段可以并发访问。

第二个技巧是缩短锁持有时间。临界区里只放必要的操作,耗时的计算、IO操作移到锁外面。我见过有人在同步块里做网络请求,这简直是灾难。

第三个技巧是锁分离。读写锁就是典型的锁分离,读和写用不同的锁。还有LinkedBlockingQueue的takeLock和putLock,入队和出队用不同的锁,并发度更高。

第四个技巧是无锁编程。能用原子类就用原子类,能用CAS就用CAS。虽然无锁编程难度高,但在合适的场景下性能提升非常明显。

优化技巧适用场景效果
减小锁粒度大集合、大对象并发度提升明显
缩短持有时间临界区有耗时操作减少线程阻塞
锁分离读写比例悬殊读并发大幅提升
无锁编程简单计数器、状态位消除锁开销
锁粗化频繁加锁释放锁减少锁操作次数

5.4 高频问题速查表

问题现象可能原因排查方法解决方案
ConcurrentModificationException遍历时修改集合检查集合操作代码用CopyOnWriteArrayList或加锁
数据丢失竞态条件检查复合操作加锁或原子类
死锁锁顺序不一致jstack查看线程栈统一锁顺序或tryLock
内存可见性问题缺少同步检查共享变量volatile或synchronized
性能下降锁竞争激烈监控线程阻塞时间减小锁粒度或锁分离
单例多实例初始化未同步检查getInstanceDCL加volatile或静态内部类

提示:排查并发问题时,jstack是利器。它能打印出所有线程的调用栈和锁持有情况,死锁、线程阻塞一目了然。Android上可以用Debug类或者第三方工具抓取线程堆栈。

6. 我个人在实际项目中的几点体会

写了这么多年代码,踩过的并发坑不计其数。最大的体会就是:不要心存侥幸。你觉得“这个变量应该不会被其他线程改”,它偏偏就被改了。你觉得“这个时序应该不会出现”,它偏偏就出现了。并发问题的特点就是概率性,一万次不出问题不代表第一万零一次不出。

第二个体会是优先用成熟的并发工具。JUC包提供了丰富的并发容器和同步工具,ConcurrentHashMap、CopyOnWriteArrayList、CountDownLatch、CyclicBarrier、Semaphore,这些类都经过大量测试和优化,比你自己手写同步靠谱得多。能用现成的就用现成的。

第三个体会是测试要覆盖并发场景。单元测试只能验证单线程逻辑,并发问题必须用多线程压力测试才能暴露。我一般会写一个简单的压测脚本,开几十个线程反复调用,跑个几万次,看看有没有异常。虽然不能保证百分百覆盖,但能发现大部分明显问题。

最后分享一个小技巧:在开发阶段可以开启StrictMode的线程检测,它能帮你发现一些主线程上的磁盘读写、网络操作,间接减少一些并发隐患。另外,Android的@WorkerThread、@MainThread、@UiThread这些注解虽然只是文档性质的,但能提醒你和你的同事注意线程约束,养成好习惯。

并发编程这件事,理论要懂,但更重要的是多写多练。看再多的书,不如自己动手写一个线程安全的缓存、一个阻塞队列、一个线程池,踩几个坑,自然就理解了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询