Java 入门指南:Java 并发编程 —— ReentrantLock 实现悲观锁(Pessimistic Locking)

悲观锁

悲观锁(Pessimistic Locking)是一种悲观的并发控制机制,它基于悲观的假设,即并发冲突会时常发生,因此在访问共享资源(如数据库记录或共享变量)之前,会先获取独占性的锁,以防止其他线程对资源的并发读写。

悲观锁适用于写操作频繁、读操作较少的场景,能够确保数据一致性,但会引入较大的性能开销和线程切换的开销。

实现方式

在 Java 中,可以使用以下方式实现悲观锁:

  1. synchronized 关键字:使用 synchronized 关键字可以实现对共享资源的悲观锁。通过在方法或代码块中加上 synchronized 关键字,只允许一个线程进入同步区域,并对共享资源进行操作。其他线程需要等待当前线程释放锁才能进入同步区域。
synchronized (sharedObject) {
    // 进入同步区域,操作共享资源
}
  1. ReentrantLock 类:ReentrantLockJava.util.concurrent 包提供的可重入锁实现。相较于 Synchronized,ReentrantLock 提供了更精细的锁控制,包括手动获取锁、手动释放锁、可重入性等特性。

注意事项

  • 悲观锁的使用需要考虑锁的粒度,过大的锁粒度可能会影响并发性能,过小的锁粒度可能会导致频繁的锁竞争。

  • 使用悲观锁时,应确保获取锁和释放锁的操作是成对出现的,否则可能会导致死锁或资源泄漏等问题。

  • 需要谨慎处理异常情况,确保在异常发生时能够正确释放锁,避免其他线程被阻塞。

ReentrantLock

在Java并发编程中,ReentrantLock 是一个非常强大且灵活的锁机制,ReentrantLock(重入锁)是实现了 Lock接口 的一个,也是在实际编程中使用频率很高的一个锁,相比于 synchronized 关键字提供了更多的功能和灵活性。

如尝试非阻塞地获取锁、可中断地获取锁、以及锁的超时获取等。支持重入性,能够对共享资源重复加锁,即当前线程获取该锁后再次获取不会被阻塞。这些特性使得 ReentrantLock 成为实现悲观锁(Pessimistic Locking)的理想选择。

ReentrantLock 继承了 AQSAbstractQueuedSynchronizer 的缩写,即 抽象队列同步器,是 Java.util.concurrent 中的一个基础工具类),内部有一个抽象类 Sync,实现了一个同步器。

使用 ReentrantLock 需要进行显式地加锁和释放锁操作,如下所示:

ReentrantLock lock = new ReentrantLock();

lock.lock(); // 加锁
try {
    // 临界区代码
} finally {
    lock.unlock(); // 释放锁
}

特性

ReentrantLock 提供了与 synchronized 类似的互斥访问资源的能力,但它还提供了一些额外的特性:

  1. 可重入性:同一个线程可以多次获取同一个 ReentrantLock 锁,而不会产生死锁。这使得在一个方法中调用另一个需要同步访问的方法成为可能。

    在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;

    由于锁会被获取 n 次,那么只有锁在被释放同样的 n 次之后,该锁才算是完全释放成功。

  2. 可中断性:ReentrantLock 提供了可中断的锁获取方式,即可以在等待锁时响应中断。

  3. 公平性:ReentrantLock 可以选择公平性非公平性的获取锁方式。

    • 在公平模式下,等待时间较长的线程更容易获得锁

    • 在非公平模式下,则不保证线程获取锁的顺序。

  4. 条件变量支持:ReentrantLock 通常与 Condition 配合使用,提供了与条件变量相关联的 Condition 对象,能够更灵活地实现精确的线程等待和唤醒操作。可以方便地实现多路选择通知,更加精确的线程等待和通知机制。

