引言

在 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 List<String> 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 都发挥着不可或缺的作用,为开发者提供了一种简单而强大的线程同步机制。

三、深入底层:对象头与 Monitor

3.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类型的name

复制代码

public 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类型的id 4 字节 + String引用类型 4 字节 = 8 字节),总大小为 20 字节,不是 8 的倍数,因此需要 4 字节的对齐填充,最终User对象占用 24 字节的内存空间 。

对象头中的 Mark Word 对于 synchronized 锁机制起着核心作用,它存储的锁状态标志位等信息,直接决定了对象当前的锁状态,进而影响着线程对对象的访问方式和同步控制 。

3.2 对象头中的 Mark Word

Mark Word 是 Java 对象头中极为关键的部分,它在 64 位 JVM 中占 8 字节,以紧凑的方式存储了对象的多种运行时数据,这些数据会根据对象的状态动态变化,尤其是与锁相关的信息,在 synchronized 锁机制中扮演着核心角色。

在不同的锁状态下,Mark Word 的结构和存储内容各不相同:

  • 无锁状态:此时 Mark Word 存储对象的哈希码(hashCode)、分代年龄(用于垃圾回收)以及锁状态标志位(值为 01)。其中,哈希码是对象的一个标识,在对象调用hashCode()方法时生成并存储在此;分代年龄则记录了对象经历垃圾回收的次数,用于判断对象是否应该晋升到老年代。
  • 偏向锁状态:当对象处于偏向锁状态时,Mark Word 存储偏向线程 ID、偏向时间戳、分代年龄以及锁状态标志位(值为 01,此时表示偏向锁)。偏向锁的设计目的是为了优化只有一个线程频繁访问同步块的场景,它会偏向于第一个获取锁的线程,在后续该线程再次访问时,只需简单判断线程 ID 是否一致,无需进行复杂的加锁解锁操作,从而提高性能。
  • 轻量级锁状态:轻量级锁适用于线程交替执行同步块的场景。在这种状态下,Mark Word 存储指向当前线程栈中锁记录(Lock Record)的指针以及锁状态标志位(值为 00)。当线程进入同步块时,如果发现对象处于无锁状态,会通过 CAS(Compare 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 为 0,ThreadA 进入_WaitSet 等待。此时,ThreadB 和 ThreadC 有机会竞争锁,假设 ThreadB 竞争成功,_owner 指向 ThreadB,_count 为 1。如果 ThreadB 调用 notify () 方法,ThreadA 会从_WaitSet 被唤醒并进入_EntryList,再次参与锁的竞争 。

Monitor 通过这种方式,有效地管理了线程的同步访问,确保了在多线程环境下共享资源的安全访问。

3.4 代码示例:对象头与 Monitor 的关系

为了更直观地理解对象头与 Monitor 在 synchronized 机制中的关系,我们通过一段代码示例,并借助 jol-core 库来查看对象头信息在加锁前后的变化。

首先,引入 jol-core 库的依赖:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.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 各阶段详解

    Logo

    汇聚全球AI编程工具,助力开发者即刻编程。

    更多推荐