news 2026/9/9 21:51:54

Android线程安全实战:synchronized底层原理、锁升级与最佳实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android线程安全实战:synchronized底层原理、锁升级与最佳实践

写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 / AtomicLongCAS自旋,无阻塞,性能远优于锁
并发MapConcurrentHashMap内部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相关问题的排查速查表

现象可能原因解决方案
IllegalMonitorStateExceptionwait/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开发里大部分线程安全问题都能迎刃而解。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 21:49:16

VolFormer:体积自注意力与光谱衰减先验驱动的高光谱图像恢复

CVPR 2025 放榜那几天&#xff0c;VolFormer 在 HSI 恢复方向上的讨论度确实高。先给不熟悉的朋友补个背景&#xff1a;HSI 就是高光谱图像&#xff0c;每个像素都带着几十上百个波段的光谱信息&#xff0c;数据本身是一个 HWB 的立方体。VolFormer 主打的体积自注意力&#xf…

作者头像 李华
网站建设 2026/9/9 21:48:30

LabVIEW用户登录程序:基于Access数据库的通用密码登录与用户管理方案

做LabVIEW上位机开发的朋友应该都遇到过这种需求&#xff1a;程序功能都写完了&#xff0c;客户突然提一句“加个登录界面吧&#xff0c;不能让谁都能操作设备”。尤其是测试设备、生产工位、实验室仪器这类场景&#xff0c;用户登录几乎是硬性要求。早些年我都是从零开始搭&am…

作者头像 李华
网站建设 2026/9/9 21:48:25

压缩提示词,却把非英语用户压缩没了:一场跨十种语言的审计实验

你有没有遇到过这种情况&#xff1a;把一段中文长文本喂给某个AI工具做摘要压缩&#xff0c;结果压缩完的内容读起来支离破碎&#xff0c;甚至完全变了意思&#xff0c;可你用同样的工具处理英文文本时&#xff0c;效果却好得多。如果你有过这种感觉&#xff0c;那不是错觉。20…

作者头像 李华
网站建设 2026/9/9 21:48:11

电子厂生产管理方案详解:如何提升效率与质量

1. 引言电子厂的生产管理涉及物料、设备、人员、工艺、质量等多个环节&#xff0c;任何一个环节出现瓶颈&#xff0c;都会直接影响整体产出效率和产品良率。随着订单交付周期不断缩短、客户对品质要求持续提高&#xff0c;电子制造企业越来越需要一套系统化、可落地的生产管理方…

作者头像 李华
网站建设 2026/9/9 21:47:10

QTestLib实战:从工程配置到信号与GUI测试

QTestLib&#xff08;也就是Qt自带的Qt Test模块&#xff09;是我在给C/Qt项目补单元测试时最常用的一套工具。如果你已经写了一阵子Qt业务代码&#xff0c;但还没系统地给项目加测试&#xff0c;或者试过Google Test之后总觉得跟Qt的信号槽、事件循环配合得不够顺手&#xff0…

作者头像 李华