MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java中的锁机制:ReentrantLock与Synchronized

2023-06-015.0k 阅读

Java 中的锁机制:ReentrantLock 与 Synchronized

在 Java 多线程编程领域,锁机制是控制并发访问、确保数据一致性和线程安全的重要手段。ReentrantLocksynchronized是 Java 中最常用的两种锁实现,它们虽然都能达到线程同步的目的,但在实现原理、使用方式和特性等方面存在诸多差异。深入理解这些差异,对于编写高效、稳定的多线程程序至关重要。

1. synchronized 关键字

1.1 基本使用

synchronized关键字可以用于修饰方法或代码块。当修饰实例方法时,锁对象是当前实例;当修饰静态方法时,锁对象是类的Class对象;当修饰代码块时,需要指定一个具体的锁对象。

修饰实例方法

public class SynchronizedExample {
    public synchronized void instanceMethod() {
        // 同步代码
        System.out.println("This is a synchronized instance method.");
    }
}

在上述代码中,instanceMethod是一个实例方法,被synchronized修饰。当一个线程调用该方法时,它会自动获取当前实例的锁。只有获得锁的线程才能执行方法体中的代码,其他线程必须等待锁的释放。

修饰静态方法

public class SynchronizedStaticExample {
    public static synchronized void staticMethod() {
        // 同步代码
        System.out.println("This is a synchronized static method.");
    }
}

这里的staticMethod是静态方法,被synchronized修饰。此时,锁对象是SynchronizedStaticExample.class。所有调用该静态方法的线程都竞争这个类级别的锁。

修饰代码块

public class SynchronizedBlockExample {
    private final Object lock = new Object();

    public void blockMethod() {
        synchronized (lock) {
            // 同步代码
            System.out.println("This is a synchronized block.");
        }
    }
}

blockMethod中,通过synchronized(lock)指定了一个具体的锁对象lock。只有获取到lock锁的线程才能执行代码块中的内容。

1.2 实现原理

从字节码层面来看,当使用synchronized修饰方法时,在方法的访问标志中会增加ACC_SYNCHRONIZED标志。JVM 在执行该方法时,会先检查方法是否有这个标志,如果有,则线程在进入方法前会自动获取锁,在方法执行完毕或抛出异常时自动释放锁。

当使用synchronized修饰代码块时,会生成monitorentermonitorexit字节码指令。monitorenter指令会尝试获取对象的监视器(锁),如果获取成功,该线程成为监视器的所有者;monitorexit指令则释放监视器。

在 HotSpot 虚拟机中,对象在内存中的布局包含对象头部分,对象头中有一部分用于存储锁相关的信息,如是否为偏向锁、轻量级锁的指针等。根据不同的竞争情况,锁会从偏向锁、轻量级锁逐步升级到重量级锁,这一过程被称为锁升级。

偏向锁:当一个线程首次访问同步块时,会在对象头中记录该线程的 ID,以后该线程再次进入同步块时,无需再次获取锁,只需要检查对象头中的线程 ID 是否与自己一致。偏向锁旨在减少无竞争情况下的锁开销。

轻量级锁:当有第二个线程访问同步块时,偏向锁会升级为轻量级锁。此时,线程会通过自旋的方式尝试获取锁,而不是立即进入阻塞状态。自旋是指线程在一段时间内不断尝试获取锁,而不放弃 CPU 资源,以期望在短时间内获取到锁,避免线程上下文切换的开销。

重量级锁:如果自旋一定次数后仍未获取到锁,轻量级锁会升级为重量级锁。重量级锁会使竞争的线程进入阻塞状态,等待操作系统的调度,这种方式会带来较大的线程上下文切换开销。

1.3 特性

  • 可重入性synchronized是可重入的。一个线程如果已经获得了某个对象的锁,那么它在调用该对象中其他被synchronized修饰的方法时,无需再次获取锁。例如:
public class ReentrantSynchronizedExample {
    public synchronized void outerMethod() {
        System.out.println("Enter outer method.");
        innerMethod();
    }

    public synchronized void innerMethod() {
        System.out.println("Enter inner method.");
    }
}

