解密synchronized:从对象头到内存屏障,搞懂Java锁的底层原理

张开发
2026/4/4 16:02:49 15 分钟阅读
解密synchronized:从对象头到内存屏障,搞懂Java锁的底层原理
一、引言在 Java 并发编程的领域中多线程环境下的数据一致性和线程安全是至关重要的课题。而 synchronized 关键字作为 Java 语言提供的内置同步机制就如同一位忠诚的守护者在保障多线程安全方面发挥着不可或缺的作用。自 JDK 1.0 诞生以来synchronized 就一直是 Java 并发编程的基础工具历经多个版本的迭代与优化从曾经被认为是 “性能杀手”逐步进化为如今智能高效的锁机制 其重要性不言而喻。它就像是多线程编程世界里的基石为众多开发者构建线程安全的程序提供了坚实的支撑。尽管在 JDK 1.5 之后诸如 ReentrantLock 等更高级的并发工具相继出现在某些特定场景下能够提供更灵活、高效的同步控制但 synchronized 凭借其简单易用、语义清晰以及 JVM 层面的深度优化等特性依然在 Java 并发编程中占据着举足轻重的地位。无论是在日常的业务开发还是在一些对性能和稳定性要求极高的系统中synchronized 的身影随处可见。例如在银行账户的余额更新操作中多个线程可能同时尝试对账户余额进行增减如果没有合适的同步机制就可能导致余额数据的不一致。此时synchronized 就可以发挥作用确保同一时刻只有一个线程能够执行余额更新操作从而保证数据的准确性和一致性。然而在实际应用中很多开发者仅仅停留在会使用 synchronized 关键字的层面对其底层原理知之甚少。当面对复杂的并发场景和性能优化问题时这种一知半解往往会成为解决问题的阻碍。例如在高并发环境下频繁地使用 synchronized 可能会导致线程阻塞和性能下降但如果不了解其底层原理就很难找到有效的优化策略。因此深入探究 synchronized 的底层原理不仅能够帮助我们更加精准地使用这一强大的工具在编写多线程代码时避免潜在的风险和问题还能让我们在面对性能瓶颈时能够从根源上进行分析和优化提升系统的整体性能和稳定性。接下来就让我们揭开 synchronized 神秘的面纱从对象头、Monitor、内存屏障等多个关键角度深入剖析其底层实现机制搞懂 Java 锁的底层原理。二、synchronized 基础回顾2.1 用法介绍在 Java 中synchronized 关键字有三种常见的用法分别是修饰实例方法、静态方法和代码块它们在实现线程同步时各有特点和适用场景。修饰实例方法当 synchronized 修饰一个实例方法时它锁定的是当前对象this。这意味着对于同一个对象实例在同一时刻只能有一个线程能够进入并执行该同步实例方法其他线程必须等待该线程执行完毕并释放锁后才有机会获取锁并执行方法。这种方式适用于对对象的实例变量进行同步访问的场景确保多个线程对实例变量的操作是线程安全的。例如public class InstanceSyncDemo { private int count 0; // 修饰实例方法锁是当前对象this public synchronized void increment() { count; System.out.println(Thread.currentThread().getName() - count: count); } public static void main(String[] args) { InstanceSyncDemo demo new InstanceSyncDemo(); // 创建5个线程并发调用increment方法 for (int i 0; i 5; i) { new Thread(() - { demo.increment(); }, Thread- i).start(); } } }在上述代码中increment方法被synchronized修饰当多个线程尝试调用demo.increment()时由于锁的存在它们会依次执行不会出现竞态条件导致count变量的更新错误。修饰静态方法当 synchronized 修饰静态方法时它锁定的是当前类的 Class 对象。因为静态方法属于类而不是类的实例所以无论创建多少个类的实例对象在同一时刻只能有一个线程能够进入并执行该同步静态方法。这种方式适用于对类的静态变量或全局资源进行同步访问的场景保证所有实例对这些资源的操作是线程安全的。例如public class StaticSyncDemo { private static int total 0; // 修饰静态方法锁是当前类的Class对象 public static synchronized void addTotal() { total; System.out.println(Thread.currentThread().getName() - total: total); } public static void main(String[] args) { // 创建5个线程并发调用addTotal方法 for (int i 0; i 5; i) { new Thread(() - { StaticSyncDemo.addTotal(); }, Thread- i).start(); } } }在这段代码中addTotal方法是静态同步方法多个线程调用StaticSyncDemo.addTotal()时会竞争类的 Class 对象锁从而保证了total静态变量的线程安全更新。修饰代码块synchronized 修饰代码块时可以更加灵活地控制锁的范围和锁对象。它可以指定任意对象作为锁当线程进入同步代码块时会获取指定对象的锁执行完代码块后释放锁。这种方式适用于只需要对部分代码进行同步或者需要针对不同的资源使用不同锁的场景能够有效减少锁的粒度提高并发性能。例如public class BlockSyncDemo { private Object lock new Object(); private ListString list new ArrayList(); public void addElement(String element) { // 非同步操作可以并行执行 System.out.println(Thread.currentThread().getName() is preparing to add element...); // 同步代码块锁是lock对象 synchronized (lock) { list.add(element); System.out.println(Thread.currentThread().getName() added element: element); } // 非同步操作可以并行执行 System.out.println(Thread.currentThread().getName() finished adding element.); } public static void main(String[] args) { BlockSyncDemo demo new BlockSyncDemo(); // 创建5个线程并发调用addElement方法 for (int i 0; i 5; i) { new Thread(() - { demo.addElement(Element- i); }, Thread- i).start(); } } }在上述代码中addElement方法中的同步代码块使用lock对象作为锁只有获取到lock锁的线程才能执行代码块内的list.add(element)操作保证了list集合的线程安全操作而方法中的其他非同步代码可以并行执行提高了程序的并发性能。2.2 作用概述synchronized 关键字在多线程编程中起着至关重要的作用其核心作用是保证多线程环境下的原子性、可见性和有序性从而有效解决数据竞争和线程安全问题。原子性synchronized 保证了被其修饰的代码块或方法在同一时刻只能被一个线程执行即一个线程在执行同步代码时其他线程无法中断它从而确保了操作的原子性。例如对于count这样的复合操作在多线程环境下如果不进行同步可能会出现数据不一致的情况但使用 synchronized 修饰包含count的方法或代码块后就可以保证这个操作是原子的要么完整执行要么不执行 。可见性当一个线程释放 synchronized 锁时会将其工作内存中的变量值刷新到主内存中而当另一个线程获取到该锁时会从主内存中读取最新的变量值。这就保证了不同线程之间对共享变量的可见性使得一个线程对共享变量的修改能够及时被其他线程看到。例如在一个多线程程序中线程 A 修改了共享变量data并释放了锁那么线程 B 在获取锁后就能够读取到线程 A 修改后的data值。有序性synchronized 通过内存屏障和 happens-before 规则保证了一定程度的有序性。它确保了在释放锁之前的所有操作对于随后获取同一把锁的线程来说都是可见的并且这些操作不会被重排序到锁的范围之外。例如线程 A 在持有锁的情况下执行了操作 1 和操作 2然后释放锁线程 B 获取锁后一定能按照线程 A 执行的顺序看到操作 1 和操作 2 的结果 。通过保证原子性、可见性和有序性synchronized 有效地解决了多线程环境下的数据竞争问题确保了程序在并发场景下的正确性和稳定性。无论是在简单的多线程数据访问还是复杂的并发业务逻辑处理中synchronized 都发挥着不可或缺的作用为开发者提供了一种简单而强大的线程同步机制。三、深入底层对象头与 Monitor3.1 Java 对象内存布局在 Java 中对象在内存中的布局由三个主要部分组成对象头Object Header、实例数据Instance Data和对齐填充Padding。理解这些组成部分对于深入掌握 synchronized 的底层原理至关重要。对象头对象头是对象在内存中的起始部分它包含了两部分关键信息Mark Word 和类型指针Klass Pointer。如果对象是数组还会额外包含一个记录数组长度的字段。Mark Word 用于存储对象的运行时数据如哈希码hashCode、GC 分代年龄、锁状态标志位等这些信息会根据对象的状态动态变化。类型指针则指向对象所属类的元数据JVM 通过它来确定对象的类型。在 64 位 JVM 中默认开启指针压缩-XX:UseCompressedClassPointers时类型指针占 4 字节未开启时占 8 字节 而 Mark Word 固定占 8 字节。实例数据这部分存储了对象的所有成员变量包括从父类继承而来的变量。其存储顺序遵循一定的规则基本类型变量按照它们的大小和声明顺序进行排列相同宽度的字段会尽量分配在一起以提高内存访问效率。父类的字段会排在子类字段之前。引用类型变量在开启指针压缩时占 4 字节未开启时占 8 字节。对齐填充由于 HotSpot JVM 要求对象的大小必须是 8 字节的整数倍当对象头和实例数据的总大小不是 8 的倍数时就需要通过对齐填充来补足。这部分填充数据本身不存储任何有效信息只是为了满足内存对齐的要求确保对象在内存中的访问效率 。例如一个简单的 Java 类User包含一个int类型的id和一个String类型的namepublic class User { private int id; private String name; public User(int id, String name) { this.id id; this.name name; } }在 64 位 JVM 且开启指针压缩的情况下User对象的内存布局如下对象头Mark Word 8 字节 类型指针 4 字节 12 字节实例数据int类型的id4 字节 String引用类型 4 字节 8 字节总大小为 20 字节不是 8 的倍数因此需要 4 字节的对齐填充最终User对象占用 24 字节的内存空间 。对象头中的 Mark Word 对于 synchronized 锁机制起着核心作用它存储的锁状态标志位等信息直接决定了对象当前的锁状态进而影响着线程对对象的访问方式和同步控制 。3.2 对象头中的 Mark WordMark Word 是 Java 对象头中极为关键的部分它在 64 位 JVM 中占 8 字节以紧凑的方式存储了对象的多种运行时数据这些数据会根据对象的状态动态变化尤其是与锁相关的信息在 synchronized 锁机制中扮演着核心角色。在不同的锁状态下Mark Word 的结构和存储内容各不相同无锁状态此时 Mark Word 存储对象的哈希码hashCode、分代年龄用于垃圾回收以及锁状态标志位值为 01。其中哈希码是对象的一个标识在对象调用hashCode()方法时生成并存储在此分代年龄则记录了对象经历垃圾回收的次数用于判断对象是否应该晋升到老年代。偏向锁状态当对象处于偏向锁状态时Mark Word 存储偏向线程 ID、偏向时间戳、分代年龄以及锁状态标志位值为 01此时表示偏向锁。偏向锁的设计目的是为了优化只有一个线程频繁访问同步块的场景它会偏向于第一个获取锁的线程在后续该线程再次访问时只需简单判断线程 ID 是否一致无需进行复杂的加锁解锁操作从而提高性能。轻量级锁状态轻量级锁适用于线程交替执行同步块的场景。在这种状态下Mark Word 存储指向当前线程栈中锁记录Lock Record的指针以及锁状态标志位值为 00。当线程进入同步块时如果发现对象处于无锁状态会通过 CASCompare and Swap操作尝试将 Mark Word 替换为指向自己栈中锁记录的指针若成功则获取到轻量级锁。重量级锁状态当出现多个线程竞争锁且轻量级锁无法满足需求时锁会膨胀升级为重量级锁。此时 Mark Word 存储指向 Monitor 对象的指针以及锁状态标志位值为 10。Monitor 是 Java 虚拟机实现的一种同步机制它负责管理线程的阻塞和唤醒当线程竞争重量级锁失败时会被放入 Monitor 的等待队列中等待锁的释放 。以一个简单的对象Object obj new Object();为例在初始状态下它处于无锁状态Mark Word 存储着对象的哈希码和分代年龄等信息。当一个线程首次访问synchronized(obj)代码块时如果偏向锁开启JDK 1.6 及以后默认开启对象会进入偏向锁状态Mark Word 中记录下该线程的 ID。若此时有另一个线程也尝试访问该同步块就会发生锁竞争偏向锁失效锁升级为轻量级锁Mark Word 的内容也相应变为指向新线程栈中锁记录的指针。如果竞争进一步加剧轻量级锁自旋一定次数后仍无法获取锁就会升级为重量级锁Mark Word 指向 Monitor 对象 。Mark Word 的这种动态变化机制使得 Java 虚拟机能够根据不同的竞争情况灵活地选择合适的锁策略从而在保证线程安全的前提下尽可能地提高性能。3.3 Monitor锁的调度中心Monitor常被译为监视器或管程是 Java 实现线程同步的核心机制之一它就像是一个锁的调度中心协调着多个线程对共享资源的访问。每个 Java 对象都可以关联一个 Monitor 对象当使用 synchronized 关键字对对象进行加锁尤其是重量级锁时对象头的 Mark Word 会指向 Monitor 对象的引用地址 。Monitor 的核心结构包含以下几个关键部分_owner指向当前持有锁的线程。初始时为 NULL表示当前没有任何线程拥有该 Monitor。当一个线程成功获取到锁时_owner 就会被设置为该线程的引用当锁被释放时_owner 又会被重置为 NULL 。_count用于记录锁的重入次数。当一个线程再次进入已经持有锁的同步块时_count 会递增当线程退出同步块时_count 递减。只有当_count 为 0 时锁才会被完全释放 。_WaitSet这是一个等待集合当获取到锁的线程调用了对象的 wait () 方法时该线程会释放锁并进入_WaitSet 等待。在等待期间线程会被阻塞直到其他线程调用 notify () 或 notifyAll () 方法唤醒它 。_EntryList这是一个竞争集合存放着等待获取锁的线程。当一个线程尝试获取被其他线程持有的锁时如果获取失败就会进入_EntryList 等待处于这个队列中的线程会不断尝试竞争锁直到获取成功 。其工作原理如下当一个线程访问被 synchronized 修饰的代码块时它首先尝试获取对象关联的 Monitor 的锁。如果此时_owner 为 NULL说明没有其他线程持有锁该线程可以成功获取锁并将_owner 设置为自己同时_count 置为 1 。如果_owner 不为 NULL即锁已被其他线程持有那么当前线程会进入_EntryList 等待。当持有锁的线程执行完同步代码块释放锁时会检查_WaitSet 中是否有等待的线程。如果有会选择一个线程使用 notify () 方法或全部线程使用 notifyAll () 方法将其从_WaitSet 移动到_EntryList这些线程将有机会重新竞争锁 。例如假设有三个线程 ThreadA、ThreadB 和 ThreadC都试图访问同一个被 synchronized 修饰的代码块。ThreadA 首先获取到锁此时_owner 指向 ThreadA_count 为 1。ThreadB 和 ThreadC 随后尝试获取锁但由于 ThreadA 持有锁它们会进入_EntryList 等待。如果 ThreadA 在同步块中调用了 wait () 方法它会释放锁_owner 变为 NULL_count 为 0ThreadA 进入_WaitSet 等待。此时ThreadB 和 ThreadC 有机会竞争锁假设 ThreadB 竞争成功_owner 指向 ThreadB_count 为 1。如果 ThreadB 调用 notify () 方法ThreadA 会从_WaitSet 被唤醒并进入_EntryList再次参与锁的竞争 。Monitor 通过这种方式有效地管理了线程的同步访问确保了在多线程环境下共享资源的安全访问。3.4 代码示例对象头与 Monitor 的关系为了更直观地理解对象头与 Monitor 在 synchronized 机制中的关系我们通过一段代码示例并借助 jol-core 库来查看对象头信息在加锁前后的变化。首先引入 jol-core 库的依赖dependency groupIdorg.openjdk.jol/groupId artifactIdjol-core/artifactId version0.17/version /dependency然后编写如下代码import org.openjdk.jol.info.ClassLayout; public class SynchronizedObjectHeaderDemo { public static void main(String[] args) throws InterruptedException { Object lock new Object(); // 查看初始状态下对象头信息 System.out.println(初始状态下对象头信息); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); synchronized (lock) { // 查看加锁后对象头信息 System.out.println(\n加锁后对象头信息); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } // 查看解锁后对象头信息 System.out.println(\n解锁后对象头信息); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); } }在上述代码中我们创建了一个 Object 对象lock并在三个阶段分别打印其对象头信息初始状态、加锁后以及解锁后。运行代码输出结果类似如下初始状态下对象头信息 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal 4 bytes external 4 bytes total 加锁后对象头信息 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 4 (object header) 00 f0 0b 00 (00000000 11110000 00001011 00000000) (112640) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal 4 bytes external 4 bytes total 解锁后对象头信息 java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal 4 bytes external 4 bytes total从输出结果可以看出初始状态下对象头的 Mark Word 处于无锁状态。当进入 synchronized 代码块加锁后Mark Word 的内容发生了变化表明锁状态已改变此时 Mark Word 指向了与 Monitor 相关的信息具体值根据实际情况而定 。当退出 synchronized 代码块解锁后Mark Word 又恢复到了无锁状态 。结合 Monitor 原理分析当线程进入 synchronized (lock) 代码块时会尝试获取lock对象关联的 Monitor 的锁。如果获取成功对象头的 Mark Word 会记录相关的锁信息指向 Monitor 对象。在同步块执行期间其他线程若尝试获取锁会进入 Monitor 的_EntryList 等待。当线程执行完同步块释放锁时Mark Word 的锁信息被清除恢复到无锁状态同时 Monitor 会根据_WaitSet 和_EntryList 的情况决定是否唤醒其他等待的线程 。通过这个示例我们可以清晰地看到对象头与 Monitor 在 synchronized 机制中的紧密协作关系。四、锁升级从 “温柔” 到 “强硬” 的策略4.1 锁升级的四个阶段在 JDK 1.6 之后synchronized 关键字为了在不同的竞争场景下都能保持较好的性能引入了锁升级机制 。其锁升级过程包含四个阶段无锁、偏向锁、轻量级锁和重量级锁 。初始状态下对象处于无锁状态此时对象头的 Mark Word 存储着对象的哈希码、分代年龄等信息 。当第一个线程访问被 synchronized 修饰的代码块时如果偏向锁开启JDK 1.6 及以后默认开启对象会进入偏向锁状态 。在偏向锁状态下Mark Word 会记录下偏向线程的 ID当该线程再次进入同步块时只需简单比对线程 ID无需进行复杂的加锁操作大大提高了性能 。然而当有第二个线程尝试获取锁时偏向锁就会失效锁会升级为轻量级锁 。轻量级锁适用于线程交替执行同步块的场景它通过 CAS 操作在用户态尝试获取锁如果获取失败线程会进行自旋等待而不是立即阻塞避免了用户态到内核态的频繁切换 。如果自旋次数达到一定阈值后仍然无法获取到锁或者竞争进一步加剧有更多线程参与竞争轻量级锁就会升级为重量级锁 。重量级锁依赖操作系统的互斥量Mutex来实现线程的阻塞与唤醒竞争失败的线程会被阻塞等待锁的释放由操作系统进行调度唤醒这种方式的开销较大 。需要注意的是锁升级是单向不可逆的一旦锁从低级别升级到更高级别就不会再降级除非对象被回收再重新分配 。例如一个对象从偏向锁升级为轻量级锁后即使后续没有线程竞争它也不会再回到偏向锁状态 。4.2 各阶段详解无锁状态这是对象的初始状态此时对象头的 Mark Word 中存储的是对象的哈希码HashCode、分代年龄用于垃圾回收以及锁状态标志位值为 01表示无锁状态 。在无锁状态下多个线程可以自由地访问对象不存在任何同步限制因为没有线程持有锁所以也不会有线程竞争的问题 。例如在一个多线程环境中多个线程可以同时读取一个无锁对象的属性不会产生数据不一致的问题 。偏向锁偏向锁的设计目的是为了优化只有一个线程频繁访问同步块的场景 。当一个线程首次访问被 synchronized 修饰的代码块时如果对象处于可偏向状态偏向锁标志位为 1锁标志位为 01且线程 ID 为空JVM 会通过 CAS 操作将当前线程的 ID 写入 Mark Word 中并将偏向锁标志位设置为已偏向状态 。此后当该线程再次进入同步块时只需比较 Mark Word 中的线程 ID 是否与自己一致如果一致就可以直接进入同步块无需进行任何加锁操作这种方式几乎没有额外的开销 。比如在一个单例模式的实现中如果使用 synchronized 来保证单例对象的创建线程安全当第一个线程创建完单例对象后后续该线程对单例对象的访问就可以通过偏向锁来实现高效的同步因为不会有其他线程竞争锁 。轻量级锁当有第二个线程尝试获取偏向锁时就会触发偏向锁的撤销锁升级为轻量级锁 。轻量级锁的工作原理是线程在自己的栈帧中创建一个 Lock Record锁记录然后将对象头中的 Mark Word 复制到 Lock Record 中接着使用 CAS 操作尝试将对象头的 Mark Word 替换为指向自己栈中 Lock Record 的指针 。如果 CAS 操作成功说明该线程获取到了轻量级锁可以进入同步块执行如果 CAS 操作失败说明存在竞争该线程会进行自旋等待在一定次数的自旋内如果获取到了锁就可以进入同步块如果自旋结束后仍未获取到锁就会触发锁膨胀升级为重量级锁 。轻量级锁通过自旋的方式避免了线程的立即阻塞适用于线程交替执行同步块的场景减少了线程上下文切换的开销 。例如在一个多线程处理任务的场景中如果多个线程依次处理不同的任务并且每个任务的执行时间较短使用轻量级锁就可以在保证线程安全的前提下提高程序的并发性能 。重量级锁当轻量级锁的自旋次数超过阈值或者竞争非常激烈时锁就会升级为重量级锁 。重量级锁依赖操作系统的互斥量Mutex来实现线程的同步当一个线程获取到重量级锁后其他线程如果尝试获取该锁就会被阻塞放入 Monitor 的_EntryList 等待队列中等待锁的释放 。当持有锁的线程执行完同步代码块释放锁时会从_EntryList 中唤醒一个或多个等待线程这些线程将重新竞争锁 。由于线程的阻塞和唤醒需要操作系统的介入涉及用户态到内核态的切换所以重量级锁的开销较大 。在高并发且竞争激烈的场景下如多个线程同时对一个共享资源进行频繁的读写操作就可能会使用到重量级锁来保证数据的一致性和线程安全 。4.3 JDK 17 的重大变化从 JDK 17 开始Java 虚拟机做出了一个重大的改变即彻底移除了偏向锁 。这一决策背后有着多方面的原因 。首先偏向锁的维护成本较高 。在现代高并发应用中偏向锁的撤销过程较为复杂需要触发 STWStop-The-World来暂停所有应用线程以便进行偏向锁的撤销操作这在一定程度上会影响系统的性能和响应时间 。例如当一个对象的偏向锁被撤销时JVM 需要遍历线程栈检查是否有线程持有该偏向锁并且需要更新对象头中的信息这些操作都需要在 STW 期间完成对系统的影响较大 。其次随着像 ConcurrentHashMap 等高性能并发数据结构的广泛使用以及许多现代应用如 Web 框架、响应式编程中对 synchronized 的使用逐渐减少偏向锁带来的实际性能收益已经不如 JDK 6 时代那么明显 。在这些场景中无竞争同步操作的需求相对较少偏向锁的优化效果难以充分体现 。此外偏向锁的实现代码增加了 JVM 锁机制的复杂性使得 JVM 在维护和升级其他同步优化如锁粗化、锁消除时变得更加困难 。移除偏向锁后现在的锁升级路径简化为无锁 → 轻量级锁 → 重量级锁 。虽然移除了偏向锁但 Java 虚拟机在无锁竞争场景下的 CAS 操作成本已经极低所以整体性能几乎不受影响 。在实际应用中开发者无需再担心偏向锁的相关问题可以更加专注于业务逻辑的实现 。4.4 代码示例锁升级过程演示为了更直观地展示 synchronized 的锁升级过程我们通过一段代码示例并结合 JVM 参数和工具来观察锁状态的变化 。public class LockUpgradeDemo { private static final Object lock new Object(); public static void main(String[] args) throws InterruptedException { // 开启偏向锁JDK 17之前有效 // -XX:UseBiasedLocking // 打印对象头信息用于观察锁状态 // -XX:PrintObjectLayout // 第一个线程获取锁触发偏向锁 Thread thread1 new Thread(() - { synchronized (lock) { System.out.println(Thread1 获取锁此时应为偏向锁); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); thread1.start(); thread1.join(); // 第二个线程获取锁偏向锁失效升级为轻量级锁 Thread thread2 new Thread(() - { synchronized (lock) { System.out.println(Thread2 获取锁此时应为轻量级锁); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); thread2.start(); thread2.join(); // 多个线程同时竞争锁轻量级锁升级为重量级锁 for (int i 0; i 5; i) { new Thread(() - { synchronized (lock) { System.out.println(Thread.currentThread().getName() 获取锁此时应为重量级锁); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }, Thread- i).start(); } } }在上述代码中我们创建了一个 Object 对象lock作为锁对象 。首先thread1线程获取锁此时对象应该处于偏向锁状态 。然后thread2线程尝试获取锁这会导致偏向锁失效锁升级为轻量级锁 。最后通过启动 5 个线程同时竞争锁模拟高并发场景使轻量级锁升级为重量级锁 。为了观察锁状态的变化我们可以在运行代码时添加相应的 JVM 参数 。在 JDK 17 之前可以使用-XX:UseBiasedLocking开启偏向锁使用-XX:PrintObjectLayout打印对象头信息从而查看对象在不同阶段的锁状态 。在 JDK 17 及之后虽然偏向锁已被移除但仍然可以通过-XX:PrintObjectLayout来观察轻量级锁和重量级锁的状态变化 。通过这种方式我们可以更加深入地理解 synchronized 的锁升级机制 。五、synchronized 与三大特性的保障5.1 原子性不可分割的操作原子性是指一个操作是不可中断的要么全部执行成功要么全部不执行不存在部分执行的情况 。在多线程环境下这一特性对于保证数据的一致性至关重要。synchronized 关键字通过 monitorenter 和 monitorexit 指令来实现原子性 。当线程执行到 monitorenter 指令时它会尝试获取对象的锁如果获取成功则进入同步块其他线程无法同时进入该同步块直到当前线程执行完同步块并执行 monitorexit 指令释放锁 。以对共享变量的操作代码示例来分析原子性保障机制public class AtomicityDemo { private static int sharedVariable 0; public static void increment() { synchronized (AtomicityDemo.class) { sharedVariable; } } public static void main(String[] args) throws InterruptedException { Thread thread1 new Thread(() - { for (int i 0; i 1000; i) { increment(); } }); Thread thread2 new Thread(() - { for (int i 0; i 1000; i) { increment(); } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(最终的共享变量值: sharedVariable); } }在上述代码中increment方法通过synchronized关键字修饰当thread1线程执行到synchronized (AtomicityDemo.class)时会执行 monitorenter 指令尝试获取AtomicityDemo.class对象的锁 。如果获取成功其他线程如thread2就无法同时进入该同步块 。在同步块内执行sharedVariable操作这个操作虽然包含读取、加 1 和写入三个步骤但由于 synchronized 的原子性保障这三个步骤被视为一个不可分割的整体不会被其他线程打断 。当thread1执行完同步块执行 monitorexit 指令释放锁后thread2才有机会获取锁并执行同步块内的代码 。通过这种方式确保了sharedVariable的递增操作在多线程环境下的原子性最终输出的sharedVariable值为 2000而不会出现小于 2000 的情况 。5.2 可见性一个线程修改其他线程立即可见可见性是指当一个线程修改了共享变量的值其他线程能够立即看到这个修改 。在多线程编程中由于每个线程都有自己的工作内存线程对共享变量的操作首先是在自己的工作内存中进行然后再同步回主内存 。如果没有适当的同步机制就可能出现一个线程修改了共享变量但其他线程无法及时看到这个修改的情况 。synchronized 关键字通过内存屏障来实现可见性 。当线程进入同步块时会插入 LoadLoad 屏障和 LoadStore 屏障 。LoadLoad 屏障确保在读取共享变量之前先读取主内存中的最新值而不是从工作内存中读取旧值LoadStore 屏障确保在读取共享变量之后对其他共享变量的写入操作不会被重排序到读取之前 。当线程退出同步块时会插入 StoreStore 屏障和 StoreLoad 屏障 。StoreStore 屏障确保在写入共享变量之后之前对其他共享变量的写入操作都已经完成StoreLoad 屏障确保在写入共享变量之后对其他共享变量的读取操作不会被重排序到写入之前 。通过这些内存屏障的插入保证了一个线程对共享变量的修改能够及时被其他线程看到 。例如假设有两个线程ThreadA和ThreadB共享变量sharedDatapublic class VisibilityDemo { private static int sharedData 0; private static final Object lock new Object(); public static void main(String[] args) { Thread threadA new Thread(() - { synchronized (lock) { sharedData 100; System.out.println(ThreadA 修改了 sharedData 为: sharedData); } }); Thread threadB new Thread(() - { synchronized (lock) { System.out.println(ThreadB 读取到的 sharedData 为: sharedData); } }); threadA.start(); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } threadB.start(); } }在上述代码中ThreadA线程在同步块内修改了sharedData的值为 100当它退出同步块时通过内存屏障将修改后的值刷新到主内存 。ThreadB线程在进入同步块时通过内存屏障从主内存中读取到ThreadA修改后的sharedData值从而保证了可见性 。运行结果中ThreadB读取到的sharedData值为 100而不会是旧值 0 。5.3 有序性禁止指令重排破坏逻辑有序性是指程序执行的顺序按照代码的先后顺序执行 。在多线程环境下由于指令重排的存在可能会导致程序的执行顺序与代码的编写顺序不一致从而引发一些难以调试的问题 。synchronized 关键字通过 as-if-serial 语义和 Acquire/Release 屏障来保证有序性 。as-if-serial 语义保证了单线程环境下程序的执行结果与代码的编写顺序一致即编译器和处理器不会对单线程程序进行重排使得单线程程序的执行结果可预测 。对于 synchronized 同步块在进入同步块时会插入 Acquire 屏障禁止同步块内的读写操作与同步块外的读写操作进行重排确保在进入同步块之前所有在同步块外的读写操作都已经完成 。在退出同步块时会插入 Release 屏障禁止同步块内的写操作与同步块外的读写操作进行重排确保在退出同步块之后所有在同步块内的写操作都已经完成对其他线程可见 。需要注意的是synchronized 并不禁止同步块内部的指令重排它只保证最终的执行结果是正确的 。例如在同步块内有a 1; b 2;这样的代码编译器和处理器可能会对这两条指令进行重排但由于最终的结果是a为 1b为 2不会影响程序的正确性 。以如下代码示例说明public class OrderlinessDemo { private static int x 0, y 0; private static final Object lock new Object(); public static void main(String[] args) { Thread thread1 new Thread(() - { synchronized (lock) { x 1; y 2; } }); Thread thread2 new Thread(() - { synchronized (lock) { System.out.println(y y , x x); } }); thread1.start(); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } thread2.start(); } }在上述代码中thread1线程在同步块内先对x赋值为 1再对y赋值为 2 。由于 synchronized 的有序性保障thread2线程在进入同步块时一定能看到thread1线程按照顺序执行后的结果即y为 2x为 1 。运行结果中thread2输出的y和x的值一定是 2 和 1而不会出现y为 0x为 1 或者其他不符合顺序的情况 。六、内存屏障幕后的 “协调者”6.1 四种内存屏障的作用在多线程编程的复杂世界中内存屏障扮演着至关重要的角色它是确保多线程环境下数据可见性和指令执行顺序的关键机制 。Java 内存模型JMM定义了四种类型的内存屏障它们各自有着独特的作用通过协同工作有效地解决了多线程编程中因缓存一致性和指令重排而引发的问题 。LoadLoad 屏障其作用是确保屏障之前的读操作Load先于屏障之后的读操作完成 。例如在以下代码中int a sharedVar1; // 读操作1 // LoadLoad屏障 int b sharedVar2; // 读操作2假设sharedVar1和sharedVar2是共享变量并且读操作 2 依赖于读操作 1 的结果或者需要确保读操作 1 先完成那么在这两个读操作之间插入 LoadLoad 屏障就显得尤为重要 。LoadLoad 屏障会强制处理器检查缓存状态基于 MESI 协议如果发现缓存行处于 Invalid 状态则重新从内存或其他处理器的缓存中加载最新数据以确保读操作 2 读取的是最新值防止了编译器和处理器对这两个读操作进行重排序 。StoreStore 屏障该屏障的作用是确保屏障之前的写操作Store先于屏障之后的写操作完成并且屏障之前的写操作结果对其他处理器可见即刷新到主内存 。例如sharedVar1 10; // 写操作1 // StoreStore屏障 sharedVar2 20; // 写操作2在这个例子中如果写操作 2 依赖于写操作 1 的结果或者需要确保写操作 1 的结果对后续写操作可见就需要插入 StoreStore 屏障 。它会强制将写缓冲区Store Buffer中的数据刷新到缓存或主内存确保其他处理器能看到写操作 1 的结果从而防止写操作被重排序 。LoadStore 屏障LoadStore 屏障的作用是确保屏障之前的读操作Load先于屏障之后的写操作Store完成 。例如int value sharedVar; // 读操作 // LoadStore屏障 anotherVar value 1; // 写操作依赖于读操作的结果当读操作后面跟着一个依赖于读操作结果的写操作时插入 LoadStore 屏障是必要的 。它会阻止处理器将写操作重排序到读操作之前同时确保读操作完成后再执行写操作避免使用过期的数据执行写操作 。StoreLoad 屏障这是四种内存屏障中最为严格的一种它确保屏障之前的所有写操作Store完成并对其他处理器可见后才执行屏障之后的读操作Load 。例如sharedVar 30; // 写操作 // StoreLoad屏障 int result anotherVar; // 读操作常见于 volatile 变量的写操作之后或者锁释放unlock时 。它会强制将写缓冲区Store Buffer中的数据全部刷新到主内存并让当前处理器丢弃缓存中失效的数据即重新从主内存加载从而确保读操作读取的是最新值 。由于需要刷新整个写缓冲区并可能使缓存失效StoreLoad 屏障的开销是四种屏障中最大的 。这四种内存屏障通过禁止重排序和强制刷新缓存 / 写缓冲区有效地解决了多核 CPU 的可见性与有序性问题 。在 Java 中volatile 变量的读写以及 synchronized 关键字的加锁解锁操作都会自动插入相应的内存屏障以保证多线程编程的正确性 。6.2 synchronized 中的内存屏障应用synchronized 关键字在保障多线程安全的过程中内存屏障发挥着不可或缺的作用它通过在进入和退出同步块时插入特定的内存屏障来确保可见性和有序性 。结合之前的代码示例public class SynchronizedMemoryBarrierDemo { private static int sharedData 0; private static final Object lock new Object(); public static void main(String[] args) { Thread threadA new Thread(() - { synchronized (lock) { sharedData 100; System.out.println(ThreadA 修改了 sharedData 为: sharedData); } }); Thread threadB new Thread(() - { synchronized (lock) { System.out.println(ThreadB 读取到的 sharedData 为: sharedData); } }); threadA.start(); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } threadB.start(); } }当threadA线程进入synchronized(lock)同步块时会插入 LoadLoad 屏障和 LoadStore 屏障 。LoadLoad 屏障确保在读取共享变量sharedData之前先读取主内存中的最新值而不是从工作内存中读取旧值避免了读取到过期数据 。LoadStore 屏障则确保在读取共享变量sharedData之后对其他共享变量的写入操作不会被重排序到读取之前保证了读操作和后续写操作的顺序性 。在threadA线程退出同步块时会插入 StoreStore 屏障和 StoreLoad 屏障 。StoreStore 屏障确保在写入共享变量sharedData之后之前对其他共享变量的写入操作都已经完成保证了写操作的顺序性 。StoreLoad 屏障则确保在写入共享变量sharedData之后对其他共享变量的读取操作不会被重排序到写入之前同时将修改后的值刷新到主内存保证了其他线程能够读取到最新的值 。当threadB线程进入同步块时同样会插入 LoadLoad 屏障和 LoadStore 屏障确保从主内存中读取到threadA修改后的sharedData值 。通过这些内存屏障的协同工作synchronized 保证了多线程环境下共享变量sharedData的可见性和有序性使得threadB能够读取到threadA修改后的最新值 。在多线程编程中正确理解和运用 synchronized 中的内存屏障机制对于编写高效、健壮的并发程序至关重要 。

更多文章