Java并发编程面试5:锁机制-Lock、ReentrantLock和ReadWriteLock、ReentrantReadWriteLock
引言
正文
在并发编程的世界中,正确地管理对共享资源的访问是至关重要的。Java提供了多种锁机制,以确保线程安全地操作共享资源。 本文将深入探讨Java中的四种锁机制:Lock、ReentrantLock、ReadWriteLock和ReentrantReadWriteLock,并了解它们的使用场景和特性。
Lock
Lock是Java中用于同步的接口,它提供了一种比传统的synchronized
关键字更加灵活的线程同步机制。Lock接口本身定义了一些用于获取和释放锁的方法,以及一些查询锁状态的方法。
使用原理
Lock接口的设计使得多种锁的实现可以遵循统一的接口。Lock接口的实现通常会使用一种内部机制来协调对共享资源的访问。这些实现可以是公平的(按请求锁的顺序授予锁)或非公平的(请求锁时可能会“插队”),并且它们可以提供比synchronized
关键字更丰富的操作,例如尝试获取锁、定时锁等待和锁中断。
Lock接口的典型实现(如ReentrantLock)通常会使用同步器(如AbstractQueuedSynchronizer,简称AQS)来实现锁的功能。AQS使用一个volatile int变量来表示状态,以及一个FIFO队列来管理等待锁的线程。
总结来说,这个方法尝试获取锁,并且在锁未被任何线程持有时,尝试立即获取锁。如果锁已经被当前线程持有,则尝试递增锁的计数。如果锁被其他线程持有,则不进行任何操作并返回 false。这种方法称为“非公平”锁,因为它不考虑其他已经在等待队列中的线程,而是允许当前线程尝试获取锁。
优点
-
提供了尝试获取锁的方法:
- Lock接口提供了
tryLock()
方法,允许线程尝试获取锁而不必等待,增加了编程的灵活性。
- Lock接口提供了
-
支持中断的锁获取:
lockInterruptibly()
方法允许线程在等待锁的过程中响应中断。
-
支持超时的锁获取:
tryLock(long time, TimeUnit unit)
方法允许线程在指定的时间内等待锁,超时后线程可以放弃等待并执行其他任务。
-
更精细的锁控制:
- Lock接口允许在不同的作用域中获取和释放锁,而
synchronized
只能在同一个作用域(即代码块或方法)中完成。
- Lock接口允许在不同的作用域中获取和释放锁,而
-
锁的公平性:
- 一些Lock的实现(如ReentrantLock)允许创建公平锁,确保按照线程请求锁的顺序来获取锁。
-
条件变量的支持:
- Lock接口允许使用
Condition
类,提供了类似于Object.wait()
/notify()
的功能,但更加灵活。
- Lock接口允许使用
缺点
-
增加了编程复杂性:
- 使用Lock接口通常需要在try/finally块中编写锁的获取和释放代码,这增加了代码的复杂性。
-
可能的死锁风险:
- 如果锁没有正确释放,就可能导致死锁的发生。
-
性能开销:
- 对于没有锁竞争的简单场景,Lock可能比
synchronized
有更多的性能开销。
- 对于没有锁竞争的简单场景,Lock可能比
-
需要手动释放锁:
- 与
synchronized
不同,Lock不会在方法或同步块结束时自动释放锁,程序员必须确保锁得到正确释放,否则可能导致死锁。
- 与
-
缺乏内存可见性保证:
synchronized
块在释放锁时自动确保了变量的内存可见性。而使用Lock时,需要额外注意内存可见性问题,可能需要使用volatile
变量或Atomic
变量。
使用场景:
- 当需要比
synchronized
关键字更灵活的锁管理时。 - 当需要尝试非阻塞地获取锁、可中断的锁获取或带超时的锁获取时。
- 当需要跨多个方法或代码块持有和释放锁时。
小结:
Lock是一个接口,它提供了不同于synchronized
的锁机制。Lock允许在不同的作用域中获取和释放锁,提供了更大的灵活性。Lock通常适用于复杂的同步场景,其中可能需要在多个方法或代码块之间持有锁,或者需要根据某些条件尝试获取锁而不是无限期等待。
总体来说,Lock接口提供了一种灵活的锁机制,适用于需要更高级别同步控制的场景。然而,这种灵活性也带来了更高的复杂性,要求更小心地管理锁的获取和释放。
ReentrantLock
使用原理
ReentrantLock的内部原理基于AbstractQueuedSynchronizer(AQS),这是一个用于构建锁和同步器的框架。AQS使用一个int成员变量来表示同步状态,以及一个FIFO队列来管理等待锁的线程。
-
状态: AQS的状态表示锁的持有情况,对于ReentrantLock,状态为0表示未锁定,状态为正数表示锁已被线程持有,且数值表示锁的重入次数。
-
获取锁: 当线程尝试获取锁时,AQS会首先检查锁状态是否为0,如果是,则尝试通过CAS(Compare-And-Swap)操作将状态设置为1,从而获取锁。如果当前线程已经持有锁,则会增加状态值来表示重入次数。如果锁已被其他线程持有,则当前线程会被加入到等待队列中。
-
释放锁: 当线程释放锁时,它会减少状态值。如果状态值变为0,则锁被完全释放,且AQS会唤醒等待队列中的下一个线程。
-
等待/通知机制: 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;
}
优点
-
可重入: ReentrantLock是可重入的,即同一个线程可以多次获取同一个锁,而不会发生死锁。
-
锁的公平性: 可以指定锁是公平的还是非公平的。公平锁意味着线程将按照请求锁的顺序来获取锁,而非公平锁可能允许线程“插队”。
-
锁的灵活性: 提供了tryLock()方法,允许线程尝试获取锁而不必等待,增加了编程的灵活性。
-
支持中断: lockInterruptibly()方法允许在等待锁的过程中响应中断。
-
支持条件变量: ReentrantLock提供了Condition类,它可以分割锁等待的线程集,提供类似于Object.wait/notify的功能,但更加灵活。
-
锁状态的查询: 可以查询锁是否被持有,以及被哪个线程持有。
-
带超时的锁获取:
tryLock(long time, TimeUnit unit)
方法允许线程在指定的时间内等待锁,超时后线程可以放弃等待并执行其他任务。 -
锁状态查询:
ReentrantLock
提供了查询当前线程是否持有锁的方法,以及锁是否被任何线程持有的方法。
缺点
-
增加了复杂性: 使用ReentrantLock通常需要在try/finally块中编写锁的获取和释放代码,这增加了代码的复杂性。
-
可能的死锁: 如果锁没有正确释放,就可能导致死锁的发生。
-
性能开销: 对于没有锁竞争的情况,ReentrantLock可能比synchronized有更多的性能开销,因为synchronized是JVM内置的同步机制,可以享受JVM的锁优化。
-
需要手动释放: 与synchronized不同,ReentrantLock不会在方法或同步块结束时自动释放锁,程序员必须确保锁得到正确释放,否则可能导致死锁。
使用场景:
- 当需要高级功能,如可定时的锁等待、可中断的锁获取、公平性或非公平性选择时。
- 当需要与Condition对象配合,实现等待/通知模式时。
- 在高竞争环境下,可能优于
synchronized
。
小结:
ReentrantLock是Lock接口的一个具体实现,它提供了可重入的互斥锁。它允许同一个线程多次获得同一把锁,从而简化了同步控制。ReentrantLock特别适用于更复杂的同步任务,其中锁的高级功能可以帮助管理锁的获取和释放。ReentrantLock还提供了创建公平锁(按顺序获取)或非公平锁(可能插队)的选项,这可以根据具体的性能和公平性需求来选择。
使用ReentrantLock时,应该仔细考虑是否真的需要它提供的额外功能,以及是否能够正确地管理锁的生命周期。在没有锁竞争的情况下,或者当需要使用简单同步机制时,使用synchronized关键字可能是更好的选择。
ReadWriteLock
ReadWriteLock是一个接口,提供了一种高级的同步机制,允许多个线程同时读取共享资源,同时仅允许一个线程写入。这种锁被认为是一种性能优化的锁,因为它允许多个读操作并行进行,而不是像一个互斥锁那样,即使是读操作也需要串行执行。
使用原理
ReadWriteLock维护了一对关联的锁——一个读锁和一个写锁。通过分离读和写操作,它允许多个读线程同时访问,只要没有线程在写入。反之,写锁是独占的。
ReadWriteLock的一个典型实现是ReentrantReadWriteLock
,它实现了ReadWriteLock接口,并且其读锁和写锁都支持重入。
-
读锁(共享锁): 读锁可以被多个读线程同时持有,只要没有写锁被持有。这意味着读操作可以并行执行,从而提高了性能。
-
写锁(独占锁): 写锁是独占的,一次只能由一个线程持有。当写锁被持有时,其他尝试获取读锁或写锁的线程将被阻塞,直到写锁被释放。
-
锁降级: ReentrantReadWriteLock允许从写锁降级为读锁,即在持有写锁的同时获取读锁,然后释放写锁,这样线程仍然持有读锁。
-
非锁升级: 从读锁升级到写锁是不可能的,因为这可能导致死锁。
ReentrantReadWriteLock内部使用AQS(AbstractQueuedSynchronizer)来实现其同步行为。AQS为等待锁的线程维护了一个队列,并且提供了方法来管理这些队列。
优点
-
提高并发性: 在读多写少的场景中,读写锁可以显著提高系统的并发能力,因为它允许多个线程同时读取数据。
-
重入性: ReentrantReadWriteLock允许线程在已经持有读锁或写锁的情况下再次获取它们,这有助于减少死锁的可能性。
-
锁降级: 支持从写锁降级为读锁,这有助于在更新数据后仍然保持对数据的读取访问。
-
公平性选择: ReentrantReadWriteLock允许创建公平锁和非公平锁,公平锁遵循先来先服务的原则。
缺点
-
复杂性: 与使用单一的ReentrantLock或synchronized关键字相比,管理读写锁的逻辑更加复杂。
-
写锁饥饿: 在读多写少的场景中,写线程可能会遇到饥饿情况,因为读锁可以被无限制地获取。
-
内存占用: ReentrantReadWriteLock内部维护了两个锁,相比于单一的互斥锁,这可能会增加内存占用。
-
锁升级不支持: 读锁不能升级为写锁,这可能限制了某些编程模式。
-
性能开销: 如果读写锁不是必需的,或者锁的竞争不激烈,那么使用读写锁可能会引入不必要的性能开销。
使用场景:
- 当数据结构被多个读操作和较少的写操作访问时。
- 在读多写少的场景中,可以提高程序的并发性能。
- 当需要允许多个线程同时读取某个资源,但在写入时需要独占访问时。
小结:
ReadWriteLock是一个接口,它允许实现读写分离的锁策略。这种锁机制在处理读多写少的数据结构时特别有用,因为它允许多个线程同时读取数据而不会相互阻塞,只有在写数据时才需要独占访问。这可以显著提高并发性能,特别是在数据结构主要被读取而很少被修改的应用程序中。
在决定使用ReadWriteLock时,应该考虑应用程序的实际需求,特别是读写操作的频率和并发级别。如果读操作远多于写操作,并且有多个线程需要同时读取数据,那么使用读写锁可能会带来性能上的优势。然而,如果写操作频繁或者读写操作大致相等,使用读写锁可能不会带来太大的好处。
ReentrantReadWriteLock是java.util.concurrent.locks包中的一个类,它实现了ReadWriteLock接口,提供了一种高级的同步机制,允许多个线程同时读取共享资源,同时仅允许一个线程写入。
ReentrantReadWriteLock
使用原理
ReentrantReadWriteLock维护了两个锁:一个读锁和一个写锁。这两个锁允许多个读线程同时访问共享数据,而写锁则是独占的。
-
读锁(共享锁):
- 读锁可以被多个读线程同时持有,只要没有线程持有写锁。
- 读锁的获取不会阻塞其他读线程,它们可以自由地获取和释放读锁。
-
写锁(独占锁):
- 写锁是独占的,一次只能有一个线程持有写锁。
- 当写锁被持有时,其他尝试获取读锁或写锁的线程将被阻塞,直到写锁被释放。
-
锁降级:
- ReentrantReadWriteLock支持锁降级,即在持有写锁的情况下获取读锁,然后释放写锁,而仍然持有读锁。
-
不支持锁升级:
- 从读锁升级到写锁是不支持的,因为这可能导致死锁。
ReentrantReadWriteLock使用AQS(AbstractQueuedSynchronizer)来实现其锁机制。AQS内部使用一个同步状态变量和一个FIFO队列来管理线程的获取和释放锁的顺序。对于读锁,AQS允许多个线程在同步状态上共享访问;对于写锁,AQS提供独占访问。
优点
-
提高并发度:
- 在读多写少的场景中,读写锁可以显著提高并发度,因为它允许多个线程同时读取数据。
-
可重入性:
- ReentrantReadWriteLock支持重入。线程可以在已经持有读锁或写锁的情况下再次获取它们。
-
锁降级:
- 支持从写锁降级为读锁,这有助于在更新数据之后仍然保持对数据的读取访问。
-
公平性选择:
- 提供了创建公平锁和非公平锁的选项。公平锁确保按请求锁的顺序来获取锁。
-
条件变量:
- ReentrantReadWriteLock提供了与Condition相关联的方法,允许线程在特定条件下等待或接收通知。
缺点
-
复杂性:
- 管���读写锁比管理单一的互斥锁更复杂,需要正确处理读锁和写锁的获取和释放。
-
写锁饥饿:
- 在读多写少的场景中,写线程可能会遇到饥饿现象,因为读锁可以被无限制地获取。
-
锁升级不支持:
- 不支持从读锁升级为写锁,这可能会限制某些编程模式。
-
性能开销:
- 如果读写锁不是必需的,或者锁的竞争不激烈,那么读写锁可能会引入不必要的性能开销。
-
更多的内存占用:
- 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();
}
}
}
}
}
流程图:
Main
线程创建两个SharedQueue
实例、四个线程实例,并启动这些线程。然后,对于FairProducer
和FairConsumer
(使用公平锁的队列),以及UnfairProducer
和UnfairConsumer
(使用非公平锁的队列),展示了一个简单的循环,其中包括生产者向队列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提供了多种锁机制,以适应不同的并发编程需求。Lock和ReentrantLock为开发者提供了高度灵活的同步控制,而ReadWriteLock和ReentrantReadWriteLock则在读多写少的情况下提供了性能优势。理解这些锁机制的特性和适用场景,将帮助构建更高效、更健壮的并发应用程序。
转载自:https://juejin.cn/post/7400605682611765286