Java Lock
synchronized、ReentrantLock、CAS——接 HashMap 面试链里「并发」那一棒的个人笔记。
为什么写这篇
关键词链走到 HashMap、ConcurrentModificationException、分段锁 之后,下一棒通常就落到锁上了。
HashMap → 红黑树 → ConcurrentModificationException → synchronized → AQS → CAS → ConcurrentHashMap → 分段锁 → ...
比如先问:ArrayList 能不能在 forEach 里一边遍历一边 remove?答「不可以」之后,引出 ConcurrentModificationException,再往下就自然问到——那怎么处理并发?synchronized?Lock?乐观锁和悲观锁又是什么?
我自己对锁的理解,谈不上能去和 Doug Lea 聊「重新设计 Java 并发框架」。但日常写 Android / Java 业务、读 ConcurrentHashMap 源码笔记、应付大部分面试追问,把下面这张「锁的地图」握住,心里会踏实一些。这篇就是按这个粒度整理的。
锁在防什么
多线程同时改同一份数据,结果可能乱套——这就是线程安全问题。锁的作用说穿了就一句:让临界区同一时刻只有一个线程(或一组合规线程)在动。
Java 里和锁相关的入口很多,但面试和日常最常碰到的就三类:
| 层级 | 机制 | 说明 |
|---|---|---|
| 语法级 | synchronized | 关键字,JVM 内置,隐式加解锁 |
| API 级 | java.util.concurrent.locks.Lock | 显式锁,如 ReentrantLock |
| 原子类 | java.util.concurrent.atomic.* | 基于 CAS,乐观锁思路 |
下面按这个顺序过一遍,最后再收束「悲观 / 乐观」「公平 / 非公平」这些分类标签。
synchronized:最老派、也最常用
synchronized 是很多人接触的第一把锁。块锁:
synchronized (obj) {
// 临界区
}方法锁:
public synchronized void add() { ... }底层靠对象头里的 Monitor(监视器锁),字节码里对应 monitorenter / monitorexit。它默认是 悲观锁——默认假设会打架,先占坑再干活。
优点是简单,JVM 帮你管加解锁;缺点是灵活度不如后面的 Lock 接口——不能 tryLock、不能中断等待、也不能自己选公平策略。分段锁 里 JDK 1.7 的 Segment 继承 ReentrantLock,和 synchronized 包 HashMap 整表锁,也是同一条线上的不同解法。
Lock 接口:显式、可配置
JDK 1.5 起有 java.util.concurrent.locks.Lock,典型用法:
Lock lock = new ReentrantLock();
lock.lock();
try {
// 临界区
} finally {
lock.unlock(); // 必须放 finally,别漏
}比 synchronized 多出来的能力:tryLock()、lockInterruptibly()、可选公平 / 非公平。ReentrantLock 底层通常挂在 AQS(AbstractQueuedSynchronizer) 上——面试若继续深挖,关键词链就拐进 AQS 了;这篇只点到为止,知道「显式锁常常走 AQS」够用了。
CAS 与原子类:乐观锁的底子
另一条路是不抢传统意义上的「互斥锁」,而是用 CAS(Compare-And-Swap) 做无锁更新:
AtomicInteger i = new AtomicInteger(0);
i.incrementAndGet(); // 内部 CASCAS 是硬件级的「比较再交换」:内存里的旧值和期望值一样才更新,否则重试。ConcurrentHashMap JDK 1.8 里不少地方也是 CAS + 细粒度 synchronized 组合拳,和 分段锁 那篇能连上。
手写一个自增的 CAS 循环,逻辑是这样的:
while (true) {
int oldValue = atomicInt.get();
int newValue = oldValue + 1;
if (atomicInt.compareAndSet(oldValue, newValue)) {
break;
}
// 冲突了,重试
}冲突少时很快;冲突猛了会一直自旋,CPU 飙高——这是乐观锁的代价。
各种「锁的标签」怎么记
面试里名词很多,我按自己的归类方式列一下,不追求教科书完备,只求对话时能接得上。
悲观锁 vs 乐观锁
- 悲观锁:觉得竞争一定会来,先锁再说。代表:
synchronized、ReentrantLock - 乐观锁:觉得冲突是少数,先改再比对,冲突就重试。代表:CAS、
AtomicInteger
synchronized (this) {
count++;
}安全,但等锁的时候线程会堵着。乐观锁快,但写不对或竞争太激烈,反而更折腾。
公平锁 vs 非公平锁
- 公平锁:先来先得,排队
- 非公平锁:允许插队,吞吐往往更高
ReentrantLock 默认非公平,可以显式要公平的:
new ReentrantLock(true); // 公平可重入锁
同一线程对同一把锁重复获取,不会把自己锁死:
public synchronized void a() {
b(); // 再次进入同一把锁,OK
}
public synchronized void b() { }synchronized 和 ReentrantLock 都是可重入的。可重入不等于可乱用,只是避免「自己调自己」时死锁。
读写锁
读多写少时,用 ReadWriteLock 把读和写分开——读可以并发,写独占:
ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock();
// ...
rwLock.writeLock().lock();自旋锁
等锁的时候不立刻挂起线程,而是循环看锁放没放。竞争不激烈时能少几次上下文切换;竞争激烈时纯自旋会烧 CPU。Java 里 CAS 的重试就带这种味道。
它们之间什么关系
我自己复习时画过一张简图,帮助把「语法锁 → 显式锁 → AQS → CAS」串起来:
锁的层级:
┌─────────────────────────────┐
│ synchronized (悲观锁,内置锁) │
│ ReentrantLock (显式锁,可重入) │
│ ReadWriteLock (细粒度锁) │
│ │
│ → AQS (底层实现框架) │
│ → CAS (底层硬件指令) │
└─────────────────────────────┘
↑
└─ 乐观锁思想(基于 CAS)不是严格继承关系,而是「从上到下越来越细、越来越灵活」的一条演化线。
一张表快速扫一眼
| 类型 | 特点 | 代表实现 |
|---|---|---|
| 悲观锁 | 先锁后干 | synchronized, ReentrantLock |
| 乐观锁 | 干完再比对,冲突重试 | AtomicInteger, CAS |
| 公平锁 | 排队 | ReentrantLock(true) |
| 非公平锁 | 可插队 | ReentrantLock() 默认 |
| 可重入锁 | 同线程可重复获取 | synchronized, ReentrantLock |
| 读写锁 | 读并发、写独占 | ReentrantReadWriteLock |
收尾
锁这块的内容,深度可以差很多。友善一点的面试,问到 synchronized 和 Lock 区别、乐观悲观各适合什么场景,差不多就能判断基本功了;要是奔着「捡漏架构师」去,一路追到 AQS 队列、CLH、公平锁实现细节,那我大概率也得和 HashMap 那篇一样——翻开笔记,接上那一棒。
日常写业务,我的习惯是:能先用高层工具(ConcurrentHashMap、viewModelScope、框架自带的线程安全结构)就别手写锁;真到了要自己 synchronized 或 ReentrantLock 的时候,先想清楚锁的粒度——整表一把锁 还是只锁必要的一小段——往往比背多少锁的分类名词更管用。