锁的升级之路:偏向锁、轻量级锁和重量级锁详解

124 阅读11分钟

Synchronized 是 Java 中的重要关键字,用来解决多线程并发问题。以下是围绕几个方面对其进行通俗化的讲解。


1、synchronized 关键字的简介

为什么需要 synchronized

在多线程编程中,多个线程可能会同时访问或修改同一个共享资源(例如变量、集合、文件等)。如果不加控制,很容易导致 数据不一致竞态条件(Race Condition),从而引发程序逻辑错误。
Synchronized 就是用来 保证线程安全 的一种机制,作用是:

  • 互斥访问:同一时间只允许一个线程访问共享资源。
  • 保证可见性:线程对共享数据的修改对其他线程可见。

synchronized 的地位

  • 在 Java 的并发控制中,synchronized 是最基础的同步工具,最早出现在 JDK 1.0 中。
  • 它通过 的机制,控制线程对资源的访问顺序,是线程安全的关键保障之一。
  • 在 JDK 1.5 之后,ReentrantLock 等高级工具逐步取代了部分场景下的 synchronized,但它仍然是非常重要的同步手段。

2、synchronized 的使用

Synchronized 可以锁住对象或类,主要分为以下两种情况:

1)对象锁

对象锁是针对某个具体的实例对象(对象级别的锁)。

使用方式:

  • 同步方法:在方法声明中加 synchronized,锁住的是调用该方法的实例对象。
  • 同步代码块:使用 synchronized(obj),锁住指定的对象。

示例:

public class Counter {
    private int count = 0;

    // 同步方法,锁住当前实例对象
    public synchronized void increment() {
        count++;
    }

    // 同步代码块,锁住某个对象(比如 this 或其他共享对象)
    public void add(int value) {
        synchronized (this) {
            count += value;
        }
    }
}

假如两个线程访问同一个 Counter 实例,increment()add() 都受 synchronized 保护,避免并发冲突。

2)类锁

类锁是针对整个类的,所有对象实例共享同一把锁(类级别的锁)。

使用方式:

  • 静态同步方法:在静态方法前加 synchronized,锁住的是 Class 对象(即类的模板)。
  • 静态同步代码块:使用 synchronized(ClassName.class)

示例:

public class Counter {
    private static int count = 0;

    // 静态同步方法,锁住整个类
    public static synchronized void increment() {
        count++;
    }

    // 静态同步代码块
    public static void add(int value) {
        synchronized (Counter.class) {
            count += value;
        }
    }
}

无论多少线程,或者多少个 Counter 实例,当它们访问 increment()add() 时,都必须等待获取到类锁。


3、synchronized 的使用注意事项

在实际使用中,synchronized 有一些常见的注意事项和使用场景:

(1) 锁住的对象要明确

  • 锁住的是方法中的 this 对象 or 某个共享对象,还是整个类。
  • 如果锁的对象不同,线程之间不会互斥。

示例:

public void method1() {
    synchronized (this) {  // 锁住当前实例对象
        // 线程安全
    }
}

public void method2() {
    synchronized (Counter.class) {  // 锁住整个类
        // 不同锁,线程互不影响
    }
}

(2) 注意锁的粒度

  • 锁的范围越大,并发性能越差(因为更多线程会被阻塞)。
  • 推荐优先使用 同步代码块,缩小锁的粒度,只锁需要同步的代码。

(3) 静态方法和实例方法的锁互不干扰

  • 静态同步方法使用类锁,实例同步方法使用对象锁,互不冲突。

(4) 不要锁住字符串常量

  • 常量对象(如 "abc")在 JVM 中是共享的,可能被其他无关代码锁住,容易引发死锁等问题。
  • 推荐:锁住具体的对象实例。

(5) 避免死锁

  • 多线程中,如果两个线程互相等待对方释放锁,会导致死锁。
  • 避免:确保锁的获取顺序一致。

(6) 多线程性能问题

  • Synchronized 是一种阻塞机制,如果锁被占用,线程会进入阻塞状态,可能降低程序性能。
  • 优化:尽量减少锁粒度或使用更高效的并发工具(如 ReentrantLock)。

4、synchronized 的两个性质