在上述代码中,outerMethod调用innerMethod时,由于当前线程已经持有了ReentrantSynchronizedExample实例的锁,所以可以直接进入innerMethod,不会出现死锁。

  • 公平性synchronized是非公平锁。在非公平锁的机制下,当一个线程释放锁后,新的线程有机会直接获取锁,而无需等待在队列中的其他线程。这种方式可以提高系统的吞吐量,但可能会导致某些线程长时间等待。

2. ReentrantLock

2.1 基本使用

ReentrantLock是 Java 并发包java.util.concurrent.locks中的一个类,提供了比synchronized更灵活的锁控制。

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void lockMethod() {
        lock.lock();
        try {
            // 同步代码
            System.out.println("This is a ReentrantLock protected method.");
        } finally {
            lock.unlock();
        }
    }
}

在上述代码中,通过lock.lock()获取锁,lock.unlock()释放锁。需要注意的是,释放锁的操作通常放在finally块中,以确保无论代码块中是否抛出异常,锁都能被正确释放,避免死锁。

2.2 实现原理

ReentrantLock基于 AQS(AbstractQueuedSynchronizer)框架实现。AQS 是一个用于构建锁和同步器的框架,它使用一个 FIFO 队列来管理等待获取锁的线程。

ReentrantLock内部有一个静态内部类Sync继承自AbstractQueuedSynchronizerSync类重写了tryAcquiretryRelease等方法,用于实现获取锁和释放锁的逻辑。

当调用lock()方法时,首先会尝试通过tryAcquire方法获取锁。如果获取成功,则当前线程成为锁的持有者;如果获取失败,则会将当前线程封装成一个节点加入到 AQS 的等待队列中,并进入阻塞状态。

当调用unlock()方法时,会通过tryRelease方法释放锁。如果锁成功释放,会唤醒等待队列中的头节点线程,使其有机会获取锁。

2.3 特性

  • 可重入性ReentrantLock同样是可重入的。同一个线程可以多次获取同一个锁,每次获取锁时,锁的持有计数会增加,每次释放锁时,持有计数会减少,当持有计数为 0 时,锁被完全释放。
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();

    public void outerMethod() {
        lock.lock();
        try {
            System.out.println("Enter outer method.");
            innerMethod();
        } finally {
            lock.unlock();
        }
    }

    public void innerMethod() {
        lock.lock();
        try {
            System.out.println("Enter inner method.");
        } finally {
            lock.unlock();
        }
    }
}

在上述代码中,outerMethod调用innerMethod时,同一个线程可以多次获取锁,不会出现死锁。

  • 公平性ReentrantLock可以通过构造函数来选择是否为公平锁。默认情况下,ReentrantLock是非公平锁,与synchronized类似。但如果使用ReentrantLock(boolean fair)构造函数并传入true,则可以创建一个公平锁。在公平锁机制下,等待时间最长的线程会优先获取锁,避免了线程饥饿问题,但公平锁的实现会带来额外的开销,导致吞吐量相对较低。

3. ReentrantLockSynchronized 的比较

3.1 功能特性

  • 锁的获取与释放灵活性ReentrantLock提供了更灵活的锁获取和释放方式。除了lock()unlock()方法外,它还提供了tryLock()方法尝试获取锁,如果获取不到立即返回false,而不是像synchronized那样一直等待。此外,tryLock(long timeout, TimeUnit unit)方法可以在指定时间内尝试获取锁,这在一些需要限时等待锁的场景中非常有用。而synchronized一旦进入同步块,必须等待锁自然释放,没有类似的灵活控制。

  • 公平性:如前文所述,synchronized只能是非公平锁,而ReentrantLock可以通过构造函数选择是否为公平锁。公平锁在某些对线程公平性要求较高的场景下很重要,例如数据库连接池的线程调度,需要确保每个线程都有机会获取连接,避免某些线程长时间等待。

  • 锁的绑定synchronized是与对象紧密绑定的,当修饰实例方法时,锁是当前实例;修饰静态方法时,锁是类的Class对象。而ReentrantLock是一个独立的对象,可以在多个不同的代码块或方法中使用同一个ReentrantLock对象来控制同步,这在一些复杂的业务逻辑中提供了更大的灵活性。

