likes
comments
collection
share

Java并发编程面试5:锁机制-Lock、ReentrantLock和ReadWriteLock、ReentrantReadWriteLock

作者站长头像
站长
· 阅读数 27

引言

正文

在并发编程的世界中,正确地管理对共享资源的访问是至关重要的。Java提供了多种锁机制,以确保线程安全地操作共享资源。 本文将深入探讨Java中的四种锁机制:Lock、ReentrantLock、ReadWriteLock和ReentrantReadWriteLock,并了解它们的使用场景和特性。

Lock

Lock是Java中用于同步的接口,它提供了一种比传统的synchronized关键字更加灵活的线程同步机制。Lock接口本身定义了一些用于获取和释放锁的方法,以及一些查询锁状态的方法。

使用原理

Lock接口的设计使得多种锁的实现可以遵循统一的接口。Lock接口的实现通常会使用一种内部机制来协调对共享资源的访问。这些实现可以是公平的(按请求锁的顺序授予锁)或非公平的(请求锁时可能会“插队”),并且它们可以提供比synchronized关键字更丰富的操作,例如尝试获取锁、定时锁等待和锁中断。

Lock接口的典型实现(如ReentrantLock)通常会使用同步器(如AbstractQueuedSynchronizer,简称AQS)来实现锁的功能。AQS使用一个volatile int变量来表示状态,以及一个FIFO队列来管理等待锁的线程。

总结来说,这个方法尝试获取锁,并且在锁未被任何线程持有时,尝试立即获取锁。如果锁已经被当前线程持有,则尝试递增锁的计数。如果锁被其他线程持有,则不进行任何操作并返回 false。这种方法称为“非公平”锁,因为它不考虑其他已经在等待队列中的线程,而是允许当前线程尝试获取锁。

优点

  1. 提供了尝试获取锁的方法:

    • Lock接口提供了tryLock()方法,允许线程尝试获取锁而不必等待,增加了编程的灵活性。
  2. 支持中断的锁获取:

    • lockInterruptibly()方法允许线程在等待锁的过程中响应中断。
  3. 支持超时的锁获取:

    • tryLock(long time, TimeUnit unit)方法允许线程在指定的时间内等待锁,超时后线程可以放弃等待并执行其他任务。
  4. 更精细的锁控制:

    • Lock接口允许在不同的作用域中获取和释放锁,而synchronized只能在同一个作用域(即代码块或方法)中完成。
  5. 锁的公平性:

    • 一些Lock的实现(如ReentrantLock)允许创建公平锁,确保按照线程请求锁的顺序来获取锁。
  6. 条件变量的支持:

    • Lock接口允许使用Condition类,提供了类似于Object.wait()/notify()的功能,但更加灵活。

缺点

  1. 增加了编程复杂性:

    • 使用Lock接口通常需要在try/finally块中编写锁的获取和释放代码,这增加了代码的复杂性。
  2. 可能的死锁风险:

    • 如果锁没有正确释放,就可能导致死锁的发生。
  3. 性能开销:

    • 对于没有锁竞争的简单场景,Lock可能比synchronized有更多的性能开销。
  4. 需要手动释放锁:

    • synchronized不同,Lock不会在方法或同步块结束时自动释放锁,程序员必须确保锁得到正确释放,否则可能导致死锁。
  5. 缺乏内存可见性保证:

    • synchronized块在释放锁时自动确保了变量的内存可见性。而使用Lock时,需要额外注意内存可见性问题,可能需要使用volatile变量或Atomic变量。

使用场景:

  • 当需要比synchronized关键字更灵活的锁管理时。
  • 当需要尝试非阻塞地获取锁、可中断的锁获取或带超时的锁获取时。
  • 当需要跨多个方法或代码块持有和释放锁时。

小结:

Lock是一个接口,它提供了不同于synchronized的锁机制。Lock允许在不同的作用域中获取和释放锁,提供了更大的灵活性。Lock通常适用于复杂的同步场景,其中可能需要在多个方法或代码块之间持有锁,或者需要根据某些条件尝试获取锁而不是无限期等待。