1)可重入性

  • 可重入性指的是:同一线程在持有锁的情况下,可以再次获取该锁。
  • Java 的 synchronized可重入锁,即同一线程可以多次进入同步方法或代码块,而不会被自己阻塞。

示例:

public synchronized void method1() {
    method2(); // 当前线程可以再次获取锁
}

public synchronized void method2() {
    System.out.println("Reentrant lock");
}

2)不可中断性

  • 当一个线程进入 synchronized 方法或代码块后,其他线程只能等待,无法中断。
  • ReentrantLock 提供的 中断支持 不同,synchronized 是无法中断的。

5、synchronized 的底层原理

Synchronized 是 Java 中实现线程同步的重要关键字。虽然它用起来很简单,但其底层实现其实非常精妙。下面从简单到深入,以通俗的方式为你讲解 synchronized 的底层原理。

在 JVM 中,synchronized 的实现依赖 对象头的监视器(Monitor)锁升级机制


(1)synchronized 的底层依赖:对象头和 Monitor

A. 对象的结构:对象头

在 Java 中,每个对象在内存中都有两部分组成:

  1. 对象头(Header):存储对象的运行时信息,比如同步锁信息、HashCode 等。
  2. 实例数据(Fields):存储对象的成员变量。

重点:对象头中包含与锁相关的信息。

  • 在 32 位 JVM 中,对象头占用 32 位,其中有一部分专门用来表示锁的状态。
  • 在 64 位 JVM 中,对象头占用 64 位,锁状态信息依然存储在其中。

B. Monitor(监视器机制)

synchronized 的底层依赖一种叫 Monitor 的机制。这是一种操作系统级别的同步工具,用来管理线程的访问权限。

当线程执行 synchronized 方法或代码块时,会进入对象的 Monitor,只有持有 Monitor 的线程才能访问共享资源。Monitor 本质上是一种 互斥锁


(2)synchronized 的锁状态和锁升级过程

synchronized 的锁是可以升级的,具体分为以下几种状态:偏向锁、轻量级锁和重量级锁。JVM 会动态调整锁的状态,以提高性能。

A. Lock 的四种状态

对象头的锁状态有以下几种:

  1. 无锁(Unlocked):没有线程竞争,资源可以自由访问。
  2. 偏向锁(Biased Locking):一个线程第一次拿到锁后,会偏向这个线程,后续不需要竞争锁,性能最高。
  3. 轻量级锁(Lightweight Locking):当多个线程访问同一个锁时,使用 CAS(Compare-And-Swap)机制来尝试竞争锁,避免线程挂起。
  4. 重量级锁(Heavyweight Locking):当线程竞争非常激烈时,JVM 会将锁升级为重量级锁,线程会进入阻塞状态。

B. 锁升级过程

  • 通常情况下:锁从无锁开始,逐步升级为偏向锁、轻量级锁,最后升级为重量级锁
  • 锁降级:锁一旦升级,无法降级

锁升级的具体过程如下:

  1. 偏向锁(默认开启):

    • 如果一个线程首次访问锁,JVM 会将锁标记为“偏向锁”,并在对象头记录该线程的 ID。
    • 后续该线程再次访问锁时,不需要任何同步操作,效率极高。
    • 如果有其他线程试图访问锁,则偏向锁会撤销,进入轻量级锁状态。
  2. 轻量级锁

    • 当多个线程访问同一个锁时,JVM 会使用 CAS 操作让线程竞争锁。
    • 如果 CAS 成功,则线程获得锁,进入临界区。
    • 如果 CAS 失败(有其他线程持有锁),线程会自旋(不断尝试获取锁)一段时间。
  3. 重量级锁

    • 如果线程自旋多次仍未成功获取锁,JVM 会将锁升级为重量级锁。
    • 此时,线程会被挂起,进入操作系统的 阻塞队列,直到其他线程释放锁。
    • 重量级锁会导致线程的上下文切换,性能较差。

(3)synchronized 的实现方式

synchronized 的实现方式在 方法代码块 中有所不同,但本质都是基于对象头中的锁信息和 Monitor 实现。

A. 同步方法的实现

当一个方法被声明为 synchronized 时,编译器会在方法的字节码中插入一条 ACC_SYNCHRONIZED 标志

  • 当线程调用同步方法时,JVM 会自动先检查 Monitor,只有持有锁的线程才能执行方法体。
  • 如果没有获得锁,线程会被挂起。

