图 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);乐观锁付了几十次重试的代价但结果正确;悲观锁零重试,代价是大家排队串行。冲突越激烈,乐观锁的重试账单越贵——这正是「写多选悲观」的直觉来源。
图 2:三种锁策略的原理对比与选型决策路径。
选型决策路径。
第一问:冲突频繁吗?频繁,直接悲观锁——乐观锁在高冲突下全是空转。不频繁,第二问:读写比如何?读多写少,乐观锁或读写锁(读操作耗时长、想并发读就用读写锁);读写均衡,看临界区长短——临界区短,CAS 自旋几下就过去;临界区长,自旋烧 CPU,回悲观锁。
易错点清单。
一,把乐观锁当成一把真实的锁,说不出「它靠版本校验而不是加锁」。二,知道 CAS 不知道 ABA,或者知道 ABA 说不出用版本号解。三,任何场景都答「用分布式锁」,不区分单机和跨机。四,读写锁乱用:写多读少的场景上读写锁,写锁长期被占,读线程照样全堵。
回到面试现场,建议的答题节奏:先一句话点破「乐观悲观是两种策略假设」,再按原理、实现、代价三栏各讲一分钟,最后落在决策路径上。提到 synchronized 的锁升级(偏向锁到重量级锁本质是 JVM 在乐观悲观之间自适应),是加分项。
你的项目里最典型的一处并发写是怎么保护的?乐观重试还是直接互斥,当时怎么权衡的?欢迎分享。