深入理解 Java 中的锁与锁优化机制
在 Java 开发中,并发编程是绕不开的重要主题,而其中的核心问题之一便是——如何控制多个线程对共享资源的访问。这就引出了本文的主角:synchronized 关键字以及 Java 中的锁机制。
一、从计算机体系结构看并发问题
要真正理解 synchronized,我们不能仅仅停留在 Java 层面,而应从计算机体系结构出发。
1.1 缓存一致性与可见性问题
CPU 的执行速度远快于内存访问速度。为缩小二者差距,现代处理器通常采用多级缓存机制(L1、L2 为核心私有,L3 为共享)。在多线程环境中,这会导致一个典型问题:
- 某线程在某 CPU 核心上修改了缓存数据;
- 数据尚未同步回主存,另一个线程从主存中读取了旧数据;
- 结果:一个线程对共享变量的修改,其他线程无法立刻看到。
这就是可见性问题(Visibility)。
1.2 指令重排序与有序性问题
出于性能考虑,编译器或 CPU 可能会对指令进行优化,即重排序,造成执行顺序与源码顺序不一致,导致并发程序出现异常行为。这便是有序性问题(Ordering)。
1.3 原子性问题
多个线程同时对共享变量进行读写操作时,如果操作不是原子性的,便可能产生竞态条件,引发程序逻辑错误。
二、synchronized 是什么?解决了什么问题?
synchronized 是 Java 提供的关键字,用于对代码加锁,以实现线程间的互斥与同步。
2.1 本质:monitorenter 与 monitorexit
synchronized 在字节码层面会被编译为:
monitorenter:加锁,尝试获取对象的监视器锁;monitorexit:释放锁。
加锁时,会强制从主存中读取变量(读屏障),以保证读到的都是最新值,解决可见性问题;解锁时,会强制将工作内存的变量刷新到主存(写屏障),确保修改对其他线程立即可见。
此外,synchronized 还通过内存屏障机制,禁止指令重排序,从而解决有序性问题。
而其本质的互斥机制,自然也解决了原子性问题。
2.2 synchronized 的用法
- 修饰实例方法:锁的是当前对象(
this)。 - 修饰静态方法:锁的是类的
Class对象(所有实例共享)。 - 修饰代码块:锁的是指定的对象(括号中的对象)。
三、锁的升级机制
在 JDK1.6 之前,synchronized 一直被批评为“性能杀手”,原因在于它底层依赖操作系统的 mutex(互斥量),一旦涉及线程阻塞/唤醒,便要进行用户态与内核态切换,开销巨大。
为了解决这个问题,JDK1.6 引入了锁优化机制(也称锁升级机制),锁的状态可以在运行时根据竞争情况进行升级,从而避免不必要的线程阻塞,提高性能。
3.1 锁升级的动因
大多数情况下,系统的并发量并不高。例如电商网站凌晨时段的访问量很低,线程间抢锁的情况非常少。这意味着绝大多数时间下锁是“轻松拿到”的。若仍旧让线程经历阻塞/唤醒的操作,会极大浪费资源。
因此,Java 引入了从“无锁”到“偏向锁”再到“轻量级锁”最后到“重量级锁” 的升级流程,尽可能延迟引入高开销的阻塞机制。
四、锁升级的全过程
4.1 无锁(No Lock)
程序刚启动时,所有对象默认处于无锁状态。
4.2 偏向锁(Biased Lock)
当一个线程第一次访问同步块时,JVM 会将对象头的 Mark Word 设为偏向锁模式,并记录该线程的 ID。后续只要是该线程访问,无需任何加锁操作,直接进入同步代码块,几乎无开销。
场景适用:
- 同步块始终只被一个线程访问。
4.3 轻量级锁(Lightweight Lock)
当第二个线程尝试获取偏向锁时,偏向锁被撤销,升级为轻量级锁。此时采用 CAS + 自旋 的方式尝试获取锁。
自旋的优势:
为什么要有轻量级锁呢?轻量级所考虑的是竞争所的线程不多,而且县城持有所的时间也不长的一个情景。如果刚阻塞(用户态内核态切换),锁就被释放了,就得不偿失了
- 避免线程阻塞带来的上下文切换:
- 保持线程在用户态执行,提高效率。
注意:
- 自旋适合持锁时间短的场景;
- 自旋时间过长将浪费 CPU。
JVM 采用自适应自旋策略,即:上一次自旋成功的时间越长,下一次允许的自旋时间越长。
4.4 重量级锁(Heavyweight Lock)
当:
- 自旋超过设定次数;
- 或有更多线程参与锁竞争;
JVM 将轻量级锁升级为重量级锁,此时线程将被阻塞,需要操作系统层面进行唤醒,代价显著增加。
当升级到重量级锁的时候,对象头的 Mark word 的指针就会指向锁监视器 monitor。
五、锁监视器(Monitor)详解
synchronized 的底层机制依赖锁监视器对象(Monitor),每个对象天生带有一个 Monitor,用于管理锁的状态和线程的阻塞唤醒。
5.1 Monitor 的核心结构
owner:记录当前持有锁的线程。recursion:重入计数器(支持可重入锁)。EntryList(锁池):抢锁失败后被阻塞的线程队列。WaitSet(等待池):主动调用wait()方法,放弃锁并等待被唤醒的线程。
5.2 可重入机制
synchronized 是可重入锁,即同一个线程可以多次获得相同的锁。实现依赖 Monitor 中的重入计数器,每次加锁+1,释放锁时-1,直到为0才真正释放。
5.3 流程
-
当有线程拿到锁,此时监视器的
owner字段就记录拿到锁的线程 -
没有拿到锁的线程就被阻塞住,进入
blocking状态,然后放到锁池中。 -
当拿到锁的线程调用了
wait方法,那该线程就释放锁,然后进入waiting状态,然后被放到等待池当中。 -
当某个线程调用了
notify(),唤醒了这个waiting的线程,那这个线程就从waiting的状态变成blocking状态,然后再被放入到锁池中,等待锁释放,重新去抢锁 —— 这就是锁池和等待池的作用
5.3 锁池 vs 等待池:不同目的,不同机制
- 锁池(EntryList):
- 存放抢锁失败的线程;
- 线程状态为
BLOCKED; - 目标是尽快重新获得锁。
- 等待池(WaitSet):
- 存放主动调用
wait()的线程; - 线程状态为
WAITING或TIMED_WAITING; - 目标是等待资源准备好后被
notify()唤醒,再去抢锁。
- 存放主动调用
这两个队列对应了两种不同的线程状态和编程语义:互斥与通信。
六、总结:synchronized 的前世今生
| 问题 | synchronized 如何解决 |
|---|---|
| 可见性 | 解锁时写屏障 + 加锁时读屏障,保证主存同步 |
| 有序性 | 通过内存屏障阻止指令重排序 |
| 原子性 | 加锁机制保证同一时间只有一个线程执行 |

浙公网安备 33010602011771号