字节码示例:

public synchronized void method() {
    System.out.println("Hello");
}

反编译后,字节码中会多一条 ACC_SYNCHRONIZED 标志,JVM 通过这个标志来实现同步。

B. 同步代码块的实现

当使用 synchronized(obj) 锁定一个对象时,编译器会在字节码中生成 monitorentermonitorexit 指令。

  • monitorenter:指令表示当前线程尝试获取对象的 Monitor。
  • monitorexit:指令表示当前线程释放对象的 Monitor。

字节码示例:

public void method() {
    synchronized (this) {
        System.out.println("Hello");
    }
}

反编译后,字节码类似如下:

0: aload_0          // 加载 this
1: dup
2: monitorenter     // 尝试获取锁
3: getstatic ...    // 执行代码
4: invokevirtual ...
5: monitorexit      // 释放锁
6: goto ...

(4)锁的性能优化(为什么说现代 JVM 优化了 synchronized)

A. 偏向锁的引入

偏向锁是一个性能优化,它的设计理念是:大多数情况下,锁只会被一个线程使用。因此,JVM 会为第一次获得锁的线程分配偏向锁,避免频繁的 CAS 操作。

B. 自旋锁

传统的重量级锁会让线程直接挂起,而 自旋锁 允许线程暂时不挂起,而是循环尝试获取锁。这种方式避免了线程上下文切换带来的额外开销。

C. 锁消除

如果 JVM 检测到某一段代码中的锁是完全不必要的(比如局部变量锁),会自动消除锁。

D. 锁粗化

如果一段代码中频繁加锁和解锁,JVM 会将多个锁合并为一个范围较大的锁,减少性能开销。


(5)通俗理解

  1. 对象头是关键:所有 Java 对象都带有一个“头”,它记录了锁的状态(无锁、偏向锁、轻量级锁、重量级锁)。
  2. 锁升级机制:锁会按竞争情况逐步升级,从无锁到偏向锁,再到轻量级锁和重量级锁。锁升级提高了性能。
  3. Monitor 是核心synchronized 底层依赖 Monitor,线程只有拿到 Monitor 才能访问共享资源。
  4. JVM 的优化:现代 JVM 针对锁的性能进行了大量优化(如偏向锁、自旋锁、锁消除等),使 synchronized 在大多数场景下性能优越。

(6)通俗比喻

可以把 synchronized 想象成电影院买票的窗口:

  • 偏向锁:如果只有一个人买票,售票员会记住这个人,下次他来了,直接优先处理,不用排队。
  • 轻量级锁:如果来了几个人,售票员会叫大家排好队,按顺序买票,避免冲突。
  • 重量级锁:如果来了太多人,大家开始争吵,售票员会强制维持秩序,让每个人按大队列等候,别人买完票后再轮到你。

通过这种机制,synchronized 保证大家都能有序买票,而不会发生混乱。


6、synchronized 的常见缺陷

尽管 synchronized 简单易用,但也有一些缺点:

(1) 性能开销

  • 如果线程竞争激烈(重量级锁),可能导致较多的上下文切换和阻塞,性能较低。
  • 现代 JVM 已优化 synchronized,降低了锁的开销。

(2) 不支持中断

  • 线程在等待锁时,无法被中断,只能等待锁释放,可能导致程序响应性较差。

(3) 容易使用不当

  • 由于锁的粒度和锁对象的选择不当,容易引发死锁或性能瓶颈。

(4) 缺乏高级功能

  • 比如 synchronized 不支持尝试获取锁或超时机制(高级用户可以选择 ReentrantLock)。

结论

  1. 作用synchronized 保证多线程对共享资源的安全访问,是 Java 中的基础同步工具。
  2. 使用:可以在方法上加锁(实例锁或类锁),也可用同步代码块锁住某个对象。
  3. 注意事项:明确锁的粒度和对象,避免死锁和性能问题。
  4. 性质:支持可重入,不支持中断。
  5. 底层原理:通过对象头和锁升级机制实现。
  6. 缺陷:性能可能较低,功能较简单,但在绝大多数场景中仍然有效。

建议:在复杂并发场景下,结合 ReentrantLock 或其他并发工具,灵活选择同步机制。