总体来说,Lock接口提供了一种灵活的锁机制,适用于需要更高级别同步控制的场景。然而,这种灵活性也带来了更高的复杂性,要求更小心地管理锁的获取和释放。

ReentrantLock

使用原理

ReentrantLock的内部原理基于AbstractQueuedSynchronizer(AQS),这是一个用于构建锁和同步器的框架。AQS使用一个int成员变量来表示同步状态,以及一个FIFO队列来管理等待锁的线程。

  1. 状态: AQS的状态表示锁的持有情况,对于ReentrantLock,状态为0表示未锁定,状态为正数表示锁已被线程持有,且数值表示锁的重入次数。

  2. 获取锁: 当线程尝试获取锁时,AQS会首先检查锁状态是否为0,如果是,则尝试通过CAS(Compare-And-Swap)操作将状态设置为1,从而获取锁。如果当前线程已经持有锁,则会增加状态值来表示重入次数。如果锁已被其他线程持有,则当前线程会被加入到等待队列中。

  3. 释放锁: 当线程释放锁时,它会减少状态值。如果状态值变为0,则锁被完全释放,且AQS会唤醒等待队列中的下一个线程。

  4. 等待/通知机制: ReentrantLock还提供了Condition接口,该接口与Object类的wait()、notify()和notifyAll()方法类似,可以让线程在某个条件上等待,或者在条件成立时接收通知。

核心代码

非公平锁

