阿里二面:Java中锁的分类有哪些?你能说全吗?

引言

在多线程并发编程场景中,锁作为一种至关重要的同步工具,承担着协调多个线程对共享资源访问秩序的任务。其核心作用在于确保在特定时间段内,仅有一个线程能够对资源进行访问或修改操作,从而有效地保护数据的完整性和一致性。锁作为一种底层的安全构件,有力地防止了竞态条件和数据不一致性的问题,尤其在涉及多线程或多进程共享数据的复杂场景中显得尤为关键。

而了解锁的分类,能帮助我们何种业务场景下使用选择哪种锁。

Java中锁分类.jpg

基于锁的获取与释放方式分类

计划于所得获取与释放方式进行分类,Java中的锁可以分为:显式锁和隐式锁。

隐式锁

Java中的隐式锁(也称为内置锁或自动锁)是通过使用synchronized关键字实现的一种线程同步机制。当一个线程进入被synchronized修饰的方法或代码块时,它会自动获得对象级别的锁,退出该方法或代码块时则会自动释放这把锁。

在Java中,隐式锁的实现机制主要包括以下两种类型:

  1. 互斥锁(Mutex): 虽然Java标准库并未直接暴露操作系统的互斥锁提供使用,但在Java虚拟机对synchronized关键字处理的底层实现中,当锁竞争激烈且必须升级为重量级锁时,会利用操作系统的互斥量机制来确保在同一时刻仅允许一个线程持有锁,从而实现严格的线程互斥控制。

  2. 内部锁(Intrinsic Lock)或监视器锁(Monitor Lock): Java语言为每个对象内建了一个监视器锁,这是一个更高级别的抽象。我们可以通过使用synchronized关键字即可便捷地管理和操作这些锁。当一个线程访问被synchronized修饰的方法或代码块时,会自动获取相应对象的监视器锁,并在执行完毕后自动释放,这一过程对用户透明,故被称为隐式锁。每个Java对象均与一个监视器锁关联,以此来协调对该对象状态访问的并发控制。

优点:

  1. 简洁易用:程序员无需手动管理锁的获取和释放过程,降低了编程复杂性。
  2. 安全性:隐式锁确保了线程安全,避免了竞态条件,因为一次只有一个线程能持有锁并执行同步代码块。
  3. 异常处理下的自动释放:即使在同步代码块中抛出异常,隐式锁也会在异常退出时被释放,防止死锁。

缺点:

  1. 锁定粒度:隐式锁的粒度通常是对象级别,这意味着如果一个大型对象的不同部分实际上可以独立地被不同线程访问,但由于整个对象被锁定,可能导致不必要的阻塞和较低的并发性能。
  2. 不灵活:相对于显示锁(如java.util.concurrent.locks.Lock接口的实现类),隐式锁的功能较有限,无法提供更细粒度的控制,如尝试获取锁、定时等待、可中断的获取锁等高级特性。
  3. 锁竞争影响:在高并发环境下,若多个线程竞争同一把锁,可能会引发“锁争用”,导致性能下降,特别是在出现锁链和死锁的情况下。

适用场景: 隐式锁适用于相对简单的多线程同步需求,尤其是在只需要保护某个对象状态完整性,且无需过多关注锁策略灵活性的场合。对于要求更高并发性和更复杂锁管理逻辑的应用场景,显示锁通常是一个更好的选择。

显式锁

显式锁是由java.util.concurrent.locks.Lock接口及其诸多实现类提供的同步机制,相较于通过synchronized关键字实现的隐式锁机制,显式锁赋予开发者更为精细和灵活的控制能力,使其能够在多线程环境中精准掌控同步动作。显式锁的核心作用在于确保在任何时刻仅有一个线程能够访问关键代码段或共享数据,从而有效防止数据不一致性问题和竞态条件。

相较于隐式锁,显式锁提供了更为多样化的锁操作选项,包括但不限于支持线程在等待锁时可被中断、根据先后顺序分配锁资源的公平锁与非公平锁机制,以及能够设定锁获取等待时间的定时锁功能。这些特性共同增强了显式锁在面对复杂并发场景时的适应性和可调控性,使之成为解决高度定制化同步需求的理想工具。

日常开发中,常见的显式锁分类有如下几种:

  1. ReentrantLock:可重入锁,继承自Lock接口,支持可中断锁、公平锁和非公平锁的选择。可重入意味着同一个线程可以多次获取同一线程持有的锁。
  2. ReentrantReadWriteLock:读写锁,提供了两个锁,一个是读锁,允许多个线程同时读取;另一个是写锁,同一时间内只允许一个线程写入,写锁会排斥所有读锁和写锁。
  3. StampedLock:带版本戳的锁,提供了乐观读、悲观读写模式,适合于读多写少的场景,可以提升系统性能。

