Synchronized:从计算机原理到JVM锁升级
彻底讲透 Synchronized:从计算机原理到 JVM 锁升级
Synchronized 是 Java 并发编程中的一个核心机制,常涉及到锁升级、无锁、偏向锁、轻量级锁、重量级锁等概念。为了深入理解 Synchronized,本文将探究其背后的设计理念和工作原理。
本文将从以下几个关键问题出发,全面阐述 Synchronized 的深层机制:
- 探讨锁升级流程设计的必要性及其复杂性。
- 分析偏向锁和轻量级锁在不同场景下的应用及其解决的问题。
- 揭示锁监视器(Monitor)在 Synchronized 体系中的核心作用。
要透彻理解 Synchronized,必须追溯到计算机体系结构的发展,从其根源问题开始解析。
根源:为什么需要锁?
CPU 的速度非常快,但是内存(主存)的速度比较慢。为了缓解 CPU 和内存速度不一致的问题,现代计算机体系结构中就有了三级缓存(Cache)。
- 一级(L1)和二级(L2)缓存是 CPU 核心私有的。
- 第三级(L3)缓存是多个核心共享的。
之前文章的一张图片,如下
问题来了:在多线程并发的环境下,当某一个 CPU 核心去执行某个线程任务时,会把内存中的数据读到自己的私有缓存中。然后,它修改了缓存中的数据。但当缓存数据还没有同步到主存时,另一个核心上的线程 B 从主存中去读数据,那读到的不就是旧数据了吗?
这就引出了并发编程中的三大核心问题:
- 可见性:一个线程修改了共享数据,另外一个线程无法立刻获取到最新的共享数据。
- 有序性:为了提升性能,CPU 或编译器可能会对指令进行重排序,导致执行顺序和代码书写顺序不一致。
- 原子性:一个或多个操作,要么全部执行且执行过程不被任何因素打断,要么就都不执行。
Synchronized 是什么?它如何解决问题?
synchronized 本质上就是一把锁。它能解决上述所有问题。
- 保证原子性:它能保证被它修饰的代码块,在同一时间只能被一个拿到锁的线程访问。
- 保证可见性与有序性:synchronized 编译之后,会生成 monitorenter 和 monitorexit 这两个 JVM 指令。
那么synchronized如何操作锁呢?synchronized 块代码在字节码层面的实现会有两个重要的指令monitorenter、monitorexit。我们分别从功能和并发三性质的保障来说起。
monitorenter:指令在同步代码块开始时执行,它会尝试获取对象的监视器锁。如果成功,锁的计数器加 1;如果失败,线程会阻塞。
monitorexit:指令在同步代码块结束时(无论是正常退出还是异常退出)执行,它会释放对象的监视器锁,并将锁的计数器减 1。
上面是功能性的描述,下面从可见性的保证,也就是JVM对JMM规约的遵循来看
monitorenter (加锁):在加锁时会强制 CPU 去从主存重新读取数据,从而保证读到的都是最新的数据(解决了可见性问题)。
monitorexit (解锁):在解锁时强制将 CPU 缓存中的变量刷新到主存中,保证线程修改后的数据对其他线程立刻可见。
monitorenter、monitorexit还会通过内存屏障的方式保证有序性。
Synchronized 的常见用法包括:
- 修饰实例方法:锁住当前对象实例(
this
)。- 修饰静态方法:锁住当前类的 Class 对象。
- 修饰代码块:
synchronized(obj)
,锁住括号内的指定对象obj
。
旧时代的痛点:为什么要做锁升级?
很多人都知道,synchronized 在 JDK 1.6 之后有了重大优化,引入了”锁升级”。那为什么要做锁升级呢?肯定是因为升级前它很慢。那到底慢在哪儿呢?
synchronized 说到根儿上,它的锁机制最终依赖操作系统底层的 Mutex(互斥量) 原语来实现。当线程获取锁失败时,就需要阻塞和唤醒,而 Java 的线程模型是一对一模型——每一个 Java 线程都直接对应一个操作系统的内核级线程。
这意味着,每次需要阻塞或唤醒一个线程,都需要操作系统从用户态切换到内核态来完成。这个切换过程开销巨大,非常耗时。这也就是 synchronized 曾经被诟病性能差的核心原因。
既然线程的阻塞和唤醒代价高,那优化的思路就很明确了:
在锁竞争不激烈的情况下,尽量避免线程阻塞。
只要不阻塞,操作系统就不需要进行状态切换,开销自然就小了,性能就上来了。只有当并发量变高,锁竞争真正激烈的时候,再让线程去阻塞。
这就是”锁升级”的核心思想,它是一个为了应对越来越激烈的锁竞争而逐步升级的过程:
1 | graph LR |
为什么可以这样做呢?因为经过大量研究发现,一个系统在绝大多数时候都不存在锁竞争,经常只有一个或少数几个线程去拿锁。即便是高并发系统,也并非时时刻刻都在高并发,大部分时间并发量并不大。
为了在低并发时降低获取锁的代价,提高性能,JVM 就引入了锁升级。
偏向锁:为“独行侠”献上的极致性能
场景洞察:绝大多数情况下,锁并不存在竞争,总是由同一个线程多次获取。
优雅过程:当第一个线程“首次到访”,JVM会慷慨地将锁“偏向”于它,把线程ID刻在对象头 (Object Header) 的Mark Word上。此后,该线程再进出同步块时,就如同进入自家门,无需任何验证,享受几乎零成本的丝滑体验。
设计哲学:为这种“无竞争”的理想状态,提供最极致的性能,避免不必要的同步开销。
轻量级锁:君子间的乐观协定
升级时机:当第二位线程前来尝试获取锁,“独占”状态被打破,锁便升级为轻量级锁。
应对策略:新来的线程会做一个“乐观”的假设——持有锁的线程很快会释放。因此,它不会立即“躺平”(阻塞),而是通过如下过程方式来获得锁。
- 在栈帧中创建锁记录 (Lock Record)
- 新线程通过CAS (Compare-and-Swap) 操作将对象头的 Mark Word 指向这个锁记录。
- 进行几轮“自旋”,在原地稍作等待。这种等待是智能的(适应性自旋),它会根据历史情况动态调整等待时间。
设计哲学:为“线程交替、短时持有”的场景设计。既然阻塞和唤醒的代价高昂,不如“稍等片刻”,用最小的代价避免兴师动众的内核态切换。
重量级锁:维护秩序的最终仲裁
升级时机:当“君子协定”失效,某个线程自旋了很久也未能如愿。现场变得拥挤,超过两个线程同时在此竞争。
终极手段:
JVM 会为该对象创建一个监视器对象 (Monitor Object),这个对象是在堆内存中分配的。此时,锁升级为重量级锁,召唤出最终的“大杀器”——操作系统的互斥锁(Mutex)。所有后续前来、无法获取锁的线程,都将被无情地挂起(阻塞),进入等待队列。
设计哲学:当短暂的自旋已无法解决问题,且可能演变成CPU空转的资源浪费时,必须果断采取重量级策略。牺牲部分响应性,换取整个系统的CPU资源稳定,这是一种顾全大局的权衡。
一句话总结:在偏向锁和轻量级锁的阶段,所有冲突都在“用户态”内部解决,如同一场快速的内部协商。只有当协商无效,竞争升级时,才会上升到需要操作系统介入的“内核态”层面。
轻量级锁升级重量级锁的过程发生了什么?
当轻量级锁升级为重量级锁时,主要发生以下几个变化:
- Mark Word 变化:对象头中的 Mark Word 不再指向栈帧中的锁记录(Lock Record),而是会变成一个指向堆中 Monitor 对象的指针。
- Monitor 对象创建:JVM 会为该对象创建一个重量级锁,即一个 Monitor 对象。这个 Monitor 对象包含了锁的拥有者、重入次数、以及两个重要的线程等待队列:
线程阻塞与唤醒:当多个线程尝试获取同一个锁,并且锁处于轻量级锁状态时,如果出现了锁竞争,轻量级锁就会膨胀为重量级锁。此时,竞争失败的线程会被阻塞,进入 _EntryList 中。当持有锁的线程释放锁时,会唤醒 _EntryList 中的一个或多个线程来重新竞争锁。
线程的阻塞和唤醒是由操作系统(内核)负责调度的。所以对Monitor的操作是非常heavy的举动。在用户态,应用程序无法直接控制线程的“睡眠”或“唤醒”,这些操作是操作系统内核的特权。内核拥有管理所有进程和线程的权限,包括它们的调度、状态转换(运行、就绪、阻塞)以及资源的分配。
重量级锁:锁监视器(Monitor)详解
当锁升级到重量级锁时,对象头的 Mark Word 会变成一个指针,指向一个锁监视器(Monitor)对象。
这个 Monitor 究竟是什么?
它主要负责记录锁的拥有者、记录锁的重入次数、以及负责线程的阻塞与唤醒。一个 Monitor 对象主要包含以下几个关键部分:
1 | Monitor { |
可重入性的实现
synchronized 是可重入锁,其实现就依赖于 _recursions 计数器。当一个线程重复获取它已经持有的锁时,计数器会加一;当它释放一次锁时,计数器减一。只有当计数器减到零时,锁才被真正完全释放。
锁池 (EntryList) vs 等待池 (WaitSet)的区别
在 Monitor 机制中,需要区分管理两种不同类型的等待线程,总结下来就是:锁池的线程是为了竞争,等待池的线程是为了协作。目标完全不同,当然要放在两个不同的集合里管理。
锁队列 ( _EntryList )
- 存放对象:竞争锁失败的线程。
- 线程状态: BLOCKED 。
- 解决问题:锁的互斥。这些线程的目标非常明确——就是想尽快抢到锁去执行任务。它们是被动等待。
等待队列 ( _WaitSet )
- 存放对象:调用了object.wait() 方法,主动放弃锁的线程。
- 线程状态: WAITING 或 TIMED_WAITING 。
- 解决问题:线程间的通信与协作。这些线程是主动放弃锁的,它们暂时不想要锁,而是在等待某个条件达成(比如等待生产者生产数据)。当其他线程调用 notify() 或 notifyAll() 之后,它们才会被唤醒,放入锁池中,重新开始竞争锁。
Synchronized:从计算机原理到JVM锁升级
https://yelihu.github.io/2025/06/22/Synchronized:从计算机原理到JVM锁升级/