JAVA synchronized锁优化:偏向锁撤销与轻量级锁膨胀全流程解析

JAVA synchronized锁优化:偏向锁撤销与轻量级锁膨胀全流程解析

各位同学,大家好!今天我们来深入探讨Java synchronized 关键字的锁优化机制,重点分析偏向锁的撤销以及轻量级锁的膨胀全流程。synchronized 作为Java并发编程中最重要的同步手段之一,其性能至关重要。了解其底层优化原理,有助于我们编写更高效的并发程序。

1. 锁的状态与升级过程

Java的synchronized锁在JDK 1.6之后引入了锁升级的概念,旨在减少不必要的锁竞争,提高并发性能。锁的状态从低到高依次为:无锁、偏向锁、轻量级锁、重量级锁。锁升级是单向的,只能升级不能降级(但可以被 JVM 优化为锁消除或锁粗化)。

锁状态 适用场景 开销 说明
无锁 没有线程竞争资源的情况 极低 对象头中没有锁标志位,或者锁标志位为无锁状态。
偏向锁 只有一个线程访问同步块的情况 非常低 线程获得锁后,会将线程ID记录在对象头中,以后该线程再次进入同步块时,无需进行任何同步操作。
轻量级锁 少量线程竞争资源,但竞争时间很短的情况 较低 线程通过CAS尝试将对象头替换为指向锁记录的指针。如果成功,则获得锁;否则,锁膨胀为重量级锁。
重量级锁 多个线程竞争资源,且竞争时间较长的情况 线程进入阻塞状态,等待操作系统的调度。涉及到用户态和内核态的切换,开销很大。

2. 偏向锁

偏向锁的核心思想是:如果一个锁总是被同一个线程持有,则后续该线程进入同步块时,无需进行任何同步操作,甚至不用CAS。

2.1 偏向锁的获取

当一个线程第一次访问一个synchronized代码块时,JVM会将对象头的Mark Word设置为偏向模式,并将当前线程的ID记录在对象头中。之后,该线程再次访问该同步代码块时,只需检查对象头的Mark Word中是否包含自己的线程ID,如果包含,则认为该线程拥有锁,直接进入同步块,无需进行任何同步操作。

2.2 偏向锁的撤销

偏向锁并非总是有效,当有其他线程尝试竞争偏向锁时,偏向锁就会被撤销。撤销过程需要等到全局安全点(safepoint),暂停拥有偏向锁的线程,然后检查锁的状态。撤销场景主要有以下几种:

  • 情况一:其他线程尝试竞争偏向锁

    如果其他线程尝试竞争已经被偏向的锁,JVM会检查拥有偏向锁的线程是否还存活。

    • 如果拥有偏向锁的线程已经死亡,则可以将对象头重置为无锁状态,允许其他线程通过CAS竞争锁。
    • 如果拥有偏向锁的线程仍然存活,JVM会暂停该线程,检查该线程是否还在执行synchronized代码块。
      • 如果该线程不在执行synchronized代码块,则可以将对象头重置为无锁状态,并升级为轻量级锁,允许其他线程通过CAS竞争锁。
      • 如果该线程还在执行synchronized代码块,则将对象头升级为轻量级锁,并将拥有偏向锁的线程的栈帧中的锁记录指向该对象。
  • 情况二:调用对象的hashCode()方法

    如果一个对象已经处于偏向锁状态,并且调用了该对象的hashCode()方法,那么偏向锁会被撤销,并升级为轻量级锁。这是因为对象的hashCode()方法需要存储在对象头的Mark Word中,而偏向锁也需要使用Mark Word来存储线程ID。两者冲突,所以偏向锁会被撤销。

2.3 偏向锁撤销的代码模拟

以下代码模拟了偏向锁撤销的过程。为了方便观察,这里使用Thread.sleep()模拟线程的运行状态。

import java.util.concurrent.locks.LockSupport;

public class BiasedLockRevocation {

    static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 预热,让对象进入可偏向状态
        for (int i = 0; i < 10000; i++) {
            synchronized (lock) {
                // do nothing
            }
        }

        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread 1 acquired biased lock");
                try {
                    Thread.sleep(5000); // 模拟线程长时间持有锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 1 releasing biased lock");
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            // 等待Thread-1获取偏向锁
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread 2 trying to acquire lock");
            synchronized (lock) {
                System.out.println("Thread 2 acquired lock");
            }
        }, "Thread-2");

        t1.start();
        t2.start();

        t1.join();
        t2.join();

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

