Java字节码指令:monitorenter/monitorexit与synchronized锁的底层实现

好的,让我们深入探讨Java字节码指令 monitorentermonitorexit,以及它们在 synchronized 锁底层实现中的作用。

讲座:Java synchronized 的字节码实现原理

引言:并发控制的基石

在多线程编程中,并发控制是至关重要的。Java 提供了 synchronized 关键字作为一种内置的锁机制,用于保证在同一时刻只有一个线程可以访问特定的代码块或方法。但 synchronized 的底层实现是什么呢?答案就隐藏在 Java 字节码指令 monitorentermonitorexit 中。

一、synchronized 的基本用法

首先,我们回顾一下 synchronized 的两种主要用法:

  1. 同步代码块:

    public class SynchronizedBlockExample {
        private Object lock = new Object();
    
        public void doSomething() {
            synchronized (lock) {
                // 需要同步的代码
                System.out.println(Thread.currentThread().getName() + ": Doing something synchronized.");
                try {
                    Thread.sleep(1000); // 模拟耗时操作
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
  2. 同步方法:

    public class SynchronizedMethodExample {
        public synchronized void doSomething() {
            // 需要同步的代码
            System.out.println(Thread.currentThread().getName() + ": Doing something synchronized.");
            try {
                Thread.sleep(1000); // 模拟耗时操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

在同步代码块中,我们需要显式指定一个对象作为锁。在同步方法中,锁是隐式的:对于实例方法,锁是 this 对象;对于静态方法,锁是该类的 Class 对象。

二、字节码分析:monitorenter 和 monitorexit 的登场

为了理解 synchronized 的底层实现,我们需要查看编译后的字节码。我们可以使用 javap -c 命令来反编译上面的代码。

  1. 同步代码块的字节码:

    public void doSomething();
      Code:
         0: aload_0
         1: getfield      #2                  // Field lock:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter
         7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: new           #4                  // class java/lang/StringBuilder
        13: dup
        14: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        17: invokestatic  #6                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        20: invokevirtual #7                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        23: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        26: ldc           #9                  // String : Doing something synchronized.
        28: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: invokevirtual #11                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        37: ldc2_w        #12                 // long 1000l
        40: invokestatic  #14                 // Method java/lang/Thread.sleep:(J)V
        43: goto          51
        46: astore_2
        47: aload_2
        48: invokevirtual #15                 // Method java/lang/Throwable.printStackTrace:()V
        51: aload_1
        52: monitorexit
        53: goto          61
        56: astore_3
        57: aload_1
        58: monitorexit
        59: aload_3
        60: athrow
        61: return
      Exception table:
         from    to  target type
             7    43    46   Class java/lang/InterruptedException
             7    53    56   any
            56    59    56   any
  2. 同步方法的字节码:

    public synchronized void doSomething();
      Code:
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: new           #4                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        10: invokestatic  #6                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        13: invokevirtual #7                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        16: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: ldc           #9                  // String : Doing something synchronized.
        21: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: invokevirtual #11                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        30: ldc2_w        #12                 // long 1000l
        33: invokestatic  #14                 // Method java/lang/Thread.sleep:(J)V
        36: goto          44
        39: astore_1
        40: aload_1
        41: invokevirtual #15                 // Method java/lang/Throwable.printStackTrace:()V
        44: return
      Exception table:
         from    to  target type
             0    36    39   Class java/lang/InterruptedException

我们可以看到,同步代码块的关键在于 monitorentermonitorexit 指令。同步方法没有显式的 monitorentermonitorexit 指令,但它的方法标志位 ACC_SYNCHRONIZED 会指示 JVM 在方法调用前后自动进行加锁和解锁操作。

深入解析 monitorenter 和 monitorexit

  • monitorenter:

    • 尝试获取指定对象的锁(monitor)。
    • 如果对象的 monitor 的进入数为 0,则该线程可以进入,并将进入数设置为 1,此时该线程为 monitor 的所有者。
    • 如果线程已经拥有该 monitor 的所有权,则进入数加 1。
    • 如果其他线程拥有该 monitor 的所有权,则该线程被阻塞,直到 monitor 的进入数为 0,才能再次尝试获取。
  • monitorexit:

    • 释放指定对象的锁(monitor)。
    • 将 monitor 的进入数减 1。
    • 如果减 1 后,进入数为 0,则该线程释放 monitor 的所有权,允许其他线程尝试获取 monitor。

关键点:

  • monitorentermonitorexit 必须配对使用,确保锁的正确释放。
  • JVM 保证 monitorentermonitorexit 的配对出现,即使在发生异常的情况下也能确保锁被释放。这通过 Exception table 实现,在任何情况下,包括异常,都会执行 monitorexit
  • 每个对象都有一个 monitor 与之关联。

三、Monitor 的内部结构

Monitor 可以认为是一种同步工具,也可以描述为一种数据结构,它被设计成允许并发线程互斥地访问共享资源。 HotSpot虚拟机中,Monitor是由ObjectMonitor实现的。ObjectMonitor类中维护了几个关键字段:

字段 描述
_owner 指向持有该monitor的线程。当_owner为null时,表示该monitor当前未被任何线程持有,任何线程都有机会竞争获取该monitor;当_owner指向一个具体的线程时,则表示该monitor已经被此线程独占。
_EntryList 是一个等待队列,用于存放那些尝试获取monitor但未能成功的线程。这些线程会被封装成ObjectWaiter对象,并加入到_EntryList中。当_owner线程释放monitor后,_EntryList中的线程有机会再次竞争获取monitor。JVM通常会按照一定的策略(如FIFO)从_EntryList中选择一个线程来唤醒,使其尝试获取monitor。
_WaitSet 是另一个等待队列,与_EntryList不同的是,进入_WaitSet的线程是因为调用了wait()方法而主动释放了monitor,并进入等待状态。这些线程只有在被其他线程调用notify()notifyAll()方法唤醒后,才会重新进入_EntryList,再次竞争获取monitor。因此,_WaitSet中的线程处于更深层次的等待状态,它们不仅需要等待monitor可用,还需要等待特定的条件被满足。
_count 记录monitor被持有的次数。当线程成功获取monitor时,_count会递增;当线程释放monitor时,_count会递减。_count的主要作用是支持可重入锁的特性。当一个线程已经持有monitor时,它可以再次获取同一个monitor而不会被阻塞,每次获取_count都会递增。只有当线程完全释放monitor,即_count递减至0时,其他线程才有机会获取该monitor。
_recursions 记录重入的次数。当一个线程首次获取锁时,_recursions被设置为0。如果同一个线程再次获取该锁(重入),则_recursions递增。释放锁时,只有当_recursions减为0,且_count也减为0时,锁才会被完全释放,其他线程才能获取该锁。

四、synchronized 的实现细节

  1. 锁的获取:

    当线程执行到 monitorenter 指令时,会尝试获取对象的 monitor。如果 monitor 的 _owner 字段为 null,表示该 monitor 未被任何线程持有,则该线程成功获取锁,并将 _owner 设置为当前线程,_count 加 1。

    如果 _owner 字段指向当前线程,表示该线程已经持有锁,则进行重入,_count 加 1。

    如果 _owner 字段指向其他线程,表示该 monitor 已经被其他线程持有,则当前线程进入 _EntryList 队列等待。

  2. 锁的释放:

    当线程执行到 monitorexit 指令时,会释放对象的 monitor。将 _count 减 1。

    如果 _count 减为 0,表示该线程完全释放了锁,将 _owner 设置为 null,并唤醒 _EntryList 队列中的一个线程,使其尝试获取锁。

  3. 异常处理:

    为了保证锁的正确释放,即使在发生异常的情况下,JVM 也会确保执行 monitorexit 指令。这通过 Exception table 实现,在任何情况下,包括异常,都会执行 monitorexit

五、锁的优化:从重量级锁到轻量级锁、偏向锁

早期的 synchronized 实现是基于操作系统的互斥量(Mutex),这是一种重量级锁。重量级锁的获取和释放涉及到用户态和内核态的切换,开销较大。

为了减少锁的开销,Java 虚拟机 (JVM) 引入了锁的优化机制,包括:

  • 偏向锁(Biased Locking):

    偏向锁的思想是,如果一个锁总是被同一个线程持有,则该线程获取锁时不需要进行任何同步操作,直接进入同步代码块。只有当有其他线程尝试获取锁时,偏向锁才会升级为轻量级锁。

    偏向锁通过在对象头中记录偏向线程 ID 来实现。当线程第一次获取锁时,JVM 会将对象头的 Mark Word 设置为偏向模式,并将线程 ID 记录在对象头中。以后该线程再次获取锁时,只需要检查对象头的 Mark Word 是否为偏向模式,以及偏向线程 ID 是否为当前线程 ID,如果都满足条件,则直接进入同步代码块,不需要进行任何同步操作。

  • 轻量级锁(Lightweight Locking):

    轻量级锁的思想是,在没有多线程竞争的情况下,避免使用重量级锁,而是使用 CAS(Compare and Swap)操作来尝试获取锁。

    轻量级锁通过在线程栈帧中创建一个锁记录(Lock Record)来实现。当线程进入同步代码块时,JVM 会将对象头的 Mark Word 复制到锁记录中,然后尝试使用 CAS 操作将对象头的 Mark Word 更新为指向锁记录的指针。如果 CAS 操作成功,表示线程成功获取锁,可以进入同步代码块。如果 CAS 操作失败,表示有其他线程正在竞争锁,此时轻量级锁会膨胀为重量级锁。

  • 自旋锁(Spin Locking):

    自旋锁的思想是,当线程尝试获取锁失败时,不会立即阻塞,而是循环尝试获取锁,直到获取成功或者达到一定的自旋次数。

    自旋锁可以减少线程切换的开销,但如果锁被其他线程长时间持有,自旋锁会消耗大量的 CPU 资源。

锁的升级过程如下:

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

表格:锁的比较

特性 偏向锁 轻量级锁 重量级锁
适用场景 单线程访问同步代码块 多线程交替访问同步代码块,竞争不激烈 多线程并发访问同步代码块,竞争激烈
实现方式 对象头记录偏向线程 ID CAS 操作更新对象头 Mark Word 操作系统互斥量(Mutex)
开销 极低 较低
优点 避免了不必要的同步操作,提高了性能 减少了线程切换的开销,适用于竞争不激烈的场景 保证了线程安全,适用于竞争激烈的场景
缺点 如果有其他线程尝试获取锁,需要撤销偏向锁,开销较大 如果 CAS 操作失败,需要进行自旋或者膨胀为重量级锁,会消耗 CPU 资源 线程切换的开销较大,性能较低

六、synchronized 的性能优化建议

  1. 减少锁的持有时间:

    只在必要的时候才进行同步,尽量减少同步代码块的大小。

  2. 使用读写锁(ReadWriteLock):

    如果读操作远多于写操作,可以使用读写锁来提高并发性能。读写锁允许多个线程同时进行读操作,但只允许一个线程进行写操作。

  3. 使用并发容器(Concurrent Collections):

    Java 提供了许多并发容器,例如 ConcurrentHashMapCopyOnWriteArrayList 等,这些容器内部已经实现了线程安全,不需要显式地进行同步。

  4. 避免死锁:

    死锁是指多个线程互相等待对方释放锁,导致所有线程都无法继续执行的情况。为了避免死锁,可以遵循以下原则:

    • 避免持有多个锁。
    • 按照固定的顺序获取锁。
    • 使用定时锁(tryLock),在等待一定时间后放弃获取锁。

代码示例:使用读写锁

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private String data;

    public String readData() {
        lock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + ": Reading data.");
            return data;
        } finally {
            lock.readLock().unlock();
        }
    }

    public void writeData(String newData) {
        lock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + ": Writing data.");
            this.data = newData;
        } finally {
            lock.writeLock().unlock();
        }
    }
}

七、总结:理解 synchronized 的底层运作

synchronized 关键字是 Java 中实现并发控制的重要手段。通过理解 monitorentermonitorexit 字节码指令,以及 Monitor 的内部结构,我们可以更深入地了解 synchronized 的底层实现原理。JVM 针对 synchronized 锁的优化机制,如偏向锁、轻量级锁和自旋锁,可以有效地提高并发性能。在实际开发中,我们需要根据具体的应用场景选择合适的并发控制策略,以充分利用多核 CPU 的性能。

锁的优化减少了开销,合理的并发策略能够充分利用硬件资源。

发表回复

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