JAVA synchronized偏向锁、轻量级锁到重量级锁升级全过程解析

JAVA synchronized 偏向锁、轻量级锁到重量级锁升级全过程解析

大家好,今天我们来深入探讨Java中synchronized关键字的锁升级过程,包括偏向锁、轻量级锁以及重量级锁。理解这个过程对于编写高效的并发程序至关重要。synchronized是Java提供的内置锁机制,用于实现线程同步,保证多线程环境下的数据一致性。为了优化性能,synchronized锁并不是一开始就使用重量级锁,而是经历一个由低到高的升级过程。

1. synchronized 的基础:对象头

在深入了解锁升级之前,我们需要理解Java对象头的结构。对象头是存储对象元数据的重要部分,它存储了对象的哈希码、GC分代年龄、锁状态标志等信息。在HotSpot虚拟机中,对象头主要包含两部分:

  • Mark Word: 存储对象的运行时数据,如哈希码、GC分代年龄、锁标志位等。Mark Word的结构会根据锁的状态而变化。
  • Klass Pointer: 指向类的元数据指针,虚拟机通过这个指针确定对象所属的类。

Mark Word是锁机制实现的关键,不同的锁状态会对应不同的Mark Word结构。以下是一个简化的Mark Word结构表:

锁状态 标志位 是否存储线程ID 描述
无锁状态 01 对象的初始状态,Mark Word存储对象的哈希码、GC分代年龄等信息。
偏向锁状态 01 对象偏向于第一个获取它的线程,Mark Word存储偏向线程ID。
轻量级锁状态 00 多个线程尝试竞争锁,但不存在激烈的竞争,Mark Word存储指向锁记录的指针。
重量级锁状态 10 存在激烈的锁竞争,线程进入阻塞状态,Mark Word存储指向互斥量(Mutex)的指针。
GC标记 11 对象正在被垃圾回收器标记。

2. 偏向锁 (Biased Locking)

偏向锁是一种针对单线程访问同步代码块场景的优化。它的核心思想是:如果一个锁总是被同一个线程持有,那么就可以消除这个线程获取锁的开销。当一个线程第一次访问一个同步代码块时,JVM会将对象头的Mark Word设置为偏向模式,并将线程ID记录在Mark Word中。以后该线程再次进入这个同步代码块时,不需要进行任何额外的同步操作,直接可以访问。

2.1 偏向锁的获取

当一个线程尝试获取一个对象的偏向锁时,JVM会检查以下几点:

  1. 对象是否处于可偏向状态? JVM启动时,默认延迟开启偏向锁。可以通过 -XX:+UseBiasedLocking 开启偏向锁,-XX:BiasedLockingStartupDelay=0 关闭延迟。
  2. 对象头的Mark Word是否指向当前线程? 如果是,则表示该线程已经持有该对象的偏向锁,可以直接访问同步代码块。
  3. 对象头的Mark Word是否指向其他线程? 如果是,则需要撤销偏向锁。

2.2 偏向锁的撤销

偏向锁的撤销发生在以下几种情况:

  1. 有其他线程尝试竞争锁: 当有其他线程尝试获取该对象的锁时,JVM会检查该对象是否处于偏向模式,如果是,则需要撤销偏向锁。
  2. 调用对象的hashCode()方法: 如果一个对象已经计算过hashCode(),那么该对象的Mark Word中存储了哈希值,无法再存储线程ID。这时,偏向锁会被撤销。
  3. 进入安全点(Safepoint): 在某些情况下,JVM会暂停所有线程,进入安全点。在安全点,JVM会检查所有对象的偏向锁状态,并进行必要的撤销。

偏向锁的撤销过程比较复杂,通常会暂停拥有偏向锁的线程,并根据对象是否正在被使用来决定是升级为轻量级锁还是直接升级为重量级锁。如果拥有偏向锁的线程仍然存活,且正在使用该对象,则升级为轻量级锁;否则,将对象头设置为无锁状态,等待其他线程竞争。

