1. 两把锁的定位差异:从临界区的角度看本质
1.1 并发问题到底在防什么
Java多线程编程里,锁永远绕不开。真正接手过一个线上并发问题的人都会认同:面试题里那些synchronized和ReentrantLock的区别,背得再熟,到了真实场景照样会翻车。因为你面对的不是“哪个性能更好”这种简单对比,而是“我的临界区该由谁保护、保护到什么程度、竞争激烈时会发生什么”这一连串问题。
先回到最根本的地方:多线程并发访问共享资源时,我们怕的是什么?无非两件事——原子性被破坏(两个线程同时执行i++,丢了一次更新)和可见性出问题(一个线程改了变量,另一个线程看不到最新值)。“锁”这个机制,本质上就是通过互斥访问临界区,同时帮你保证临界区内变量的可见性。这一点上,synchronized和ReentrantLock目标完全一致,这也是它们能放在一起对比的前提。
1.2 一条完全不同的实现路径
但两者的实现路径天差地别。
synchronized是JVM层面提供的内置锁,由字节码指令monitorenter和monitorexit驱动,你不需要手动释放,方法正常返回或抛出异常时JVM都会帮你释放。它的锁对象直接就是任意Java对象——因为每个Java对象在JVM里都有一个monitor概念,这也是为什么synchronized能用在实例方法、静态方法、代码块三种位置。
ReentrantLock则是JDK层面用Java代码实现的一个并发工具类。它内部基于AQS(AbstractQueuedSynchronizer)构建,完全靠编码方式实现锁的获取和释放,所以你必须手动lock()和unlock(),并且习惯上要求把unlock放在finally里。
打个比方:synchronized是“酒店前台帮你关门”——你不需要管钥匙,进房间就自动锁,离开房间自动开;ReentrantLock是“你自己拿钥匙锁门”——灵活、可控,但你必须记得锁门,忘了就麻烦大了。
2. synchronized的锁膨胀过程:从偏向锁到重量级锁,JVM到底做了什么
2.1 一个常被误解的性能历史
很多开发者对synchronized的印象还停留在“性能差”。这个印象来自JDK 1.5及更早的版本——那时候synchronized一遇到竞争就直接调用操作系统级别的互斥量(Mutex),线程阻塞和唤醒都要经过用户态到内核态的切换,这个切换成本极高,大概是普通Java指令的几十倍甚至上百倍。
但JDK 1.6之后,HotSpot团队对synchronized做了一系列重大优化,引入了偏向锁、轻量级锁、自旋锁、锁消除、锁粗化。从那以后,“synchronized性能差”这个结论就已经过时了。我在实际压测中也验证过:低竞争场景下synchronized和ReentrantLock的性能差距可以忽略不计,甚至synchronized因为有偏向锁的加持,在单线程反复进入同一临界区时表现还要更好。
2.2 锁升级的完整链路
synchronized的锁升级路径很清晰,大家可以按这个顺序理解。
第一步,偏向锁。当某个线程第一次进入同步块时,JVM会在对象头中记录这个线程的ID,后续这个线程再次进入同一个同步块时,只需要做一个简单的对比,如果线程ID一致就直接通过,不用做任何加锁解锁操作。这相当于给“几乎不存在竞争”的场景开了条绿色通道。但要注意,偏向锁不是绝对不加锁,而是“认人免检”。
第二步,轻量级锁。一旦出现另一个线程来竞争,偏向锁会撤销,升级为轻量级锁。这个阶段线程不是直接阻塞,而是通过CAS自旋抢锁。如果抢到了,就把锁对象的Mark Word改成指向当前线程栈中的锁记录。因为自旋不会挂起线程,所以在临界区执行时间极短、竞争不激烈时,轻量级锁能避免内核态切换的昂贵开销。
第三步,重量级锁。如果自旋多次仍然抢不到锁,或者竞争的线程数逐渐变多,JVM会选择把锁升级为重量级锁。这个锁就是之前提到的那种依赖操作系统Mutex的锁,线程进入阻塞状态,由操作系统调度唤醒。重量级锁性能开销大,但保证了强互斥和公平调度。
整个过程有个很关键的点:锁只能升级,不能降级。一旦竞争激烈导致升到重量级锁,即使后续竞争又变小了,它也会一直保持重量级锁状态。这一点在写长生命周期共享对象时要心里有数。
2.3 JIT层面的两个隐藏优化:锁消除和锁粗化
除了偏向锁和轻量级锁,还有两个优化经常被忽略——锁消除和锁粗化,它们发生在即时编译阶段。
锁消除听起来玄乎,实际很简单:JIT编译器如果通过逃逸分析发现某个锁对象只被当前线程使用,根本不可能被其他线程访问到,它就会把这个锁直接去掉。比如代码里有个局部变量,它的作用是作为锁对象包裹一段不会逃逸的逻辑,JVM发现这个变量不可能有线程安全问题,就会把synchronized优化掉。Java的StringBuffer的每个方法都是synchronized的,但如果你在一个方法内部局部使用StringBuffer进行字符串拼接,JIT就很可能把锁消除掉,因为那个StringBuffer根本不会逃逸出当前方法。
锁粗化则相反,它会把多个相邻的小临界区合并成一个大临界区。比如循环里每次迭代都加锁解锁,JIT发现锁对象没变,这个反复加锁解锁的开销不值得,就会直接把这个锁的范围扩展到整个循环。这也提醒我们:刻意把同步块切得特别碎,未必是好事,频繁加锁解锁本身就是成本。
2.4 synchronized在你代码里的正确姿势
理解了底层原理,写代码时你自然会注意几件事。
一是锁对象不要选字符串常量或Integer等常量池对象。你写synchronized("abc"),等于所有用同一字符串常量的地方共享一把锁,极容易造成不可控的全局串行。更麻烦的是自己代码里和引用的某个框架内部同时锁了同一个字符串常量,会发生灵异死锁,排查极其困难。
二是锁对象要尽量final。如果你允许锁对象引用被替换,一个线程持有锁执行临界区,另一个线程把锁对象引用指向了另一个新对象,然后去锁新对象,两边锁的根本不是同一个东西,临界区就失效了。这种bug隐蔽得很,代码review很难看出来。
3. ReentrantLock的底层骨架:AQS队列同步器的工作方式
3.1 AQS的核心设计
ReentrantLock能实现的那些灵活能力,几乎全部来自AQS。AQS本身是一个抽象队列同步器,核心就三块东西:一个volatile int state变量、一个FIFO等待队列、一组模板方法钩子。
state变量是锁状态的核心。对于可重入锁,state记录的就是当前持有锁的线程重入了几次。线程第一次加锁,state从0变成1;同一个线程再次加锁,state变成2;每解锁一次state减1,直到减回0,锁才会真正被释放并允许其他线程竞争。
FIFO等待队列用来管理获取不到锁的线程。当一个线程尝试获取锁失败,AQS会把它包装成一个Node节点,挂到CLH队列变体的尾部,并通过LockSupport.park()把它阻塞住。锁释放时,队列头部的等待线程会被唤醒去重新抢锁。这套机制保证了锁的排队秩序。
3.2 公平锁和非公平锁:底层就差一个判断
ReentrantLock的构造函数里传true就是公平锁,不传或传false默认是非公平锁。两者的区别,说白了发生在tryAcquire这一刻。
非公平锁版本的tryAcquire方法,线程到这一步根本不去看等待队列里有没有人排队,直接尝试CAS修改state抢锁。抢到了就进临界区,抢不到才乖乖去排队。这会导致一种现象:一个等待了很久的线程排在队列前,结果后来者插队抢走了锁。
公平锁的tryAcquire方法先做一次检查——hasQueuedPredecessors(),看看队列里有没有比自己排得更靠前的线程。如果有,只能进队等待,绝不插队。
那为什么JDK默认非公平锁?因为非公平锁的吞吐量更高。插队抢锁的线程在执行完临界区后立刻释放锁,期间没有线程从阻塞状态被唤醒,省掉了一次操作系统唤醒成本。而且非公平锁让“刚来的线程”有机会利用CPU时间片,而不是所有线程都去睡觉再被叫醒。代价就是可能造成线程饥饿,所以需要权衡。
3.3 ReentrantLock真正有优势的能力
我之前一直强调,普通互斥场景选synchronized就够了。ReentrantLock真正值得用的场景,是下面这四种能力:
第一,可中断获取锁。lockInterruptibly()方法允许一个在排队等待锁的线程响应中断——它被中断时不会继续死等锁,而是抛出InterruptedException。这在处理业务取消、请求超时等场景中非常实用。
第二,限时获取锁。tryLock(long timeout, TimeUnit unit)在规定时间内尝试获取锁,拿不到就返回false,不会无限期阻塞。这是synchronized完全做不到的,也是我处理分布式任务防重复执行时最常用的API。
第三,公平锁支持。虽然默认非公平锁性能好,但有些场景对公平性有硬性要求:比如你给待充值的用户分配资源,前后顺序很重要,一旦乱序可能引发客诉。
第四,多条件队列Condition。synchronized的wait/notify只能有一个等待队列,而ReentrantLock通过newCondition()可以创建多个条件队列。生产者消费者场景很适合用两个Condition分别管理生产者和消费者的等待队列,唤醒时可以按业务精确唤醒,而不是notifyAll把所有人都叫起来再做无谓检查。
3.4 一个典型的tryLock应用
举个例子,某管理系统有一个定时任务,需要每天凌晨从某数据源拉取一批数据写入本地数据库。因为生产环境同时部署了多个实例节点,如果每个节点都执行这个定时任务,就会造成重复数据。
我的处理方案是:每台节点在执行任务时,先尝试用tryLock获取一个本地分布式锁(配合某个集中存储,比如数据库唯一索引或ZooKeeper的临时节点)。ReentrantLock的tryLock(long timeout, TimeUnit unit)在这里起的是限时等待的作用:如果当前有一台节点正在跑任务,其他节点的tryLock在超时时间内拿不到锁,就放弃执行,直接退出。整个流程既不需要引入额外的分布式锁组件,代码也足够安全。
4. 工程实战怎么选:Synchronized和ReentrantLock的全面对比
4.1 一张表格理清取舍
讲完原理,选型问题就水到渠成了。我把两者日常使用的关键区别整理成一张表,大家可以保存起来参考。
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层级 | JVM内置,字节码指令层面 | JDK工具类,基于AQS的Java代码 |
| 锁获取与释放 | 自动获取,自动释放 | 手动lock/unlock,unlock必须放finally |
| 是否可中断 | 不支持,线程只能阻塞等待 | 支持lockInterruptibly |
| 限时获取锁 | 不支持 | 支持tryLock(timeout, unit) |
| 公平性 | 非公平锁 | 可配置公平/非公平 |
| 条件等待队列 | 一个等待队列,wait/notify | 多个Condition,精确唤醒 |
| 锁粒度控制 | 基本不可干预 | 可控制、可判断是否持有锁(isHeldByCurrentThread) |
| 异常时的处理 | 自动释放锁,异常安全 | 需要finally中释放,否则锁泄露 |
4.2 场景导向的选型原则
我自己在实际项目里遵循的选型逻辑是这样的。
默认用synchronized。理由很实在:它不用考虑锁释放问题,代码更简洁,绝不会有“忘记unlock导致整个应用卡死”这种问题。而且JVM持续优化synchronized,在绝大多数业务场景里,性能不会成为瓶颈。你代码写得干净,可维护性比那点锁性能差异值钱得多。
只有当出现以下明确诉求时,才考虑ReentrantLock:需要尝试性获取锁、需要响应中断、需要公平策略、需要多个条件队列。什么时候算“明确诉求”?比如:接口是提供给外部系统回调的,外层有超时控制,你不想因为等锁把请求拖垮,那就上tryLock;再比如:你有多个Bank账户,A扣款、B入款、C退款,三套流程相互独立,但共享账户余额数据,用Condition按类型精确唤醒比notifyAll更优雅。
还有一个经常被忽略的因素:维护者水平。如果你的团队里有不少初级开发者,synchronized反而是更安全的选择——它不容易写出“忘了解锁”的bug。这不是看不起谁,而是工程上的现实考量,我们要为整个团队的最低维护成本负责。
4.3 不要再迷信“性能差异”这个伪命题
网上很多文章喜欢列一堆基准测试数据,证明ReentrantLock比synchronized快多少,或者反过来。就我多年的实践看,这基本属于伪命题。两者的性能差异高度取决于竞争强度和临界区代码执行时间。
低竞争下,synchronized因为有偏向锁,单线程反复进入时性能很好;ReentrantLock因为是纯Java实现,偏向锁这部分优化享受不到,但差距也在微秒级以下,对于绝大多数业务场景完全不重要。
高竞争下,两者都会升级到阻塞挂起线程的模式,主要开销都在线程唤醒和上下文切换上,差距进一步拉小。真正的性能收益来源永远是减小锁粒度、降低锁竞争,而不是换一把锁。所以我的建议是:别在性能上传闻上纠结,把精力花在分析锁竞争本身。
5. 锁策略全景图:乐观锁、悲观锁、自旋锁、读写锁这些概念怎么放到一张图里
5.1 乐观锁和悲观锁,两种完全不同的世界观
锁策略这个话题,宏观上首先是乐观锁和悲观锁的二分。
悲观锁认为“总会有人跟我抢”,所以进临界区前直接就加锁。synchronized和ReentrantLock都是悲观锁的典型实现。它们安全,代价是阻塞。阻塞意味着线程从运行态转为等待态,需要操作系统介入调度,这个成本不容小觑。
乐观锁则认为“大部分时候都没人抢”,所以不加锁,而是在更新数据时检查数据有没有被别人改过。最经典的实现就是CAS(Compare And Swap)——比较并交换,一个原子操作,先比较当前值和期望值是否相等,相等才更新。Java的AtomicInteger、AtomicLong、LongAdder这些类的底层,全部是基于CAS的Unsafe操作实现的。AtomicInteger的incrementAndGet方法,内部就是一个死循环CAS,成功了才退出。
乐观锁的问题是CAS的ABA问题:线程A读出值是1,线程B把值改成2又改回1,线程A再次CAS时发现还是1,就认为没有人动过,实际上已经被改了两次。解决方式是加版本号,比如数据库乐观锁常用的version字段,Java里AtomicStampedReference提供了类似设计。不过大多数业务场景ABA并不会引发问题,强制加版本号反而增加复杂度,还是要按实际情况取舍。
5.2 公平锁和非公平锁的正确使用姿势
第3章我讲ReentrantLock时已经详细提过公平锁和非公平锁的机制差异。这里给个更宏观的判断:非公平锁适合绝大多数请求本就能快速完成、且对顺序没有强要求的场景,比如每笔交易的处理时间都很短,即使后面来的线程偶尔插队,前面等待的线程也不会等太久。公平锁适合处理时间较长且顺序敏感的批量任务,比如批量退款处理,先来的订单先出库,一定不能乱。
从实践经验看,公平锁触发线程切换和唤醒频率更高,吞吐量的损失往往在5%到10%左右,个别极端竞争场景下损失更明显。所以不到万不得已,不要为了“显得规范”去用公平锁,这没有道德上的优势,只有性能上的代价。
5.3 可重入锁的意义
synchronized和ReentrantLock都是可重入的。所谓可重入,就是同一个线程在外层拿到锁之后,内部调用其他同步方法时可以再次获取同一把锁,而不会把自己锁死。
举个实际例子:某个订单服务,外部方法需要加锁更新订单状态,方法内部又调用了一个私有方法去更新明细,那个方法也加了同一把锁。如果锁不可重入,第二次获取锁时就会因为自己还持有锁而卡死——经典的自锁死锁。可重入机制就是为这种嵌套调用设计的,它的实现核心就是记录当前持有锁的线程是谁,state值记录重入次数。
很多不熟悉并发的人写锁时没意识到这一点,选择了不可重入的锁实现,然后一调嵌套方法就死锁,排查半天才发现是锁的问题。所以标准库帮你解决了这个坑的99%的场景。
5.4 共享锁、独占锁和读写锁
锁的另一个维度是共享和独占。独占锁同一个时刻只能被一个线程持有,synchronized和ReentrantLock都是独占锁。读写锁(ReentrantReadWriteLock)则把锁拆成读锁和写锁:多个线程可以同时持有读锁,但写锁只能由一个线程持有,且写锁持有期间不能有读锁。
读多写少的场景,例如配置中心、权限缓存、菜单树查询,使用读写锁能极大提升并发吞吐。需要注意的问题是,如果业务代码里读锁内嵌套调用了写操作,就会出问题。而且读写锁在写多读少时性能不如普通的独占锁,因为读锁之间也要做维护开销。
JDK 8+还提供了StampedLock,它比ReentrantReadWriteLock更进了一步——支持乐观读,也就是读的时候完全不加锁,最后校验一下版本,如果写线程确实改过再进行重读或升级锁。这种思想在处理“读频率远大于写频率”的超高并发场景时很有价值。不过StampedLock不是可重入的,使用时要格外小心,不要在锁内部再调用自己。
5.5 自旋锁和锁粒度的革命
自旋锁跟大家平时写的synchronized是两码事。它是指当线程获取锁失败时,不会立刻挂起,而是在原地循环重试,直到拿到锁。JVM的轻量级锁升级过程中就使用了自旋策略,但自旋锁真正常见的地方是写并发组件——比如JDK的ThreadPoolExecutor,它的addWorker方法里用了自旋加CAS来并发添加工作线程。
自旋锁适合临界区极短且竞争不激烈的场景。自旋会耗尽CPU,所以限制最大自旋次数是有必要的。JVM内部对自旋也做了自适应优化——不固定次数,而是根据过去线程等待后成功获取锁的概率来动态调整。
至于锁粒度,这是比自旋更深层的策略。核心思想是:能不加锁就不加锁;必须加锁就只锁必要的那几行;一整块代码能被拆成多个独立的小锁就拆。最典型的例子是ConcurrentHashMap在早期版本中使用的分段锁机制——整个Map被切成16个段,每个段有一把独立的锁,不同段的读写互不干扰。虽然JDK 8之后的实现已经不再使用分段锁,但那种“拆跨度增大并发”的思路仍然值得借鉴:你把一个不可变对象的创建过程从synchronized整体锁,改成不同字段用不同锁来保护,并发能力就上来了。
6. 加锁实战中踩过的坑和可复用的经验
6.1 锁对象的三大禁忌
写并发代码多年,我发现很多人挂得最多的不是锁实现选错,而是锁对象没选对。
排序器、字符串字面量、枚举常量这些对象,绝对不要拿来做锁对象。它们都可能是共享的、被常量池复用的。另一个代码里一个线程用“clientId_A”作为锁对象,你的代码里也用同样的字符串作为锁对象去锁不太相关的资源,两边就莫名成了同一把锁,一个线程持有锁就可能把另一个模块的并发量压到1。遇到生产环境那个模块吞吐量突然下降的诡异问题,先查这个。
还有一点是锁对象的可见性。锁对象不能是null,为null时synchronized会抛NullPointerException。更重要的是锁对象不应被频繁创建再丢弃。很多人喜欢Lock lock = new ReentrantLock()放在方法内部用——这是完全错误的,每次调用都创建新的锁,等于没有锁。锁对象必须作为类的共享字段存在,保证所有线程访问的是同一个对象。
6.2 死锁的完整排查链路和预防
死锁是锁策略最容易出的极端问题。四个必要条件:互斥、持有且等待、不可剥夺、循环等待。其中最后一个“循环等待”是我们在编码中几乎唯一能控制的点。
我建议所有涉及多把锁的场景,给每把锁编一个编号,然后约定加锁顺序一律从小到大。比如一个转账操作,A账户转B账户,代码里同时锁账户A和账户B,如果线程1锁了A再去锁B,线程2锁了B再去锁A,两边同时对对方想要的那把锁等待,死锁就出现了。解决办法很简单:对账户对象的hashCode做一个排序,谁的哈希值小谁先加锁,两个线程无论先后顺序如何,都会按同一个方向加锁,循环等待就不容易发生了。即使业务上有缓慢路径(比如账户hashCode相等),也要有统一预案。
现实中排查死锁,最实用的工具是jstack。先把Java进程的PID找到,jstack $PID输出线程快照,里面会有“Found one Java-level deadlock”字样,并标明线程等待的锁和持有锁的情况。实测下来,有了这个输出,十分钟内就能定位是哪两个方法在互相锁。
6.3 从全局synchronized到读写锁的性能改造实录
某个模块的缓存对象,是一个读多写少的长生命周期对象。起初团队直接用一个全局synchronized方法保护整个对象的更新逻辑——这种做法在数据量小时完全没问题,但数据量涨到几万条以后,QPS直接掉了一个量级。
改造思路很明确:读请求不互斥,写请求才互斥。把读方法加读锁ReentrantReadWriteLock.ReadLock,把写方法加写锁ReentrantReadWriteLock.WriteLock。改造完实测,读QPS从原来的几百直接跳到两千多。这里有一个优化细节:读锁内部不要再放耗时过长的计算逻辑,否则多个读线程同时拿锁后,一起争抢CPU,反而比原来单线程更慢。临界区的代码一定要保持轻量。
6.4 尽量让并发问题消灭在无锁层面
最后一条经验,也是所有策略里我认为优先级最高的一条:能用无锁方案解决,就不要加锁。
Java并发包提供了大量无锁或低竞争的现成方案:CopyOnWriteArrayList适合读多写极少的集合;ConcurrentHashMap在JDK 8之后全CAS化,读操作完全无锁;ThreadLocal把变量从共享变成每个线程私有,彻底消除竞争;原子类AtomicInteger适合计数器场景。它们处理特定数据结构时的性能远胜于你用synchronized手动保护一个普通集合。
我个人在项目里见过太多人拿着HashMap去手动加锁做并发,明明用ConcurrentHashMap一行代码就解决了,非要自己维护一个全局锁,结果并发上不去不说,还容易出bug。能用并发包就用并发包,这是并发编程里性价比最高的事。
再分享一个实践中的体会:锁不是越多越好,更不是越大越好。判断一个并发方案好不好的标准,不是代码里用了几种锁,而是高负载下系统的吞吐量、响应时间、以及它在并发尖峰时的稳定性。写完并发代码后,建议花一点时间做一次jstack线程转储,看看你的锁等待到底集中在哪里,往往会发现,真正需要优化的不是锁本身,而是那些被锁保护得太久、做了太多无关事务的临界区逻辑。把临界区代码瘦身,远比试图找一个更快的锁实在得多。