优点:

  1. 灵活控制:显式锁提供了多种获取和释放锁的方式,可以根据实际需求进行选择,比如中断等待锁的线程,设置超时获取锁等。
  2. 性能优化:在某些特定场景下,显式锁可以提供比隐式锁更好的性能表现,尤其是当需要避免死锁或优化读多写少的情况时。
  3. 公平性选择:显式锁允许创建公平锁,按照线程请求锁的顺序给予响应,保证所有线程在等待锁时有一定的公平性。

缺点:

  1. 使用复杂:相较于隐式锁,显式锁需要手动调用lock()unlock()方法,增加了编程复杂性,如果不正确地使用(如忘记释放锁或未捕获异常导致锁未释放),容易造成死锁或其他并发问题。
  2. 性能开销:在某些简单场景下,显式锁的额外API调用和锁状态管理可能带来额外的性能开销,尤其当公平锁启用时,由于需要维护线程队列和线程调度,可能会影响整体性能。
  3. 错误可能性:由于显式锁的操作更加细致,因此更容易出错,开发者需要具备较高的并发编程意识和技能才能妥善使用。

基于对资源的访问权限

按照线程对资源的访问权限来分类,可以将锁分为:独占锁(Exclusive Lock)和共享锁(Shared Lock)。

独占锁

独占锁(Exclusive Lock),又称排他锁或写锁,是一种同步机制,它确保在任一时刻,最多只有一个线程可以获得锁并对受保护的资源进行访问或修改。一旦线程获得了独占锁,其他所有试图获取同一锁的线程将被阻塞,直到拥有锁的线程释放锁为止。独占锁主要用于保护那些在并发环境下会被多个线程修改的共享资源,确保在修改期间不会有其他线程干扰,从而维护数据的一致性和完整性。

对于独占锁就像图书馆里的某本书,这本书只有唯一的一本。当一个读者想要借阅这本书时,他会去图书管理员那里登记并拿到一个“借书凭证”(相当于独占锁)。此时,这本书就被锁定了,其他读者无法借阅这本书,直至第一个读者归还书本并交回“借书凭证”。这就像是线程获得了独占锁,只有拥有锁的线程可以修改或操作资源(书本),其他线程必须等待锁的释放才能执行相应的操作。

而独占锁的实现方式,主要有如下两种:

  1. synchronized关键字:通过synchronized关键字实现的隐式锁,它是独占锁的一种常见形式,任何时刻只有一个线程可以进入被synchronized修饰的方法或代码块。
  2. ReentrantLock:可重入的独占锁,提供了更多的控制方式,包括可中断锁、公平锁和非公平锁等。

优点:

  1. 简单易用:对于synchronized关键字,语法简单直观,易于理解和使用。
  2. 线程安全:确保了对共享资源的独占访问,避免了并发环境下的数据竞争问题。
  3. 可重入性:像ReentrantLock这样的锁,支持同一个线程重复获取同一把锁,提高了线程间协作的便利性。

缺点:

  1. 粒度固定:对于synchronized,锁的粒度是固定的,无法动态调整,可能导致不必要的阻塞。
  2. 缺乏灵活性:隐式锁不能主动中断等待锁的线程,也无法设置超时等待。
  3. 性能瓶颈:在高度竞争的环境中,synchronized可能会造成上下文切换频繁,效率低下;而显式锁虽提供了更灵活的控制,但如果使用不当也可能导致额外的性能损失。

共享锁

共享锁(Shared Lock)也称为读锁(Read Lock),是一种多线程或多进程并发控制的同步机制,它允许多个线程同时读取共享资源,但不允许任何线程修改资源。在数据库系统和并发编程中广泛使用,确保在并发读取场景下数据的一致性。

共享锁就像图书馆里有一套多人阅读的杂志合订本,这套合订本可以被多个读者同时翻阅,但是任何人都不能带走或在上面做标记。当一个读者要阅读时,他会向图书管理员申请“阅读凭证”(相当于共享锁)。如果有多个读者想阅读,图书管理员会给他们每人一份阅读凭证,这样大家都可以坐在阅览室里一起阅读这套合订本,但是都不能单独占有或改变它。在并发编程中,多个线程可以同时获取共享锁进行读取操作,但都不能修改数据,这就像是多个线程同时持有共享锁读取资源,但不允许在此期间进行写操作。

实现共享锁的关键机制是读写锁(ReadWriteLock),这是一种特殊类型的共享锁机制,它巧妙地将对共享资源的访问权限划分为了读取权限和写入权限两类。在读写锁的控制下,多个线程可以同时进行对共享数据的读取操作,形成并发读取,而对数据的写入操作则采取独占式处理,确保同一时间段内仅有一个线程执行写入操作。在写入操作正在进行时,无论是其他的读取操作还是写入操作都会被暂时阻塞,直至写操作结束。