2.3 代码示例

public class BiasedLockExample {

    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {

        Runnable task = () -> {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + " acquired the lock.");
                try {
                    Thread.sleep(1000); // Simulate some work
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " released the lock.");
            }
        };

        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");

        thread1.start();
        Thread.sleep(100); // Ensure Thread-1 acquires the lock first

        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Main thread finished.");
    }
}

在这个例子中,如果开启了偏向锁,Thread-1 第一次获取锁时,lock 对象会偏向 Thread-1。后续 Thread-1 再次进入同步块时,无需任何额外的同步操作。当 Thread-2 尝试获取锁时,会触发偏向锁的撤销,锁会升级为轻量级锁或者重量级锁,具体取决于JVM的实现和当时的线程状态。

3. 轻量级锁 (Lightweight Locking)

轻量级锁是一种针对多个线程交替访问同步代码块场景的优化。它的核心思想是:在没有真正锁竞争的情况下,使用CAS(Compare and Swap)操作尝试获取锁,避免了线程阻塞和上下文切换的开销。

3.1 轻量级锁的获取

当一个线程尝试获取轻量级锁时,JVM会执行以下步骤:

  1. 在当前线程的栈帧中创建一个锁记录(Lock Record): 锁记录用于存储对象头的Mark Word的副本。
  2. 使用CAS操作尝试将对象头的Mark Word替换为指向锁记录的指针: 如果替换成功,则表示该线程成功获取了锁。
  3. 如果CAS操作失败: 说明有其他线程已经持有了该对象的锁。此时,该线程会尝试自旋(Spin)一段时间,再次尝试CAS操作。如果自旋达到一定的次数仍然无法获取锁,则轻量级锁会膨胀为重量级锁。

3.2 轻量级锁的释放

释放轻量级锁的过程如下:

  1. 使用CAS操作尝试将对象头的Mark Word替换为锁记录中存储的原始Mark Word: 如果替换成功,则表示该线程成功释放了锁。
  2. 如果CAS操作失败: 说明在释放锁的过程中,有其他线程尝试获取锁,导致对象头的Mark Word发生了变化。此时,需要唤醒被阻塞的线程,进入重量级锁的竞争。

3.3 自旋

自旋是一种忙等待策略,线程会不断地尝试获取锁,而不是立即进入阻塞状态。自旋的目的是为了避免线程上下文切换的开销。但是,如果自旋时间过长,会消耗大量的CPU资源。因此,JVM会对自旋的次数进行限制。自旋的次数可以通过 -XX:PreBlockSpin 参数进行设置。在JDK 7 之后,自旋次数不再固定,而是由JVM根据历史的锁竞争情况动态调整。

3.4 代码示例

public class LightweightLockExample {

    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {

        Runnable task = () -> {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + " acquired the lock.");
                try {
                    Thread.sleep(100); // Simulate some work
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " released the lock.");
            }
        };

        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");

        thread1.start();
        Thread.sleep(1); // Ensure Thread-1 acquires the lock first (likely)

        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Main thread finished.");
    }
}

在这个例子中,如果 Thread-1 已经持有锁,Thread-2 尝试获取锁时,会进入轻量级锁的竞争。Thread-2 会在自己的栈帧中创建一个锁记录,并使用CAS操作尝试将 lock 对象的 Mark Word 替换为指向锁记录的指针。如果 CAS 操作失败,Thread-2 会自旋一段时间,再次尝试 CAS 操作。如果自旋达到一定的次数仍然无法获取锁,轻量级锁会膨胀为重量级锁。

4. 重量级锁 (Heavyweight Locking)

重量级锁是一种传统的互斥锁,它依赖于操作系统的互斥量(Mutex)来实现线程同步。当锁竞争非常激烈时,轻量级锁会膨胀为重量级锁。

4.1 重量级锁的获取

