4. synchronized关键字:用法与锁的底层实现
说到Java并发编程,synchronized绝对是个绕不开的话题。我记得刚入行那会儿,总觉得它就是个简单的加锁工具,用起来也没什么技术含量。直到后来在线上遇到一次诡异的性能抖动,排查了整整两天,才发现是对synchronized的理解不够深。嗯,今天我们就把它彻底讲透。
4.1 synchronized的三种用法
先说说最基础的东西。synchronized可以用在三个地方:实例方法、静态方法、代码块。每种用法的锁对象都不一样,这个必须搞清楚。
4.1.1 修饰实例方法
当synchronized加在实例方法上时,锁的是当前对象实例(也就是this)。
public class Counter {
private int count = 0;
// 锁的是当前Counter实例
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
这里有个坑,我踩过。如果你有两个不同的Counter实例,它们各自调用increment()是不会互斥的。为什么?因为锁的是对象本身,不是类。说白了,每个对象都有自己的锁。
4.1.2 修饰静态方法
静态方法就不一样了。锁的是Class对象,也就是整个类级别的锁。
public class Counter {
private static int totalCount = 0;
// 锁的是Counter.class
public static synchronized void addTotal() {
totalCount++;
}
}
我曾经在项目中见过一个案例:有人把实例方法和静态方法混着用,以为它们互斥。结果呢?线上数据对不上了。因为实例方法锁的是对象,静态方法锁的是类,它们根本就不是同一把锁。
4.1.3 修饰代码块
代码块是最灵活的用法。你可以指定任意对象作为锁,粒度更细。
public class SafeList {
private final Object lock = new Object();
private List<String> list = new ArrayList<>();
public void add(String item) {
synchronized (lock) {
list.add(item);
}
}
public String get(int index) {
synchronized (lock) {
return list.get(index);
}
}
}
我个人习惯用专门的锁对象,而不是直接用this。为什么?因为this可能被外部代码拿去当锁,容易造成死锁或者意外的阻塞。你想想看,别人拿着你的对象锁,你这边还傻等着呢。
4.2 锁的底层实现:Monitor
好,用法说完了。接下来我们深入底层看看。synchronized到底是怎么实现的?
每个Java对象在内存里都有一个对象头(Object Header)。对象头里存着一些元数据,其中就包括锁相关的信息。这个锁机制的核心,就是Monitor(监视器锁)。
在HotSpot虚拟机里,每个对象都可以关联一个Monitor。当线程要进入synchronized代码块时,它必须先获取这个Monitor的所有权。获取不到?那就得等着,进入一个叫EntryList的队列里排队。
我简单画个流程:
- 线程尝试获取Monitor的所有权
- 如果Monitor没有被占用,线程直接进入
- 如果被占用了,线程进入EntryList等待
- 持有锁的线程执行完,释放Monitor
- EntryList里的线程被唤醒,重新竞争
这里有个细节:wait()和notify()也是基于Monitor实现的。调用wait()的线程会进入WaitSet,而不是EntryList。这两个队列是不同的,千万别搞混。
synchronized被称为「重量级锁」。
4.3 锁升级过程
说到重量级锁,就不得不提Java 6之后做的优化——锁升级。说白了,就是JVM根据竞争情况,动态调整锁的「重量」。
整个过程分为四个阶段:
| 锁状态 | 特点 | 适用场景 |
|---|---|---|
| 无锁 | 没有线程竞争 | 初始状态 |
| 偏向锁 | 只有一个线程反复获取 | 单线程访问 |
| 轻量级锁 | 少量线程交替获取 | 低竞争场景 |
| 重量级锁 | 多线程激烈竞争 | 高竞争场景 |
4.3.1 偏向锁
偏向锁的想法很聪明:如果一个线程反复获取同一把锁,那何必每次都做昂贵的同步操作呢?干脆把锁「偏向」给这个线程。
怎么实现的?在对象头里记录线程ID。下次这个线程再来,直接比对ID,一样的话就放行,连CAS操作都省了。
不过偏向锁有个问题:一旦有其他线程来竞争,它就得撤销。撤销过程需要等待全局安全点(SafePoint),这个开销其实不小。
我的经验:在高并发场景下,偏向锁的撤销反而会成为性能瓶颈。我曾经优化过一个系统,关闭偏向锁(-XX:-UseBiasedLocking)后,吞吐量提升了15%。所以别迷信偏向锁,它不一定适合所有场景。
4.3.2 轻量级锁
当偏向锁被撤销后,锁会升级为轻量级锁。轻量级锁的核心是CAS自旋。
线程不会立即阻塞,而是通过CAS操作尝试获取锁。如果成功了,那就皆大欢喜。如果失败了,线程会自旋一会儿(空转CPU),再试一次。
自旋的好处是避免了线程阻塞带来的上下文切换。但坏处也很明显:自旋会消耗CPU。如果锁被占用的时间很长,自旋就是白费力气。
// 轻量级锁的CAS操作(伪代码)
if (compareAndSwap(&lock, 0, 1)) {
// 获取锁成功
} else {
// 自旋等待
spin();
}
4.3.3 重量级锁
如果自旋也搞不定,锁就会升级为重量级锁。这时候就回到我们前面说的Monitor机制了,线程会真正地阻塞,进入内核态等待。
重量级锁虽然开销大,但它是最后的保障。在高竞争场景下,让线程阻塞反而比自旋更高效——至少CPU不用空转了。
4.4 避坑指南
最后分享几个我踩过的坑:
- 锁对象不要用字符串字面量:字符串常量池可能导致意想不到的锁共享
- 不要锁住包装类型:
Integer、Long等包装类型有缓存机制,容易出问题 - 锁的粒度要适中:太粗影响性能,太细容易死锁
- 注意锁的可见性:
synchronized不仅能保证原子性,还能保证可见性(内存屏障)
我曾经在项目里用String作为锁对象,结果两个不相关的模块莫名其妙地互相阻塞了。查了半天才发现,它们用了同一个字符串常量。嗯,从那以后我再也不敢偷懒了。
好了,关于synchronized的核心内容就这些。记住:用法要准确,原理要理解,坑要避开。下一章我们聊聊volatile关键字,它和synchronized有什么异同?到时候见。