读写锁包含两种锁模式:读锁(ReadLock) 和 写锁(WriteLock)。当多个线程需要访问同一份共享数据时,只要这些线程都是进行读取操作,则都能成功获取并持有读锁,从而实现并行读取。然而,一旦有线程尝试进行写入操作,那么不论是其他正在执行读取的线程还是准备进行写入的线程,都无法继续获取读锁或写锁,直至当前写操作全部完成并释放写锁。这样,读写锁有效地平衡了读取密集型任务的并发性和写入操作的原子性要求。

优点:

  1. 提高并发性:对于读多写少的场景,共享锁可以使多个读取操作并行执行,显著提高系统的并发性能。
  2. 数据保护:在读取阶段避免了数据被意外修改,确保读取到的是稳定的数据状态。

缺点:

  1. 写操作阻塞:只要有共享锁存在,其他事务就不能对数据加排他锁(Exclusive Lock)进行写操作,这可能导致写操作长时间等待,降低系统的写入性能。
  2. 可能导致死锁:在复杂的事务交互中,如果没有合适的锁管理策略,共享锁可能会参与到死锁循环中,导致事务无法正常完成。
  3. 数据一致性问题:虽然共享锁能保护读取过程中数据不被修改,但并不能阻止数据在读取操作之后立即被其他事务修改,对于要求强一致性的应用可能不够。

如以下使用共享锁示例:

public class SharedResource {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
    private int data;

    public void modifyData(int newData) {
        // 获取写锁(独占锁),在同一时刻只有一个线程可以获取写锁
        writeLock.lock();
        System.out.println(Thread.currentThread().getName() + " Modify Data");
        try {
            // 修改数据
            this.data = newData;
            // 数据修改相关操作...
        } finally {
            // 无论如何都要确保解锁
            writeLock.unlock();
        }
    }

