深蓝黑背景品牌封面:左侧大标题「乐观锁 · 悲观锁 · 读写锁」,副标题「原理差异与选型决策」;右侧原创装饰——一枚锁形图标分出三条岔路,分别通向「先抢着改,提交再校验」「先锁住,谁也不许碰」「读共享,写独占」三个标签;左上标注「经典题解析 · Java 后端」,右上标注「Day 10 · 10-08」 图 1:本文封面,点明三种锁策略的分岔。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:乐观锁、悲观锁、互斥锁、读写锁的原理、实现与区别,分别怎么选?

这道题最常见的失分方式是把四种锁背成四个名词解释。面试官真正想确认的是:你明不明白「乐观」和「悲观」不是两把具体的锁,而是两种对冲突概率的假设——先抢着改、提交再校验,还是先锁住、谁也不许碰。假设不同,适用场景完全不同。

核心结论:悲观锁假设冲突必然发生,乐观锁假设冲突很少,读写锁假设读远多于写。选型就是判断你的业务符合哪个假设。

悲观锁:先锁住,谁也不许碰。

访问共享资源之前先加锁独占,用完了才放。典型实现:Java 的 synchronized、ReentrantLock,数据库里的 SELECT ... FOR UPDATE。优点是简单可靠——拿到锁就没人能干扰你;代价是并发度低,拿不到锁的线程只能排队,锁多了还有死锁风险。它适合写多、冲突激烈的场景:反正大概率要撞,不如一开始就把门关上。

乐观锁:先抢着改,提交再校验。

读数据时不加锁,拿一个版本号(或旧值快照)就走;写回时校验「我读的版本是不是还没变」,没变就写入并升版本,变了说明有人抢先,本次作废重试。典型实现:CAS 原子类( compareAndSwap 一条指令完成校验加写入)、数据库表加 version 字段的 UPDATE ... WHERE version = 旧值。优点是无锁、并发度高;代价是冲突多的时候大量空转重试,以及著名的 ABA 问题——值从 A 变 B 又变回 A,CAS 看着没变其实动过,用版本号(每次修改单调递增)即可堵住。它适合读多写少、冲突稀少的场景。

读写锁:把锁拆成两半。

读锁共享,一百个线程同时读互不干扰;写锁独占,写的时候读写都不许进。典型实现:ReentrantReadWriteLock、数据库的共享锁与排他锁。它是悲观锁的精细化——同样是先锁住,但承认「读不冲突」,只把互斥留给写。适合读远多于写、且读操作耗时较长的场景,比如本地缓存、配置中心。

写段代码,把三种策略的账目算出来。

模拟十个并发任务同时给同一个账户扣款:无锁会丢更新,乐观锁靠版本重试保正确,悲观锁靠互斥保正确。用 Node.js 22.15.0 直接运行(node lock-strategies.js)即可:

'use strict';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 十个并发任务各扣 5,初始余额 100,正确结果应为 50
async function run(mode) {
  let balance = 100, version = 0, retries = 0;
  let locked = false;
  const acquire = async () => { while (locked) await sleep(0); locked = true; };
  const release = () => { locked = false; };

  const worker = async () => {
    for (;;) {
      if (mode === 'pessimistic') await acquire();      // 悲观锁:先拿锁再干活
      const v = version, b = balance;                    // 读版本与余额(乐观锁的快照)
      await sleep(Math.random() * 3);                    // 模拟业务耗时,制造交错
      if (mode === 'optimistic' && v !== version) {      // 乐观锁:提交前校验版本
        retries++;
        continue;                                        // 版本变了,重试
      }
      version++;
      balance = b + -5;                                  // 校验到写入之间无 await,类比 CAS 的原子性
      if (mode === 'pessimistic') release();
      return;
    }
  };
  await Promise.all(Array.from({ length: 10 }, worker));
  return { mode, balance, retries };
}

console.log(await run('none'));
console.log(await run('optimistic'));
console.log(await run('pessimistic'));

实测输出(每次运行数字会抖动,这是一次真实运行的结果):

{ mode: 'none', balance: 95, retries: 0 }
{ mode: 'optimistic', balance: 50, retries: 45 }
{ mode: 'pessimistic', balance: 50, retries: 0 }

账目一目了然:无锁丢了九次更新(余额每次运行都不同,但几乎必然不是 50);乐观锁付了几十次重试的代价但结果正确;悲观锁零重试,代价是大家排队串行。冲突越激烈,乐观锁的重试账单越贵——这正是「写多选悲观」的直觉来源。

对比与决策图:上半部分三列对比三种锁策略。悲观锁(互斥锁):假设冲突必然发生,访问前先加锁独占,典型实现 synchronized、数据库 select for update;优点是简单可靠,代价是并发度低、有死锁风险,适合写多、冲突激烈的场景。乐观锁:假设冲突很少,读时不加锁,提交时用版本号或 CAS 校验「我读的值有没有被别人改过」,冲突就重试,典型实现 CAS 原子类、数据库 version 字段;优点是无锁高并发,代价是冲突多时空转重试、存在 ABA 问题(用版本号解决),适合读多写少。读写锁:把锁拆成读锁(共享,多人同时读)与写锁(独占,写时谁都不能进),典型实现 ReentrantReadWriteLock,适合读远多于写且读操作耗时的场景(如本地缓存)。下半部分是选型决策路径:冲突频繁吗——频繁选悲观锁;不频繁再看读写比——读多写少用乐观锁或读写锁,读写均衡且临界区短用 CAS 自旋,临界区长还是回悲观锁。底部提醒:乐观悲观的本质不是两把锁,而是两种「先干还是先防」的策略假设 图 2:三种锁策略的原理对比与选型决策路径。

选型决策路径。

第一问:冲突频繁吗?频繁,直接悲观锁——乐观锁在高冲突下全是空转。不频繁,第二问:读写比如何?读多写少,乐观锁或读写锁(读操作耗时长、想并发读就用读写锁);读写均衡,看临界区长短——临界区短,CAS 自旋几下就过去;临界区长,自旋烧 CPU,回悲观锁。

易错点清单。

一,把乐观锁当成一把真实的锁,说不出「它靠版本校验而不是加锁」。二,知道 CAS 不知道 ABA,或者知道 ABA 说不出用版本号解。三,任何场景都答「用分布式锁」,不区分单机和跨机。四,读写锁乱用:写多读少的场景上读写锁,写锁长期被占,读线程照样全堵。

回到面试现场,建议的答题节奏:先一句话点破「乐观悲观是两种策略假设」,再按原理、实现、代价三栏各讲一分钟,最后落在决策路径上。提到 synchronized 的锁升级(偏向锁到重量级锁本质是 JVM 在乐观悲观之间自适应),是加分项。

你的项目里最典型的一处并发写是怎么保护的?乐观重试还是直接互斥,当时怎么权衡的?欢迎分享。