synchronized 只能通过 waitnotify/notifyAll 方法唤醒一个线程或者唤醒全部线程(单路通知

公平锁和非公平锁

公平锁(Fair Lock)和非公平锁(Unfair Lock)是针对锁的获取顺序而言的。

ReentrantLock 内部有两个非抽象类 NonfairSyncFairSync,即 非公平同步器 和 公平同步器,它们都继承了 Sync 类,都调用了 AOS(AbstractOwnableSynchronizer,这个类于 JDK 1.6 引入。用于表示锁与持有者之间的关系(独占模式)) 的 setExclusiveOwnerThread 方法,即 公平锁和非公平锁都是独占锁

公平锁

公平锁(Fair Lock):当锁处于可用状态时,锁会先分配给等待时间最长的线程,也就是先排队的线程。

这样能够保证线程获取锁的顺序与线程启动的顺序一致,避免了等待时间过长的情况,确保较低优先级的线程也有机会获取到锁。在公平锁的情况下,锁的获取顺序是按照线程请求锁的顺序(FIFO)来进行排序的

公平锁比非公平锁的性能更差一些,因为需要维护队列,而队列的操作是会对性能产生影响的。此外,使用公平锁时还可能出现 活锁 现象,即一个线程不断尝试获取锁,但总是失败的情况。

如果希望保证响应时间足够短且资源利用率不低,可以使用公平锁。

非公平锁

非公平锁(Unfair Lock):当锁处于可用状态时,锁会立即分配给一个准备好的线程,而不考虑其他等待获取锁的线程。在非公平锁的情况下,获取锁的线程是随机选择的,不具有先来先服务的特点。

在一般情况下,使用非公平锁的性能会更好,因为非公平锁减少了线程上下文的切换,从而提高了并发性。

创建 ReentrantLock

  1. ReentrantLock 的无参构造方法是构造非公平锁
public ReentrantLock() {
    sync = new NonfairSync();
}
  1. ReentrantLock 的有参构造方法可传入一个 boolean 值,true 时为公平锁,false 时为非公平锁:
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

公平锁的实现方式与非公平锁的实现方式基本一致,只是在获取锁时增加了判断当前节点是否有前驱节点的逻辑判断

使用 ReentrantLock 时,锁必须在 try 代码块开始之前获取,并且加锁之前不能有异常抛出,否则在 finally 块中就无法释放锁( ReentrantLock 的锁必须在 finally 中手动释放)

ReentrantLock lock = new ReentrantLock();
// ...
lock.lock();
try {
    doSomething();
    doOthers();
} finally {
    lock.unlock();
}

ReentrantLock 使用示例

下面是一个使用 ReentrantLock 实现悲观锁的简单示例:

import java.util.concurrent.locks.Lock;  
import java.util.concurrent.locks.ReentrantLock;  
  
public class Counter {  
    // 使用 ReentrantLock 作为悲观锁  
    private final Lock lock = new ReentrantLock();  
    private int count = 0;  
  
    // 线程安全地增加计数  
    public void increment() {  
        lock.lock(); // 尝试获取锁  
        try {  
            count++; // 访问共享资源  
        } finally {  
            lock.unlock(); // 释放锁  
        }  
    }  
  
    // 获取当前计数  
    public int getCount() {  
        lock.lock(); // 尝试获取锁  
        try {  
            return count; // 访问共享资源  
        } finally {  
            lock.unlock(); // 释放锁  
        }  
    }  
  
    public static void main(String[] args) throws InterruptedException {  
        Counter counter = new Counter();  
  
        // 假设有多个线程同时调用 increment() 方法  
        // 这里为了演示,我们只用一个线程来模拟  
        for (int i = 0; i < 1000; i++) {  
            counter.increment();  
        }  
  
        System.out.println("Final count: " + counter.getCount());  
    }  
}

在这个示例中,Counter 类中的 increment()getCount() 方法都使用了 ReentrantLock 来确保线程安全。尽管在这个简单的例子中我们只使用了单个线程来调用 increment() 方法,但在多线程环境下,ReentrantLock 会确保 count 变量的增加操作是线程安全的。

ReentrantReadWriteLock

在并发场景中,为了解决线程安全问题,我们通常会使用关键字 sychronized 或者 JUC 包(Java Util Concurrent Java 并发工具包)中实现了 Lock 接口的 ReentrantLockopen。但它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。

而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性,而如果在这种业务场景下,依然使用独占锁的话,很显然会出现性能瓶颈。针对这种读多写少的情况,Java 提供了另外一个实现 Lock 接口的 ReentrantReadWriteLock

ReentrantReadWriteLock 读写锁,它是对传统的互斥锁(如 ReentrantLock)的扩展,可以允许多个线程同时读取共享资源,而对写操作进行互斥,提供了读写分离的机制。

特性

ReentrantReadWriteLock 具有以下特点:

  1. 读锁共享性:多个线程可以同时获取读锁,读取共享资源,而不会互斥。这使得多个线程可以同时读取数据,提高了并发性能。

  2. 写锁独占性:一旦线程获取了写锁,其他线程无法获取读锁或写锁。这样可以确保只有一个线程进行写操作,保持数据的一致性。

  3. 可重入性:和 ReentrantLock 一样,ReentrantReadWriteLock 支持重入,同一个线程可以多次获取读锁或写锁。

  4. 锁降级:读写锁支持锁降级,即写锁降级,是一种允许写锁转换为读锁的过程。不支持锁升级

由于读锁是共享的,所以当存在读锁时,写操作会被阻塞。这使得写操作的优先级较高,可以防止写操作长时间被读操作阻塞。

ReentrantReadWriteLock 管理读锁和写锁的机制使得读写操作可以并发进行,读锁和写锁是分离的,实现了读写、写读、写写的过程互斥,从而提高了并发性能。

适用于读操作远远多于写操作的场景,允许多线程同时读取共享资源,避免了读-读之间的互斥。

但在读操作和写操作的频率相差不大,或者读操作频率较高的情况下,仍然可能导致写操作长时间被延迟,影响系统的响应性能。

写锁降级

ReentrantReadWriteLock 的内部实现使用了 写锁降级 的机制,即一个线程在持有写锁的同时可以获取读锁,并逐步释放写锁,从而实现了锁的降级。

写锁降级是一种允许写锁转换为读锁的过程。通常的顺序是:

  1. 获取写锁:线程首先获取写锁,确保在修改数据时排它访问。

  2. 获取读锁:在写锁保持的同时,线程可以再次获取读锁。

  3. 释放写锁:线程保持读锁的同时释放写锁。

  4. 释放读锁:最后线程释放读锁。

这样,写锁就降级为读锁,允许其他线程进行并发读取操作,但仍然排除其他线程的写操作。

![[processCachedData().png]]

  1. 获取读锁:首先尝试获取读锁来检查某个缓存是否有效。

  2. 检查缓存:如果缓存无效,则需要释放读锁,因为在获取写锁之前必须释放读锁。

  3. 获取写锁:获取写锁以便更新缓存。此时,可能还需要重新检查缓存状态,因为在释放读锁和获取写锁之间可能有其他线程修改了状态。

  4. 更新缓存:如果确认缓存无效,更新缓存并将其标记为有效。

  5. 写锁降级为读锁:在释放写锁之前,获取读锁,从而实现写锁到读锁的降级。这样,在释放写锁后,其他线程可以并发读取,但不能写入。

  6. 使用数据:现在可以安全地使用缓存数据了。

  7. 释放读锁:完成操作后释放读锁。

读写状态的记录

AQS 内部的 state 字段(int 类型,32 位),用于描述有多少线程持有锁。

  • 同步状态的低 16 位用来表示写锁的获取次数

  • 同步状态的高 16 位用来表示读锁被获取的次数

如果是重入锁的话 state 值就是重入的次数

![[record of sharedCount and exclusiveCount.png]]

读锁和写锁

ReentrantReadWriteLock 内部维护了两把锁,分别为 读锁 ReadLock写锁 WriteLock

![[ReentrantReadWriteLock Variable.png]]

ReadLockWriteLock 是靠 AQSAbstractQueuedSynchronizer 的缩写,即 抽象队列同步器,是 Java.util.concurrent 中的一个基础工具类)的子类 Sync 实现的锁

