一、前言:为什么需要“花式”锁?
想象一下,你有一个公共的储物柜,很多人都会来存取东西。如果每次有人来,你都把整个储物柜房间的大门锁上,只让这一个人进去,那效率就太低了。其他人只能在门口干等着。
在Java的多线程世界里,一个对象(就像那个储物柜)也可能被多个线程(就像多个人)争抢。最原始的解决办法就是“重量级锁”——直接把整个房间(对象)锁死,只让一个线程进去操作。这确实安全,但代价是性能低下,其他线程都得挂起等待,涉及到操作系统层面的线程切换,成本很高。
聪明的JVM设计者们就想:“能不能看情况用不同的锁?人少的时候用简单的锁,真打起来再用重锁?”于是,一套精妙的锁升级机制——偏向锁、轻量级锁、重量级锁——就诞生了。它们的目标只有一个:在保证线程安全的前提下,最大限度地提升程序性能。
二、第一道防线:偏向锁——我的地盘我做主
偏向锁的想法非常“偏心”。它假设在绝大多数情况下,一个锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。
应用场景: 适用于绝大多数时间都是单线程访问同步块的场景,比如一些工具类、单例模式下的初始化等。
工作原理: 当一个线程第一次访问同步块时,JVM会在对象头的Mark Word部分,通过CAS操作记录下这个线程的ID。之后,这个线程再进入和退出这个同步块时,就不需要进行任何同步操作(比如加锁、解锁、CAS),直接检查一下对象头里的线程ID是不是自己就行了。这就像在储物柜上贴了个标签:“此柜已由张三专用”,张三自己再来,看一眼标签就直接用,省去了开锁关锁的麻烦。
技术栈:Java
public class BiasLockExample {
private static final Object lock = new Object();
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
// 模拟一个长时间只有单线程访问的场景
Thread singleThread = new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
synchronized (lock) { // 此处,锁极有可能偏向于这个线程
counter++;
}
}
System.out.println("单线程累加完成,counter=" + counter);
});
singleThread.start();
singleThread.join(); // 等待单线程执行完毕
// 此时,lock对象处于已偏向状态(偏向于singleThread)
System.out.println("主线程查看,counter=" + counter);
// 现在,第二个线程尝试获取锁,偏向锁将被撤销
Thread anotherThread = new Thread(() -> {
synchronized (lock) { // 这里会触发偏向锁撤销,并升级为轻量级锁
System.out.println("第二个线程获得了锁");
}
});
anotherThread.start();
anotherThread.join();
}
}
// 注释:在JVM启动初期(通常前4秒),偏向锁是默认延迟开启的。
// 可以通过JVM参数 `-XX:BiasedLockingStartupDelay=0` 来关闭延迟。
// 使用 `-XX:-UseBiasedLocking` 可以完全关闭偏向锁。
优缺点:
- 优点: 对于只有一个线程访问同步块的场景,加锁和解锁几乎零成本。
- 缺点: 一旦出现另一个线程尝试竞争,就需要进行“偏向锁撤销”,这个操作需要暂停持有偏向锁的线程(STW),成本较高。因此,如果明确知道存在多线程竞争,关闭偏向锁可能反而有性能提升。
三、第二道防线:轻量级锁——君子动口不动手
当偏向锁被撤销(因为出现了第二个线程竞争),锁不会直接升级到最重的级别,而是先尝试“轻量级锁”。它的理念是:多线程竞争不激烈,可以通过“自旋”的方式稍作等待,避免直接挂起线程。
应用场景: 适用于线程交替执行同步块,竞争程度很低,且同步块执行速度非常快的场景。
工作原理: 线程在进入同步块前,JVM会在当前线程的栈帧中创建一个名为“锁记录”的空间,用于存储锁对象当前的Mark Word拷贝(Displaced Mark Word)。然后,线程会尝试用CAS操作将对象头的Mark Word更新为指向这个锁记录的指针。
- 如果成功,当前线程就获得了轻量级锁。
- 如果失败,说明至少有两个线程在竞争同一个锁。这时,获得锁的线程不会立刻挂起,而是会进行“自旋”(空循环)一小段时间,期待持有锁的线程很快就能释放锁。如果自旋期间成功获得了锁,那么可以继续轻量级锁模式。如果自旋失败(比如自旋次数超过阈值,或者又来了第三个线程竞争),锁就会膨胀。
技术栈:Java
public class LightweightLockExample {
private static final Object lock = new Object();
public static void main(String[] args) {
// 线程A和线程B交替执行,竞争很轻微
Thread threadA = new Thread(() -> {
for (int i = 0; i < 5; i++) {
synchronized (lock) { // 这里很可能使用轻量级锁
System.out.println(Thread.currentThread().getName() + " 持有锁,执行任务...");
try {
Thread.sleep(50); // 模拟一个非常短的任务
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 释放锁后,线程B有机会获得
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Thread-A");
Thread threadB = new Thread(() -> {
for (int i = 0; i < 5; i++) {
synchronized (lock) { // 这里很可能使用轻量级锁
System.out.println(Thread.currentThread().getName() + " 持有锁,执行任务...");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Thread-B");
threadA.start();
threadB.start();
}
}
// 注释:轻量级锁的“自旋”是通过忙循环实现的,会消耗CPU。
// JVM有自旋次数的自适应策略(Adaptive Spinning),会根据以往的成功经验动态调整。
// 在JDK 6之后,自旋锁是默认开启的。
关联技术:CAS操作
CAS(Compare-And-Swap)是轻量级锁和偏向锁实现的基础。你可以把它理解为一个乐观的原子操作:“我认为这个位置的值应该是A,如果是,我就把它改成B;如果不是,说明被别人改过了,那我就不改了。” 这个过程不需要重量级的互斥锁,是现代并发编程的基石。Java中的 java.util.concurrent.atomic 包下的类,底层大量使用了CAS。
优缺点:
- 优点: 竞争的线程不会被挂起,减少了操作系统线程调度和上下文切换的开销,响应速度快。
- 缺点: 如果同步块执行时间很长,或者竞争激烈,自旋的线程会白白消耗CPU资源。这就是所谓的“忙等待”。
四、最终手段:重量级锁——裁判介入,排队解决
当轻量级锁竞争失败(通常是自旋失败),锁就会“膨胀”为重量级锁。这是最传统、最“重”的锁实现。
应用场景: 高并发、线程竞争激烈的场景,或者同步块内执行的操作非常耗时。
工作原理: 此时,对象头的Mark Word指向的是一个操作系统层面的“互斥量”。未获得锁的线程会被直接挂起,放入一个等待队列中,等待操作系统调度。当持有锁的线程释放锁后,操作系统会从等待队列中唤醒一个线程来获取锁。这个过程涉及到用户态到内核态的切换,以及线程的挂起和唤醒,成本最高。
技术栈:Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HeavyweightLockExample {
private static final Object lock = new Object();
private static int sharedResource = 0;
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(10); // 创建10个线程的池
// 提交大量任务,制造激烈竞争
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
synchronized (lock) { // 在高并发下,此锁极有可能升级为重量级锁
// 模拟一个相对耗时的操作,增加持有锁的时间
for (int j = 0; j < 100; j++) {
sharedResource++; // 非原子操作,需要同步
}
// 持有锁时间较长,其他线程自旋很难成功
}
});
}
executor.shutdown();
while (!executor.isTerminated()) {
// 等待所有任务完成
}
System.out.println("最终结果: " + sharedResource); // 应该是 1000 * 100 = 100000
}
}
// 注释:重量级锁的管理依赖于操作系统的互斥量(Mutex)和条件变量。
// 在Java中,重量级锁对应的是`ObjectMonitor`对象,它维护了入口队列、等待队列等。
// 可以使用`jstack`或`jconsole`工具查看线程的锁状态,如“BLOCKED (on object monitor)”。
优缺点:
- 优点: 在竞争激烈时,能有效避免CPU空转,让未获得锁的线程休眠,把CPU让给其他真正需要工作的线程。
- 缺点: 性能开销最大,线程切换、内核态切换成本高,响应延迟大。
五、锁的转换路径与注意事项
整个锁的转换路径是一个单向升级的过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。注意,锁可以升级,但一般不会降级。因为降级发生在STW期间(如垃圾回收时),对运行时的性能优化意义不大。
注意事项:
- 不要过度同步: 锁优化再强,也比不上无锁。尽量减少同步块的范围,避免在同步块中执行耗时操作(如IO)。
- 理解竞争程度: 根据你的应用场景,可以调整JVM参数。例如,对于明确存在高并发的服务,可以考虑使用
-XX:-UseBiasedLocking关闭偏向锁,避免无谓的撤销开销。 - 自旋的代价: 在单核CPU或者CPU密集型任务下,自旋锁(轻量级锁的核心)可能弊大于利,因为它会浪费宝贵的CPU时间片。
- 锁消除与锁粗化: JVM还有另外两种高级优化。锁消除:如果JIT编译器通过逃逸分析,发现某个锁对象不可能被其他线程访问,就会把这个锁完全去掉。锁粗化:如果一系列连续的操作都对同一个对象反复加锁解锁,JVM可能会把锁的范围扩大到整个操作序列外部,减少锁的请求次数。
六、总结
JVM的这套锁优化方案,生动地体现了计算机科学中“根据情况选择合适工具”的思想。它不是一个死板的机制,而是一个动态的、自适应的系统:
- 偏向锁是给“独行侠”的快速通道。
- 轻量级锁是给“文明竞争者”的协商区。
- 重量级锁则是给“混乱战场”的强制仲裁。
理解它们,不仅能帮助我们在面试中侃侃而谈,更能让我们在编写高性能、高并发的Java程序时,做出更明智的设计决策。知道锁在背后如何“升级打怪”,我们就会更自觉地写出对锁更友好的代码,让JVM的优化器能更好地为我们工作,最终提升整个系统的吞吐量和响应能力。
评论