final boolean nonfairTryAcquire(int acquires) {
    //首先获取当前执行线程 current。
    final Thread current = Thread.currentThread();
    //获取锁的当前状态 `c`。通常情况下,状态 `0` 表示锁未被任何线程持有,非零值表示锁已被持有
    int c = getState();
    if (c == 0) {
        //如果状态 `c` 为 `0`,表示锁未被任何线程持有。
        
        //使用 `compareAndSetState(0, acquires)` 尝试原子地将锁状态从 `0` 设置为 `acquires`(通常是 `1`
        if (compareAndSetState(0, acquires)) {
        //如果设置成功,表示当前线程成功获取了锁。
            setExclusiveOwnerThread(current);
            //设置当前线程为锁的所有者,通过调用 `setExclusiveOwnerThread(current)`
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
    //如果锁已被当前线程持有
    
    //计算新的锁状态 `nextc`,即当前状态加上请求获取的次数 `acquires`
        int nextc = c + acquires;
        
        //-   如果新的状态小于 `0`(这通常发生在整数溢出的情况下),抛出 `Error` 异常,表示锁的计数超过了最大值。
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
            
         //更新锁的状态为 `nextc`
        setState(nextc);
        return true;
    }
    
    //如果锁已经被其他线程持有,方法返回 `false`,表示当前线程未能获取锁
    return false;
}

优点

  1. 可重入: ReentrantLock是可重入的,即同一个线程可以多次获取同一个锁,而不会发生死锁。

  2. 锁的公平性: 可以指定锁是公平的还是非公平的。公平锁意味着线程将按照请求锁的顺序来获取锁,而非公平锁可能允许线程“插队”。

  3. 锁的灵活性: 提供了tryLock()方法,允许线程尝试获取锁而不必等待,增加了编程的灵活性。

  4. 支持中断: lockInterruptibly()方法允许在等待锁的过程中响应中断。

  5. 支持条件变量: ReentrantLock提供了Condition类,它可以分割锁等待的线程集,提供类似于Object.wait/notify的功能,但更加灵活。

  6. 锁状态的查询: 可以查询锁是否被持有,以及被哪个线程持有。

  7. 带超时的锁获取: tryLock(long time, TimeUnit unit)方法允许线程在指定的时间内等待锁,超时后线程可以放弃等待并执行其他任务。

  8. 锁状态查询: ReentrantLock提供了查询当前线程是否持有锁的方法,以及锁是否被任何线程持有的方法。

缺点

  1. 增加了复杂性: 使用ReentrantLock通常需要在try/finally块中编写锁的获取和释放代码,这增加了代码的复杂性。

  2. 可能的死锁: 如果锁没有正确释放,就可能导致死锁的发生。

  3. 性能开销: 对于没有锁竞争的情况,ReentrantLock可能比synchronized有更多的性能开销,因为synchronized是JVM内置的同步机制,可以享受JVM的锁优化。

  4. 需要手动释放: 与synchronized不同,ReentrantLock不会在方法或同步块结束时自动释放锁,程序员必须确保锁得到正确释放,否则可能导致死锁。

使用场景:

  • 当需要高级功能,如可定时的锁等待、可中断的锁获取、公平性或非公平性选择时。
  • 当需要与Condition对象配合,实现等待/通知模式时。
  • 在高竞争环境下,可能优于synchronized

小结:

ReentrantLock是Lock接口的一个具体实现,它提供了可重入的互斥锁。它允许同一个线程多次获得同一把锁,从而简化了同步控制。ReentrantLock特别适用于更复杂的同步任务,其中锁的高级功能可以帮助管理锁的获取和释放。ReentrantLock还提供了创建公平锁(按顺序获取)或非公平锁(可能插队)的选项,这可以根据具体的性能和公平性需求来选择。

使用ReentrantLock时,应该仔细考虑是否真的需要它提供的额外功能,以及是否能够正确地管理锁的生命周期。在没有锁竞争的情况下,或者当需要使用简单同步机制时,使用synchronized关键字可能是更好的选择。

ReadWriteLock

ReadWriteLock是一个接口,提供了一种高级的同步机制,允许多个线程同时读取共享资源,同时仅允许一个线程写入。这种锁被认为是一种性能优化的锁,因为它允许多个读操作并行进行,而不是像一个互斥锁那样,即使是读操作也需要串行执行。

使用原理

ReadWriteLock维护了一对关联的锁——一个读锁和一个写锁。通过分离读和写操作,它允许多个读线程同时访问,只要没有线程在写入。反之,写锁是独占的。

ReadWriteLock的一个典型实现是ReentrantReadWriteLock,它实现了ReadWriteLock接口,并且其读锁和写锁都支持重入。

  1. 读锁(共享锁): 读锁可以被多个读线程同时持有,只要没有写锁被持有。这意味着读操作可以并行执行,从而提高了性能。

  2. 写锁(独占锁): 写锁是独占的,一次只能由一个线程持有。当写锁被持有时,其他尝试获取读锁或写锁的线程将被阻塞,直到写锁被释放。

  3. 锁降级: ReentrantReadWriteLock允许从写锁降级为读锁,即在持有写锁的同时获取读锁,然后释放写锁,这样线程仍然持有读锁。

  4. 非锁升级: 从读锁升级到写锁是不可能的,因为这可能导致死锁。

ReentrantReadWriteLock内部使用AQS(AbstractQueuedSynchronizer)来实现其同步行为。AQS为等待锁的线程维护了一个队列,并且提供了方法来管理这些队列。

优点

  1. 提高并发性: 在读多写少的场景中,读写锁可以显著提高系统的并发能力,因为它允许多个线程同时读取数据。

  2. 重入性: ReentrantReadWriteLock允许线程在已经持有读锁或写锁的情况下再次获取它们,这有助于减少死锁的可能性。

  3. 锁降级: 支持从写锁降级为读锁,这有助于在更新数据后仍然保持对数据的读取访问。

  4. 公平性选择: ReentrantReadWriteLock允许创建公平锁和非公平锁,公平锁遵循先来先服务的原则。

缺点

  1. 复杂性: 与使用单一的ReentrantLock或synchronized关键字相比,管理读写锁的逻辑更加复杂。

  2. 写锁饥饿: 在读多写少的场景中,写线程可能会遇到饥饿情况,因为读锁可以被无限制地获取。

  3. 内存占用: ReentrantReadWriteLock内部维护了两个锁,相比于单一的互斥锁,这可能会增加内存占用。

  4. 锁升级不支持: 读锁不能升级为写锁,这可能限制了某些编程模式。

  5. 性能开销: 如果读写锁不是必需的,或者锁的竞争不激烈,那么使用读写锁可能会引入不必要的性能开销。

使用场景:

  • 当数据结构被多个读操作和较少的写操作访问时。
  • 在读多写少的场景中,可以提高程序的并发性能。
  • 当需要允许多个线程同时读取某个资源,但在写入时需要独占访问时。

小结:

ReadWriteLock是一个接口,它允许实现读写分离的锁策略。这种锁机制在处理读多写少的数据结构时特别有用,因为它允许多个线程同时读取数据而不会相互阻塞,只有在写数据时才需要独占访问。这可以显著提高并发性能,特别是在数据结构主要被读取而很少被修改的应用程序中。

在决定使用ReadWriteLock时,应该考虑应用程序的实际需求,特别是读写操作的频率和并发级别。如果读操作远多于写操作,并且有多个线程需要同时读取数据,那么使用读写锁可能会带来性能上的优势。然而,如果写操作频繁或者读写操作大致相等,使用读写锁可能不会带来太大的好处。

ReentrantReadWriteLock是java.util.concurrent.locks包中的一个类,它实现了ReadWriteLock接口,提供了一种高级的同步机制,允许多个线程同时读取共享资源,同时仅允许一个线程写入。

ReentrantReadWriteLock

使用原理

ReentrantReadWriteLock维护了两个锁:一个读锁和一个写锁。这两个锁允许多个读线程同时访问共享数据,而写锁则是独占的。

  1. 读锁(共享锁):

    • 读锁可以被多个读线程同时持有,只要没有线程持有写锁。
    • 读锁的获取不会阻塞其他读线程,它们可以自由地获取和释放读锁。
  2. 写锁(独占锁):

    • 写锁是独占的,一次只能有一个线程持有写锁。
    • 当写锁被持有时,其他尝试获取读锁或写锁的线程将被阻塞,直到写锁被释放。
  3. 锁降级:

    • ReentrantReadWriteLock支持锁降级,即在持有写锁的情况下获取读锁,然后释放写锁,而仍然持有读锁。
  4. 不支持锁升级:

    • 从读锁升级到写锁是不支持的,因为这可能导致死锁。

ReentrantReadWriteLock使用AQS(AbstractQueuedSynchronizer)来实现其锁机制。AQS内部使用一个同步状态变量和一个FIFO队列来管理线程的获取和释放锁的顺序。对于读锁,AQS允许多个线程在同步状态上共享访问;对于写锁,AQS提供独占访问。

优点

  1. 提高并发度:

    • 在读多写少的场景中,读写锁可以显著提高并发度,因为它允许多个线程同时读取数据。
  2. 可重入性:

    • ReentrantReadWriteLock支持重入。线程可以在已经持有读锁或写锁的情况下再次获取它们。
  3. 锁降级:

    • 支持从写锁降级为读锁,这有助于在更新数据之后仍然保持对数据的读取访问。
  4. 公平性选择:

    • 提供了创建公平锁和非公平锁的选项。公平锁确保按请求锁的顺序来获取锁。
  5. 条件变量:

    • ReentrantReadWriteLock提供了与Condition相关联的方法,允许线程在特定条件下等待或接收通知。

缺点

  1. 复杂性:

    • 管���读写锁比管理单一的互斥锁更复杂,需要正确处理读锁和写锁的获取和释放。
  2. 写锁饥饿:

    • 在读多写少的场景中,写线程可能会遇到饥饿现象,因为读锁可以被无限制地获取。
  3. 锁升级不支持:

    • 不支持从读锁升级为写锁,这可能会限制某些编程模式。
  4. 性能开销:

    • 如果读写锁不是必需的,或者锁的竞争不激烈,那么读写锁可能会引入不必要的性能开销。
  5. 更多的内存占用:

    • ReentrantReadWriteLock内部维护了两个锁,相比于单一的互斥锁,这可能会增加内存占用。

使用场景:

  • 与ReadWriteLock的使用场景相同,但需要可重入的锁功能。
  • 当读操作比写操作频繁得多,并且需要优化读操作的并发性能时。
  • 当需要锁降级(从写锁降级为读锁)的高级功能时。

小结:

ReentrantReadWriteLock是ReadWriteLock的一个具体实现,它提供了读锁和写锁,并且这两种锁都是可重入的。这意味着,如果一个线程已经持有写锁,它可以再次获取写锁,或者在持有写锁的同时获取读锁(锁降级)。ReentrantReadWriteLock特别适用于读操作远多于写操作的场景,它可以允许多个线程同时读取数据,从而提高并发性能。同时,它也提供了与Condition对象配合使用的能力,实现复杂的等待/通知模式。

在决定使用ReentrantReadWriteLock时,应该考虑应用程序的实际需求,特别是读写操作的频率和并发级别。如果读操作远多于写操作,并且有多个线程需要同时读取数据,那么使用ReentrantReadWriteLock可能会带来性能上的优势。然而,如果写操作频繁或者读写操作大致相等,使用ReentrantReadWriteLock可能不会带来太大的好处。

使用示例

ReentrantLock使用示例

package com.dereksmart.crawling.lock;

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
 * @Author derek_smart
 * @Date 2024/8/8 8:18
 * @Description ReentrantLock测试类
 */
public class ReentrantLockTest {

    public static void main(String[] args) {
        // 创建公平锁的共享队列
        SharedQueue fairSharedQueue = new SharedQueue(10, true);
        Thread fairProducer = new Thread(new Producer(fairSharedQueue), "FairProducer");
        Thread fairConsumer = new Thread(new Consumer(fairSharedQueue), "FairConsumer");

        // 创建非公平锁的共享队列
        SharedQueue unfairSharedQueue = new SharedQueue(10, false);
        Thread unfairProducer = new Thread(new Producer(unfairSharedQueue), "UnfairProducer");
        Thread unfairConsumer = new Thread(new Consumer(unfairSharedQueue), "UnfairConsumer");

        // 启动公平锁和非公平锁的线程
        fairProducer.start();
        fairConsumer.start();
        unfairProducer.start();
        unfairConsumer.start();
    }

    static class SharedQueue {
        private Queue<Integer> queue;
        private int maxSize;
        private ReentrantLock lock;
        private Condition notFull;
        private Condition notEmpty;

        public SharedQueue(int maxSize, boolean fair) {
            this.queue = new LinkedList<>();
            this.maxSize = maxSize;
            this.lock = new ReentrantLock(fair); // 可以选择公平或非公平锁
            this.notFull = lock.newCondition();
            this.notEmpty = lock.newCondition();
        }

        public void put(int value) throws InterruptedException {
            lock.lock();
            try {
                while (queue.size() == maxSize) {
                    System.out.println("Queue is full. Producer is waiting.");
                    notFull.await(); // 等待队列非满
                }
                queue.add(value);
                System.out.println("Produced " + value);
                notEmpty.signalAll(); // 通知消费者队列非空
            } finally {
                lock.unlock();
            }
        }

        public int take() throws InterruptedException {
            lock.lock();
            try {
                while (queue.isEmpty()) {
                    System.out.println("Queue is empty. Consumer is waiting.");
                    notEmpty.await(); // 等待队列非空
                }
                int value = queue.poll();
                System.out.println("Consumed " + value);
                notFull.signalAll(); // 通知生产者队列非满
                return value;
            } finally {
                lock.unlock();
            }
        }
    }

    static class Producer implements Runnable {
        private SharedQueue sharedQueue;

        public Producer(SharedQueue sharedQueue) {
            this.sharedQueue = sharedQueue;
        }

        @Override
        public void run() {
            for (int i = 0; i < 50; i++) {
                try {
                    sharedQueue.put(i);
                    // 输出当前线程名称,以区分公平和非公平生产者
                    System.out.println(Thread.currentThread().getName() + " produced " + i);
                    Thread.sleep((int) (Math.random() * 100));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }


    static class Consumer implements Runnable {
        private SharedQueue sharedQueue;

        public Consumer(SharedQueue sharedQueue) {
            this.sharedQueue = sharedQueue;
        }

        @Override
        public void run() {
            for (int i = 0; i < 50; i++) {
                try {
                    int value = sharedQueue.take();
                    // 输出当前线程名称,以区分公平和非公平消费者
                    System.out.println(Thread.currentThread().getName() + " consumed " + value);
                    Thread.sleep((int) (Math.random() * 100));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

流程图:

Yes
No
Yes
No
Yes
Yes
Start
Create Shared Queues
Create Producer and Consumer Threads
Start Threads
Queue Full?
Producer Waits
Producer Puts Item
Queue Empty?
Producer Gets Lock and Continues
Consumer Waits
Consumer Takes Item
Consumer Gets Lock and Continues
End ofConsumer Loop
End of Producer Loop
All Items Consumed?
All Items Produced?
Consumer Thread Ends
Producer Thread Ends
All Threads Complete

Main线程创建两个SharedQueue实例、四个线程实例,并启动这些线程。然后,对于FairProducerFairConsumer(使用公平锁的队列),以及UnfairProducerUnfairConsumer(使用非公平锁的队列),展示了一个简单的循环,其中包括生产者向队列put元素和消费者从队列take元素的过程。在队列满时,生产者会等待notFull条件;在队列空时,消费者会等待notEmpty条件。当条件得到满足时,相应的线程会被唤醒

ReentrantReadWriteLock使用示例

package com.dereksmart.crawling.lock;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * @Author derek_smart
 * @Date 2024/8/8 8:32
 * @Description ReentrantReadWriteLock测试类
 */
public class ReentrantReadWriteLockExample {

    private final Map<String, String> cache = new HashMap<>();
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

    public String get(String key) {
        readLock.lock(); // 获取读锁
        try {
            // 模拟读取数据的耗时操作
            Thread.sleep(100);
            return cache.get(key);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // 重新设置中断状态
            return null;
        } finally {
            readLock.unlock(); // 释放读锁
        }
    }

    public void put(String key, String value) {
        writeLock.lock(); // 获取写锁
        try {
            // 模拟写入数据的耗时操作
            Thread.sleep(100);
            cache.put(key, value);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // 重新设置中断状态
        } finally {
            writeLock.unlock(); // 释放写锁
        }
    }

    public String readWriteOperation(String key, String newValue) {
        String value = null;
        boolean upgrade = false;
        readLock.lock(); // 先获取读锁
        try {
            value = cache.get(key);
            if (value == null) {
                // 准备升级为写锁
                upgrade = true;
            }
        } finally {
            readLock.unlock(); // 释放读锁
        }
        if (upgrade) {
            writeLock.lock(); // 获取写锁
            try {
                // 再次检查状态,因为其他线程可能已经写入数据
                value = cache.get(key);
                if (value == null) {
                    value = newValue;
                    cache.put(key, value);
                }
            } finally {
                writeLock.unlock(); // 释放写锁
            }
        }
        return value;
    }

    public static void main(String[] args) {
        ReentrantReadWriteLockExample cacheSystem = new ReentrantReadWriteLockExample();

        // 启动写线程
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                cacheSystem.put("key" + i, "value" + i);
            }
        }).start();

        // 启动读线程
        for (int i = 0; i < 5; i++) {
            int finalI = i;
            new Thread(() -> {
                System.out.println("Value for key" + finalI + ": " + cacheSystem.get("key" + finalI));
            }).start();
        }

        // 启动读写线程
        new Thread(() -> {
            String value = cacheSystem.readWriteOperation("key5", "newValue5");
            System.out.println("Updated value for key5: " + value);
        }).start();
    }
}

Java并发编程面试5:锁机制-Lock、ReentrantLock和ReadWriteLock、ReentrantReadWriteLock

总结

Java提供了多种锁机制,以适应不同的并发编程需求。Lock和ReentrantLock为开发者提供了高度灵活的同步控制,而ReadWriteLock和ReentrantReadWriteLock则在读多写少的情况下提供了性能优势。理解这些锁机制的特性和适用场景,将帮助构建更高效、更健壮的并发应用程序。

转载自:https://juejin.cn/post/7400605682611765286
评论
请登录