写锁
写锁的获取

ReentrantReadWriteLock 的写锁是排他锁(独享锁),而实现写锁的同步语义是通过重写 AQS 中的 tryAcquire 方法实现的:

首先获取写锁当前的同步状态,当读锁已经被读线程获取或者写锁已经被其他写线程获取,则写锁获取失败;否则,获取写锁成功并支持重入,增加写状态 exclusiveCount

tryAcquire() 除了重入条件(当前线程为获取写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取

原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。

因此只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。

写锁的释放

写锁释放通过重写 AQStryRelease 方法实现,与 ReentrantLock 的释放基本一致:

![[Release WirteLock Source Code.png]]

  1. 判断当前线程是否持有写锁,若未持有则抛出 IllegalMonitorStateException 异常。

  2. 将写状态变量减去对应的计数值 releases
    int nextc = getState() - releases; 因为写状态是由同步状态的低 16 位表示的,只需要用当前同步状态直接减去写状态

  3. 如果计数器为 0,则唤醒一个等待写锁的线程

  4. 如果计数器不为 0,则需要唤醒所有等待的线程

读锁
读锁的获取

读锁是一种共享式锁,同一时刻该锁可以被多个读线程获取。实现共享式同步组件的同步语义需要通过重写 AQSAbstractQueuedSynchronizer 的缩写,即 抽象队列同步器,是 Java.util.concurrent 中的一个基础工具类) 的 tryAcquireShared 方法和 tryReleaseShared 方法

首先获取写锁的同步状态,如果写锁已经其他被获取,获取读锁失败,进入等待状态,如果当前线程获取了写锁或者写锁未被获取,当前线程增加读状态,获取读锁成功,利用 CAS 更新同步状态。

如果 CAS 失败或者已经获取读锁的线程再次获取读锁时,是通过 fullTryAcquireShared 方法实现的

读锁的释放

读锁释放的实现主要通过重写 AQS 的方法 tryReleaseShared 实现

  1. 判断当前线程是否持有读锁,若未持有则抛出 IllegalMonitorStateException 异常。

  2. 将当前线程的读锁计数器减 1。

  3. 如果当前线程的读锁计数器为 0,从读锁的等待队列中唤醒一个线程。

  4. 如果当前线程的读锁计数器还不为 0,则说明当前线程还持有至少一个读锁,不需要释放锁。

  • 14
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值