当一个线程尝试获取重量级锁时,如果该锁已经被其他线程持有,则该线程会被阻塞,进入等待队列。当持有锁的线程释放锁时,操作系统会唤醒等待队列中的一个线程,使其获得锁。

4.2 重量级锁的释放

持有重量级锁的线程释放锁时,操作系统会唤醒等待队列中的一个线程,使其获得锁。唤醒哪个线程是由操作系统的调度算法决定的,通常是按照先进先出的原则。

4.3 重量级锁的缺点

重量级锁的缺点是性能开销比较大。线程阻塞和唤醒需要进行上下文切换,这会消耗大量的CPU资源。因此,在锁竞争不激烈的情况下,应该尽量避免使用重量级锁。

4.4 代码示例

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class HeavyweightLockExample {

    private static Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {

        Runnable task = () -> {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " acquired the lock.");
                Thread.sleep(100); // Simulate some work
                System.out.println(Thread.currentThread().getName() + " released the lock.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        };

        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");

        thread1.start();
        Thread.sleep(1); // Ensure Thread-1 acquires the lock first (likely)

        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Main thread finished.");
    }
}

在这个例子中,ReentrantLock 实际上也是依赖于操作系统的互斥量来实现线程同步的。当 Thread-1 已经持有锁,Thread-2 尝试获取锁时,会被阻塞,进入等待队列。当 Thread-1 释放锁时,操作系统会唤醒 Thread-2,使其获得锁。

5. 锁升级的过程

synchronized 锁升级的过程可以用以下图表总结:

无锁状态 -> 偏向锁 -> 轻量级锁 -> 重量级锁

这个过程是不可逆的。也就是说,锁只能从低级别向高级别升级,不能从高级别向低级别降级。

6. 优化synchronized的使用

理解锁升级的过程可以帮助我们更好地使用synchronized关键字,并优化并发程序的性能。以下是一些建议:

  • 减少锁的持有时间: 尽量只在必要的时候才持有锁,避免长时间持有锁导致其他线程阻塞。
  • 减少锁的粒度: 尽量使用细粒度的锁,避免使用粗粒度的锁导致大量的线程竞争。例如,可以使用ConcurrentHashMap代替Hashtable。
  • 尽量避免锁竞争: 尽量设计无锁的数据结构和算法,例如使用原子变量、CAS操作等。
  • 合理使用偏向锁: 偏向锁适合单线程访问同步代码块的场景。如果锁经常被多个线程竞争,则应该禁用偏向锁,以避免偏向锁撤销的开销。
  • 合理设置自旋次数: 自旋次数应该根据实际的锁竞争情况进行调整。如果锁竞争不激烈,可以适当增加自旋次数,以减少线程上下文切换的开销。如果锁竞争非常激烈,则应该减少自旋次数,以避免消耗大量的CPU资源。

7. 锁消除和锁粗化

除了锁升级之外,JVM还会进行锁消除(Lock Elimination)和锁粗化(Lock Coarsening)两种优化。

7.1 锁消除

锁消除是指JVM在编译时或运行时,判断某个锁是不必要的,从而消除该锁。例如,如果一个StringBuffer对象只在单线程中使用,那么该对象的append()方法上的锁就是不必要的,可以被消除。锁消除可以通过 -XX:+EliminateLocks 参数开启。

7.2 锁粗化

锁粗化是指JVM将多个相邻的锁合并成一个更大的锁。例如,如果一个循环中多次获取同一个锁,那么可以将这些锁合并成一个更大的锁,减少锁的获取和释放的次数。锁粗化可以通过 -XX:+DoEscapeAnalysis-XX:+EliminateAllocations 参数开启。

8. 总结,理解锁升级过程,编写高效并发代码

我们详细讲解了 Java synchronized 锁的升级过程,从偏向锁到轻量级锁再到重量级锁,以及锁升级的原因和优化策略。理解锁升级过程有助于我们更好地选择合适的同步机制,编写高效的并发代码。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注