ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

问懵逼:请站在 JVM 角度谈谈 Java 的锁

问懵逼:请站在 JVM 角度谈谈 Java 的锁 一、写在前面为什么一定要站在 JVM 角度看锁很多 Java 开发者对锁的理解停留在应用层 API 上只知道写一个synchronized或者new ReentrantLock()然后lock()加锁、unlock()解锁。但如果你被人追问一句「synchronized 底层到底做了什么」「为什么加了锁就能保证线程安全」「偏向锁、轻量级锁、重量级锁到底是怎么切换的」很多人就答不上来了。原因在于锁本质上不是一个纯 Java 语法概念而是 JVM、操作系统、CPU 硬件三者共同协作的结果。Java 语言只提供了锁的用法和语义真正的加锁、解锁、等待、唤醒、同步、内存可见性全部由 JVM 在运行时完成。换句话说你在 Java 源码里写的synchronized编译成字节码之后长什么样JVM 又是如何解释和执行这些字节码运行时又如何借助对象头、CAS 指令、monitor 管程、操作系统互斥量来真正实现互斥这一整条链路才是「锁」的完整面貌。站在 JVM 角度重新审视锁你会发现自己之前熟悉的那套「公平锁、非公平锁、可重入锁、读写锁」只是水面之上的冰山水面之下还隐藏着更底层、也更精彩的内容对象的内存布局、Mark Word 的位设计、偏向锁批量撤销、轻量级锁的 CAS 抢锁、重量级锁的 monitor 结构、自旋与自适应自旋、锁消除与锁粗化、内存屏障、happens-before 规则、Unsafe 类中的原子操作、AQS 与 LockSupport 的 park/unpark 机制等等。本文尝试以一条清晰的脉络把「JVM 角度看锁」这件事讲透。全文适合有一定 Java 基础、希望深入理解并发底层的同学阅读。阅读完你至少能回答下面这些问题synchronized 方法的字节码和 synchronized 代码块有什么不同Java 对象头里到底存了什么Mark Word 在不同锁状态下分别是什么含义锁的四种状态无锁、偏向锁、轻量级锁、重量级锁为什么会这样设计它们分别解决什么问题锁升级是单向的还是可以降级批量重偏向和批量撤销又是什么轻量级锁和重量级锁各自的开销来自哪里为什么说轻量级锁「轻」自旋锁为什么能提升性能又为什么必须「自适应」锁消除和锁粗化是编译器的哪些优化什么场景会触发volatile 到底做了什么内存屏障如何工作ReentrantLock 底层依赖的 AQS 与 JVM 层级的 park/unpark 有什么关系实际项目中如何通过 JVM 参数和代码习惯来优化锁性能下面我们从地基开始一层一层向上搭建。二、地基并发编程的三大特性与 JMM2.1 为什么需要锁原子性、可见性、有序性在理解「锁」之前我们要先想清楚一个更根本的问题多线程环境下到底是什么出了问题才让我们需要锁答案是三个经典特性原子性、可见性、有序性。只要多个线程同时读写共享数据这三者就会被破坏锁和各种同步机制存在的意义就是把它们重新「修好」。原子性Atomicity一个操作要么全部执行完要么完全不执行中间不允许被其他线程插入。经典的i并非原子操作它实际上分成「读取 i、计算 i1、写回 i」三步。线程 A 读到 i0还没来得及写回线程 B 也读到 i0两个线程都执行 1 后写回结果是 1 而不是 2这就是原子性被破坏。可见性Visibility一个线程对共享变量的修改必须能够及时被其他线程看到。由于 CPU 有多级缓存、每个核心有自己的寄存器线程 A 修改了变量后值可能只停留在 A 所在核心的缓存中线程 B 从主存或自己的缓存读到的还是旧值。有序性Ordering程序实际执行顺序可能和代码书写顺序不一致。原因有两个层面一是编译器会在不改变单线程语义的前提下重排序指令二是 CPU 在硬件层面也会乱序执行。单线程下这种重排序没有问题但多线程下就可能导致诡异的并发 bug。2.2 JMMJava 内存模型的抽象Java 内存模型也就是 JMMJava Memory Model是 JVM 对硬件内存模型的一层抽象。它并没有真正创造一个「主内存」和「工作内存」的物理实体而是用抽象规则来规范多线程之间的内存交互保证 Java 程序在不同硬件、不同操作系统上有一致的并发语义。JMM 的核心可以概括为两点主内存Main Memory所有线程共享的内存区域保存共享变量的「官方副本」。工作内存Working Memory每个线程私有的内存区域保存该线程用到的共享变量的副本。线程对变量的所有操作都必须在工作内存中完成不能直接读写主内存。线程之间的通信必须经过主内存线程 A 把工作内存中的变量刷回主内存线程 B 再从主内存读取。这个过程就是「可见性」的抽象描述。JMM 定义了八种原子操作来刻画交互过程分别是 read、load、use、assign、store、write、lock、unlock。其中lock 和 unlock 正好对应锁的加锁与解锁这也从 JMM 层面解释了锁为什么能解决内存可见性问题。但 JMM 最重要的价值是提供了happens-before 规则。它是一组偏序关系告诉我们哪些操作的结果对哪些操作可见。常用规则包括程序顺序规则、监视器锁规则同一个锁的 unlock 先行发生于后续的 lock、volatile 变量规则、传递性规则、线程启动与终止规则等。我们在后文讲 synchronized 和 volatile 时还会反复回到这些规则。理解 JMM 是理解锁的前提锁不只是「防止两个线程同时进入临界区」它同时承担着「刷新工作内存、建立 happens-before 关系」的重任。加锁时线程会从主内存重新读取共享变量解锁时线程会把工作内存的修改刷回主内存。这正是 synchronized 块内部与外部的变量可见性契约。三、对象是锁的载体Java 对象内存布局与对象头3.1 为什么 synchronized 可以锁住任意对象在 Java 中synchronized可以作用于任意对象、任意类对象甚至一段代码块可以自己指定锁对象。你可能会好奇一个普通对象为什么有资格当「锁」锁的信息又存在哪里答案在于每个 Java 对象在堆内存中都携带一块被称为「对象头Object Header」的数据结构对象头中专门有一块区域叫 Mark Word用来存储对象的锁状态、哈希码、GC 分代年龄等信息。JVM 就是通过读写这块内存来实现锁的。因此任何对象天然就可以成为锁的载体不需要额外的锁对象包装。这与很多其他语言「锁是一个独立类型」的设计截然不同。3.2 对象内存布局的三块区域一个 Java 对象在 HotSpot 虚拟机中通常分为三部分区域作用是否一定存在对象头Object Header存储运行时数据Mark Word和类型指针Klass Pointer一定存在实例数据Instance Data存储对象内部字段的实际值包括从父类继承的字段由字段决定对齐填充Padding保证对象大小是 8 字节的整数倍便于内存管理不一定存在其中对象头又可以分为两部分Mark Word存储对象运行时的动态数据典型的长度在 32 位 JVM 中是 32 bit在 64 位 JVM 中默认是 64 bit。锁状态就记录在这里。Klass Pointer类型指针指向方法区中该类的元数据JVM 通过它判断对象属于哪个类。默认情况下64 位 JVM 会开启指针压缩-XX:UseCompressedOopsJDK 8 默认开启将其压缩到 32 bit。如果是数组对象对象头中还会有一段记录数组长度的区域。3.3 Mark Word一颗「多义复用」的钻石Mark Word 最精妙的地方在于同一块内存会根据不同的锁状态被解释成完全不同的数据结构。这是典型的「空间换时间」与「位复用」设计目的是在对象大部分时间都处于无竞争状态的情况下尽量不占用额外内存。以 64 位 JVM 为例未开启指针压缩的典型情况压缩后仍为 64 bit只是内容略有不同Mark Word 在无锁、偏向锁、轻量级锁、重量级锁四种状态下的大致布局如下锁状态Mark Word 含义无锁0125 bit 未使用 31 bit 哈希码 1 bit 未使用 4 bit 分代年龄 1 bit 偏向标志0 2 bit 锁标志01偏向锁0154 bit 持有偏向锁的线程 ID 2 bit epoch 1 bit 未使用 4 bit 分代年龄 1 bit 偏向标志1 2 bit 锁标志01轻量级锁0062 bit 指向栈中锁记录Lock Record的指针 2 bit 锁标志00重量级锁1062 bit 指向 monitor管程对象的指针 2 bit 锁标志10GC 标记11用于垃圾回收过程中的标记此时对象不能被其他线程访问从上表可以看出几个关键点锁状态由最低 2 bit 的锁标志位决定01 表示无锁或偏向锁再由前面的偏向标志位区分00 表示轻量级锁10 表示重量级锁11 表示 GC 标记。偏向锁状态下Mark Word 中存的是持有该偏向锁的线程 ID哈希码反而没有位置存储了。因此一旦对象计算过 identity hash code就无法进入偏向锁状态JVM 会直接跳过分偏向锁可用阶段反之偏向锁状态要撤销时如果对象还没有哈希码也需要额外计算一遍。轻量级锁和重量级锁状态下都存了一个「指针」轻量级锁指向线程栈中的 Lock Record重量级锁指向堆中的 monitor 对象。前者栈私有、轻量后者堆共享、重量。正是 Mark Word 的这种多义复用才支撑起了下文要讲的锁升级机制。四、从源码到字节码synchronized 的两种形态4.1 synchronized 的三种使用方式Java 中的 synchronized 有三种写法修饰实例方法锁对象是当前实例this。修饰静态方法锁对象是当前类的Class对象。修饰代码块锁对象是括号中显式指定的任意对象。虽然写法和锁对象不同但最终都要落到 JVM 的同步机制上。值得注意的是方法级别的 synchronized 和代码块级别的 synchronized 在字节码层面是完全不同的这一点很多人不清楚。4.2 synchronized 代码块monitorenter 与 monitorexit对于 synchronized 代码块编译器会在进入代码块的位置插入一条monitorenter指令在正常退出和异常退出的位置各插入一条monitorexit指令确保无论代码块正常结束还是抛出异常锁都会被释放。下面给出一个简单例子。Java 源码javapublic class LockDemo { public void block() { synchronized (this) { System.out.println(hello); } } }使用 javap 反编译后关键字节码大致如下textpublic void block(); Code: 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #2 // Field java/lang/System.out 7: ldc #3 // String hello 9: invokevirtual #4 // Method java/io/PrintStream.println 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 18: aload_1 19: monitorexit 20: aload_2 21: athrow 22: return可以看到进入时执行monitorenter正常路径在goto 22之前执行一次monitorexit异常处理器中又执行一次monitorexit。两次monitorexit保证了锁在任何情况下都能释放。4.3 synchronized 方法ACC_SYNCHRONIZED 标志synchronized 方法则不同它不会在方法体内插入monitorenter和monitorexit指令而是在方法的常量池中由一个叫作ACC_SYNCHRONIZED的访问标志来标识。JVM 在调用方法时会先检查这个标志如果设置了该标志就会隐式地获取锁实例方法获取this的锁静态方法获取 Class 对象的锁。方法执行结束后同样会隐式地释放锁。这种设计的好处是方法级别的同步不需要在每个返回路径和异常路径上插入monitorexit实现更简洁。但由于它是「方法级」锁的粒度比较粗。4.4 从指令到实现解释器与 JIT 的分工字节码层面的monitorenter、monitorexit和ACC_SYNCHRONIZED只是「代码形态」。真正执行时JVM 有两套路径解释器逐条解释字节码指令。遇到monitorenter时调用底层运行时函数完成加锁。JIT 编译器如 C2把热点代码编译成本地机器码。对于同步块JIT 会生成内联的加锁解锁代码甚至会进一步应用后文要讲的锁消除、锁粗化等优化。值得一提的是HotSpot 针对「无竞争」这一最常见场景做了极致的优化路径大多数同步块在优先考虑无锁、偏向锁、轻量级锁的情况下根本不会走到操作系统层的重量级互斥量只有在真正发生竞争且长期竞争时才会膨胀为重量级锁。下面这一章我们就进入本文最核心的部分锁升级。五、核心锁的四种状态与升级过程5.1 总体设计思想分场景分阶段逐步升级HotSpot 的锁机制经历了多轮演化。传统方式是一把 synchronized 直接对应操作系统 mutex互斥量每次加锁解锁都要陷入内核态开销巨大。但实际上大多数锁在运行过程中几乎不存在真正的线程竞争甚至很多时候锁从头到尾只有一个线程使用。如果对所有锁都动用重量级 mutex就是巨大的性能浪费。因此HotSpot 把锁状态划分为无锁、偏向锁、轻量级锁、重量级锁四种按照「先轻后重、按需升级」的思路先用成本最低的方式尝试加锁只有竞争逐步升级时才切换到更重、更昂贵的实现。5.2 无锁状态锁升级的起点所谓「无锁」并不是说对象不需要同步而是指对象当前没有成为任何一个线程的锁也没有处于偏向、轻量或重量状态。在无锁状态下对象的 Mark Word 主要保存对象自己的元信息哈希码、分代年龄、偏向标志和锁标志位。JOLJava Object Layout工具可以帮助我们直观地观察一个普通对象的对象头。下面是一个未开启偏向延迟时new Object()的大致布局textjava.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0x00000c00 12 4 (object alignment gap) Instance size: 16 bytes其中 mark word 的最低 2 bit 是 01且偏向标志位为 0表示当前处于无锁状态。一个关键细节是对象的 identity hash code 是惰性计算的。如果程序从来没有调用过System.identityHashCode(obj)对象头里就没有哈希码一旦计算过哈希码它就会被写入无锁状态的 Mark Word。也正因为哈希码会占用 Mark Word 的空间而偏向锁状态需要整段空间存线程 ID所以计算过 identity hash code 的对象不能再进入偏向锁状态。无锁状态是四种状态中成本最低的一种。但一旦有线程试图获取该对象锁JVM 就会根据对象是否可偏向、是否存在竞争把它推向偏向锁或轻量级锁。5.3 偏向锁为「单线程反复加锁」而生的优化偏向锁基于一条经验观察大多数情况下锁不仅没有竞争甚至从头到尾总是由同一个线程多次获取。如果每次加锁都要执行一次 CAS那么这次 CAS 虽然比系统调用轻很多但仍然是一种不必要的开销。偏向锁的核心思路是当第一个线程访问某个对象锁时把该线程 ID 写入对象头并把偏向标志位设为 1。之后同一线程再次进入同步块时只需要比较 Mark Word 中的线程 ID 是否还是自己不需要任何 CAS 操作也不需要进入内核态。示例代码javapublic class BiasLockDemo { public static void main(String[] args) { Object lock new Object(); for (int i 0; i 10; i) { synchronized (lock) { // 第一次进入会尝试偏向后续进入只比较线程 ID System.out.println(i); } } } }默认情况下偏向锁不会在 JVM 启动时立即生效因为 JVM 启动阶段有大量同步代码很多锁都会发生竞争立即开启偏向锁会造成频繁撤销。HotSpot 设置了BiasedLockingStartupDelay默认约 4 秒后才开始启用偏向锁可以通过-XX:BiasedLockingStartupDelay0立即开启。偏向锁的加锁过程大致如下检查 Mark Word 的偏向标志位和锁标志位确认对象处于可偏向状态。如果 Mark Word 中的线程 ID 为空则通过 CAS 将当前线程 ID 写入 Mark Word成功即获得偏向锁。如果 Mark Word 中的线程 ID 正是当前线程则直接进入临界区无需额外操作。如果 Mark Word 中的线程 ID 是其他线程说明发生了竞争JVM 需要撤销偏向锁并升级到轻量级锁或重量级锁。偏向锁虽然节省了 CAS但也不是没有代价当另一个线程真正到来时JVM 需要撤销偏向锁撤销过程必须到达安全点Safepoint并修改 Mark Word整体开销并不低。因此如果业务中锁经常被多个线程竞争开启偏向锁反而可能更慢。5.3.1 批量重偏向与批量撤销如果某个类的对象频繁发生偏向锁撤销JVM 会认为这个场景可能不适合偏向锁。为了避免重复「偏向、撤销、再偏向、再撤销」的抖动HotSpot 引入两个阈值批量重偏向阈值默认-XX:BiasedLockingBulkRebiasThreshold20。当同一个类的偏向锁撤销次数达到该阈值后JVM 认为这些对象可能还会被另一个线程访问于是后续撤销时不再直接撤销而是尝试进行重偏向。批量撤销阈值默认-XX:BiasedLockingBulkRevokeThreshold40。当撤销次数继续增加并达到该阈值后JVM 认为该类对象从根本上不适合偏向锁于是批量撤销该类所有对象的偏向能力之后这类对象创建出来也不再可偏向。理解这两个阈值可以帮助我们解释一些线上现象有时某个类的锁竞争开始频繁后行为并不是突然变慢而是经历一段「批量重偏向」再到「批量撤销」的过程。另外需要说明的是偏向锁在 JDK 15 中已经被默认关闭并标记为废弃官方给出的原因是实现复杂度很高而随着现代 JVM 和硬件的发展偏向锁带来的收益已经不如从前明显。因此在较新的 JDK 版本上默认已经没有偏向锁状态synchronized 的起点通常是无锁或轻量级锁。5.4 轻量级锁用栈上 Lock Record 避免内核态当偏向锁不可用或者对象已经发生轻微竞争但竞争持续时间很短时JVM 会尝试使用轻量级锁。轻量级锁的目标是在操作系统不介入的情况下通过 CAS 操作完成加锁和解锁从而避免内核态切换。轻量级锁的关键结构是线程栈帧中的 Lock Record。Lock Record 是一小块栈上内存存储两部分内容一是从对象头复制出来的 Mark Word称为 displaced mark word二是指向锁对象的指针。加锁过程可以概括为当前线程在自己的栈帧中创建一个 Lock Record。把对象头的 Mark Word 复制到 Lock Record 中。使用 CAS 操作把对象头中的 Mark Word 替换为指向该 Lock Record 的指针。如果 CAS 成功说明当前线程获得了轻量级锁对象头最低 2 bit 变为 00。如果 CAS 失败说明有其他线程同时竞争JVM 会进一步判断对方是否实际持有该锁如果竞争时间很短可能先尝试自旋等待如果竞争升级则膨胀为重量级锁。解锁时JVM 使用 CAS 把对象头恢复为之前复制出来的 displaced mark word。如果恢复失败说明锁已经膨胀为重量级锁此时需要走 monitorenter 对应的 monitor 释放路径。轻量级锁之所以「轻」是因为它把 64 位 Mark Word 的修改放在用户态完成不涉及系统调用也不创建堆上的重型对象。但它也有代价每次加锁解锁都要复制 Mark Word、执行 CAS当锁竞争激烈、临界区较长时CAS 会大量失败甚至把压力转移到自旋和重量级锁上整体性能反而不如直接使用重量级锁。5.5 重量级锁ObjectMonitor 与内核态阻塞当竞争变得激烈例如多个线程长时间争抢同一把锁或者有线程需要阻塞等待时锁会膨胀为重量级锁。重量级锁不再只依赖 Mark Word 和 CAS而是真正创建或复用一个 ObjectMonitor 结构该结构存在于堆中。ObjectMonitor 是 HotSpot 中实现 synchronized 重量级语义的核心数据结构它大致包含以下字段_owner指向当前持有锁的线程。_EntryList等待进入临界区的线程队列。_WaitSet调用wait()后等待被唤醒的线程队列。_recursions记录重入次数。synchronized 是可重入的同一个线程重复进入同步块时只需增加计数不需要重新抢锁。_cxq竞争队列线程抢锁失败后先进入该队列。重量级锁的加锁过程通常包含以下逻辑线程通过 CAS 尝试把_owner设置为自己成功则进入临界区。如果失败且锁已被其他线程占用则线程进入_cxq或_EntryList排队。排队线程通过操作系统的 park 机制挂起陷入内核态阻塞不再消耗 CPU。持有锁的线程退出同步块时按照一定策略唤醒队列中的后继线程。重量级锁的特点是稳定、完整支持真正的阻塞等待适合锁竞争激烈、临界区较长或线程数量较多的场景。缺点是加锁解锁成本高涉及操作系统 mutex、线程调度和上下文切换。JVM 中的自适应自旋和后续锁优化本质上都是在「低竞争」和「高竞争」之间寻找平衡。一个常见误区是认为「轻量级锁一定快、重量级锁一定慢」。实际上如果临界区很短、竞争很轻轻量级锁和自旋更优如果临界区很长、线程很多重量级锁能避免无谓的忙等反而更稳定。5.6 自旋锁与自适应自旋重量级锁的代价主要是线程阻塞和唤醒带来的上下文切换。但在很多业务中锁虽然偶尔发生竞争但临界区非常短持锁线程很快就能释放锁。此时如果竞争线程立即进入内核态阻塞可能刚挂起又马上需要被唤醒白白消耗两次上下文切换。因此 JVM 引入自旋锁当线程发现锁被占用时不立即阻塞而是在用户态执行一段空循环也就是自旋等待持锁线程释放。早期的自旋次数是固定的例如通过-XX:PreBlockSpin设置。但固定次数的问题在于如果锁很快释放自旋是划算的如果锁长时间不释放自旋只是在浪费 CPU。因此 HotSpot 后来改为自适应自旋JVM 会根据同一把锁上一次自旋是否成功来决定本次自旋次数如果上一次自旋成功获得锁说明锁通常很快释放JVM 会增加自旋次数。如果上一次自旋失败说明锁竞争时间较长JVM 会减少自旋次数甚至不再自旋直接阻塞。自适应自旋让 JVM 不用依赖固定参数能够根据运行时统计动态调整。对于热点代码这种优化收益明显但对于不可预测的低竞争场景自旋失败只会带来额外 CPU 消耗。5.7 锁升级完整路径与能否降级综合前文经典 HotSpot 中的锁状态变化路径可以概括为无锁对象尚未被任何线程用作锁。偏向锁只有一个线程反复进入Mark Word 记录该线程 ID。轻量级锁发生轻微竞争通过栈上 Lock Record 和 CAS 完成加解锁。重量级锁竞争激烈或需要阻塞等待使用 ObjectMonitor 和操作系统互斥量。需要强调的是正常情况下HotSpot 中锁升级是单向的不会从重量级锁降级回轻量级锁也不会从轻量级锁降级回偏向锁。这是因为一旦发生竞争对象头、栈结构和 monitor 的复杂关系已经建立降级需要额外的一致性维护成本过高收益有限。实际工程中我们更应该关注如何减少竞争、缩短临界区而不是指望锁自动降级。六、JVM 中的锁优化锁消除与锁粗化除了运行时的锁状态升级JVM 的 JIT 编译器还会在编译阶段对锁做一些静态优化。其中最常见的两类是锁消除和锁粗化。6.1 锁消除去掉根本不会被竞争的锁锁消除是指编译器通过逃逸分析发现某个锁对象不会被其他线程访问也就是不会发生逃逸那么即使代码中写了 synchronized这个锁也无法产生真正的竞争。此时加锁只会白白增加性能开销JIT 编译器可以直接把加锁、解锁代码消除。典型场景是局部对象被误当成共享锁使用。例如 StringBuffer 的某些方法内部使用 synchronized但在单线程环境下如果对象不会逃逸出当前方法编译器可以消除内部同步。锁消除依赖逃逸分析可通过-XX:DoEscapeAnalysis和-XX:EliminateLocks观察和配置。如果业务中存在大量的局部同步开启这些优化往往能获得明显提升。6.2 锁粗化多个连续加锁合并为一次锁粗化指的是如果一段代码中多次对同一个对象反复加锁、解锁且这些同步块之间没有其他会破坏线程安全的操作JIT 编译器可以把这些细粒度锁合并成一个更大的同步块从而减少加锁解锁次数。例如下面的循环javapublic void append(StringBuilder sb, String[] items) { for (String item : items) { synchronized (sb) { sb.append(item); } } }如果编译器判断 sb 不会逃逸或者中间没有其他线程可见的操作它可能会把 synchronized 块扩大到循环外部只加一次锁。锁粗化可以减少 CAS、复制 Mark Word 和 Lock Record 的次数尤其在循环中收益明显。6.3 锁消除与锁粗化的限制需要注意的是锁消除和锁粗化都属于编译器优化不改变 Java 语言层面的锁语义。它们只能发生在 JIT 编译器能够证明安全的情况下。如果锁对象可能逃逸或者同步块之间存在复杂的控制流、内存可见性要求编译器会保守处理保持原始加锁逻辑。因此我们不应该在源码层面主动假设这些优化一定发生而应同时保持同步代码尽可能简洁、清晰。七、volatile轻量级同步与内存屏障很多人把 volatile 和 synchronized 混为一谈但从 JVM 角度看二者差别很大。volatile 不是锁它不保证原子性但提供可见性和有序性。当一个共享变量被 volatile 修饰后对该变量的写会强制刷回主内存读会强制从主内存读取同时禁止编译器对相关读写进行重排序。7.1 volatile 的可见性可见性方面线程 A 对 volatile 变量的修改会立即对其他线程可见。它对应 JMM 中的 volatile 变量规则一个 volatile 写先行发生于后续对这个 volatile 变量的读。这意味着读到新值之后写线程在该 volatile 写之前的所有操作结果对读线程也是可见的。这也是 volatile 常被用来做「状态标志位」的原因一个线程修改标志另一个线程轮询标志只要标志是 volatile 的就能保证看到最新值。7.2 volatile 的有序性与内存屏障有序性方面volatile 通过在读写前后插入内存屏障来禁止重排序。具体来说JVM 会在 volatile 写之前插入 StoreStore 屏障在写之后插入 StoreLoad 屏障在 volatile 读之后插入 LoadLoad 和 LoadStore 屏障。这些屏障的作用是保证指令的执行顺序并让缓存一致性协议及时同步数据。从硬件角度看内存屏障会让 CPU 和缓存做出相应的同步动作从而避免「指令重排导致读到未初始化对象」这类经典问题。单例模式中的双重检查锁正是依赖 volatile 才能正确工作的。7.3 volatile 不能做什么volatile 不保证原子性。volatile int i; i仍然是「读、计算、写」三步多线程下依然可能丢失更新。需要原子性时应该使用 synchronized、Lock 或 Atomic 系列类。理解这一点才能避免把 volatile 当成万能同步手段。八、ReentrantLock、AQS 与 JVM 层级的 park/unparksynchronized 由 JVM 直接实现而 ReentrantLock 由 JDK 在 Java 层面实现两者底层都要依赖操作系统提供的阻塞与唤醒能力。8.1 AQS 的核心思想AQSAbstractQueuedSynchronizer是 JDK 并发包中锁和同步器的基础框架。它内部维护一个 volatile 的 state 变量表示同步状态以及一个 CLH 双向队列保存等待线程。获取锁失败的线程会被包装成节点加入队列并通过 LockSupport.park 挂起释放锁的线程会唤醒队列中的后继节点。ReentrantLock 的公平锁和非公平锁区别就在于新线程是否需要先检查队列中是否有更早的等待者。非公平锁允许插队吞吐更高公平锁严格按顺序延迟更稳定。8.2 park/unpark 与 JVM 的关系LockSupport.park 和 unpark 是 JDK 提供的线程阻塞与唤醒工具它们的底层通过 Unsafe 类调用 JVM 的本地方法最终对应到操作系统的线程挂起与唤醒机制。这正好与 synchronized 重量级锁中的 park 机制在底层是同源的。理解这一层后你会发现 synchronized 和 ReentrantLock 并不是两套完全独立的东西它们在「用户态 CAS 抢锁」和「内核态阻塞唤醒」这两个维度上遵循相似的思路只是 synchronized 由 JVM 内置、优化更多而 ReentrantLock 由 JDK 提供、功能更灵活例如支持超时、可中断、公平锁、多条件变量等。九、生产实践如何优化锁性能理解了锁的底层机制后实际项目中可以从以下几个方面优化锁性能缩小锁范围只保护真正需要同步的共享状态避免在锁内执行数据库操作、远程调用等慢操作。减少锁粒度使用分段锁、ConcurrentHashMap 等并发容器把一把大锁拆成多把小锁。避免无谓加锁优先使用不可变对象、ThreadLocal、局部变量减少对共享状态的依赖。选择合适的锁低竞争场景优先 synchronized需要超时、可中断、公平性时使用 ReentrantLock读多写少场景考虑读写锁或 StampedLock。关注 JVM 参数了解-XX:UseBiasedLocking、-XX:BiasedLockingStartupDelay、-XX:EliminateLocks等参数对锁行为的影响。监控锁竞争使用 JFR、Arthas、jstack 观察 BLOCKED 线程比例判断是否存在严重的锁竞争。十、总结站在 JVM 角度看锁本质上是在看一条从 Java 源码到字节码、从对象头到 Mark Word、从用户态 CAS 到内核态阻塞的完整链路。这条链路包含以下关键点锁要解决的是原子性、可见性和有序性问题JMM 提供了抽象规则和 happens-before 关系。任何 Java 对象都可以作为锁因为对象头中的 Mark Word 承载了锁状态。synchronized 代码块通过 monitorenter/monitorexit 实现synchronized 方法通过 ACC_SYNCHRONIZED 标志实现。锁有四种状态无锁、偏向锁、轻量级锁、重量级锁按竞争程度逐步升级正常情况下不降级。偏向锁面向单线程反复加锁轻量级锁用栈上 Lock Record 和 CAS 避免内核态重量级锁用 ObjectMonitor 实现真正的阻塞等待。自旋与自适应自旋在低竞争场景下避免上下文切换锁消除与锁粗化通过 JIT 编译优化进一步减少加锁开销。volatile 通过内存屏障提供可见性和有序性但不保证原子性。ReentrantLock 依赖 AQS 和 LockSupport底层与 synchronized 共享 park/unpark 能力。
返回列表