在这个例子中,Thread-1首先获取lock对象的偏向锁,并持有一段时间。Thread-2尝试获取lock对象锁,此时会触发偏向锁的撤销。具体过程如下:

  1. Thread-1获取lock对象的偏向锁,并将自己的线程ID记录在lock对象的对象头中。
  2. Thread-2尝试获取lock对象的锁,发现对象头中记录的线程ID不是自己的,于是触发偏向锁的撤销。
  3. JVM暂停Thread-1,检查Thread-1是否还在执行synchronized代码块。
  4. Thread-1还在执行synchronized代码块,JVM将lock对象的对象头升级为轻量级锁,并将Thread-1的栈帧中的锁记录指向lock对象。
  5. Thread-2尝试通过CAS获取lock对象的轻量级锁,成功后进入synchronized代码块。

3. 轻量级锁

轻量级锁适用于线程交替执行同步块的场景。其核心思想是:避免使用操作系统互斥量(Mutex),而是通过CAS操作尝试获取锁。

3.1 轻量级锁的获取

  1. 线程在进入同步块之前,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record),用于存储锁的信息。
  2. 线程尝试使用CAS操作将对象头的Mark Word替换为指向锁记录的指针。
    • 如果CAS操作成功,表示线程获取锁成功,对象头的Mark Word指向线程的锁记录。
    • 如果CAS操作失败,表示有其他线程正在持有锁,当前线程会尝试自旋等待一段时间,再次尝试CAS操作。如果自旋超过一定次数仍然失败,则轻量级锁会膨胀为重量级锁。

3.2 轻量级锁的膨胀

当多个线程竞争同一个锁,并且自旋超过一定次数仍然无法获取锁时,轻量级锁会膨胀为重量级锁。膨胀过程如下:

  1. JVM会创建一个互斥量(Mutex),并将对象头的Mark Word指向该互斥量。
  2. 当前线程进入阻塞状态,等待操作系统的调度。
  3. 当持有锁的线程释放锁时,会唤醒等待队列中的一个线程。
  4. 被唤醒的线程尝试获取锁,如果成功,则进入同步块。

3.3 轻量级锁膨胀的代码模拟

以下代码模拟了轻量级锁膨胀的过程。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

public class LightweightLockEscalation {

    static Object lock = new Object();
    static int counter = 0;
    static final int THREAD_COUNT = 10;

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < THREAD_COUNT; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    synchronized (lock) {
                        counter++;
                    }
                }
                latch.countDown();
            });
            threads.add(thread);
            thread.start();
        }

        latch.await();
        System.out.println("Counter value: " + counter);
    }
}

在这个例子中,多个线程同时竞争lock对象锁。由于线程数量较多,竞争激烈,轻量级锁会很快膨胀为重量级锁。

4. 锁消除与锁粗化

除了偏向锁和轻量级锁,JVM还提供了锁消除和锁粗化两种优化手段。

4.1 锁消除

锁消除是指JVM在编译时,如果发现某些synchronized代码块中的锁实际上不会发生竞争,那么JVM会消除这些锁。

例如:

public String appendString(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    synchronized (sb) {
        sb.append(s1);
        sb.append(s2);
    }
    return sb.toString();
}

在这个例子中,StringBuffer对象sb只在appendString方法中使用,不会被其他线程访问,因此synchronized (sb)实际上不会发生竞争。JVM可以消除这个锁,提高性能。

4.2 锁粗化

锁粗化是指JVM将多个相邻的synchronized代码块合并成一个更大的synchronized代码块,从而减少锁的获取和释放次数。

例如:

public void processData(Data data) {
    synchronized (data) {
        data.updateField1();
    }
    synchronized (data) {
        data.updateField2();
    }
    synchronized (data) {
        data.updateField3();
    }
}

在这个例子中,有三个相邻的synchronized代码块,它们都使用同一个锁data。JVM可以将这三个synchronized代码块合并成一个更大的synchronized代码块,减少锁的获取和释放次数。

public void processData(Data data) {
    synchronized (data) {
        data.updateField1();
        data.updateField2();
        data.updateField3();
    }
}

5. 总结:理解锁优化,提升并发性能

通过以上分析,我们详细了解了synchronized锁的优化机制,包括偏向锁的获取与撤销、轻量级锁的获取与膨胀,以及锁消除和锁粗化。理解这些优化原理,有助于我们编写更高效的并发程序,避免不必要的锁竞争,提升系统性能。在实际开发中,我们需要根据具体的场景选择合适的同步策略,才能充分发挥多核CPU的性能优势。

发表回复

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