并发安全与锁机制
并发安全与锁机制
在后端开发、服务端编程、高并发系统开发中,并发安全是绕不开的核心话题。而锁,是解决并发安全问题最基础、最核心的手段之一。
很多开发者日常开发中都会用到锁,但大多只停留在“加锁防止数据错乱”的表层认知,不清楚锁的底层原理、不同锁的适用场景,也时常遇到死锁、性能卡顿、锁失效等问题。
一、什么是并发安全?不安全的根源是什么?
1. 并发安全定义
多线程、多进程同时操作共享资源(共享变量、数据库数据、文件、缓存等)时,程序最终执行结果和串行执行结果一致,且不会出现数据脏读、覆盖、错乱等问题,即为并发安全。反之,就是并发不安全。
2. 并发不安全的三大核心原因
以最常见的多线程编程为例,并发安全问题本质由三个计算机特性导致:
(1)原子性破坏
原子性指一个操作要么全部执行成功,要么完全不执行,不可被中断。
我们日常的i++操作看似是一行代码,底层实际分为三步:读取变量值、计算新值、写入新值。在多线程场景下,这三步可能被多个线程穿插执行,导致数据覆盖、计数不准,这就是原子性缺失导致的安全问题。
(2)可见性问题
现代CPU为了提升性能,会存在缓存机制。每个线程会将共享变量从主内存拷贝到自己的工作内存(CPU缓存)。
当一个线程修改了变量值并写入工作内存,其他线程无法立即感知到这个变化,会一直读取旧值,导致数据可见性失效。
(3)有序性重排
编译器、CPU为了优化执行效率,会在不影响单线程执行结果的前提下,对代码指令进行重排序。
单线程下指令重排无任何影响,但多线程场景下,重排会导致代码执行顺序错乱,引发未知的业务异常。
二、锁的核心作用:解决并发三大问题
锁的本质,就是通过资源竞争、互斥访问的机制,强制让共享资源在同一时刻只被一个线程操作,从而兜底解决原子性、可见性、有序性三大问题,最终保证并发安全。
简单来说:锁是并发场景下共享资源的“访问通行证”,只有拿到锁的线程才能操作资源,未拿到锁的线程只能等待。
三、主流锁分类与实战场景
锁的种类繁多,我们不用死记硬背,只需按照悲观/乐观、公平/非公平、可重入/不可重入三大维度区分,结合场景使用即可。
1. 悲观锁 vs 乐观锁(核心分类)
(1)悲观锁
核心思想:默认并发一定会出现冲突,因此每次操作共享资源前,都会先加锁,独占资源,阻止其他线程访问。
特点:加锁开销大、阻塞等待、并发性能低、安全性极高。
常见实现:synchronized、ReentrantLock、数据库行锁/表锁。
适用场景:写多读少、冲突频繁的场景,比如订单扣款、库存扣减、数据修改。
(2)乐观锁
核心思想:默认并发冲突极少发生,因此全程不加锁,直接操作资源,只在提交结果时校验是否发生冲突,冲突则重试或放弃。
特点:无锁开销、不阻塞、并发性能高、冲突时需要重试。
常见实现:CAS自旋、版本号机制(数据库version)、时间戳校验。
适用场景:读多写少、冲突概率低的场景,比如商品详情查询、用户信息读取。
2. 可重入锁 vs 不可重入锁
(1)可重入锁
同一个线程拿到锁后,可以再次重复获取该锁,不会发生自我阻塞,避免死锁问题。
日常开发中synchronized、ReentrantLock都是可重入锁,也是我们最常用的锁。
举例:A方法加锁后调用同样加锁的B方法,可重入锁可以正常执行,无需释放锁再竞争。
(2)不可重入锁
同一个线程无法重复获取已持有的锁,再次获取会直接阻塞,极易引发死锁,现代开发中基本不再手动使用。
3. 公平锁 vs 非公平锁
(1)公平锁
线程按照请求锁的先后顺序排队获取锁,先到先得,无线程插队。
特点:线程切换开销大、吞吐量低、线程执行公平,无饥饿问题。
(2)非公平锁
线程获取锁不遵循排队顺序,新线程可以直接抢占锁,大概率比排队的老线程先拿到锁。
特点:减少线程切换、吞吐量高、性能更好,是默认锁模式(ReentrantLock、synchronized默认非公平)。
四、开发高频锁:synchronized 与 ReentrantLock 对比
Java 开发中最常用的两把锁就是 synchronized 和 ReentrantLock,很多人不知道该如何选择,这里直接给出对比结论和使用规范。
1. synchronized(内置锁)
- 特性:JVM原生锁、自动加锁释放、可重入、非公平、不可中断
- 优点:使用简单、无需手动释放锁、底层持续优化(偏向锁/轻量级锁/重量级锁)
- 缺点:灵活性差、无法超时等待、无法精准唤醒线程
- 适用:简单的代码块同步、低并发场景
2. ReentrantLock(显式锁)
- 特性:JDK代码实现、手动加锁释放、可重入、可公平/非公平、可中断、可超时
- 优点:灵活性极强、支持锁超时、条件精准唤醒、高并发性能稳定
- 缺点:需要手动解锁,容易遗漏unlock()引发死锁
- 适用:高并发、复杂同步逻辑、需要超时控制、精准唤醒的场景
3. 最终选择原则
简单同步逻辑、低并发 → 优先synchronized
高并发、需要超时、精准控制、复杂业务 → 优先ReentrantLock
五、并发锁高频坑与解决方案
1. 锁失效问题
常见场景:锁对象被修改、锁的是成员变量而非全局对象、字符串锁常量池复用。
原因:锁的本质是锁定对象的监视器,对象变了,锁就彻底失效。
解决方案:使用final 修饰锁对象,保证锁对象唯一且不可修改。
2. 死锁问题
死锁四大必要条件:互斥、请求保持、不可剥夺、循环等待。
最常见场景:多个线程互相持有对方需要的锁,且同时等待对方释放锁。
解决方案:
- 统一锁的获取顺序,避免循环等待;
- 使用 ReentrantLock 的 tryLock 超时机制,超时自动释放锁;
- 减少嵌套锁,尽量只持有一把锁。
3. 锁粒度太大,导致性能雪崩
问题:为了简单,直接锁住整个方法、整个业务逻辑,大量线程阻塞排队,并发完全退化串行。
解决方案:减小锁粒度,只锁住共享资源操作的核心代码块,无关逻辑尽量移出锁范围。
4. 读写并发低效问题
问题:普通排他锁会让读写、读读之间互相阻塞,读多场景下性能极差。
解决方案:使用ReentrantReadWriteLock 读写锁,读读共享、读写互斥、写写互斥,大幅提升读场景并发性能。
六、总结
- 并发不安全的本质是原子性、可见性、有序性三大特性缺失;
- 锁的核心价值是通过互斥访问,兜底保证共享资源的并发安全;
- 悲观锁适合写多场景,乐观锁适合读多场景,按需选择;
- 简单场景用 synchronized,高并发复杂场景用 ReentrantLock;
- 并发编程的核心不是滥用锁,而是最小化锁范围、避免锁竞争、杜绝锁坑。
掌握并发安全与锁机制,是从初级开发者进阶为高级后端工程师的必经之路。后续遇到并发数据异常、接口卡顿、死锁问题时,都可以从本文的原理和解决方案中快速定位问题。