一把锁的四级进化
早期 synchronized 直接对接操作系统的互斥量,加锁解锁都要陷入内核态——哪怕根本没有竞争。「synchronized 很重」的黑历史就是这么来的。JDK 6 之后引入锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,只升不降,按竞争烈度逐步加码。四级形态的差距,本质是「花多少代价防万一」。
偏向锁:只有一个线程的场合
大多数锁整个生命周期只有同一个线程在访问——那第一次拿到锁后,干脆在 Mark Word 里记下线程 ID,之后这个线程再进来连 CAS 都省了,直接放行。听起来很美,但 JDK 15 起偏向锁已被默认禁用并标记废弃(JEP 374):撤销偏向要安全点停顿、维护成本高、在现在多线程常态化的应用里收益萎缩。老项目的启动参数里还能见到 -XX:+UseBiasedLocking,新代码不该再依赖它。了解它,主要是为了看懂面试题和旧代码。
轻量级锁:CAS 自旋的短跑
第二个线程来了,升级为轻量级锁:线程在自己栈帧里划一块 Lock Record,把 Mark Word 复制进去,再用 CAS 把对象头指向这块记录。抢不到的线程不自挂,原地自旋几圈——赌的是「锁马上就放」。它适合持有时间极短、冲突不激烈的场景;自旋是空耗 CPU 的,转久了得不偿失,于是就有了下一级。
重量级锁:内核态的大枪
自旋若干次还没抢到,膨胀为重量级锁:monitor 对接操作系统互斥量,抢不到的线程被直接挂起(park),让出 CPU——上下文切换进内核态,代价大,但不再空烧 CPU。适合持锁时间长、竞争激烈的场景。synchronized 的聪明之处就在这:根据现场自动选形态,你一行代码都不用改。所以别再背「synchronized 是重量级锁」的老黄历了——那是 JDK 6 之前的故事。
wait/notify 铁律一:必须在 synchronized 里
wait 和 notify 是挂在 Object 上的方法,配套规则第一条:必须在 synchronized 块内调用同一把锁对象的 wait/notify,否则抛 IllegalMonitorStateException。道理也直白:wait 的语义是「我先睡,醒来时之前看到的状态仍然可信」——只有持着锁,这个承诺才成立;不持锁就 wait,唤醒后拿到的状态毫无保障,那还睡个什么劲。
铁律二:等待条件必须写在 while 里
用 wait/notify 写一个最小生产者消费者缓冲区:
class Buffer {
private final Queue<Integer> data = new LinkedList<>();
private final int cap = 10;
public synchronized void put(int v) throws InterruptedException {
while (data.size() == cap) { // 必须 while,不能 if
wait(); // 释放锁,进入 WAITING
}
data.offer(v);
notifyAll(); // 唤醒所有等待者重新判断
}
public synchronized int take() throws InterruptedException {
while (data.isEmpty()) {
wait();
}
int v = data.poll();
notifyAll(); // 消费完,唤醒等空间的另一边
return v;
}
}为什么是 while 不是 if?因为 wait 返回不代表条件成立:你可能是被 notifyAll 唤醒的十个线程之一,条件早被手脚快的抢完了;也可能只是碰上一次虚假唤醒。醒后重走判断,条件不成立就继续等,这才是完整语义。那为什么用 notifyAll 而不是 notify?notify 只随机喊醒一个,而所有等待者共用一个等待队列——万一喊醒的是「等错条件」的那个(比如等空间的被喊来消费),该醒的没醒,全体永久等待,经典死锁。notifyAll 多唤醒几个看起来浪费,但正确性永远排在性能前面。
synchronized 篇完。下一篇进入 Java 内存模型的主战场:为什么线程 A 改了变量,线程 B 就是看不见——volatile 登场。
评论 (0)