1. 从一份并发笔记仓库说起这个标题到底在讲什么第一次看到这个标题的人大概率会愣一下。前半段是一串看起来像路径的字符中间夹着docs/java/concurrent/SynBottom.md后面跟着一个代码托管平台的仓库地址。如果只扫一眼很容易把它当成某个随手收藏的链接或者误以为标题里混进了不相干的内容。但把标题拆开看真正有价值的信息其实非常明确这是一个围绕Java 并发编程建立的学习笔记仓库而SynBottom.md这个文件名指向的是并发领域里最基础也最容易讲不清楚的一块——synchronized 的底层实现机制。我这些年带过不少刚接触并发的同学发现一个特别普遍的现象大家能背出synchronized能保证原子性、可见性、有序性也能写出加锁的代码但一旦被问到这个锁到底加在哪个对象上锁升级是怎么发生的为什么有时候加了锁还是出问题就答不上来了。这份笔记仓库的价值恰恰在于它把这类会用但不懂的知识点落到了文档层面去整理。标题里的SynBottom从命名习惯推断就是 synchronized 加 bottom底层的组合意图很直白——把 synchronized 的底层原理讲透。所以这篇博文要聊的不是某个具体项目的业务功能而是如何系统性地理解和整理 Java 并发中 synchronized 的底层机制以及围绕这类知识点做笔记、做验证、做踩坑复盘的一套方法。适合的读者范围其实挺宽正在准备技术面试、想把并发基础打牢的开发者平时写业务代码偶尔用到锁、但没深究过原理的工程师以及有做技术笔记习惯、想把零散知识沉淀成体系的人。哪怕你之前对偏向底层的概念有点发怵我也会尽量用生活化的类比把机制讲明白再配上可以直接跑起来的验证代码。需要先说明一点标题里那段看起来像域名的字符与本文要讲的技术内容没有实质关联我在整理时会把它当作一个普通的仓库标识来看待重点全部放在docs/java/concurrent这个目录所代表的并发知识体系上。下面进入正题。2. synchronized 到底锁住了什么对象头与监视器锁的真实关系2.1 锁的不是代码块而是对象很多人初学时的直觉是给这段代码加了锁这个说法在结果上没错但在机制上是误导。synchronized锁的从来不是代码而是对象。代码块只是规定了在持有某个对象锁的前提下才能执行这段逻辑。理解这一点后面所有的锁升级、锁消除、死锁分析才有落脚点。具体锁的是哪个对象取决于你写的是哪种形式写法锁定的对象常见误判synchronized修饰实例方法当前实例this以为锁的是方法本身synchronized修饰静态方法当前类的Class对象以为和实例方法共用一把锁synchronized(obj)同步块括号里那个obj的引用所指对象以为锁的是括号里的变量名这里有个特别容易踩的坑同步块锁的是引用指向的对象不是引用变量本身。如果括号里的对象引用在中途被重新赋值那锁的对象就变了两个线程可能各锁各的互不阻塞。我见过一段代码同步块里对一个成员变量重新赋值结果并发问题照旧出现排查了半天才发现是锁对象被换掉了。2.2 对象头里的 Mark Word 才是锁的身份证锁信息存在哪里答案是对象的对象头。以常见的 64 位虚拟机为例一个对象在内存里大致由三部分组成对象头、实例数据、对齐填充。对象头里又分 Mark Word 和类型指针Klass Pointer。Mark Word 在 64 位下占 8 字节它是个多面手会根据对象当前的状态复用同一块内存空间存储不同的信息。在无锁状态下Mark Word 里存的是对象的哈希码、分代年龄、锁标志位等。当对象被加锁后Mark Word 的内容会随锁的状态变化。这就是为什么说 synchronized 的锁状态是记录在对象自己身上的。你可以把 Mark Word 想象成一张随身份变化而改写内容的证件平时写着基本信息一旦进入某种被管控状态证件上就换成对应的管控标记。锁标志位的取值大致对应几种状态无锁01、偏向锁01但偏向标志位为 1、轻量级锁00、重量级锁10、GC 标记11。这套状态机是理解锁升级的关键下一节展开。2.3 Monitor重量级锁背后的那套结构当锁升级到重量级Mark Word 里存的就不再是线程相关信息而是一个指向Monitor也叫管程/监视器对象的指针。Monitor 是 JVM 层面的一套同步结构在 HotSpot 里由 ObjectMonitor 实现内部维护着几个关键字段持有锁的线程owner、重入次数recursions、等待锁的线程队列EntryList、调用了 wait 的线程集合WaitSet等。线程要进入同步块本质上是要抢到这个 Monitor 的所有权。抢不到就进 EntryList 排队阻塞被唤醒后再竞争。调用了wait()的线程则进入 WaitSet等notify()/notifyAll()把它挪回竞争队列。这套机制解释了为什么wait/notify必须配合synchronized使用——因为它们操作的就是 Monitor 内部的等待集合没有 Monitor 就无从谈起。提示wait()会释放 Monitor 并进入 WaitSet而sleep()只是让线程暂停、不释放任何锁。这两个方法经常被拿来对比本质区别就在于是否与 Monitor 的等待集合发生交互。理解了对象头和 Monitor 的关系再回头看锁的是对象这句话就具体了锁的状态写在对象头的 Mark Word 里重量级状态下通过指针关联到 Monitor线程的排队、唤醒都围绕 Monitor 展开。这条链路打通之后锁升级就不再是几个名词的堆砌。3. 锁升级的完整链路从偏向到重量级到底发生了什么3.1 为什么要有锁升级这套设计如果所有锁一上来就用 Monitor 这种重量级结构会有一个明显问题Monitor 的竞争涉及操作系统层面的线程阻塞与唤醒也就是常说的用户态到内核态的切换开销不小。但现实中大量同步代码根本不存在真正的并发竞争——同一个锁在绝大多数时间里只被同一个线程反复获取。为这种其实没竞争的场景付出重量级开销显然不划算。锁升级的设计动机就在这根据竞争激烈程度让锁从轻量逐步加重能省则省。这有点像小区门禁平时刷脸就进偏向偶尔有访客登记一下轻量级自旋真遇到大规模人流才开闸限流重量级。升级是单向的一旦升上去基本不会降回来偏向锁在特定条件下可以撤销但整体趋势是只升不降因为既然已经证明存在竞争再退回轻量状态意义不大。3.2 偏向锁假设只有一个线程在玩偏向锁的核心假设是锁不仅不存在多线程竞争而且总是由同一个线程多次获得。它的做法是在 Mark Word 里记录下第一个获取锁的线程 ID之后这个线程再来获取只要比对一下 ID 一致就直接认为拿到了锁连 CAS 都不用做。这个认人的过程就是偏向二字的由来。偏向锁的获取流程大致是检查 Mark Word 的偏向标志和线程 ID如果指向自己直接进入如果没偏向用 CAS 把当前线程 ID 写进去如果已经偏向了别的线程就进入撤销流程在安全点Safe Point暂停偏向线程检查它是否还活着、是否还需要这把锁再决定是撤销偏向还是升级。这里有个实操中值得注意的点偏向锁的启用和撤销是有成本的撤销需要在安全点进行会带来一定的停顿。所以在高并发、锁竞争频繁的场景下偏向锁反而可能成为负担。较新版本的 JDK 里偏向锁的默认策略有过调整实际项目里如果发现偏向锁撤销频繁可以考虑关闭它。判断依据可以看 JVM 的偏向锁撤销计数相关日志如果撤销次数居高不下说明这个锁根本不适合偏向。3.3 轻量级锁CAS 自旋的乐观尝试当偏向锁被撤销或者一开始就存在轻微竞争时锁会进入轻量级状态。轻量级锁的思路是先乐观地假设竞争不激烈用 CAS 去抢抢不到就自旋等一会儿而不是立刻阻塞。具体过程是线程在自己的栈帧里划出一块空间叫 Lock Record把对象头 Mark Word 的内容拷贝进去这叫 Displaced Mark Word然后用 CAS 尝试把 Mark Word 替换成指向自己 Lock Record 的指针。CAS 成功就拿到锁失败说明有别人在抢那就自旋重试。自旋到一定次数还拿不到才升级为重量级锁。自旋的本质是用 CPU 时间换线程切换开销。如果锁很快就能释放自旋几下就拿到了比阻塞再唤醒划算得多但如果锁持有时间长自旋就是白白烧 CPU。所以自旋次数是有上限的早期是固定次数后来引入了自适应自旋——根据上一次在同一把锁上的自旋成功率动态调整。这个细节说明 JVM 在锁优化上是相当务实的不是死板地套规则。3.4 重量级锁交给 Monitor 排队自旋失败、竞争激烈时锁升级为重量级。此时 Mark Word 指向 Monitor没抢到锁的线程进入 EntryList 阻塞等待由操作系统调度唤醒。这一路下来开销最大但胜在稳定适合真正高并发的场景。把整条链路串起来看锁升级的触发条件其实可以归纳成一张表锁状态触发条件核心动作开销特征偏向锁首次获取且无竞争Mark Word 记录线程 ID极低仅比对轻量级锁偏向撤销或存在轻微竞争CAS 替换 Mark Word 自旋中等消耗 CPU重量级锁自旋失败、竞争激烈关联 Monitor线程阻塞排队高涉及内核态切换注意锁升级是单向的轻量级不会退回偏向重量级不会退回轻量级。所以一段代码如果曾经在高并发下把某把锁推到了重量级之后即使并发降下来这把锁也还是重量级。这也是为什么有些压测场景下第一轮和后续轮次的性能表现会有差异。3.5 用代码验证锁状态的变化光看理论容易飘实际验证一下会踏实很多。下面这段代码通过反射读取对象头的 Mark Word观察加锁前后状态的变化。需要说明的是不同 JDK 版本、不同虚拟机参数下输出会有差异这里给的是思路不是固定答案。import java.lang.reflect.Field; public class LockStateDemo { public static void main(String[] args) throws Exception { Object lock new Object(); System.out.println(加锁前: printMarkWord(lock)); synchronized (lock) { System.out.println(加锁中: printMarkWord(lock)); } System.out.println(释放后: printMarkWord(lock)); } static String printMarkWord(Object obj) throws Exception { // 借助 Unsafe 读取对象头具体偏移量随虚拟机实现不同 Class? unsafeClass Class.forName(sun.misc.Unsafe); Field f unsafeClass.getDeclaredField(theUnsafe); f.setAccessible(true); Object unsafe f.get(null); var method unsafeClass.getMethod(getLong, Object.class, long.class); long markWord (long) method.invoke(unsafe, obj, 0L); return String.format(0x%016X, markWord); } }跑这段代码时建议加上-XX:PrintFlagsFinal先确认当前 JDK 的偏向锁相关参数状态因为不同版本默认值不一样直接对比输出容易得出错误结论。我自己的经验是验证类实验一定要先固定环境JDK 版本、启动参数否则换个机器结果就变了反而更困惑。4. 把并发笔记做成可复用的知识资产整理方法与踩坑复盘4.1 为什么并发知识特别需要落文档并发这块知识有个特点懂了和写下来是两回事写下来和能复现又是两回事。你可能今天看懂了锁升级过两周再被问到脑子里只剩偏向、轻量、重量三个词细节全糊了。这不是记性问题而是并发机制本身涉及对象头、CAS、Monitor、安全点等多个层面的交叉不落到纸面上很难形成稳定记忆。所以像docs/java/concurrent/SynBottom.md这种按主题拆分的笔记结构价值不在于存了多少字而在于把一条完整的因果链固定下来。我建议每篇并发笔记至少包含四块内容机制是什么、为什么这么设计、怎么验证、踩过什么坑。前两块保证理解后两块保证可复现和可迁移。缺了验证笔记就退化成抄书缺了踩坑笔记就没有个人增量。4.2 笔记目录怎么切分才不打架并发知识点多且互相牵连目录切不好很容易出现这个内容放哪都行的尴尬。我的做法是按机制层次而不是按面试题来切分。比如 synchronized 单独成篇讲锁对象和锁升级volatile 单独成篇讲内存语义和指令重排CAS 和原子类放一起讲无锁编程AQS 单独讲同步器框架。这样切的好处是每篇的边界清晰引用关系也明确——讲 AQS 时可以自然引用 synchronized 那篇里关于 Monitor 的说明。反例是按高频面试题切分比如锁的八股文合集。这种切法短期看着方便长期会越来越乱因为同一道题可能横跨好几个机制最后每篇都在重复讲基础概念。我在早期笔记里就吃过这个亏后来花了不少时间重构目录才把重复内容合并掉。4.3 一个真实的排查链路加了锁为什么还是出错这里分享一个我印象比较深的排查过程也是并发笔记里最值得记录的那类内容。现象是一段用了synchronized的计数逻辑压测时结果偶尔偏小。按常理加了锁不该出问题于是按下面的顺序逐步排查。第一步确认锁对象是否唯一。检查代码发现同步块锁的是一个成员变量而这个成员变量在某个分支里被重新赋值了。锁对象一变两个线程锁的就不是同一个东西等于没锁。这是最典型的锁错对象。第二步确认锁的范围是否覆盖了全部临界区。把同步块前后的读写操作列出来发现有一处自增写在了同步块外面虽然只差一行但并发下就是会丢更新。第三步确认是否存在锁消除。这个案例里没有但值得提一句JIT 编译器在某些情况下会判定锁对象不会逃逸出当前线程从而把锁优化掉。如果代码逻辑依赖锁的副作用比如靠锁保证顺序而锁被消除了行为就可能和预期不符。判断方法是看 JIT 编译日志里是否有锁消除相关记录。第四步确认可见性。即使锁对了、范围对了如果临界区外的读没有经过同步读到的可能是旧值。这一步需要结合 happens-before 规则去分析不能只看有没有加锁。整个链路走下来问题出在第一步和第二步的叠加。这个案例让我形成一个习惯排查并发问题先别急着怀疑 JVM先把锁对象、锁范围、可见性这三件事逐一确认。绝大多数加了锁还出错的情况都出在这三处而不是什么高深的底层机制。4.4 笔记里该记什么、不该记什么我见过不少笔记把整段源码或者大段规范原文贴进去看着很充实实际复用价值很低。并发笔记真正该记的是三类东西一是自己画过的机制图哪怕是文字版的流程描述因为画的过程就是理解的过程二是自己跑过的验证代码和输出带环境说明三是踩坑的完整链路包括当时怎么想的、怎么排除的、最后根因是什么。不该记的是那些随手能查到的定义。定义类内容查文档比翻笔记快笔记的价值在于文档里没有的个人经验。比如偏向锁在高并发下撤销频繁反而拖慢性能这种判断文档不会直接告诉你得自己踩过或者验证过才写得出来。5. 从 synchronized 延伸到并发知识体系的几个衔接点5.1 synchronized 和 volatile 的分工把 synchronized 的底层理清之后很容易顺势理解 volatile。两者经常被放在一起比较但它们的定位完全不同。synchronized 解决的是互斥问题保证同一时刻只有一个线程进入临界区顺带保证可见性volatile 解决的是可见性和有序性问题但不保证原子性。换句话说volatile 是轻量级的可见性保证synchronized 是重量级的互斥保证。一个常见的误用是用 volatile 修饰计数器以为能保证自增的线程安全。实际上count是读-改-写三步volatile 只能保证每次读到的值是最新的但三步之间仍可能被其他线程插入结果照样丢更新。这个坑我在笔记里专门标了红因为太容易犯。5.2 从 Monitor 到 AQS 的思想延续理解了 Monitor 的等待队列和唤醒机制再看 AQSAbstractQueuedSynchronizer会顺很多。AQS 本质上也是一个状态位 一个等待队列的结构只不过它把排队、唤醒、状态管理抽象成了可复用的框架让 ReentrantLock、Semaphore、CountDownLatch 这些同步器都能基于它实现。Monitor 是 JVM 内置的同步原语AQS 是 Java 层面的同步框架两者在用队列管理竞争线程这个思路上是一脉相承的。我在整理笔记时会特意在 synchronized 那篇的末尾留一个延伸阅读指向 AQS这样读者顺着读下去不会断档。这种衔接比孤立地背每个类的用法要有价值得多。5.3 锁优化的几个实用方向除了锁升级JVM 还有几项锁优化值得了解它们直接影响实际性能表现。锁消除前面提过基于逃逸分析判定锁对象不会逃逸就消除锁锁粗化是把相邻的多次加锁合并成一次减少反复加解锁的开销自适应自旋则是根据历史成功率动态调整自旋次数。这几项优化都是 JIT 在运行时做的开发者通常感知不到但了解它们有助于解释一些看起来不该快却很快或者看起来该快却很慢的现象。实际调优时我的建议是先别急着动这些参数。绝大多数业务代码的瓶颈不在锁的微观优化上而在锁的粒度和持有时间上。把一个大锁拆成几个小锁、把锁内的耗时操作挪出去收益往往比调 JVM 参数大得多。这个判断是我踩过几次调参调半天不如改代码的坑之后才形成的。6. 写在整理这些内容之后的一点个人体会做并发笔记这些年我最大的感受是底层机制的价值不在于背下来而在于它能在你排查问题时提供一条可靠的推理路径。当你遇到加了锁还出错脑子里能自动跳出锁对象、锁范围、可见性这几个检查点当你看到性能异常能想到锁升级、自旋、Monitor 阻塞这些可能因素这套知识才算真正长在身上。另外一点是关于笔记本身的。我早期追求全恨不得把每个知识点都写满结果笔记越写越厚回头看的次数却越来越少。后来改成每篇只解决一个明确问题配一段能跑的验证代码记一次真实的踩坑反而更愿意反复翻。像SynBottom.md这种以单个机制为主题的文档如果能把机制、验证、踩坑三样都写扎实它的长期价值远高于一篇大而全的综述。如果你也在整理并发相关的笔记我的建议是从一个你真正困惑过的点开始先把它验证清楚、写明白再往外扩。知识体系是长出来的不是一次性搭出来的。至于锁升级这块先把对象头和 Monitor 的关系吃透剩下的偏向、轻量、重量就是水到渠成的事。
阅读完成 · 觉得有帮助?