一行 synchronized,超卖归零
第 1 篇的超卖 Demo,最小修复只加一个词:把扣库存的方法用 synchronized 修饰,1000 件卖出正好 1000 件。但先回答更重要的问题——synchronized 到底锁住了什么?很多人以为它锁的是方法、是那段代码。错。它锁的是对象,代码只是被这把对象锁圈起来的「排队进场区域」。
三种用法,三把不同的锁
synchronized 有三种用法,锁的对象完全不同,混着用就是三个互不干扰的队列:
class Counter {
private int count;
// 用法一:修饰实例方法 → 锁 this
public synchronized void inc() { count++; }
// 用法二:修饰静态方法 → 锁 Counter.class(类对象,全局唯一)
public static synchronized void staticInc() { }
// 用法三:修饰代码块 → 锁你指定的那个对象
private final Object lock = new Object();
public void dec() {
synchronized (lock) { count--; }
}
}用法三最值得玩味——任何对象都能当锁,因为每个 Java 对象出厂自带一把锁。这也解释了最经典的翻车:两个线程各自 new 了 Counter,各自调 synchronized 实例方法——锁的是两个不同的 this,互不干扰,该超卖照旧超卖。要互斥,必须抢同一把锁。
锁的信息藏在哪:对象头 Mark Word
对象在堆里除了字段数据还有一段对象头,其中 Mark Word 是锁的主战场:无锁、偏向、轻量级、重量级四种锁状态,全靠它里面几个比特位的组合来标记,哈希码、GC 分代年龄也挤在这里面。所以给对象「上锁」不需要额外的锁对象——锁状态就记在对象自己头上。这也为下一篇的锁升级铺了路:锁为什么能升级?因为 Mark Word 的位布局可以按需切换。
字节码里的两条实现路径
javap -v 反编译看真相:同步代码块靠一对 monitorenter / monitorexit 指令包住——enter 时 monitor 计数加一(若发现计数者就是自己则直接加一,这叫可重入,同一线程反复进同一把锁不会卡死自己),exit 时减一,减到零才真正放锁。同步方法则没有这对指令,只有方法 flags 里多一个 ACC_SYNCHRONIZED 标志,JVM 调用时看到它就自动完成加解锁。两条路径殊途同归,背后都是同一个 monitor:
$ javap -v Counter
public synchronized void inc();
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 同步方法:标志位实现
public void dec();
...
monitorenter // 同步代码块:指令对实现
...
monitorexit // 注意:会有两个 monitorexit
... // 一个走正常出口,一个走异常出口留意那个细节:编译器会生成两个 monitorexit,一个是正常出口,一个挂在异常路径上——保证代码就算抛异常,锁也一定被释放。synchronized 从语法层面杜绝了「忘了解锁」,这正是它比手动 Lock 省心的地方。
等锁的线程去哪了
呼应第 2 篇的状态图:拿不到锁的线程进 BLOCKED,挂在锁对象的等待队列里,锁释放时 JVM 唤醒一个接着抢。这套排队不保证公平——刚释放锁的线程可能立刻又抢回去,排了很久的队也可能一直抢不到。非公平是 synchronized 的默认性格,也是第 11 篇 ReentrantLock 专门提供公平选项的动机。
骨架讲完。下一篇拆 synchronized 最玄学的部分:明明是同一把锁,为什么有四种形态、还会自动升级?以及让无数人栽跟头的 wait / notify——为什么必须在 synchronized 块里调用,为什么等待条件必须写在 while 里。
评论 (0)