Java语言提供了synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。

从Java 5开始,引入了一个处理并发的java.util.concurrent包,它提供了大量更高级的并发功能。其中Lockjuc包下面的一个接口,ReentrantLock是它的常用实现类。

ReentrantLock是一个可重入的,API层面上的互斥锁,用于替代synchronized加锁。它拥有与synchronized相同的并发性和内存语义,但是添加了定时锁等候、可中断锁等候等一些特性。

基本使用

传统synchronized代码:

public class Counter {
    private int count;

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

如果用ReentrantLock替代,代码为:

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count;

    public void add(int n) {
        lock.lock();
        try {
        	// synchronized do something
            count += n;
        } finally {
            lock.unlock();
        }
    }
}
复制代码

synchronized 是 Java 语言层面提供的一个关键字,锁和获取和释放都不需要显式调用,在异常时也会自动释放锁。

与之相比,ReentrantLock 是 Java 语法层面提供的API,是一种手动锁,需要手动获取锁lock()和释放锁unlock(),并要手动处理异常。一方面使用更加灵活,另一方面也需要特别注意,一定要记得释放锁,不然就会造成死锁。

ReentrantLock因为要手动释放锁,为避免异常时无法释放锁,需要配合try/finally语句块来完成,在finally中释放锁,保证异常时锁也能够释放成功。

所以ReentrantLock的通常使用步骤是:在try模块前获取锁,在try中执行同步代码,在finally中释放锁。

ReentrantLock锁的三个特征

“锁”是为了保护竞争资源,防止多个线程同时操作而出错。ReentrantLock锁在同一个时间点只能被一个线程所持有,当某线程获取到“锁”时,其它线程就必须等待。

ReentrantLock锁有以下三个特征:

一、轻量级锁

synchronized是重量级锁。重量级锁需要将线程从内核态和用户态来回切换。如:A线程切换到B线程,A线程需要保存当前现场,B线程切换也需要保存现场。这样做的缺点是耗费系统资源。而ReentrantLock是轻量级锁。

二、可重入

ReentrantLock,意为再重入锁,顾名思义,它是可重入锁,和synchronized一样,一个线程可以多次获取同一个锁。

对于ReentrantLock,在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。ReentrantLock内部有一个与锁相关的获取计数器,如果拥有锁的某个线程再次得到锁,那么获取计数器就加1,然后锁需要被释放两次才能获得真正释放。

如果想要实现锁的重入,至少要解决一下两个问题:

  • 线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  • 锁的最终释放:线程重复n次获取了锁,随后在n次释放该锁后,其他线程能够获取该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经释放。

三、锁类型:公平锁与非公平锁

公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁; 非公平锁:不保证先到先得,在锁被释放时,任何一个等待锁的线程都有机会获得锁,是一种抢占机制。这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。它的效率和吞吐率通常比公平锁更好。

synchronized锁是非公平锁。

ReentrantLock的构造函数提供了两种锁:公平锁和非公平锁,默认是非公平锁,代码如下:

/**
 * 默认构造方法,非公平锁
 */
public ReentrantLock() {
    sync = new NonfairSync();
}

/**
 * true公平锁,false非公平锁
 * @param fair
 */
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

// 创建一个 ReentrantLock ,默认是“非公平锁”。
ReentrantLock()
// 创建策略是fair的 ReentrantLock。fair为true表示是公平锁,fair为false表示是非公平锁。
ReentrantLock(boolean fair)
复制代码

ReentraantLock是通过一个FIFO的等待队列来管理获取该锁所有线程的。在“公平锁”的机制下,线程依次排队获取锁;而“非公平锁”在锁是可获取状态时,不管自己是不是在队列的开头都可能获取锁。

公平锁保证一个阻塞的线程最终能够获得锁,因为是有序的,所以总是可以按照请求的顺序获得锁。

非公平锁性能高于公平锁性能的原因:在恢复一个被挂起的线程与该线程真正运行之间存在着严重的延迟。假设线程A持有一个锁,并且线程B请求这个锁。由于锁被A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因此B会再次尝试获取这个锁。与此同时,如果线程C也请求这个锁,那么C很可能会在B被完全唤醒之前获得、使用以及释放这个锁。这样就是一种双赢的局面:B获得锁的时刻并没有推迟,C更早的获得了锁,并且吞吐量也提高了。当持有锁的时间相对较长或者请求锁的平均时间间隔较长,应该使用公平锁。在这些情况下,插队带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)可能不会出现。

