Java并发编程--JUC并发工具类之ReentrantReadWriteLock

摘要

  • 本文介绍ReentrantReadWriteLock相关技术

  • 本文基于jdk1.8

读写锁

  • 读写锁ReadWriteLock,顾名思义一把锁分为读与写两部分,读锁允许多个线程同时获得,因为读操作本身是线程安全的。而写锁是互斥锁,不允许多个线程同时获得写锁。并且读与写操作也是互斥的。读写锁适合多读少写的业务场景。

  • 线程进入读锁的前提条件:

    • 没有其他线程的写锁(读写互斥,但是读读不互斥)
    • 没有写请求,或者有写请求但调用线程和持有锁的线程是同一个。(写时可以读,但读时不可以写)
  • 线程进入写锁的前提条件:

    • 没有其他线程的读锁(读写互斥)
    • 没有其他线程的写锁(写写互斥)
  • 而读写锁有以下三个重要的特性:

    • 公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
    • 可重入:读锁和写锁都支持线程重入。以读写线程为例:读线程获取读锁后,能够再次获取读锁。写线程在获取写锁之后能够再次获取写锁,同时也可以获取读锁。
    • 锁降级:遵循获取写锁、再获取读锁最后释放写锁的次序,写锁能够降级成为读锁。

ReentrantReadWriteLock

  • ReentrantReadWriteLock 是可重入的读写锁实现类。在它内部,维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。

  • 只要没有 Writer 线程,读锁可以由多个 Reader 线程同时持有。也就是说,写锁是独占的,读锁是共享的。

  • RentrantReadWriteLock支持锁降级,但不支持锁升级,目的也是保证数据可见性。

基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 创建读锁
private Lock readLock = readWriteLock.readLock();
// 创建写锁
private Lock writeLock = readWriteLock.writeLock();

// 读操作上读锁
public Data get(String key) {
readLock.lock();
try {
// TODO 业务逻辑
} finally {
readLock.unlock();
}
}

// 写操作上写锁
public Data put(String key, Data value) {
writeLock.lock();
try {
// TODO 业务逻辑
} finally {
writeLock.unlock();
}
}

小贴士

锁降级

锁降级指的是写锁降级成为读锁。
如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。
锁降级是指在持有写锁的情况下获取读锁,然后释放写锁。
锁降级可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private Lock r = readWriteLock.readLock();
private Lock w = readWriteLock.writeLock();

public void performWriteOperation() {
w.lock();
try {
// 执行写操作,例如修改共享数据

// 锁降级:获取读锁,并释放写锁
r.lock();
} finally {
w.unlock();
}

try {
// 执行读操作,例如读取共享数据
} finally {
r.unlock();
}
}

锁降级中读锁的获取是否必要呢?答案是必要的。
主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。
如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

锁升级

锁降级指的是读锁升级成为写锁。
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。
目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。

ReentrantReadWriteLock应用场景

  • 读多写少:ReentrantReadWriteLock适用于读操作比写操作频繁的场景,因为它允许多个读线程同时访问共享数据,而写操作是独占的。

  • 缓存:ReentrantReadWriteLock可以用于实现缓存,因为它可以有效地处理大量的读操作,同时保护缓存数据的一致性。

ReentrantLock 和 ReentrantReadWriteLock 比较

特性 ReentrantLock ReentrantReadWriteLock
锁类型 独占锁(Exclusive Lock) 读写锁(Read-Write Lock)
读-读并发性 不支持 支持
读-写并发性 不支持 支持
写-写并发性 不支持 不支持
锁的公平性 支持设置为公平或非公平锁 支持设置为公平或非公平锁
性能 适用于读操作少、写操作多的场景 适用于读操作频繁、写操作较少的场景
锁降级 不支持 支持
可重入性 支持 支持
API 提供基本锁操作方法(lock、unlock、tryLock等) 提供与ReentrantLock类似的锁操作方法,以及读锁和写锁的获取方法
适用场景 读操作较少、写操作较多的场景,互斥访问共享资源 读操作频繁、写操作较少的场景,读多写少的并发访问

ReentrantReadWriteLock 的问题

  • 如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。

悲观锁与乐观锁

锁策略 悲观锁(Pessimistic Locking) 乐观锁(Optimistic Locking)
预期 悲观锁认为并发访问中会发生冲突,因此默认情况下假设其他线程会干扰当前线程的操作。 乐观锁认为并发冲突的概率较低,因此默认情况下假设其他线程不会干扰当前线程的操作。
实现方式 悲观锁通常使用互斥锁(如ReentrantLock)或数据库的行级锁来实现。 乐观锁通常使用版本号(Versioning)或比较并交换(Compare and Swap)等机制来实现。
加锁时机 悲观锁在访问共享资源之前获取锁,以确保其他线程无法同时访问资源。 乐观锁在更新数据时不进行加锁,而是在更新时检查是否有其他线程修改了数据。如果未发生变化,则进行更新,否则进行冲突处理。
数据一致性 悲观锁保证了数据的完整性和一致性,因为始终保持对共享资源的独占访问。 乐观锁无法保证数据的一致性,因为在更新时可能会发生冲突,需要进行相应的冲突处理,可能导致部分更新被丢弃或需要重试。
竞争与性能 悲观锁在并发访问高的情况下可能导致线程竞争和性能下降,因为每个线程都需要等待获取锁。 乐观锁避免了大部分加锁和解锁开销,适用于并发冲突概率较低的场景,可以提高并发性能。但如果冲突频率较高,乐观锁可能需要进行多次重试或回滚操作。
适用场景 悲观锁适用于并发冲突概率较高的场景,需要保证数据的一致性和完整性,适用于互斥访问共享资源的场景。 乐观锁适用于并发冲突概率较低的场景,适用于读多写少的并发访问,可以提高并发性能,但需要处理可能的冲突和数据一致性