    public int readData() {
        // 获取读锁(共享锁),允许多个线程同时获取读锁进行读取操作
        readLock.lock();
        System.out.println(Thread.currentThread().getName() + " Read Data");
        try {
            // 读取数据,此时其他读取线程也可以同时读取,但不允许写入
            return this.data;
        }finally {
            // 释放读锁
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread reader1 = new Thread(() -> System.out.println("Reader 1 reads: " + resource.readData()), "Reader1");
        Thread reader2 = new Thread(() -> System.out.println("Reader 2 reads: " + resource.readData()), "Reader1");

        Thread writer = new Thread(() -> resource.modifyData(42), "Writer1");

        reader1.start();
        reader2.start();
        writer.start();

        // 等待所有线程执行完成
        try {
            reader1.join();
            reader2.join();
            writer.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

打印结果:

image.png

在这个示例中,使用了 ReentrantReadWriteLock 来控制对 data 的读写操作。readData() 方法使用读锁,允许多个线程同时读取数据,而 modifyData() 方法使用写锁,确保同一时间只有一个线程可以修改数据。这样就可以在并发场景下既保证数据读取的并发性,又避免了数据因并发写入而造成的不一致性问题。

基于锁的占有权是否可重入

按照锁的占有权是否可以重入,可以把锁分为:可重入锁以及不可重入锁。

可重入锁

可重入锁(Reentrant Lock)作为一种线程同步机制,具备独特的重入特性,即当线程已经获取了锁后,它可以再次请求并成功获得同一把锁,从而避免了在递归调用或嵌套同步块中产生的死锁风险。这意味着在执行锁保护的代码区域时,即便调用了其他同样被该锁保护的方法或代码片段,持有锁的线程也能顺利完成操作。

在多线程环境下,可重入锁扮演着至关重要的角色,它严格限制了同一时间只能有一个线程访问特定的临界区,有效防止了并发访问引发的数据不一致和竞态条件问题。此外,通过允许线程在持有锁的状态下重新获取该锁,可重入锁巧妙地解决了同类锁之间由于互相等待而形成的潜在死锁状况,从而提升了多线程同步的安全性和可靠性。

可重入锁主要可以通过以下三种方式实现:

  1. synchronized关键字:synchronized关键字实现的隐式锁就是一种可重入锁。
  2. ReentrantLockjava.util.concurrent.locks.ReentrantLock类实现了Lock接口,提供了显式的可重入锁功能,它允许更细粒度的控制,例如支持公平锁、非公平锁,以及可中断锁、限时锁等。
  3. ReentrantReadWriteLockReentrantReadWriteLock 是一种特殊的可重入锁,它通过读写锁的设计,既实现了可重入特性的线程安全,又能高效地处理读多写少的并发场景。

优点:

  1. 线程安全性:确保了在多线程环境下的数据一致性。
  2. 可重入性:简化了代码编写,特别是在递归调用或嵌套同步块的场景中。
  3. 灵活性:显式可重入锁(如ReentrantLock)提供了更多控制选项,如尝试获取锁、设置锁的公平性、中断等待线程等。

缺点:

  1. 使用复杂性:相比于隐式锁(synchronized),显式锁需要手动管理锁的获取和释放,增加了编程复杂性和出错概率。
  2. 性能开销:在某些情况下,显式锁可能因为额外的API调用和状态管理而带来一定的性能开销。
  3. 死锁风险:如果开发者不谨慎地管理锁的获取和释放顺序,或者滥用锁的特性,可能会导致死锁的发生。尤其是对于显式锁,如果未正确释放,可能会导致资源无法回收。

以下为可重入锁使用示例:

public class ReentrantLockExample {

    private final Lock lock = new ReentrantLock();

    // 假设这是一个需要同步访问的共享资源
    private int sharedResource;

    public void increment() {
        // 获取锁
        lock.lock();

        try {
            // 在锁保护下执行操作
            sharedResource++;

            // 这里假设有个内部方法也需要同步访问sharedResource
            doSomeOtherWork();
        } finally {
            // 无论发生什么情况,最后都要释放锁
            lock.unlock();
        }
    }

    // 可重入的内部方法
    private void doSomeOtherWork() {
        // 因为当前线程已经持有锁,所以可以再次获取
        lock.lock();

        try {
            // 执行依赖于sharedResource的操作
            sharedResource -= 1;
            System.out.println("Inner method executed with sharedResource: " + sharedResource);
        } finally {
            // 释放锁
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample example = new ReentrantLockExample();

        Thread thread1 = new Thread(example::increment);
        Thread thread2 = new Thread(example::increment);

        thread1.start();
        thread2.start();

        // 等待两个线程执行完毕
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出最终的sharedResource值
        System.out.println("Final sharedResource value: " + example.sharedResource);
    }
}

image.png

示例中,increment()方法和内部的doSomeOtherWork()方法都需要在获取锁的情况下执行。由于ReentrantLock是可重入的,所以在increment()方法内部调用doSomeOtherWork()时,线程仍然可以成功获取锁,并继续执行操作。当所有操作完成时,通过finally块确保了锁的释放。这样可以避免死锁,并确保在多线程环境下对共享资源的访问是线程安全的。

不可重入锁

不可重入锁(Non-reentrant Lock)是一种线程同步机制,它的核心特征在于禁止同一个线程在已经持有锁的前提下再度获取相同的锁。若一个线程已取得不可重入锁,在其执行路径中遇到需要再次获取该锁的场景时,该线程将会被迫等待,直至原先获取的锁被释放,其他线程才有可能获取并执行相关临界区代码。

此类锁机制同样服务于多线程环境下的资源共享保护,旨在确保同一时间内仅有单一线程能够访问临界资源,从而有效规避数据不一致性和竞态条件等问题。相较于可重入锁,不可重入锁在递归调用或涉及锁嵌套的复杂同步场景下表现出局限性,因其可能导致线程阻塞和潜在的死锁风险,降低了线程同步的灵活性和安全性。在实际开发中,除非有特殊的需求或场景约束,否则更建议采用可重入锁以实现更为稳健高效的线程同步控制。

在Java标准库中并没有直接提供名为“不可重入锁”的内置锁,通常我们会通过对比ReentrantLock(可重入锁)来理解不可重入锁的概念。理论上,任何不具备可重入特性的锁都可以认为是不可重入锁。但在实际应用中,Java的synchronized关键字修饰的方法或代码块在早期版本中曾经存在过类似不可重入的行为,但在目前Java的所有版本中,synchronized关键字所实现的锁实际上是可重入的。

优点:

  1. 简单性:从实现角度来看,不可重入锁可能在设计和实现上相对简单,因为它不需要处理递归锁定的复杂性。

缺点:

  1. 容易引发死锁:如果在一个线程已持有不可重入锁的情况下,它又试图再次获取同一把锁,那么就可能导致死锁。因为线程自身无法进一步推进,也无法释放已持有的锁,其他线程也无法获取锁,从而形成死锁状态。
  2. 限制性较强:不可重入锁极大地限制了线程的自由度,特别是在递归调用或含有嵌套锁的复杂同步结构中,往往无法满足需求。
  3. 线程栈跟踪复杂:对于编程者而言,需要更加小心地管理锁的层次结构,以防止无意间陷入死锁或资源浪费的情况。

基于锁的获取公平性

按照获取锁的公平性,也即请求顺序,将锁分为公平锁盒非公平锁。

公平锁

公平锁是一种线程调度策略,在多线程环境下,当多个线程尝试获取锁时,锁的分配遵循“先请求先服务”(First-Come, First-Served, FCFS)原则,即按照线程请求锁的顺序来分配锁资源。这意味着等待时间最长的线程将优先获得锁。公平锁可以有效避免某个线程长期得不到锁而导致的饥饿现象,所有线程都有平等获取锁的机会。它确保了线程的调度更加有序,减少了不公平竞争导致的不确定性。

公平锁的实现,可以通过java.util.concurrent.locks.ReentrantLock的构造函数传入true参数,可以创建一个公平的ReentrantLock实例。

ReentrantLock fairLock = new ReentrantLock(true); //创建一个公平锁

优点:

  1. 公平性:所有线程都遵循先来后到的原则,不会出现新来的线程总是抢占锁的现象,提高了系统的公平性和稳定性。
  2. 避免线程饥饿:减少或消除了由于锁的不公平分配而导致的线程长时间等待锁的情况。

缺点:

  1. 性能开销:公平锁在每次释放锁后,都需要检查是否有等待时间更长的线程,这通常涉及到线程调度的额外开销,可能会降低系统的整体并发性能。
  2. 线程上下文切换频繁:为了实现公平性,可能需要频繁地进行线程上下文切换,而这本身就是一种相对昂贵的操作。
  3. 可能导致“convoy effect”:即大量线程因等待前面线程释放锁而形成队列,即使后来的线程只需要很短时间处理,也会不得不等待整个队列中的线程依次完成,从而降低了系统的吞吐量。

以下使用公平锁示例:

public class FairLockExample {

    private final ReentrantLock fairLock = new ReentrantLock(true); // 使用true参数创建公平锁

    public void criticalSection() {
        fairLock.lock(); // 获取公平锁

        try {
            // 在此区域内的代码是临界区,同一时间只有一个线程可以执行
            System.out.println(Thread.currentThread().getName() + " entered the critical section at " + LocalDateTime.now());
            // 模拟耗时操作
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            fairLock.unlock(); // 释放公平锁
        }
    }

    public static void main(String[] args) {
        final FairLockExample example = new FairLockExample();

        Runnable task = () -> {
            example.criticalSection();
        };

        // 创建并启动多个线程
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(task);
            t.start();
        }
    }
}

image.png
在这个示例中,我们创建一个公平锁,我们创建了多个线程,每个线程都在执行criticalSection方法,该方法内部的代码块受到公平锁的保护,因此在任何时候只有一个线程能在临界区内执行。当多个线程尝试获取锁时,它们会按照请求锁的顺序来获取锁,确保线程调度的公平性。

非公平锁

非公平锁是一种线程调度策略,在多线程环境下,当多个线程尝试获取锁时,锁的分配不遵循“先请求先服务”(First-Come, First-Served, FCFS)原则,而是允许任何等待锁的线程在锁被释放时尝试获取,即使其他线程已经在等待队列中等待更长时间。非公平锁在某些场景下可以提高系统的并发性能,因为它允许刚释放锁的线程或者其他新到达的线程立刻获取锁,而不是强制排队等待。

实现方式也同公平锁,也是通过java.util.concurrent.locks.ReentrantLock的构造函数,但是我们要传入false参数,可以创建一个非公平的ReentrantLock实例。

ReentrantLock fairLock = new ReentrantLock(false); //创建一个非公平锁

优点:

  1. 性能优化:非公平锁在某些条件下可能会提供更高的系统吞吐量,因为它允许线程更快地获取锁,减少线程上下文切换次数,尤其在锁竞争不激烈的场景下,这种效果更为明显。

缺点:

  1. 线程饥饿:非公平锁可能导致某些线程长时间无法获取锁,即存在线程饥饿的风险,因为新到达的线程可能连续多次获取锁,而早前就已经在等待的线程始终得不到执行机会。
  2. 难以预测的线程调度:非公平锁会导致线程调度的不确定性增大,不利于系统的稳定性和性能分析。
  3. 潜在的连锁反应:非公平锁可能导致线程之间的依赖关系变得复杂,可能会引发连锁反应,影响整体系统的性能和稳定性。

基于对共享资源的访问方式

我们常说或者常用的悲观锁以及乐观锁就是以对共享资源的访问方式来区分的。

悲观锁

悲观锁(Pessimistic Lock)是一种并发控制策略,它假设在并发环境下,多个线程对共享资源的访问极有可能发生冲突,因此在访问资源之前,先尝试获取并锁定资源,直到该线程完成对资源的访问并释放锁,其他线程才能继续访问。悲观锁的主要作用是在多线程环境中防止数据被并发修改,确保数据的一致性和完整性。当一个线程获取了悲观锁后,其他线程必须等到锁释放后才能访问相应资源,从而避免了数据竞态条件和脏读等问题。悲观锁适合写操作较多且读操作较少的并发场景。

而悲观锁的实现可以通过synchronized关键字实现的对象锁或类锁。或者通过java.util.concurrent.locks.Lock接口的实现类,如ReentrantLock

悲观锁虽然在并发场景下数据的一致性和完整性。但是他却有一些缺点,例如:

  1. 性能开销:频繁的加锁和解锁操作可能带来较大的性能消耗,尤其是在高并发场景下,可能导致线程频繁上下文切换。
  2. 可能导致死锁:如果多个线程间的锁获取顺序不当,容易造成死锁。
  3. 资源利用率低:在读多写少的场景下,悲观锁可能导致大量的读取操作等待,降低系统的并发能力和响应速度。

以下我们使用显式锁ReentrantLock实现一个悲观锁的示例:

import java.util.concurrent.locks.ReentrantLock;

public class Account {
    private final ReentrantLock lock = new ReentrantLock();
    private double balance;

    public void deposit(double amount) {
        lock.lock();
        try {
            // 持有锁进行存款操作
            balance += amount;
            // 更新账户余额的其他逻辑...
        } finally {
            lock.unlock(); // 保证锁一定会被释放
        }
    }

    public void withdraw(double amount) {
        lock.lock();
        try {
            // 持有锁进行取款操作
            if (balance >= amount) {
                balance -= amount;
                // 更新账户余额的其他逻辑...
            }
        } finally {
            lock.unlock();
        }
    }
}

乐观锁

乐观锁并不是Java本身提供的某种内置锁机制,而是指一种并发控制策略,它基于乐观假设:即在并发访问环境下,认为数据竞争不太可能发生,所以在读取数据时并不会立即加锁。乐观锁适用于读多写少的场景或者并发较少的场景。

Java中的乐观锁通过CAS(Compare and Swap / Compare and Set)算法实现,而数据库层面我们常使用版本号或者时间戳等进行控制。

CAS(Compare and Swap / Compare and Set): Java提供了java.util.concurrent.atomic包中的原子类,如AtomicIntegerAtomicLong等,它们通过CAS操作来实现乐观锁。CAS操作是一个原子指令,它只会修改数据,当且仅当该数据的当前值等于预期值时才进行修改。例如,AtomicInteger中的compareAndSet方法就是在乐观锁思想下实现的一种无锁化更新操作。

import java.util.concurrent.atomic.AtomicInteger;

AtomicInteger counter = new AtomicInteger(0);

// 乐观锁更新示例
public void incrementCounter() {
    while (true) {
        int expected = counter.get();
        int updated = expected + 1;
        if (counter.compareAndSet(expected, updated)) {
            // 更新成功,退出循环
            break;
        }
        // 更新失败,意味着有其他线程在此期间改变了值,继续尝试
    }
}

优点:

  • 更高的并发性能:因为在读取阶段不加锁,所以理论上可以支持更多的并发读取操作。
  • 降低死锁可能性:因为不存在长时间的加锁过程,从而减少了死锁的发生机会。

缺点:

  • 冲突处理成本:如果并发更新较为频繁,乐观锁会导致大量事务因并发冲突而重试甚至失败,这在某些情况下反而会增加系统开销。
  • 循环依赖问题:在遇到连续的并发更新时,乐观锁可能导致事务不断重试,形成“ABA”问题(即某个值被改回原值后再次更改)。

基于锁的升级以及优化

在Java中,JVM为了解决多线程环境下的同步问题,对锁机制进行了优化,将其分为偏向锁、轻量级锁和重量级锁三种状态。

偏向锁

偏向锁是一种Java虚拟机(JVM)在多线程环境下优化同步性能的锁机制,它适用于大多数时间只有一个线程访问同步代码块的场景。当一个线程访问同步代码块时,JVM会把锁偏向于这个线程,后续该线程在进入和退出同步代码块时,无需再做任何同步操作,从而大大降低了获取锁和释放锁的开销。偏向锁是Java内存模型中锁的三种状态之一,位于轻量级锁和重量级锁之前。

优点
对于没有或很少发生锁竞争的场景,偏向锁可以显著减少锁的获取和释放所带来的性能损耗。

缺点

  • 额外存储空间:偏向锁会在对象头中存储一个偏向线程ID等相关信息,这部分额外的空间开销虽然较小,但在大规模并发场景下,累积起来也可能成为可观的成本。

  • 锁升级开销:当一个偏向锁的对象被其他线程访问时,需要进行撤销(revoke)操作,将偏向锁升级为轻量级锁,甚至在更高竞争情况下升级为重量级锁。这个升级过程涉及到CAS操作以及可能的线程挂起和唤醒,会带来一定的性能开销。

  • 适用场景有限:偏向锁最适合于绝大部分时间只有一个线程访问对象的场景,这样的情况下,偏向锁的开销可以降到最低,有利于提高程序性能。但如果并发程度较高,或者线程切换频繁,偏向锁就可能不如轻量级锁或重量级锁高效。

轻量级锁

轻量级锁是一种在Java虚拟机(JVM)中实现的同步机制,主要用于提高多线程环境下锁的性能。它不像传统的重量级锁那样,每次获取或释放锁都需要操作系统级别的互斥操作,而是尽量在用户态完成锁的获取与释放,避免了频繁的线程阻塞和唤醒带来的开销。轻量级锁的作用主要是减少线程上下文切换的开销,通过自旋(spin-wait)的方式让线程在一段时间内等待锁的释放,而不是立即挂起线程,这样在锁竞争不是很激烈的情况下,能够快速获得锁,提高程序的响应速度和并发性能。

在Java中,轻量级锁主要作为JVM锁状态的一种,它介于偏向锁和重量级锁之间。当JVM发现偏向锁不再适用(即锁的竞争不再局限于单个线程)时,会将锁升级为轻量级锁。

轻量级锁适用于同步代码块执行速度快、线程持有锁的时间较短且锁竞争不激烈的场景,如短期内只有一个或少数几个线程竞争同一线程资源的情况。

在Java中,轻量级锁的具体实现体现在java.util.concurrent.locks包中的Lock接口的一个具体实现:java.util.concurrent.locks.ReentrantLock,它支持可配置为公平或非公平模式的轻量级锁机制,当使用默认构造函数时,默认是非公平锁(类似于轻量级锁的非公平性质)。不过,JVM的内置synchronized关键字在JDK 1.6之后引入了锁升级机制,也包含了偏向锁和轻量级锁的优化。

优点

  • 低开销:轻量级锁通过CAS操作尝试获取锁,避免了重量级锁中涉及的线程挂起和恢复等高昂开销。
  • 快速响应:在无锁竞争或者锁竞争不激烈的情况下,轻量级锁使得线程可以迅速获取锁并执行同步代码块。

缺点

  • 自旋消耗:当锁竞争激烈时,线程可能会长时间自旋等待锁,这会消耗CPU资源,导致性能下降。
  • 升级开销:如果自旋等待超过一定阈值或者锁竞争加剧,轻量级锁会升级为重量级锁,这个升级过程本身也有一定的开销。

重量级锁

重量级锁是指在多线程编程中,为了保护共享资源而采取的一种较为传统的互斥同步机制,通常涉及到操作系统的互斥量(Mutex)或者监视器锁(Monitor)。在Java中,通过synchronized关键字实现的锁机制在默认情况下就是重量级锁。确保任何时刻只有一个线程能够访问被锁定的资源或代码块,防止数据竞争和不一致。保证了线程间的协同工作,确保在并发环境下执行的线程按照预定的顺序或条件进行操作。

在Java中,重量级锁主要指的是由synchronized关键字实现的锁,它在JVM内部由Monitor实现,属于内建的锁机制。另外,java.util.concurrent.locks包下的ReentrantLock等类也可实现重量级锁,这些锁可以根据需要调整为公平锁或非公平锁。

优点

  • 强一致性:重量级锁提供了最强的线程安全性,确保在多线程环境下数据的完整性和一致性。
  • 简单易用synchronized关键字的使用简洁明了,不易出错。

缺点

  • 性能开销大:获取和释放重量级锁时需要操作系统介入,可能涉及线程的挂起和唤醒,造成上下文切换,这对于频繁锁竞争的场景来说性能代价较高。
  • 延迟较高:线程获取不到锁时会被阻塞,导致等待时间增加,进而影响系统响应速度。

重量级锁适用于

  • 高并发且锁竞争激烈的场景,因为在这种情况下,保证数据的正确性远比微小的性能损失重要。
  • 对于需要长时间持有锁的操作,因为短暂的上下文切换成本相对于长时间的操作来说是可以接受的。
  • 当同步代码块中涉及到IO操作、数据库访问等耗时较长的任务时,重量级锁能够较好地防止其它线程饿死。

在Java中,偏向锁、轻量级锁和重量级锁之间的转换是Java虚拟机(JVM)为了优化多线程同步性能而设计的一种动态调整机制。转换条件如下:

  1. 偏向锁到轻量级锁的转换
    当有第二个线程尝试获取已经被偏向的锁时,偏向锁就会失效并升级为轻量级锁。这是因为偏向锁假定的是只有一个线程反复获取锁,如果有新的线程参与竞争,就需要进行锁的升级以保证线程间的互斥。

  2. 轻量级锁到重量级锁的转换
    当轻量级锁尝试获取失败(CAS操作失败),即出现了锁竞争时,JVM会认为当前锁的持有者无法很快释放锁,因此为了避免后续线程无休止地自旋等待,会将轻量级锁升级为重量级锁。这个转换过程通常发生在自旋尝试获取锁达到一定次数(自旋次数是可配置的)或者系统处于高负载状态时。

  3. 偏向锁到重量级锁的转换
    如果当前线程不是偏向锁指向的线程,那么首先会撤销偏向锁(解除偏向状态),然后升级为轻量级锁,之后再根据轻量级锁的规则判断是否需要进一步升级为重量级锁。

锁状态的转换是为了在不同的并发环境下,既能保证数据的正确性,又能尽可能地提高系统性能。JVM会根据实际情况自动调整锁的状态,无需我们手动干预。

分段锁

分段锁(Segmented Lock 或 Partitions Lock)是一种将数据或资源划分为多个段(segments),并对每个段分配单独锁的锁机制。这样做的目的是将锁的粒度细化,以便在高并发场景下提高系统的并发性能和可扩展性,特别是针对大型数据结构如哈希表时非常有效。通过减少锁的粒度,可以使得在多线程环境下,不同线程可以同时访问不同段的数据,减小了锁争抢,提高了系统的并行处理能力。在大规模数据结构中,如果只有一个全局锁,可能会因为热点区域引发大量的锁竞争,分段锁则能有效地分散锁的压力。

Java中,分段锁在实现上可以基于哈希表的分段锁,例如Java中的ConcurrentHashMap,将整个哈希表分割为多个段(Segment),每个段有自己的锁,这样多个线程可以同时对不同段进行操作。例外也可以基于数组或链表的分段锁,根据数据索引将数据分布到不同的段,每段对应一个独立的锁。

分段锁可以提高并发性能,减少锁竞争,增加系统的并行处理能力。其优点:

  1. 减小锁的粒度:通过将一个大的锁分解为多个小锁,确实可以提高并发程度,降低锁的粒度,减少单点瓶颈,提高系统性能。
  2. 减少锁冲突:确实可以降低不同线程间对锁资源的竞争,减少线程等待时间,从而提升并发度。
  3. 提高系统的可伸缩性:通过分段,可以更好地支持分布式和集群环境下的系统扩展,增强系统的并发处理能力和可扩展性。

分段锁也有一些缺点:

  1. 增加了锁的管理复杂度:确实需要额外的内存和复杂度来管理和维护多个锁,确保锁的正确使用和释放,以及在不同分段间的一致性和可靠性。
  2. 可能导致线程饥饿:分段不合理或者热点分段可能导致某些线程长时间等待锁资源,出现线程饥饿问题。
  3. 可能会降低并发度:如果分段策略设计不当,可能会增加锁竞争,降低并发性能。设计合理的分段策略和锁协调机制对于分段锁的效能至关重要,同时也增加了开发和维护的复杂度。
  4. 内存占用:每个分段所需的锁信息和相关数据会占用额外的内存空间,对系统内存有一定的消耗。

分段锁适用于大数据结构的并发访问,如高并发环境下对哈希表的操作。以及分布式系统中,某些分布式缓存或数据库系统也采用类似的分片锁策略来提高并发性能。

自旋锁

自旋锁(Spin Lock)是一种简单的锁机制,用于多线程环境中的同步控制,它的工作原理是当一个线程试图获取已经被另一个线程持有的锁时,该线程不会立即进入睡眠状态(阻塞),而是不断地循环检查锁是否已经被释放,直到获取到锁为止。这种“循环等待”的行为被称为“自旋”。自旋锁主要用于保证同一时刻只有一个线程访问临界区资源,防止数据竞争。相比传统阻塞式锁,自旋锁在持有锁的线程很快释放锁的情况下,可以减少线程的上下文切换开销。

我们使用AtomicInteger实现一个简单的自旋锁:

import java.util.concurrent.atomic.AtomicInteger;

class SimpleSpinLock {
    private AtomicInteger locked = new AtomicInteger(0);

    public void lock() {
        while (locked.getAndSet(1) == 1) {
            // 自旋等待
        }
        // 已经获取锁,执行临界区代码
    }

    public void unlock() {
        locked.set(0);
    }
}

自旋锁优点

  • 对于持有锁时间很短的场景,自旋锁能有效减少线程上下文切换,提高系统性能。
  • 自旋锁适用于多处理器或多核心系统,因为在这种环境下,线程可以在等待锁释放时继续占用CPU时间。

自旋锁缺点

  • 如果持有锁的线程需要很长时间才能释放锁,自旋锁会导致等待锁的线程持续消耗CPU资源,浪费CPU周期。
  • 在单处理器系统中,自旋锁的效率不高,因为等待锁的线程无法执行任何有用的工作,只是空转。

死锁

说到各种锁,就会想到死锁问题,对于死锁有兴趣的可以参考这篇文章:
这里就不过多赘述。

总结

本文介绍了多种Java中的锁机制,包括可重入锁(Reentrant Lock)、公平锁、非公平锁、悲观锁、乐观锁、偏向锁、轻量级锁、重量级锁、分段锁以及自旋锁。这些锁各有优缺点和适用场景,如可重入锁支持递归锁定,悲观锁确保数据一致性但可能引起性能开销,乐观锁在读多写少场景下表现优异,偏向锁和轻量级锁用于优化单线程重复访问,重量级锁提供严格的互斥性,分段锁通过减小锁粒度提高并发性能,而自旋锁则在短时间内获取锁的场景中能减少线程上下文切换。根据不同的并发需求和性能考量,开发者可以选择合适的锁机制。

本文已收录于我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

posted @ 2024-03-25 09:43  码农Academy  阅读(1834)  评论(0编辑  收藏  举报