获取锁的四种方式

  1. lock()
  2. tryLock()
  3. tryLock(long timeout, TimeUnit unit)
  4. lockInterruptibly()

阻塞式 lock()

lock()是阻塞式等待锁。如果线程没有获取到锁,就一直阻塞在这里等待,不能中断响应,可能会造成死锁。

可轮询的锁请求 tryLock()

可轮询的锁获取模式,由tryLock()方法实现。此方法不阻塞,仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回值true。如果锁不可用,则此方法将立即返回值false。

可限时尝试获取锁 tryLock(long, TimeUnit)

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        // do something synchronized
    } finally {
        lock.unlock();
    }
}
复制代码

上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去,造成死锁。
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。面使用内部锁synchronized时,一旦开始请求,锁就不能停止了,会一直阻塞等待下去,所以内部锁给实现具有时限的活动带来了风险。
使用lock.tryLock(long timeout, TimeUnit unit)来实现可限时锁,参数为时间和单位。

可中断的锁获取请求 lockInterruptibly()

synchronized请求锁时是不可中断的,ReentrantLock提供可中断和不可中断两种方式。lock()是不可不断的,lockInterruptibly()是可中断的。可中断的锁获取操作在可取消的活动中使用,允许获得锁的时候响应中断。

锁绑定多个条件实现同步

在 synchronized 中,锁对象使用Object的 wait() 和 notify() 或 notifyAll() 等方法实现线程间的通信和同步。而ReentrantLock相应的提供了Condition的await()、signal()、singalAll()方法作为替代。

synchronized中,与wait,notify配合,可以实现一个隐含的条件,但如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁。而ReentrantLock 通过同时绑定多个 Condition 对象,可以绑定多个条件,只需要多次调用 newCondition() 方法即可。

final ConditionObject newCondition() { //ConditionObject是Condition的实现类
    return new ConditionObject();
} 
复制代码

Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。因此通常来说比较推荐使用Condition。Conditon中的await()对应Object的wait(),Condition中的signal()对应Object的notify(),Condition中的signalAll()对应Object的notifyAll()。

调用Condition的await()和signal()方法,都必须在lock保护之内,就是说必须在lock.lock()和lock.unlock之间才可以使用。

与synchronized比较

相同点

ReentrantLock提供了synchronized类似的功能和内存语义。

不同点

  1. 功能更加丰富,具有可中断、可轮询尝试、可限时、公平锁等特点。
  2. Synchronized自动释放锁,ReentrantLock手动释放锁。且ReentrantLock需要捕获异常,在finally中释放锁。
  3. ReentrantLock提供了条件Condition,锁可以绑定多个条件,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,ReentrantLock更加适合。
  4. ReentrantLock并不是一种替代内置加锁的方法,而是作为一种可选择的高级功能。

缺点

  1. lock 必须在 finally 块中释放。否则,如果受保护的代码将抛出异常,锁就有可能永远得不到释放!这一点区别看起来可能没什么,但是实际上,它极为重要。忘记在 finally 块中释放锁,可能会在程序中留下一个定时炸弹,当有一天炸弹爆炸时,您要花费很大力气才有找到源头在哪。而使用同步,JVM 将确保锁会获得自动释放。
  2. 当 JVM 用 synchronized 管理锁定请求和释放时,JVM 在生成线程时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。 Lock 类只是普通的类,JVM 不知道具体哪个线程拥有 Lock 对象,不方便调试。

性能

在jdk1.5里面,ReentrantLock的性能是明显优于synchronized的,但是在jdk1.6里面,synchronized做了优化,他们之间的性能差别已经不明显了。

总结

ReentrantLock提供比内置锁更灵活的加锁方式,在JCU包中有较多应用。随着synchronized的性能优化,在简单加锁情况下,还是优选synchronized关键字,其使用简单且不需要显示释放,使用出错的几率较低。如果需要灵活的加锁策略或需要超时机制等可以考虑使用重入锁。

尤其需要注意释放锁,推荐在finally代码块中释放锁,保证正常异常情况都能成功释放。


原文链接:https://juejin.cn/post/7055564564376584222