Java并发编程:ReentrantLock

什么是ReentrantLock

ReentrantLock是concurrent包下的一个处理并发同步的类,实现了Lock接口,是一个可重入且独占式的锁,基本功能作用和synchronized关键字类似。

但ReentrantLock更灵活、强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。可以用来代替synchronized关键字。

举例

如果用synchronized关键字,代码如下:

java 复制代码
public class Counter{
    private int count;
    
    public void add(int n){
        synchronized(this) {
            count += n;
        }
    }
}

如果用ReentrantLock替代,代码如下:

java 复制代码
public class Counter{
    private final Lock lock = new ReentrantLock();
    private int count;
    
    private void add(int n) {
        lock.lock();
        try {
            count += n;
        } finally {
            lock.unlock();
        }
    }
    
}

公平锁和非公平锁

  • 公平锁:锁被释放之后,先申请的线程先得到锁。为了保证时间上的绝对顺序、公平,上下文切换更频繁,因此性能较差一些
  • 非公平锁:锁被释放之后,后申请的线程可能会先得到锁,是随机或者按照其他优先级进行排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

可中断锁和不可中断锁有什么区别?

  • 可中断锁 :获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁 :一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

独占模式和共享模式

  • 排他锁也叫独占锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程既能读数据又能修改数据。

  • 共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

ReentrantLock和synchronized对比

两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当该线程再次想要获取这个对象锁的时候还是可以获取的。如果是不可重入锁的话,会造成死锁。 JDK提供的所有Lock实现类,以及synchronized关键字,实现的锁都是可重入的。

重入场景举例

java 复制代码
// synchronized关键字修饰
public class SynchronizedDemo{
    public synchronized void method1(){
        System.out.println("方法1");
        method2();
    }
    
    public synchronized void method2(){
        System.out.println("方法2")
    }
}


//使用ReentrantLock
class LockReentrant implements Runnable{
    private final Lock lock = new ReentrantLock();

    public void method1() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "method1()");
            method2();
        } finally {
            lock.unlock();
        }
    }

    public void method2() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "method2()");
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void run() {
        //线程启动 执行方法1
        method1();
    }
}

由于synchronizedReentrantLock都是可重入锁,同一个线程在调用method1()时获取当前对象锁之后,再执行method2()的时候,可以再次获取这个对象的锁,不会产生死锁问题。假如两个都不是可重入锁,由于该对象的锁已经被当前线程所持有且无法释放,这会导致线程在执行method2()时获取锁失败,会出现死锁问题。

synchronized依赖于JVM,ReentrantLock依赖于API

  • synchronized实现并发同步的功能,是依赖于JVM实现的,通过字节码分析,可以看到这个是通过获取monitor对象来获取锁的。它的加锁与释放是自动的,无需我们关心,不需要在代码里进行处理。
  • ReentrantLock是在JDK层面实现的,需要使用lock、unlock方法,配合try、finally来实现。

ReentrantLock相比synchronized增加了一些高级功能

  • 等待可中断ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptible来实现。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁ReentrantLock可以指定是公平锁还是非公平锁,而synchronized只能是非公平锁。 ReentrantLock默认是非公平锁,可以通过ReentrantLock(boolen fair)构造方法来指定是否公平锁。
  • 可实现选择性通知synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock也可以实现,但是需要借助于Condition接口与newCondition()方法。(具体使用方式待补充

实际项目中的应用

暂时没在业务代码中看到过,目前业务代码并发相关的处理,直接使用hutool的ThreadUtil。

使用注意事项

  1. 默认情况下 ReentrantLock 为非公平锁而非公平锁;
  2. 加锁次数和释放锁次数一定要保持一致,否则会导致线程阻塞或程序异常;
  3. 加锁操作一定要放在 try 代码之前,这样可以避免未加锁成功又释放锁的异常;
  4. 释放锁一定要放在 finally 中,否则会导致线程阻塞。 反例
java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class LockExample {
    // 创建锁对象
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        try {
            // 此处异常
            int num = 1 / 0;
            // 加锁操作
            lock.lock();
        } finally {
            // 释放锁
            lock.unlock();
            System.out.println("锁释锁");
        }
        System.out.println("程序执行完成.");
    }
}

ReentrantLock源码分析

类结构

ReentrantLock类内部共存在SyncNonfairSyncFairSync三个内部类。

  • NonfairSyncFairSync类继承自Sync类
  • Sync类继承自AbstractQueuedSynchronizer抽象类

加锁

java 复制代码
// 代码中使用ReentrantLock的方式
public class ReEntrantLockDemo {
    public static void main(String[] args) {
        new Thread(new LockReentrant()).start();

    }
}

class LockReentrant implements Runnable{
    private final Lock lock = new ReentrantLock();

    public void method1(){
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " method1()");
            method2();
        }finally {
            lock.unlock();
        }
    }

    public void method2(){
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " method2()");
        }finally {
            lock.unlock();
        }
    }

    @Override
    public void run() {
        //线程启动 执行方法1
        method1();
    }
}

上面demo中的lock.lock实际调用的是ReentrantLock中的下面方法

java 复制代码
public void lock() { 
    sync.lock(); 
} 

上述lock方法实际又调用了sync对象的lock方法,该方法是1个抽象方法,需要实现类:NonfairSyncFairSync进行具体的实现.

java 复制代码
abstract static class Sync extends AbstractQueuedSynchronizer {
    private static final long serialVersionUID = -5179523762034025860L;

    /**
     * Performs {@link Lock#lock}. The main reason for subclassing
     * is to allow fast path for nonfair version.
     */
    abstract void lock();

NonfairSync

java 复制代码
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;

    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        //以cas方式尝试将AQS中的state从0更新为1
        //更新成功则表明获取锁成功,并设为独占模式,其他线程不可再获取该锁。
        
