ARTICLE DETAIL

资讯详情

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

synchronized与Lock深度对比:从JVM锁升级到AQS并发控制

synchronized与Lock深度对比:从JVM锁升级到AQS并发控制 1. 从一个死锁现场说起同步机制到底在管什么先讲一个我调试到凌晨两点的故事。当时线上有个跑批任务数据量一大就卡死排查时发现线程全部阻塞在一个对象的monitor上dump出来看是两个线程互相持有对方需要的锁典型的死锁。我当时的第一个念头是用Lock就好了但冷静下来一想到底哪里好、为什么好其实很多同学都说不清楚。这篇文章不打算从课本上的定义开始讲。我想直接从为什么Java需要两套同步机制这个问题切入把synchronized和Lock接口放在同一张工作台上拆开看各自的底层原理、能力边界和适用场景。无论你是刚入门的多线程新手还是在面试中被问到谈谈Lock和synchronized的区别的老兵这篇内容都能给你一个比较完整的视角。要理解这两者的对比先要理解它们共同面对的问题多线程环境下多个线程同时访问共享资源比如一个计数器、一个队列、一个缓存Map时如何保证数据的正确性和一致性。synchronized和Lock都是用来实现互斥访问的也就是同一时刻只允许一个线程进入临界区。但二者的实现路径、功能丰富度和使用约束差异很大这就导致了很多经典的线上故障——比如锁泄漏、条件变量误唤醒、锁粒度不合理等。先说一个最直观的差异synchronized是Java语言层面的关键字由JVM负责实现使用后不需要手动释放锁因为JVM会自动在异常路径上释放监视器锁而Lock是一个接口需要手动lock()和unlock()如果忘记在finally里释放锁就会造成其他线程永久阻塞。这个差异看起来是使用习惯问题实际上体现了两种设计哲学一个追求简单和低出错率一个追求灵活和高控制力。在进入细节之前我先给出一个结论性的判断如果你的需求只是简单的互斥同步synchronized是默认选择原因有三——零额外编码、JVM持续优化的锁升级机制、异常安全如果你的需求涉及超时获取锁、可中断等待、多个条件队列、公平锁策略等高级同步语义Lock接口通常指ReentrantLock才是正确的工具。下面逐个展开。2. synchronized的进化从重量级锁到偏向锁的JVM优化路线很多人对synchronized的印象还停留在性能差上这是远古时代的结论了。如今的HotSpot JVM对synchronized做了大量优化核心思路是尽量不阻塞线程因为线程阻塞和唤醒涉及操作系统内核态切换开销极大。2.1 监视器锁Monitor机制synchronized的底层对象是Java对象头中的Monitor。每个Java对象天生就带有一个monitor重量级锁状态下体现为ObjectMonitor它维护了持有者线程、等待队列和阻塞队列。线程进入synchronized代码块时需要获取该对象对应的monitor获取失败则进入阻塞状态等持有者释放后重新竞争。这里有一个关键认知monitor是依赖操作系统Mutex Lock实现的在JDK 1.6之前synchronized的每一次获取和释放都直接走系统调用所以性能很差。这也是当年很多高性能中间件宁可自己写读写锁也不愿意用synchronized的原因。2.2 锁升级偏向锁、轻量级锁、重量级锁JDK 1.6之后引入了锁升级机制锁一共有四种状态按竞争激烈程度逐步升级无锁状态线程直接操作共享资源没有锁竞争。偏向锁同一个线程多次获取锁时只需在对象头中CAS记录线程ID无需重复竞争。适合单线程访问的场景。轻量级锁多个线程交替获取锁时通过CAS自旋尝试获取避免线程切换。适合锁持有时间很短的场景。重量级锁当CAS自旋失败次数达到阈值-XX:PreBlockSpin默认10次或者有线程在等待时升级为重量级锁走monitor的阻塞唤醒流程。这套升级机制让synchronized在低竞争下可以非常快在高竞争下才退化为传统阻塞锁。实测下来在JDK 8以上synchronized与ReentrantLock在绝大多数场景下的性能已经打平甚至因为偏向锁的存在单线程访问时更快。所以那些还在纠结用synchronized会不会拖慢性能的结论基本可以放下了。2.3 synchronized的隐性约束只有一个条件队列synchronized的另一个重要特性是每个对象只有一个等待集合wait set通过wait()和notify()/notifyAll()来协调线程。这带来了两个问题。第一个问题是无法精确唤醒。假设一个对象有多个不同条件的等待者比如生产者消费者模型中消费者等待队列非空生产者等待队列非满。如果都用同一个对象的wait方法消费者被唤醒后需要重新检查条件不满足就继续wait。这就是著名的虚假唤醒负担虽然用while循环检查条件可以规避但会造成大量无效的线程切换。第二个问题是可移植性差。wait和notify是Object的方法与对象绑定难以在设计时显式表达这是消费者队列这是生产者队列。对于复杂的状态机同步代码可读性和可维护性会大幅下降。最大的一点是synchronized不支持超时等待。等待线程如果一直没有被notify那么永远不会醒过来也无法响应中断。这在某些场景下是致命的比如外部接口调用超时后希望中断等待线程synchronized办不到。3. Lock接口的完整能力图谱tryLock、可中断、条件变量与AQS底色Lock接口是JUCjava.util.concurrent包中并发控制的核心抽象JDK提供的主要实现是ReentrantLock可重入锁和ReentrantReadWriteLock读写锁。它之所以比synchronized灵活根本原因在于它构建在AQSAbstractQueuedSynchronizer抽象队列同步器之上用状态位CLH变体队列实现了线程的排队与唤醒逻辑开发者可以基于AQS构建出几乎任意语义的同步器——信号量、门闩、读写锁都是这么来的。3.1 Lock接口的核心APILock接口定义了五个主要方法void lock()获取锁获取不到则一直阻塞。void lockInterruptibly()获取锁时响应中断线程被interrupt会抛出InterruptedException可用于实现可中断的等待。boolean tryLock()尝试非阻塞地获取锁立即返回结果不等待。boolean tryLock(long time, TimeUnit unit)在一定时间内尝试获取锁超时返回false。void unlock()释放锁。还有一个经常被忽略但极其强大的方法——Condition newCondition()它返回一个与Lock绑定的一致条件变量用于实现synchronized中wait/notify的效果但支持多个条件队列。这里要特别说明lockInterruptibly的价值。在分布式场景中如果一个线程长时间等锁运维想通过jstack发一个中断信号让线程退出普通lock()方法根本不会响应。而lockInterruptibly()可以在等待锁的过程中被interrupt线程得以退出阻塞、执行清理并抛出异常。这在设计可观测的并发组件时非常重要。3.2 ReentrantLock的实现细节ReentrantLock是Lock接口最常用的实现支持可重入同一个线程可以重复获取同一把锁内部通过AQS的state字段记录持有次数。每次lock()时state自增每次unlock()时state自减减到0才真正释放锁。它有两个重要特性公平性和非公平性。默认是非公平锁即新到的线程可以插队去抢锁公平锁则按照请求顺序排队。非公平锁的吞吐量通常更高但可能造成线程饥饿公平锁则保证等待时间最长的线程能拿到锁但换来的是略低的吞吐量和高一些的上下文切换。实际业务中绝大多数场景建议用非公平锁只有需要严格公平的批处理任务才考虑公平锁。3.3 条件变量比wait/notify精确一个量级Condition接口是Lock的秘密武器。它允许在一个Lock下创建多个条件变量每个条件变量都有独立的等待队列。例如一个阻塞队列可以这样设计// 示例代码基于Lock和Condition实现阻塞队列的核心结构 final Lock lock new ReentrantLock(); final Condition notFull lock.newCondition(); final Condition notEmpty lock.newCondition();当队列已满时生产者线程调用notFull.await()阻塞等待非满条件。当队列为空时消费者线程调用notEmpty.await()阻塞等待非空条件。入队成功后调用notEmpty.signal()唤醒一个消费者出队成功后调用notFull.signal()唤醒一个生产者。这种设计可以让生产者只唤醒生产者相关的等待者、消费者只唤醒消费者相关的等待者避免了synchronized中notifyAll带来的连锁唤醒开销。实测高并发阻塞队列场景下Condition比wait/notifyAll有明显优势。Condition的await方法同样支持超时和中断语义await(long time, TimeUnit unit)等待一段时间超时自动返回。awaitUninterruptibly()等待时忽略中断但不会抛出异常。4. 七个维度的正面刚Lock与synchronized的真实差异清单下面这张表是我在实际编码和面试复盘中最常用的对比框架覆盖了从底层实现到使用约束的七个核心维度。对比维度synchronizedLock接口以ReentrantLock为例实现方式JVM关键字基于MonitorJava类基于AQS锁获取方式自动获取自动释放手动lock和unlock需在finally释放超时获取不支持tryLock(timeout) 支持可中断等待不支持lockInterruptibly 支持条件队列每对象一个wait set可创建多个Condition公平性非公平不可配置支持公平和非公平异常安全锁随异常自动释放必须显式释放否则锁泄漏逐项展开解释一下。实现方式synchronized是JVM字节码层面的monitorenter/monitorexit指令运行在虚拟机内部Lock是纯Java层的API底层借助Unsafe的CAS操作和LockSupport.park/unpark来管理线程状态。这意味着Lock可以做到对象无关、不依赖方法边界比如跨方法获取和释放锁但这同样增加了出错的可能。异常安全synchronized最大的优点是异常时自动释放锁这是语言层面的保证Lock做不到如果某个try块内部抛出运行时异常且unlock不在finally里锁就永远不会被释放。这个坑我在生产上见过不止一次最终表现就是应用还在跑但某个线程池的队列越积越长。可重入性两者都支持可重入。synchronized的可重入基于monitor的计数器Lock的可重入基于AQS的state计数。需要说明的是synchronized的可重入是JVM自动处理的而Lock的可重入语义需要实现类自己保证ReentrantLock确实保证了但如果你自定义一个Lock实现就要格外小心。性能与吞吐在JDK 8及以后两者在高竞争下性能接近。synchronized在JDK 16进一步做了偏向锁相关优化但Lock在高度竞争、线程频繁阻塞时因为可以配合Condition做针对性唤醒整体吞吐可能更好。这个结论要基于实际压测不能一概而论。一个经常被误传的区别很多人说synchronized只能实现非公平锁而Lock可以公平——这个说法不准确。准确的表述是synchronized的锁获取顺序完全不可控确实是非公平而Lock既支持公平也支持非公平默认非公平。但要注意公平锁的开销明显高于非公平锁不要为了公平而公平实际业务中非公平锁往往更合理。5. 场景化选型哪些项目里我坚持用synchronized哪些必须换Lock理论对比完了来点实战选型建议。我的经验是能用synchronized的场景尽量用synchronized只有synchronized确实满足不了需求时才用Lock。这个顺序很重要。5.1 首选synchronized的场景简单的互斥操作如操作一个共享List、Map、Counter循环内执行时间极短synchronized加锁开销很小代码也最清晰。方法级或代码块级同步直接加上synchronized关键字即可没有解锁遗漏的风险。不需要超时和中断控制的场景例如内部缓存更新、配置刷新的临界区。性能敏感且锁竞争低的场景偏向锁和轻量级锁的优化非常有效。我给一个实际例子一个订单号生成器用AtomicInteger配合计数器取模就行但如果用了synchronized来保护一个当前序号日期的双写操作开销完全可接受。单机每秒几万的取号量synchronized完全够用不必上Lock。5.2 必须上Lock的场景需要超时控制。例如连接池获取连接时如果锁等待超过500ms就放弃tryLock(500, TimeUnit.MILLISECONDS)是唯一的选择。需要可中断等待。例如线程池关闭时希望正在等待锁的线程能响应中断退出lockInterruptibly是关键。需要多个条件变量。例如阻塞队列、连接池的空闲连接和等待连接两个条件独立管理。需要公平锁。例如FIFO任务调度器要求严格按提交顺序执行。需要读写锁。ReentrantReadWriteLock允许读读并发、写写互斥、读写互斥是缓存实现的高效工具synchronized完全没有替代方案。关于线程池的选择再补充一句在运行长任务的线程池内使用synchronized如果锁竞争激烈会导致线程全部阻塞在锁上线程池无法感知还会发生任务堆积。改用Lock的tryLock让无法获取锁的任务直接走失败策略或延后重试整个系统的弹性会好很多。5.3 手动释放锁的正确姿势与常见错误既然Lock需要手动释放那如何释放就不是小事。推荐的标准写法如下// 正确写法try-finally必须配套 Lock lock new ReentrantLock(); boolean acquired false; try { acquired lock.tryLock(1, TimeUnit.SECONDS); if (!acquired) { // 获取失败走降级策略比如返回错误或排队等待 return false; } // 业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (acquired) { lock.unlock(); } }这里有两个常见错误值得特意指出。第一个是只判断tryLock返回true就执行逻辑而不在finally做释放一旦业务代码抛异常就锁泄漏第二个是忘记catch InterruptedException就释放锁会导致中断信号丢失。我的习惯是永远在finally里先判断acquired再解锁防止重复unlock抛IllegalMonitorStateException。6. 实测中容易翻车的细节与排查经验我整理了这几年实际使用这两套机制时踩过的坑这部分内容花了不少线上排查时间的代价值得认真看。6.1 锁泄漏的经典症状线程池饥饿有一次排查一个RPC服务进程假死jstack看到大量线程处于WAITING状态点开栈后发现它们都停在某个业务对象锁的blocked队列里而持有锁的线程其实早已经因为异常被回收了。这就是典型的unlock没被执行的锁泄漏。由于线程池的处理线程全部在等锁新提交的任务也没有可用线程整个服务表现为不报错、不崩溃、但请求全部超时。排查方法很简单jstack导出线程栈搜索parking to wait for或waiting on monitor看是否有锁的持有者线程不在了而其他线程全部阻塞在同一把锁上。一旦确认先恢复服务重启再定位代码里漏掉finally的路径。6.2 公平锁的幻觉你以为的公平不一定是公平公平锁的排队逻辑是按CLH队列的先后顺序唤醒但它保证的是获取锁的顺序不保证线程执行临界区的顺序。更微妙的是如果业务里在锁内又调用了异步任务或线程切换那么从业务逻辑角度看执行顺序完全可能越过先等待的线程。所以公平锁只适合需要严格锁定获取顺序的场景比如分布式锁的排队不要指望它来解决所有业务顺序问题。另外公平锁的入队操作本身需要CAS和自旋复杂度比非公平锁高高并发下反而可能更慢。实测过一个10线程压测的场景公平锁的TPS比非公平锁低大约15%线程调度延迟也更高。6.3 条件变量的误唤醒与循环检查即使使用了Conditionawait返回后条件也不会自动满足必须在while循环里重新检查条件再决定是否继续await。这一点与synchronized的wait处理一致是并发编程的常识但很多人会写成if这是典型的bug会导致队列为空时消费者继续消费出null或抛出异常。正确写法// 必须在循环中检查条件防止虚假唤醒或非预期唤醒 while (queue.isEmpty()) { notEmpty.await(); } // 此时才真正消费元素6.4 synchronized的粗粒度问题synchronized的粒度绑定在对象上如果你用同一个对象锁保护两个互不相关的临界区就会造成锁竞争的无谓放大。举个例子一个服务同时有读缓存和写日志两个操作如果共用同一个锁那么写日志时会阻塞所有读缓存请求。这种情况下应该用两把不同的锁或改用读写锁。我见过一个直接把整个方法加上synchronized的类方法内有网络IO和耗时的计算结果所有线程排队等待服务吞吐量直线下降。优化方法是缩小锁的范围到真正共享的变量上而不是整个方法。这块值得反复校对。6.5 关于锁对象的可见性还有一点容易忽略锁对象必须声明为final或者至少保证不会在运行时被重新赋值。如果锁对象被改变那么不同线程看到的是不同对象的monitor锁就等于失效了。这也是很多隐蔽并发bug的来源因为代码上看不出来问题只有压测时才会偶发数据不一致。我在设计自己的并发组件时有一个硬性习惯凡是作为锁的引用一律final并且在注释里标明LOCK字样方便后人阅读代码时不会误改。6.6 Atomic与锁的选择最后补充一个常见的讨论——什么时候用锁什么时候用CAS原子类。如果共享变量的修改可以抽象为读-改-写的原子操作比如计数器、累加器、序号生成器那么优先用AtomicInteger或AtomicLong。Lock和synchronized解决的是代码块级别的互斥而CAS解决的是单个变量的无锁更新。实测经验像简单自增操作AtomicLong在高并发下比synchronized和Lock都快得多因为它没有线程切换和上下文切换的开销。而如果需要同时更新多个关联变量如余额和流水号同时变只能用锁来保证原子性因为CAS无法跨多个变量做原子提交。7. 建议收尾根据实际场景做选择而不是跟风选型回到本文开头的那个死锁问题我最后的确用了Lock解决——因为在那个组件里获取锁和释放锁跨了多个方法并且需要支持超时放弃和响应中断synchronized确实做不到。但这个选择是基于需求做出的不是Lock比synchronized高级。我在实际项目中的判断顺序是先看需求是否包含超时、中断、多条件、公平性、读写锁任意一种如果包含就选Lock如果只是最简单的互斥就选synchronized。这是基于排除法的最优解而不是基于教条的最新技术最好。补充一点性能相关的经验谈在JDK 8及以上的版本无竞争状态下的synchronized和Lock性能几乎没有差异差异主要出现在竞争激烈、线程频繁阻塞和唤醒时。此时要优化的往往不是锁的选型而是锁的粒度、持有时间、替代方案如无锁化、分区锁、读写锁。也就是说先优化并发设计再考虑换锁。最后我分享三个能立刻落地的小建议。第一代码里所有的Lock使用务必统一封装成工具方法或模板类把try-finally强制写进模板让调用方只传业务接口。这样可以最大化避免锁泄漏。第二定期做线程dump压测验证最好在测试环境模拟高并发观察是否有线程长时间处于BLOCKED或WAITING状态。一旦发现优先分析是不是锁粒度问题。第三对条件变量的使用一定要在工程规范里写入必须用while循环检查条件没有例外。这是代价最低、收益最高的并发安全守则。
返回列表