先说一个我在面试和带人过程中反复遇到的场景:Java并发这块,面试官最爱问的除了线程池就是锁机制。几乎每个人都能背出synchronized和ReentrantLock的区别,可你再往下追问一句"偏向锁升级到轻量级锁,底层到底动了什么",能讲清楚的就不多了。锁机制之所以成为"八股文重灾区",是因为它的知识链条特别长——从CPU指令、JVM内存模型到JDK工具类,每一层都有说法,而大部分人的学习只停在了最表层的结论上。
这篇文章不打算做那种单纯的八股背诵,我想把Java锁机制从底层到使用整个串一遍:synchronized在对象头里怎么玩状态位,ReentrantLock背后的AQS到底怎么排队,CAS是怎么做到"不加锁的锁",volatile在什么场景下能替代锁、什么场景下不能。你既可以拿着它准备面试,也能在真正排查并发问题的时候翻回来看看。我尽量用大白话讲原理,该给代码给代码,该给参数给参数,都是实际能落地的东西。
1. 为什么锁机制是并发八股文的重灾区
1.1 锁要解决的三类问题
先回到最底层。多线程同时访问共享资源,归根结底会出三类问题:互斥、可见性、有序性。这三个词是理解所有锁的前提,也是面试连环问的起点。
互斥不用多解释,同一时刻只能有一个线程进入临界区。可见性说的是:线程A把数据改了,线程B不一定能立刻看到,因为CPU缓存和主内存之间存在中间层。有序性就更隐蔽了,编译器、JIT、CPU都可能为了性能打乱指令的执行顺序,这在单线程下没影响,多线程下就可能出事。
锁这件事,表面上是保护一段代码,本质上是在这三个维度上建立屏障。synchronized能做到,volatile只能管可见性和有序性,管不了互斥。把这个底层逻辑理清了,后面所有概念都不会散。
1.2 一把锁,可以从四个维度去看
业界习惯把"锁"这个词按各种维度切分,新手最容易懵的就是这里。我见过有人把"乐观锁"和"轻量级锁"搞混,其实它们的分类维度完全不一样。
| 维度 | 分类 | 典型代表 | 一句话理解 |
|---|---|---|---|
| 竞争态度 | 悲观锁 / 乐观锁 | synchronized / CAS | 悲观锁认为一定冲突,乐观锁假设大多不冲突 |
| 获取顺序 | 公平锁 / 非公平锁 | ReentrantLock 两种构造 | 先来后到,还是允许插队 |
| 重入能力 | 可重入 / 不可重入 | ReentrantLock / 自定义锁 | 同一线程能否反复持有 |
| 占用模式 | 独占 / 共享 | ReentrantLock / 读写锁 | 一把锁是只能一人用,还是可以多人读 |
| JVM锁状态 | 偏向锁 / 轻量级锁 / 重量级锁 | synchronized 的三态 | 同一把synchronized锁的升级过程 |
| 加锁方式 | 自旋 / 阻塞 | CAS循环 / 重量级锁 | 失败后是转圈重试还是挂起 |
这个表格建议你存下来。面试的时候,只要能把"维度"说清楚,再举一个反面例子,就已经超过了大多数人。
1.3 八股文的正确学法
我自己面试别人的时候,最怕听到的答案是那种背得很顺、却经不起追问的结论。"偏向锁性能高?为什么高?""synchronized在JDK6之后优化了?优化了什么?"答案一旦是"因为JVM优化了",在我这里基本就挂了。
正确的学法是把每个结论往底层追问一层:偏向锁效率高,是因为加锁时只需要比对线程ID,连CAS都不需要;synchronized性能提升,是因为引入了锁升级机制,减少了用户态和内核态的切换次数。概念落到原理上,八股文就变成了真技术。接下来我就按这个思路,把锁机制一层层拆开。
2. synchronized的底层宇宙:对象头、Mark Word与锁升级路线
2.1 每个Java对象天生带着锁的元数据
synchronized锁的是对象,但锁信息存在哪里?答案在对象头里。每个Java对象的内存布局里,最前面有一段叫Mark Word的区域,用来记录对象的运行时数据:hashCode、GC分代年龄、锁状态等。
关键点是:Mark Word是一块可复用的内存。锁状态不同,它里面记录的东西就完全不同。无锁状态下它存hashCode和分代年龄;变成偏向锁后,腾出大部分空间存偏向的线程ID;变成轻量级锁后,存指向线程栈中锁记录的指针;变成重量级锁后,存指向ObjectMonitor的指针。
所以synchronized的加锁过程,本质上就是根据竞争情况不断改写对象头里那几位标志位的过程。
2.2 锁状态编码:一张表看懂对象头的秘密
在HotSpot虚拟机里,Mark Word的最低2位叫lock标志位,旁边还有一个1位的biased_lock标志位,组合起来就能表示四种锁状态:
| 锁状态 | biased_lock | lock标志位 | Mark Word里存的什么 |
|---|---|---|---|
| 无锁 | 0 | 01 | hashCode、GC分代年龄 |
| 偏向锁 | 1 | 01 | 偏向线程ID、epoch、分代年龄 |
| 轻量级锁 | 0 | 00 | 指向栈中Lock Record的指针 |
| 重量级锁 | 0 | 10 | 指向ObjectMonitor的指针 |
| GC标记 | - | 11 | 转发指针等 |
这里有个容易踩的坑:偏向锁和无锁的lock标志位都是01,区别在于biased_lock那一位。所以判断一个对象是否可偏向,要看完整的低三位对不对。
2.3 偏向锁:单线程反复加锁的"绿色通道"
偏向锁的思想非常朴素:如果一个线程反复获得同一把锁,那就让这个线程"偏向"它,之后加锁只需要检查Mark Word里记录的线程ID是不是自己,是就直接进临界区,连CAS都不用,更不需要陷入内核态。
偏向锁的获取流程大概是这样的:
- 检查Mark Word是否为可偏向状态。
- 如果可偏向,尝试通过CAS把当前线程ID写入Mark Word。
- 如果成功,当前线程获得锁,后续重入只需要比对线程ID。
- 如果CAS失败,说明存在竞争,需要撤销偏向。
注意,偏向锁的撤销不是当前线程自己做的。JVM会等到全局安全点(SafePoint),暂停持有偏向锁的线程,再判断这个线程到底有没有退出临界区。如果已经退出了,就把对象头改回无锁或轻量级锁状态;如果还在临界区里跑,就直接升级成重量级锁,让线程真正阻塞。
经验提醒:偏向锁的撤销成本其实不低,因为它要等安全点、暂停线程。低竞争时偏向锁省下的CAS开销,未必能抵消偶尔一次撤销的开销。这也是后来JVM团队对它动刀的原因。