3.2 性能表现

在低竞争环境下,synchronized由于是 JVM 内置的关键字,经过了大量的优化,性能与ReentrantLock相近。但在高竞争环境下,synchronized的性能会因为锁升级、线程阻塞和唤醒等开销而下降。ReentrantLock的非公平锁在高竞争环境下可以通过减少线程上下文切换来提高吞吐量,因为它允许新线程在锁可用时立即获取锁,而无需等待队列中的其他线程。但如果使用公平锁,ReentrantLock的性能会因为公平性的保证而有所下降,因为需要维护等待队列的顺序。

3.3 应用场景

  • 简单场景:如果业务逻辑简单,对锁的功能要求不高,只是为了确保基本的线程安全,synchronized关键字是一个很好的选择。它的语法简洁,不需要额外引入并发包,对于初学者来说更容易理解和使用。例如,在一个简单的单例模式实现中,如果需要保证线程安全,可以使用synchronized修饰获取单例实例的方法。

  • 复杂场景:当需要更灵活的锁控制,如公平锁、限时获取锁、可中断获取锁等功能时,ReentrantLock更适合。例如,在实现一个高性能的缓存系统时,可能需要在获取锁时设置超时时间,避免线程长时间等待导致系统响应变慢,这时ReentrantLocktryLock(long timeout, TimeUnit unit)方法就非常有用。

4. 示例代码对比

4.1 简单计数器示例

使用synchronized

public class SynchronizedCounter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

使用ReentrantLock

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockCounter {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

在这个简单的计数器示例中,synchronized通过修饰方法来实现同步,而ReentrantLock通过显式的lock()unlock()方法来控制同步。

4.2 公平性对比示例

ReentrantLock公平锁示例

import java.util.concurrent.locks.ReentrantLock;

public class FairReentrantLockExample {
    private static final ReentrantLock lock = new ReentrantLock(true);

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + " acquired the lock.");
                } finally {
                    lock.unlock();
                }
            }).start();
        }
    }
}

ReentrantLock非公平锁示例(默认)

import java.util.concurrent.locks.ReentrantLock;

public class UnfairReentrantLockExample {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + " acquired the lock.");
                } finally {
                    lock.unlock();
                }
            }).start();
        }
    }
}

在这两个示例中,通过设置ReentrantLock的构造函数参数,可以观察到公平锁和非公平锁在多线程竞争锁时的不同表现。公平锁会按照线程等待的顺序分配锁,而非公平锁可能会让新线程有机会插队获取锁。

4.3 限时获取锁示例

使用ReentrantLock限时获取锁

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

public class TimeoutReentrantLockExample {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " acquired the lock.");
                // 模拟长时间操作
                try {
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } finally {
                lock.unlock();
            }
        }).start();

        new Thread(() -> {
            try {
                if (lock.tryLock(2, TimeUnit.SECONDS)) {
                    try {
                        System.out.println(Thread.currentThread().getName() + " acquired the lock within timeout.");
                    } finally {
                        lock.unlock();
                    }
                } else {
                    System.out.println(Thread.currentThread().getName() + " failed to acquire the lock within timeout.");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

在上述示例中,第二个线程尝试在 2 秒内获取锁。如果在规定时间内获取到锁,则执行相应的业务逻辑;否则,输出获取锁失败的信息。这种限时获取锁的功能在ReentrantLock中很容易实现,而synchronized则无法直接提供类似功能。

5. 总结

ReentrantLocksynchronized是 Java 多线程编程中重要的锁机制。synchronized作为 JVM 内置的关键字,语法简洁,适用于简单的线程同步场景,在低竞争环境下性能表现良好。ReentrantLock基于 AQS 框架实现,提供了更灵活的锁控制功能,如公平性选择、限时获取锁等,在高竞争环境下通过合理配置可以获得更好的性能。在实际应用中,需要根据具体的业务需求和场景特点,选择合适的锁机制来确保多线程程序的正确性和高效性。通过深入理解它们的原理和特性,开发者能够编写出更健壮、更高效的并发程序。