很多人学 JVM,学到垃圾回收、类加载、内存模型的时候还能跟上,一到“锁”这个章节就开始发怵。原因也简单,锁这个东西在 JVM 里不是独立存在的,它一头连着操作系统内核,一头连着 Java 对象头,中间还夹着 JIT 编译器的一堆优化,知识密度特别大。但锁又是 JVM 面试题和日常性能排查里绕不开的硬骨头,尤其是背了一堆“偏向锁会升级成轻量级锁”之类的结论,真要你说清楚在什么条件下升级,或者为什么 JDK 15 又把偏向锁给废弃了,很多人就开始含糊。
这篇文章我想按照实际排查问题的思路,把 JVM 锁这摊事从头到尾撸一遍。我不打算只讲结论,我会把锁设计的动机、升级路径上的关键判断、以及 JUC 工具和 synchronized 之间的取舍逻辑都拆开说,最后再给一份面试和实战里常见的误区清单。相信耐心读完,你对 JVM 锁的理解会有一个质的提升。
1. JVM 锁的底层博弈:互斥的代价与优化的空间
1.1 锁的本质是并发控制,但核心矛盾在于“挂起线程”太贵
先从一个最基础的问题说起:为什么 JVM 需要锁,而且需要一大堆不同类型的锁?答案很简单——因为多线程并发访问共享资源时,需要保证临界区的互斥。但这个互斥如果直接交给操作系统来实现,代价是很高的。
在 Java 里,synchronized 关键字在底层映射为 monitor 锁,依赖操作系统的 mutex 互斥量。当一个线程拿不到锁、需要被阻塞挂起的时候,操作系统要执行线程上下文切换,切换到内核态,把当前线程的 CPU 寄存器状态、程序计数器、内核栈全部保存下来,再加载另一个线程的状态。这个切换开销是非常大的,实测下来一次上下文切换通常在微秒这个量级,和一条普通 CPU 指令的纳秒级耗时相比,损耗要高出几个数量级。
这就形成了一个矛盾:我们明明只是想保护一小段共享变量的读写,结果因为锁竞争,却要付出线程挂起和唤醒的巨额代价。尤其是在锁竞争并不激烈的时候,这种代价显得极其不值。JVM 锁优化的整个演进史,本质上就是围绕“尽量减少线程挂起”这个目标展开的。
1.2 对象头:所有锁优化方案的物理载体
要理解 JVM 如何优化锁,就必须先认识对象头,因为锁的优化状态其实是记录在对象头里的。在 HotSpot 虚拟机中,Java 对象在堆内存中的存储布局分为三块:对象头、实例数据、对齐填充。其中对象头又分成两部分,第一部分叫 Mark Word,用于存储对象自身的运行时数据,比如哈希码、GC 分代年龄、锁状态标志等,第二部分是类型指针,指向方法区的类元数据,如果对象是数组,还要额外记录数组长度。
Mark Word 是一个很有意思的设计,它不是固定存储一种数据,而是根据对象状态动态复用。在 64 位 JVM 下,Mark Word 是 64 位,不同锁状态下各个位段的布局完全不同。比如无锁状态下,前 56 位存储哈希码和分代年龄,最后 2 位是锁标志位 01;偏向锁标志位是 1 + 锁标志位 01;轻量级锁状态下,前 62 位是指向栈中锁记录的指针,最后 2 位是 00;重量级锁状态下,前 62 位是指向 monitor 对象的指针,最后 2 位是 10。这 2 位的锁标志位组合,正是 JVM 判断当前锁处于什么状态的依据。
我第一次看对象头的时候觉得特别抽象,后来把它类比成共享单车的锁状态:无锁就是车没锁,谁都能骑;偏向锁是这个车记住了上一个骑它的人,只要还是这个人来骑,直接开锁走人,不用去运营后台登记;轻量级锁就是车没记住人,但骑车的人动作很快,在车前转一圈确认没有别的人在抢,就赶紧骑走;重量级锁就是这车边上围了一圈人在排队,谁来都得按顺序来。这样再去看 JVM 锁升级,会直观很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized 的锁升级之路:从偏向锁到重量级锁的完整演进
2.1 偏向锁:为“只有一个线程访问”的场景做优化
JDK 1.6 之后,HotSpot 对 synchronized 做了一系列优化,其中最先引入的是偏向锁。偏向锁的优化目标非常明确:很多情况下,一个锁不仅没人竞争,而且总是被同一个线程反复获取。比如某个同步代码块里的逻辑只被单线程执行,或者虽然方法被声明成了 synchronized,但从头到尾只有一个线程调用,这种情况下每次加锁解锁都要走 CAS 甚至互斥逻辑,纯属浪费。
偏向锁的思路是,当锁第一次被线程 A 获取时,JVM 会在 Mark Word 里记录线程 A 的 ID,相当于在锁上盖了个章“这个锁偏向线程 A”。之后线程 A 再进入同步块时,只要判断 Mark Word 里存的线程 ID 是不是自己,如果是,虚拟机就可以什么都不做,直接进入临界区,连 CAS 操作都省了。这里有一个关键细节:偏向锁的撤销并不是在获取锁时触发的,而是在另一个线程 B 尝试获取这个锁的时候才触发。如果线程 B 来了,发现锁偏向的是线程 A,才会去撤销偏向、升级成轻量级锁。
需要特别注意,偏向锁有一个已知的“小毛病”——它默认有 4 秒左右的延迟开启时间。因为 JVM 启动初期,几乎所有锁都是多线程竞争过的,如果立刻开启偏向锁,大量的偏向撤销反而会带来额外开销。这块可以通过 JVM 参数 -XX:BiasedLockingStartupDelay=0 来关闭延迟,不过在实际生产环境里我建议保持默认,没必要为了在启动阶段省这几秒去做这种调整,收益很小。
2.2 偏向撤销的批量机制:重偏向与批量撤销背后的启发
偏向锁的好处是极低开销,但它的致命弱点是怕竞争。一旦出现第二个线程尝试获取锁,偏向锁就要被撤销。而撤销过程是不能由当前持有锁的线程自己完成的,需要 JVM 等待到达一个全局安全点,暂停所有线程,再判断持有偏向锁的线程是否活着,如果已退出或者不在临界区,就尝试将这个锁重偏向为当前线程,否则升级成轻量级锁。
为了避免频繁撤销偏向锁导致性能雪崩,HotSpot 引入了批量重偏向和批量撤销机制。JVM 为每个类维护了一个偏向锁撤销计数器,当某个类的偏向锁撤销次数达到 20 次,就会触发批量重偏向,允许之后获取该锁的线程直接重偏向;达到 40 次,就会触发批量撤销,将整个类的所有实例的偏向锁直接撤销,之后这个类的新实例默认处于不可偏向状态。这个阈值可以通过 -XX:BiasedLockingBulkRebiasThreshold 和 -XX:BiasedLockingBulkRevokeThreshold 配置。
批量重偏向和批量撤销的设计给了我一个很好的启发:JVM 的优化策略不是一成不变的,而是会根据运行时数据不断调整。这一点在理解自旋锁的自适应策略时也有体现。所以后面我分析锁优化,都会带着“JVM 在运行时是怎么做决策的”这个视角去看,而不是傻记结论。
2.3 轻量级锁与自旋:竞争不激烈时的最优解
当偏向锁被撤销后,锁并不会直接升级成重量级锁,而是先进化到轻量级锁。轻量级锁的出发点是:很多锁竞争并没有那么激烈,多个线程是交替执行的,这种情况下没必要把线程挂起到内核态,完全可以靠线程在用户态自旋等待来获取锁。
我来模拟一下轻量级锁的获取过程。线程执行到同步代码块时,如果发现对象处于无锁状态,JVM 会在当前线程的栈帧中创建一块名为 Lock Record 的空间,然后把 Mark Word 复制到 Lock Record 中,这个操作叫 Displaced Mark Word。接着线程通过 CAS 尝试把对象头中的 Mark Word 替换为指向锁记录的指针。如果替换成功,说明抢锁成功,对象头中锁标志位变为 00;如果替换失败,说明有别的线程抢先了一步,当前线程就会进入自旋,反复尝试 CAS,而不是立刻挂起。
这里有一个非常容易被问到的点:自旋不是无限的,不可能一个线程拼死自旋下去浪费 CPU。JDK 6 之后引入了自适应自旋,JVM 会根据前一次在同一个锁上自旋等待的时间和锁持有者的情况,动态调整自旋次数。比如对一个锁来说,如果上次一个线程自旋成功过,JVM 判断这次自旋获取锁的概率也高,就会多旋转一会儿;如果自旋从来没成功过,那 JVM 就会飞快地跳过自旋,直接把线程挂起。这种动态调整是 JVM 锁非常高明的地方,我后面讲调优和面试题的时候会再提到。
2.4 重量级锁:不得已而为之的最终方案
如果自旋也无法获取锁,或者自旋了几轮还是失败,轻量级锁就会膨胀为重量级锁。重量级锁依赖操作系统的互斥量实现,加锁流程是:JVM 创建一个 ObjectMonitor 对象,然后把 Mark Word 的指针指向这个 monitor,锁标志位变为 10。monitor 内部维护了一个 _owner 字段标识持有锁的线程、一个 _WaitSet 队列存放调用 wait 方法后进入等待状态的线程、以及一个 _EntryList 队列存放竞争锁但还没获取到的线程。
重量级锁的优点是任何情况下都能保证互斥,即使锁竞争极其剧烈也能保证特性正确,但缺点是线程的阻塞和唤醒都需要从用户态切换到内核态,加锁解锁的耗时可以达到毫秒级。和轻量级锁的纳秒到微秒级开销相比,重量级锁的代价是高昂的。所以在实际项目中,只要发现线程大量阻塞在重量级锁的 monitor 上,基本就可以断定这块代码存在严重的锁竞争,需要从业务层面优化。
我个人在排查生产问题时,最常用来确认锁状态的工具是 jhsdb jmap --object-heap 或者配合 JFR 的事件来观察 lock contended 和 lock blocked。JDK 11 之后,JFR 已经开放了锁竞争相关的采样事件,比如 jdk.ObjectMonitorWait 和 jdk.JavaMonitorEnter,通过火焰图能看到线程到底阻塞在哪把锁上,效果非常直接。
3. 公平与非公平、可重入与互斥的 JUC 生态
3.1 AQS:Java 并发工具包的基石架构
说完 synchronized,就绕不开 JUC 包里的锁——尤其是 ReentrantLock。JUC 锁的核心不是直接操作对象头,而是基于 AbstractQueuedSynchronizer,也就是 AQS 框架。AQS 的设计精髓在于它用 Java 代码自己管理一个等待队列,而不是直接依赖操作系统的线程挂起机制。
AQS 内部维护了一个 volatile 修饰的 int 类型成员变量 state,以及一个 CLH 队列的变体——一个双向链表。state 的含义是共享资源被占用的状态,比如 ReentrantLock 中 state 等于 0 表示锁空闲,大于 0 表示锁被占用,并且数字还记录了重入次数。线程请求锁的时候,先尝试通过 CAS 修改 state,修改成功就直接拿到锁,失败则把当前线程封装成 Node 节点,加入队列尾部,然后调用 LockSupport.park 使线程进入等待状态。
AQS 框架的很多模板方法设计得非常巧妙,比如它把“尝试获取锁”和“尝试释放锁”设计成 protected 方法,交给子类实现;把“排队”“阻塞”“唤醒”这些固定流程设计成模板方法,子类无法修改。这样一套骨架能让 ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 都复用同一个队列机制。理解了 AQS,你再看这些工具类的源码,会发现它们只是定义了对 state 的不同解释方式,背后的排队机制完全一样。这也是为什么源码面试里 AQS 永远是被问最细的部分。
3.2 公平锁与非公平锁:排序逻辑的两套处理方式
JUC 锁和 synchronized 有一个很大的不同,就是可以控制公平性。以 ReentrantLock 为例,构造函数传 true 就是公平锁,传 false (默认) 就是非公平锁,两者在 AQS 里只差一个判断。
非公平锁的加锁逻辑是:线程进来先直接尝试 CAS 把 state 从 0 改成 1,不管队列里有没有其他线程在排队,抢到了就直接入主,抢不到才去排队。公平锁则不同,线程会先检查队列里有没有前驱节点,只要有前驱节点在排队,就老老实实去排队,不抢。
有人可能会有疑问:既然公平锁更公平,为什么默认反而用非公平锁?答案在于性能。非公平锁这么设计,可以减少线程切换带来的开销。如果刚好在锁释放的时刻有一个新线程请求锁,让它直接拿到锁是很划算的,因为排队队列里那个线程可能还需要被唤醒、上下文切换,而当前线程正处于活跃状态,立刻执行效率更高。当然,非公平锁的代价是可能导致某些排队线程饿死——比如锁一直被新来的线程截胡。不过在实际场景里,这种极端情况很少见,加上非公平锁吞吐量明显更高,所以默认选择非公平锁是用吞吐换公平的典型体现。
3.3 synchronized 和 ReentrantLock 的抉择:从偏向锁废弃说起
很早以前,网上流传着“synchronized 性能比 ReentrantLock 差”的说法,这是基于 JDK 1.5 的旧视角。JDK 1.6 对 synchronized 做了大量优化之后,在竞争不激烈的情况下 synchronized 的性能已经几乎不输甚至优于 ReentrantLock,因为 synchronized 底层有偏向锁和轻量级锁两级用户态优化,而 ReentrantLock 一上来就要走 AQS 的 CAS 逻辑。
那实际项目里该怎么选择?我个人的经验是:优先用 synchronized。原因很简单,synchronized 语法简洁、不会忘记释放锁、语义清晰。只有在需要尝试非阻塞获取锁、可中断获取锁、超时获取锁,或者明确要求公平锁,再或者实现读写分离场景时,才会去用 ReentrantLock 和 ReentrantReadWriteLock。从 JDK 8 的长期支持版本看,synchronized 依然是并发编程的第一选择,别被很多老博客带偏了。
顺带提一句偏向锁废弃的原因。JDK 15 里 JEP 374 正式废弃偏向锁,原因是偏向锁的维护和撤销成本已经超过了它带来的收益。现代应用的线程数量越来越多,锁竞争模式更加复杂,加上 JUC 包的广泛使用,偏向锁在新场景下反而成了拖累。从 JDK 15 开始,我们把偏向锁作为一个已经被淘汰的优化机制去理解就好,重点应该放在轻量级锁和重量级锁上。面试时如果被问到偏向锁,能说出它的适用场景和为什么被废弃,其实是加分项。
4. 锁的编译器级优化:锁消除与锁粗化背后的哲学
4.1 逃逸分析和锁消除:你以为有锁,其实没有锁
JVM 锁优化的维度不只是运行时和操作系统,编译期也有大量工作。典型的就是锁消除。锁消除的核心是逃逸分析,JIT 编译器会分析对象的作用域,如果判断一个对象不会逃逸出方法,也就是没有别的线程能访问到这个对象,那么这个对象上的锁操作就是没有意义的,可以直接消除。
最经典的例子是 StringBuffer 的 append 方法,它是 synchronized 的。如果一个 StringBuffer 对象只在方法内部使用,没有被返回、没有被存储到堆上,那 JVM 就会在 JIT 编译时把这个方法的锁消除掉。类似的情况还有 Vector 的某些方法,在单线程环境下 JIT 也可能消除掉不必要的锁。
我印象最深的是很多人的业务代码里写了一个加锁方法,在方法内部 new 了局部变量,还在局部变量上加锁,然后自以为实现了线程安全。其实 JVM 早就帮他们把锁删掉了。这种优化在绝大多数情况下是好事,但也给排查问题带来了一个表层陷阱:你以为某个同步块在执行加锁逻辑,实际上它在 JIT 编译后可能根本就是无锁的,一旦后续代码被优化得超出预期,可能会导致线上表现和逻辑设计的不一致。
4.2 锁粗化:把多次加锁合并成一次加锁
锁消除优化的是“多余的锁”,锁粗化优化的是“频繁加锁”。如果一个代码块里连续多次对同一个锁对象进行加锁解锁,或者在循环体内部反复加锁而锁的粒度又很粗,JIT 会把这些加锁解锁操作合并成一个更大范围的加锁操作,这就是锁粗化。
举个例子,一个循环内部频繁调用某个 synchronized 方法,虽然每次调用之间临界区并不长,但反复加锁解锁的累计开销是很大的。JIT 可能会把这个循环体整体作为一个同步块来编译,让锁只加一次。锁粗化背后的逻辑是:加锁解锁也有固定开销,与其在做 100 次无用功,不如一次把活干完。这是典型的“用更长的临界区换更少的加锁次数”的取舍。
不过锁粗化和我们写代码时强调的降低锁粒度是两个方向,并不是矛盾。锁粗化由 JIT 决定,我们没法直接控制;但我们在编写代码时可以尽量避免在一个循环内反复加锁——与其期待 JIT 帮我们合并,不如从源头把锁外提。日常开发中写 synchronized 的时候,可以考虑把循环体放到同步块外面,只保留真正需要保护的写操作在临界区内。
4.3 栈上分配与标量替换:对象都不存在了,锁自然也消失了
聊到逃逸分析,顺带把栈上分配和锁消除放到一起说。如果对象不逃逸出方法,JVM 在 JIT 编译时有两种优化路径:第一,把这个对象分配在栈上而不是堆上,随着方法栈帧弹出而自动销毁,完全不给 GC 制造压力;第二,标量替换,把对象拆解成一个个原始数据类型字段,直接使用寄存器或栈上变量来替代,对象实体直接不存在了,锁对象也没有了,锁消除自然也就生效了。
很多人测试 synchronized 性能的时候容易忽略这些优化,在最简单的场景里 new 一个 Object 当锁、在循环里跑几百万次,然后误认为 synchronized 开销很低。实际上你测到的只是 JIT 优化后的结果,对象直接被栈上分配或者标量替换掉了,锁也被消除了,当然快。这不算坏事,但测试结论不能轻率地推广到所有场景。真正有竞争、共享对象逃逸到多个线程的锁,开销依然非常可观。
这也是我想提醒大家的一点:性能测试一定要尽量贴近真实场景,避免在过于简化的基准测试里得出错误结论,尤其是并发性能这类对上下文极其敏感的场景。
5. 锁粒度决策与实战避坑:不是在代码里加 synchronized 就算并发安全
5.1 类锁、对象锁与各种花式错误的锁法
接下来聊点实战里的琐碎事。很多人在写并发代码时,对锁的选择是有问题的。一个最常见的认知误区是:以为随便选一个对象做锁就能保证线程安全。这里有一个非常隐蔽的坑:如果是用 String 做锁对象,比如 synchronized ("lock"),而这段代码如果经过了字符串字面量的 intern 机制,那锁的就是常量池里同一个对象。万一项目里有其他模块也用了这个字符串做锁,就会出现两个毫不相干的线程互相阻塞的情况。
另一个高频错误是使用 Integer、Long 这类包装类做锁。看似安全的写法 synchronized (count),实际上是每次对 count 重新赋值都会创建新的 Integer 对象,锁对象一直在变,根本起不到互斥作用。我在排查一个订单超卖问题时见过这种代码——服务端开了 8 个线程,用 synchronized (id) 保护库存扣减,id 是 Long 类型的,结果每次 id 变化,锁对象就变了,并发控制完全失效。后来改成 ReentrantLock 并保证锁对象和资源一一对应,问题才解决。
还有一个更容易被人忽视的是类锁和对象锁的区别。synchronized 修饰静态方法锁的是类的 Class 对象,修饰实例方法锁的是当前实例,两者互不相干。如果某个同步方法在多个实例上并发执行,而共享资源从设计上应该是全局唯一的,那实例对象锁根本护不住,必须用静态锁或者一个全局的锁对象。
5.2 锁粒度的设计:从 synchronized 方法到分段锁
锁粒度选择是并发性能设计的核心问题之一。锁粒度太粗,会导致大量线程都阻塞在同一个锁上,吞吐量直线下降;锁粒度太细,加锁次数变多,锁本身的管理开销变大,而且代码复杂度暴增。实际项目中,这需要我们对临界区的大小和访问频率做细致评估。
一个敏感性很高的实际案例是缓存数据结构的选择。假设使用同步的 HashMap 对缓存做读写,那么所有读写操作都在一把锁上竞争,并发性能非常糟糕。换成 ConcurrentHashMap 后,JDK 8 采取的是 CAS + synchronized 对每个 bucket 加锁的方式,不同 key 的读写基本互不影响,锁的粒度被控制到了尽可能小的范围。这种“分段锁/分桶锁”思路不仅在 JUC 里大量使用,在数据库分库分表层面也有类似的哲学。
但并不是所有场景都适合细粒度锁。如果临界区非常短,只是对几个变量做加减,那么一个锁就够了;如果临界区涉及外部 IO、网络请求、比较耗时的计算,那把锁粒度缩到最小反而让频繁加锁解锁成为瓶颈。可以参考我们对 Redis 操作的实践:如果多个 Redis 命令之间存在一致性要求,与其在业务代码里对每一步都加锁,不如用 Lua 脚本把几条命令合并成一个原子操作。锁的设计本质上是对“共享数据的访问频率、临界区耗时、冲突概率”三个因素的权衡,一定要结合业务特征来取舍。
5.3 死锁、活锁与锁超时:常见并发故障的定位思路
谈 JVM 锁,必须把死锁问题拿出来说。死锁的四个必要条件——互斥、占有且等待、不可抢占、循环等待——相信大家都背过,但真正在线上定位死锁时,很多新手还是无从下手。
JVM 自带的线程转储工具 jstack 就是定位死锁的利器。当怀疑发生死锁时,执行 jstack -l <pid>,把 Java 进程的线程快照导出来,查看等待锁的线程信息。如果是死锁,jstack 会在末尾明确提示检测到死锁,并列出循环等待的资源链。比如线程 A 持有锁 L1 在等待 L2,线程 B 持有锁 L2 在等待 L1,JVM 就会输出 Found one Java-level deadlock 以及详细线程信息。配合线程的状态,如果大批线程处于 BLOCKED 状态且长时间异常堆积,基本可以断定死锁发生了。
面对死锁,除了事后排查,更理想的是在设计阶段规避。例如多个同步块嵌套时,始终按固定的顺序获取多个锁,避免交叉;使用 tryLock 加锁,配合超时重试,让获取不到锁的线程放弃而不是一直等待;拆分过大的临界区,减少锁内耗时。这些手段不能杜绝死锁,但能显著降低发生概率。
另外还要分清锁等待与锁阻塞。线程阻塞在进入 synchronized 时,状态是 BLOCKED;通过 LockSupport.park 进入等待时,状态是 WAITING 或 TIMED_WAITING。定位问题时这两者性质完全不同,前者大概率是锁竞争激烈,后者可能是线程间通信设计不合理或者忘记调用 signal 唤醒了。
6. 面试高频题与实战排查的最后一公里
6.1 锁相关面试题里最容易丢分的几个细节
结合这些年的面试经历,我把 JVM 锁相关的高频题整理了一份速查表,每个问题背后都藏着容易踩的坑。
第一个必问题:synchronized 是可重入的吗?答案是肯定的。synchronized 的重入靠的是监视器锁对持有者线程的识别,同一个线程再次进入自己持有锁的同步块时,不会阻塞。这也是为什么 ReentrantLock 默认就支持重入,而 Semaphore 这样的计数信号量需要自己处理重入逻辑。
第二个高频问题:sleep 会释放锁吗?答案是不会。Thread.sleep 在持有锁期间调用,只是让线程进入 TIMED_WAITING 状态,锁依然由当前线程持有,其他线程只能继续等待。这和 wait 方法完全不同,wait 会释放锁,让其他线程有机会进入临界区。很多并发 Bug 恰恰是因为把 sleep 当 wait 使,导致线程明明在睡觉,却把整把锁锁死了。
第三个必须知道的是:锁的升级方向是单向的,只能从无锁升级为偏向锁、再升级为轻量级锁、再升级为重量级锁,而不会反向降级。这也就意味着一旦锁升级为重量级锁,即使后续竞争消失,它也会一直维持重量级锁状态,直到对象被回收。这解释了为什么某些频繁被多线程访问的锁对象,即使在单线程阶段也会有性能下降。
我把这些细节整理成了一张速查表,方便大家面试前快速回顾:
| 问题方向 | 核心要点 | 常见误区 |
|---|---|---|
| synchronized 可重入性 | 同一线程可多次获取同一把锁 | 误认为需要手动记录重入次数 |
| sleep vs wait | sleep 不释放锁;wait 释放锁 | 误将 sleep 当作释放锁的手段 |
| 锁升级方向 | 只升不降 | 误以为竞争消失后锁会自动降级 |
| 偏向锁废弃 | JDK 15 起废弃 | 误以为偏向锁仍适合现代应用 |
| 自旋锁 | 默认开启,自适应调整 | 误以为自旋次数固定 |
| 公平锁 | ReentrantLock 可选 | 误以为公平锁吞吐一定更高 |
6.2 一块完整的锁性能排查实操模板
最后,给出一套我在日常项目中会使用的锁性能排查流程,大家可以拿去直接落地。先使用 jps -l 找到目标 Java 进程的 PID,然后执行 jstack -l <pid> > thread_dump.txt 采集线程快照,重点观察处于 BLOCKED 和 WAITING 状态的线程数量与等待的锁对象。如果 BLOCKED 线程数量多,再执行 jcmd <pid> Thread.print -l 做二次确认,并可以借助 JFR 记录一段时间的锁竞争事件。
采集到足够数据之后,用火焰图呈现锁等待的调用栈,能最快定位到瓶颈代码。理想情况下,你会看到大量线程在同一个方法的同一行 synchronized 上堆积,这就是一个非常明确的信号:锁竞争战场就在这里。接下来的优化思路从三个方向展开,第一,能不能减少临界区大小,比如只对真正需要保护的写操作加锁;第二,能不能降低锁的持有时间,比如把耗时的 IO 操作移出临界区;第三,能不能用无锁的数据结构或者读写锁替代粗粒度独占锁,比如使用 LongAdder 替代 AtomicLong、用 ConcurrentHashMap 替代同步的 HashMap。
6.3 关于 JVM 锁优化的最后一点心得
写到最后,我想起自己刚入门并发编程时,也曾经陷入过一个思维误区:以为并发安全的唯一办法就是加大锁的范围和持有时间,后来在线上被锁竞争导致的超时告警教育了很多次,才真正理解“锁是最后的方案,而不是第一选择”。
在实际开发中,判断一处代码该不该加锁,我会先问自己三个问题:这个共享数据真的需要多线程同时访问吗?能不能通过线程封闭、不可变设计或者 CopyOnWrite 来规避共享?如果必须要加锁,能不能用 CAS 或者原子类来替代独占锁?这个思考顺序帮我减少了很多不必要的锁竞争,也让代码的并发能力和可维护性都有了明显提升。锁优化是 JVM 和操作系统层面的艺术,但业务层面的模型设计可能是更值得优先优化的地方。