        //state在ReentrantLock的语境下等同于锁被线程重入的次数,
        //state为0意味着只有当前锁未被任何线程持有时该动作才会返回成功
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        // 如果锁被占用,或者set失败,则执行该方法
        else
            acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

上述acquire(1)方法,就是AbstractQueuedSynchronizer类中的下面方法

java 复制代码
//AbstractQueuedSynchronizer类中的方法
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        // 获取锁失败的线程要通过addWaiter方法加入同步队列
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

//tryAcquire方法,又调用NonfairSync类的下述方法
protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

//nonfairTryAcquire方法,是Sync类下面的方法
final boolean nonfairTryAcquire(int acquires) {
    // 获取当前线程实例
    final Thread current = Thread.currentThread();
    // 获取state变量的值,即当前锁被重入的次数
    int c = getState();
    // 如果锁被重入的次数为0,即当前没有线程持有该锁
    if (c == 0) {
        // 以cas方式更新state,更新成功,则说明获取锁成功
        if (compareAndSetState(0, acquires)) {
            // 获取锁成功,则将当前线程标记为持有锁的线程
            setExclusiveOwnerThread(current);
            // 返回获取锁成功,非重入
            return true;
        }
    }
    // 如果锁被重入的次数不为0,而且当前线程就是持有锁的线程
    else if (current == getExclusiveOwnerThread()) {
        // 计算锁被重入的次数,用于更新state值
        int nextc = c + acquires; 
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // 非同步方式更新state值
        setState(nextc);
        // 返回获取锁成功,重入
        return true;
    }
    return false;
}

获取锁(加锁)失败后的处理

java 复制代码
//下面都是AbstractQueuedSynchronizer类中的方法
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        // 获取锁失败的线程要通过addWaiter方法加入同步队列
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

private Node addWaiter(Node mode) {
    //首先创建一个新节点,并将当前线程实例封装在内部,mode这里为null
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    // 下面这部分逻辑,在enq方法里都有
    // 之所以加上这部分"重复代码"和尝试获取锁时的"重复代码"一样,对某些特殊情况 进行提前处理,牺牲一定的代码可读性换取性能提升。
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node); //入队的逻辑
    return node;
}



private Node enq(final Node node) {
    for (;;) {
        //t指向当前队列的最后一个节点,队列为空则为null
        Node t = tail;
        // 如果队列为空,则需要初始化
        if (t == null) { // Must initialize
            // 构造一个空节点,cas方式设置为队首元素,当head=null的时候更新成功(仅当原值为null时更新成功)
            if (compareAndSetHead(new Node()))
                // 尾指针指向首结点
                tail = head;
        } else { // 队列不为空
            node.prev = t;
            // cas方式将尾指针指向当前节点,当t(原来的尾指针)==tail(当前真实的尾指针)时执行成功
            //(仅当原值为t时更新成功)
            if (compareAndSetTail(t, node)) {
                // 原尾节点的next指针指向当前结点
                t.next = node;
                return t;
            }
        }
    }
}

加锁流程图总结

ReentrantLock是如何实现公平锁和非公平锁得

  • FairSync 在 tryAquire 方法中,当判断到锁状态字段state == 0 时,不会立马将当前线程设置为该锁的占用线程,而是去判断是在此线程之前是否有其他线程在等待这个锁(执行hasQueuedPredecessors() 方法),如果是的话,则该线程会加入到等待队列中,进行排队(FIFO,先进先出的排队形式)。这也就是为什么 FairSync 可以让线程之间公平获得该锁。

  • NoFairSync的tryAquire 方法中,没有判断是否有在此之前的排队线程,而是直接进行获锁操作,因此多个线程之间同时争用一把锁的时候,谁先获取到就变得随机了,很有可能线程A比线程B更早等待这把锁,但是B却获取到了锁,A继续等待(这种现象叫做:线程饥饿)

如何实现可重入

加锁操作会对 state字段进行 +1 操作

这里需要注意到 AQS 中很多内部变量的修饰符都是采用的 volital,然后配合 CAS 操作来保证 AQS 本身的线程安全(因为 AQS 自己线程安全,基于它的衍生类才能更好地保证线程安全),这里的 state 字段就是 AQS 类中的一个用 volitale 修饰的 int 变量

state 字段初始化时,值为 0。表示目前没有任何线程持有该锁。当一个线程每次获得该锁时,值就会在原来的基础上加 1,多次获锁就会多次加 1(指同一个线程),这里就是可重入。因为可以同一个线程多次获锁,只是对这个字段的值在原来基础上加1; 相反 unlock 操作也就是解锁操作,实际上是调用 AQS 的 release 操作,而每执行一次这个操作,就会对 state 字段在原来的基础上减1,当 state==0 的 时候就表示当前线程已经完全释放了该锁。

参考文章

  1. 从源码角度彻底理解ReentrantLock(重入锁)

  2. JavaGuide面试题

  3. 这一次,彻底搞懂Java中的ReentrantLockt实现原理

  4. Java并发Lock之ReentrantLock实现原理

  5. 彻底理解Java并发:ReentrantLock锁

相关推荐
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
测试19983 小时前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
小码编匠3 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
AskHarries3 小时前
Java字节码增强库ByteBuddy
java·后端
佳佳_3 小时前
Spring Boot 应用启动时打印配置类信息
spring boot·后端
马剑威(威哥爱编程)4 小时前
MongoDB面试专题33道解析
数据库·mongodb·面试
许野平5 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono