ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java锁机制全解:从synchronized到AQS的并发核心原理与实践

Java锁机制全解:从synchronized到AQS的并发核心原理与实践 线上真正出问题的时候你才会理解Java锁机制到底在解决什么问题。有段时间我在电商团队负责订单系统上线一个月后促销活动把流量拉起来监控突然报警某热门SKU的库存被扣成负数订单比实际库存还多。查日志发现扣减库存的接口同时被多个线程执行读库存、判断库存、扣库存这三步中间被穿插A线程读到库存剩1B线程也读到剩余1两边都判断可以下单最后库存被扣成-1。这个问题的本质是多个线程并发访问同一个共享变量而“读-判断-写”这个操作序列不是原子的没有正确的互斥保护。当时团队里有人提议直接在方法上加synchronized有人觉得应该上分布式锁还有人坚持用数据库乐观锁讨论到最后才发现大家连“synchronized到底锁的是什么”都没完全统一。这篇文章我就从这类真实场景出发把Java锁机制的各个层面串起来讲一遍适合正在准备Java面试的人也适合写业务代码时遇到并发问题不知道怎么下手的开发者。看完你至少能回答清楚这几个问题synchronized和Lock分别用在什么场景、锁升级是怎么回事、AQS到底做了什么事、读写锁什么时候该用、线上死锁怎么排查。1. 并发场景下的锁它到底锁的是什么1.1 临界区、竞态条件与共享变量的三角关系先说三个最基础的概念这三个概念理解了后面所有锁的知识都能挂上去共享变量多个线程都能访问到的变量。比如上面的库存字段、static集合、缓存里的对象、数据库里某一行记录映射到Java层的对象。临界区访问共享变量的代码片段。比如“判断库存大于0 - 扣库存”这段代码就是一个临界区。竞态条件多个线程同时进入临界区执行结果依赖于线程的执行时序导致结果不正确。锁的作用本质上就是让临界区在同一时间点只有一个线程能进去保证“互斥”。Java锁机制里无论synchronized、ReentrantLock还是读写锁核心目标都是互斥访问只不过在不同场景下用不同机制去实现互斥。这里有个常被忽视的点锁不是锁“变量”而是锁“代码路径”。同一个变量如果有一处访问被锁保护另一处访问没被锁保护照样会出现竞态条件。这也是很多bug的根源——你以为加了锁其实只保护了部分代码路径。我在代码评审里见过太多次“局部加锁”问题建议大家都形成一个习惯一个共享变量所有读写路径都必须走同一把锁。1.2 synchronized三种用法和锁对象判定大多数Java开发者第一反应是用synchronized因为它是JVM内置的语法最简单。但synchronized用起来简单不代表不会用错。它有三种用法修饰实例方法锁的是当前实例对象this。修饰静态方法锁的是当前类的Class对象。修饰代码块锁的是括号里指定的对象。public class Counter { private int count 0; public synchronized void increment() { count; } public static synchronized void staticIncrement() { // ... } public void incrementBlock() { synchronized (this) { count; } } }三种写法的锁对象分别是this、Counter.class、this。如果是同一个锁对象那多个线程对这些方法就是互斥的如果锁对象不一样那就各锁各的互不影响。有个非常常见的坑在Spring默认单例Bean里用synchronized修饰方法锁对象是this通常没问题但如果这个类被new了多个实例synchronized方法只能锁住同一个实例上的并发访问跨实例之间是无效的。也就是说synchronized只能解决JVM进程内、同一个对象上的并发问题进程间的分布式场景它管不了。这也是为什么后来要引入分布式锁。顺便提醒一句synchronized修饰代码块时锁对象的选择直接决定锁粒度。如果你把一个大业务方法整个包进synchronized(this)那所有线程都串行执行性能会非常难看。正确做法是尽量缩小临界区范围只锁真正需要保护的共享变量操作。1.3 锁同时解决的原子性与可见性问题很多人以为锁只解决“互斥”其实它还承担着另一个重要职责可见性。Java内存模型里每个线程有自己的工作内存线程A修改了变量线程B不一定能立刻看到。如果不用锁又没有volatile修饰B可能一直读到旧值。synchronized和Lock在底层都会建立内存屏障保证锁释放时对共享变量的修改能对后续获取同一把锁的线程可见。所以你在处理共享变量时光加锁还不够还必须让所有读写都在“同一把锁的保护范围内”完成。如果读操作不加锁靠“运气”去读倒也不会马上出错但线上的偶发问题基本都是这么埋下的。等到出了问题排查链路会非常长。我在实际项目里遇到过这样一个案例某个热点配置使用了volatile修饰大家觉得volatile能保证可见性就够了但配置更新涉及两个关联字段需要一次性原子更新。结果并发读的时候有的线程读到了A字段是新值、B字段是旧值的中间态逻辑直接错乱了。后来改成用读写锁保护问题才消失。这说明volatile只保证可见性不保证复合操作的原子性而锁可以同时保证原子性和可见性。2. 从synchronized到Lock接口两大派系怎么选2.1 Lock接口补上了synchronized的哪些短板synchronized虽然简单但它在JDK 5之前的能力是有限的主要体现在不能中断一个正在等待锁的线程。线程进入阻塞状态后外部没法打断它。不能设置超时时间。如果某把锁一直不释放其他线程只能一直等下去。非公平锁是唯一模式没法主动选择公平性。没法实现“读读并发、写写互斥”这种细粒度控制锁的粒度只有“完全互斥”一种。这些痛点催生了Lock接口public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }其中tryLock和lockInterruptibly是两个非常实用的API。tryLock()可以立刻返回尝试结果拿不到锁就返回false业务代码可以选择执行其他逻辑或稍后重试。lockInterruptibly允许线程在等待锁的过程中响应中断这一点在需要优雅停止任务的场景特别重要。比如你有一个后台线程池任务如果线程卡在等待锁上你又希望它能响应关闭信号用lockInterruptibly就有办法让它退出。2.2 ReentrantLock的可重入与公平性细节ReentrantLock是Lock接口最经典的实现名字里的“Reentrant”指可重入——同一个线程可以重复获取同一把锁不会把自己锁死。ReentrantLock lock new ReentrantLock(); public void outer() { lock.lock(); try { inner(); } finally { lock.unlock(); } } public void inner() { lock.lock(); try { // ... } finally { lock.unlock(); } }如果不是可重入锁outer里调用inner第二次lock()的时候线程会发现自己已经持有这把锁然后陷入死锁。可重入锁内部会为每个线程记录持有次数每次lock加1每次unlock减1减到0才真正释放锁。ReentrantLock还支持传入fair参数new ReentrantLock(true)创建公平锁new ReentrantLock(false)创建非公平锁。公平锁会让等待时间最长的线程优先获得锁听起来很合理但实际项目中公平锁的性能会打折因为它在每次获取时都要维护严格的排队顺序。非公平锁允许新来的线程尝试“插队”性能通常更好但极端情况下可能导致某些线程长时间等待。如果你的业务对公平性没有硬性要求默认用非公平锁就好不要为了“图心理安慰”选公平锁。2.3 面试常问的synchronized与Lock对比这个问题几乎是Java八股文必考我在面试别人时也经常问。整理成表格方便大家记忆对比维度synchronizedReentrantLock实现层级JVM关键字C实现JDK APIJava类实现锁获取与释放自动获取异常自动释放手动lock/unlock需在finally中保证释放是否可中断等待锁时不可中断lockInterruptibly支持中断是否可超时否tryLock(timeout)支持公平性只能非公平可选公平/非公平条件变量通过wait/notify配合Condition可创建多个条件队列锁粒度只有互斥互斥/读写分级JDK 6以后synchronized经过锁升级优化性能已经和ReentrantLock差距很小了。选型时能直接写synchronized的简单场景就选它代码短、不易出错需要超时、中断、公平性、多条件变量的时候再考虑ReentrantLock。不要一上来就Lock也不要死守着Synchronized不放两者不是对立关系是互补。3. 锁升级全路径无锁、偏向锁、轻量级锁、重量级锁3.1 HotSpot为什么要设计多级锁聊到JDK 6就不得不提锁升级。很多人会把“锁升级”和“锁粗化”搞混其实这是两个完全不同的机制锁升级是运行时根据竞争程度动态调整锁的状态锁粗化是编译器层面的优化把多个相邻的加锁操作合并成一个。锁升级的设计动机很简单不是所有锁都存在激烈竞争。大部分情况可能只有一个线程访问少数情况有几个线程交替访问极端情况才是多线程同时竞争。如果一律用重量级锁那每次加锁都要走操作系统内核态成本很高。所以HotSpot把锁分成了四种状态无锁、偏向锁、轻量级锁、重量级锁根据竞争情况从轻到重逐级升级。3.2 Mark Word里的锁状态变迁Java对象头里的Mark Word是记录锁状态的关键区域。32位JVM里它只有32位但既放hashCode又放GC分代年龄还要放锁状态所以设计得非常“挤”。不同锁状态对应的Bit位布局不同直接说结论锁状态Mark Word记录内容触发条件无锁对象hashCode、分代年龄等信息初始状态偏向锁持有锁的线程ID同一线程再次进入直接比对线程ID无需CAS轻量级锁指向线程栈中锁记录的指针第二个线程尝试获取偏向锁时偏向锁撤销升级重量级锁指向操作系统管程Monitor的指针CAS自旋失败竞争激烈时膨胀整个过程叫“锁升级”但注意它只会升级不会降级。这也是面试里一个容易答错的点偏向锁撤销后不会恢复成偏向锁重量级锁即使竞争减小也不会变回轻量级锁。3.3 锁消除与锁粗化编译器层面的另外两招除了运行时锁升级JIT编译阶段也有两招值得了解锁消除如果JVM通过逃逸分析判定某个对象不会逃逸出当前线程那对这个对象加锁就没有意义编译器会直接去掉锁。比如局部变量只在单线程内使用却用了StringBuffer它的方法带synchronizedJVM发现锁没作用就消除了。锁粗化如果JVM发现相邻的多个同步块使用的是同一把锁且中间没有其他线程介入它会把多个小的同步块合并成一个大的同步块减少反复加锁/解锁的开销。比如循环里反复对同一个锁对象加锁JIT可能会把整个循环都框进锁里。这两招说明一个问题JVM的锁优化不是死的运行时表现和编译后的代码可能已经和你写的代码不完全一样了。所以性能调优不要靠猜要以压测和JMH基准测试为准。3.4 偏向锁的遗留问题与JDK 15之后的默认关闭偏向锁在JDK 15开始被默认关闭JDK 18之后已被标记为废弃。为什么因为偏向锁在某些场景下不但没有提速反而成为负担。偏向锁的撤销需要触发安全点STW即使只有一个线程在竞争也可能因为撤销逻辑导致短暂的停顿。现代应用普遍使用线程池线程复用导致锁竞争模式复杂偏向锁带来的收益变得不明显。加上Stop-The-World对延迟敏感应用影响很大官方最终选择默认关闭偏向锁。这个案例告诉我们JVM的锁优化不是静态的它也在跟着应用场景演进。面试时问到偏向锁不要只背状态流转能说出“为什么JDK 15之后默认关闭”会显得更有深度。4. AQS与ReentrantLock几乎所有Lock的底座4.1 AQS的state、CLH队列和模板方法AQSAbstractQueuedSynchronizer几乎是Java并发包的地基。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock底层全都依赖它。它的核心就三个东西state一个volatile修饰的int变量代表锁状态。state0表示没有线程持有锁state1表示有一个线程持有ReentrantLock可重入时会变成2、3……CLH变体队列等待获取锁的线程排成的FIFO双向链表。模板方法获取锁和释放锁的骨架逻辑由AQS定义子类只需要实现tryAcquire、tryRelease等方法决定“什么条件下能拿到锁”。4.2 ReentrantLock加锁/解锁源码级拆解以非公平锁的lock()为例大致流程final void lock() { // 1. 先尝试CAS把state从0改成1 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); // 2. 失败则进入AQS的acquire流程 }acquire里会调用tryAcquire如果再次失败就把当前线程包装成Node加入等待队列然后通过LockSupport.park挂起线程。释放锁的时候state减1减到0说明锁完全释放再唤醒队列里的下一个线程。这个流程有几个点很值得琢磨非公平锁为什么“非公平”因为lock()上来就先做了一次CAS插队没排队就直接抢这是第一层“插队”。如果CAS失败进入acquire里面还有一次tryAcquire机会。所以非公平锁的获取机会比公平锁多吞吐更高。LockSupport.park/unpark和Object的wait/notify不同park不需要在synchronized块里使用机制上更轻量。可重入是怎么实现的tryAcquire里会判断当前线程是不是已经持有锁的线程是的话state1并且只增加持有计数不改变持有者。4.3 用AQS手写一个简单的不可重入锁不用AQS的时候你很难感受到它的价值。我写一个超简单的不可重入锁帮助你理解模板方法import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SimpleLock { private static class Sync extends AbstractQueuedSynchronizer { Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } Override protected boolean tryRelease(int arg) { if (getState() 0) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } } private final Sync sync new Sync(); public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } }这段代码的tryAcquire和tryRelease决定了锁的核心行为线程排队、阻塞唤醒这些复杂逻辑全部由AQS处理。所以在读并发源码时只要抓住每个同步器里重写的那几个方法就能很快读懂全貌。面试时如果被问到“AQS是什么”不要只说“一个队列同步器”最好能说出state、CLH队列、模板方法三个关键点然后举一个ReentrantLock的加锁流程基本就能过关。5. 读写锁与StampedLock读多写少场景的进阶选择5.1 ReentrantReadWriteLock的使用边界实际项目中很多共享数据的操作是“读多写少”比如配置表、热榜数据、商品详情缓存。如果全部用互斥锁保护读和读之间也串行明显浪费性能。读写锁的思路是读锁和读锁不互斥读锁和写锁互斥写锁和写锁互斥。这样多个线程可以同时读写的时候才独占。private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final Lock readLock rwLock.readLock(); private final Lock writeLock rwLock.writeLock(); private MapString, Object cache new HashMap(); public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } }但这里有个隐藏很深的坑Java里的ReentrantReadWriteLock是写锁可以降级为读锁读锁不能升级为写锁。如果你在持有一个读锁的线程里尝试获取写锁会一直阻塞因为写锁需要等待所有读锁释放。经典的死锁案例就是这么来的。另外读锁虽然允许并发读但高并发写请求频繁时写锁可能会被读锁饿死尽管默认非公平模式下会尽量减少这种情况但并不能完全禁止。5.2 StampedLock的乐观读与锁升级陷阱JDK 8引入了StampedLock它的核心创新是“乐观读”。乐观读在进行读操作时不加锁只记录一个stamp版本号读完之后再校验版本号有没有变化。如果没变化说明读的过程没有写操作介入数据有效如果变了说明有并发写这时再升级为读锁重新读一遍。long stamp lock.tryOptimisticRead(); Object val cache.get(key); if (!lock.validate(stamp)) { stamp lock.readLock(); try { val cache.get(key); } finally { lock.unlockRead(stamp); } }这里有几个必须注意的点乐观读只在没有写入时高效。如果有写线程经常改数据它会频繁CAS失败然后升级为真正的读锁性能反而可能比ReentrantReadWriteLock还差。StampedLock是不可重入的同一个线程不能重复获取同一把锁这和ReentrantLock完全不同。StampedLock没有实现Condition接口。它也不支持中断在获取锁时使用park如果处理不好可能导致线程难以中断。所以我个人的建议是StampedLock是性能优化选项不是默认选项。绝大多数业务场景ReentrantReadWriteLock就够用了。想清楚你的数据到底有多高的并发读频率再去优化这把锁。6. 死锁排查与锁粒度调优实战避坑经验6.1 一次死锁的完整排查链路某次线上服务出现偶发超时排查后发现是死锁。具体表现是线程A持有锁1等待锁2线程B持有锁2等待锁1。两边卡死。当时我用jstack打印线程堆栈看到的关键信息是Thread-A - waiting to lock 0x00000000f1234567 (a java.lang.Object) Thread-B - holding 0x00000000f1234567 ... waiting to lock 0x00000000f89abcdef看到这种交叉等待基本就能定位到死锁。解决方式有几个锁顺序全局限定所有需要获取多把锁的地方都按固定顺序获取比如先锁A再锁B杜绝反向获取。使用tryLock(timeout)获取锁失败后不无限等待超时后执行补偿逻辑或重试。使用lockInterruptibly配合线程中断机制在外部让线程响应中断。不想手工分析的话JDK自带的jcmd、jconsole、VisualVM都有死锁检测功能。高并发服务建议在测试环境压测时直接跑一轮死锁检测尽早暴露问题不要等线上出了故障再去翻堆栈。6.2 锁粒度与锁顺序的取舍一次性能调优实录锁粒度调优是并发编程里最需要权衡的一环。我举一个实际例子某个公共缓存对象同时被多个接口读写最开始用一把大锁把所有操作都保护起来压测发现吞吐量上不去GC压力也大。后来我做了三件事把锁粒度从“整个缓存对象一把锁”降到“每个缓存key一把锁”。写操作使用读写锁避免读读互斥。热点数据单独拆出来用无锁的ConcurrentHashMapvolatile原子更新。这三件事下来吞吐量提升明显。但要注意锁粒度越细代码复杂度越高加锁顺序的错乱风险也越大。一定要在“性能收益”和“代码可维护性”之间做权衡不要为了性能把所有地方都搞成细粒度锁到时候出了并发问题都找不到排查入口。6.3 根据场景选锁的一页纸总结最后给你一个选型参考这也是我在项目里给团队定的一条通用规则场景推荐方案原因简单方法级互斥低竞争synchronized代码简洁JVM有锁升级优化性能足够需要超时、中断、公平性ReentrantLockAPI丰富灵活控制等待行为读多写少读操作不频繁写ReentrantReadWriteLock读写分离读读并发超高频读、数据一致性要求稍宽松StampedLock乐观读无锁读取性能最高分布式多节点互斥Redis/ZooKeeper分布式锁跨进程/跨节点需要外部协调单key热点数据读多写少volatile CAS无互斥等待性能最优频繁读写的极热数据ConcurrentHashMap Striped锁避免全局争抢你可能注意到这里没有提到“原子类”。实际上AtomicInteger、LongAdder这些原子类用的就是CAS无锁方案在单变量计数器、累加器场景下比任何锁都快但只能保证单个变量的原子性多变量组合操作没办法靠它实现。我在实际项目里的体会是锁方案的选择没有银弹。你不需要把每种锁的原理都背得一字不差但需要知道每个方案适合什么问题、不适合什么问题以及线上出了问题怎么排查。Java锁机制这套东西从底层JVM的锁升级到JDK并发包的AQS再到应用层的选型是一条完整的技术链路把这条路走通你写并发代码就踏实了。
返回列表