【Java】锁 - ReentrantLock 类

Posted by 西维蜀黍 on 2019-02-28, Last Modified on 2021-09-21

ReentrantLock 类

ReentrantLock 重入锁,是实现了 Lock 接口的一个类,也是在实际编程中使用频率很高的一个锁。

ReentrantLock 具有以下特点:

  • 支持重入性(reentrancy),表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。
  • 支持**公平锁(fair locking)非公平锁(unfair locking)**两种方式。
  • 是**可中断(Interruptible)**锁,即是一个可以被中断持有的锁。
  • 是**可限时锁(Timed Lock)**锁,可以传入一个超时值作为参数。当在到达超时时间前,仍然没有获得锁,则放弃对锁的持有申请。

ReentrantLock是基于乐观锁(Optimistic Locking)的实现。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap)

ReentrantLock内部实现主要通过AbstractQueuedSynchronizer类实现的,AbstractQueuedSynchronizer是抽象类,

可重入性的实现原理

**可重入锁(reentrancy locking)**允许一个线程可以反复得到相同的一把锁,

本质上,可重入锁有一个与锁相关的获取计数器,如果拥有锁的某个线程尝试再次得到锁,那么获取计数器就加1。最终,当锁被释放两次后,该锁才会真正被释放。

获取可重入锁的实现

ReentrantLock 是可重入锁,下面我们来看看 ReentrantLock 是怎样实现的,以非公平锁为例,判断当前线程能否获得锁为例,核心方法为 nonfairTryAcquire:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    //1. 如果该锁未被任何线程占有,该锁能被当前线程获取
	if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
	//2.若被占有,检查占有线程是否是当前线程
    else if (current == getExclusiveOwnerThread()) {
		// 3. 再次获取,计数加一
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

分析:

这段代码的逻辑也很简单,具体请看注释。

为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加1返回true,表示可以再次获取成功。

释放重入锁的实现

每次重新获取都会对同步状态进行加一的操作,那么释放的时候处理思路是怎样的呢?

依然还是以非公平锁为例,核心方法为 tryRelease:

protected final boolean tryRelease(int releases) {
	//1. 同步状态减1
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
		//2. 只有当同步状态为0时,锁成功被释放,返回true
        free = true;
        setExclusiveOwnerThread(null);
    }
	// 3. 锁未被完全释放,返回false
    setState(c);
    return free;
}

分析:

代码的逻辑请看注释,需要注意的是,重入锁的释放必须得等到同步状态为 0 时锁才算成功释放,否则锁仍未释放。

如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。

到现在我们可以理清ReentrantLock重入性的实现了,也就是理解了同步语义的第一条。

公平锁与非公平锁

ReentrantLock 支持两种情况:公平锁(fair locking)和非公平锁(unfair locking)

何谓公平性(fairness),是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合线程请求锁时的绝对时间顺序,满足 FIFO。换句话说,等待时间最久的线程(最先请求的线程)会先获得该锁。

满足公平性的锁,就称为公平锁(fair locking),反之,称为非公平锁(unfair locking)

ReentrantLock 的构造方法无参时是构造非公平锁,源码为:

public ReentrantLock() {
    sync = new NonfairSync();
    ...
}

另外还提供了另外一种方式,可传入一个boolean值,true 时为公平锁,false 时为非公平锁,源码为:

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

在上面非公平锁获取时(nonfairTryAcquire方法)只是简单的获取了一下当前状态做了一些逻辑处理,并没有考虑到当前同步队列中线程等待的情况。我们来看看公平锁的处理逻辑是怎样的,核心方法为:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
  }
}

这段代码的逻辑与 nonfairTryAcquire 基本上一直,唯一的不同在于增加了hasQueuedPredecessors 的逻辑判断,从方法名上就可知道该方法用来判断当前节点在同步队列中是否有前驱节点,如果有前驱节点,说明有线程比当前线程更早的请求资源。

根据公平性,当前线程请求资源失败。如果当前节点没有前驱节点的话,才有做后面的逻辑判断的必要性。

公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁

公平锁 VS 非公平锁

公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象(starvation)。

公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock 默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量

可中断锁 (Interruptible Lock)

ReentrantLock 是可中断锁 (Interruptible Lock),而 synchronized 并不提供中断机制。

可中断锁 (Interruptible Lock),顾名思义,就是可以被中断持有的锁。

比如,某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,由于等待时间过长,线程B急着想处理一个优先级相对更高的事务,我们可以让线程A中断自己正在持有的锁,或者线程B中断线程A正在持有的锁,这就是可中断锁。

可中断锁解决死锁

可中断锁可以帮助解决死锁(deadlock)问题,比如:

import java.util.concurrent.locks.ReentrantLock;

public class IntLock implements Runnable{
    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;
    /**
     * 控制加锁顺序,产生死锁
     */
    public IntLock(int lock) {
        this.lock = lock;
    }
    public void run() {
        try {
            if (lock == 1) {
                lock1.lockInterruptibly(); // 如果当前线程未被中断,则获取锁。
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock2.lockInterruptibly();
                System.out.println(Thread.currentThread().getName()+",执行完毕!");
            } else {
                lock2.lockInterruptibly();
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock1.lockInterruptibly();
                System.out.println(Thread.currentThread().getName()+",执行完毕!");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 查询当前线程是否保持此锁。
            if (lock1.isHeldByCurrentThread()) {
                lock1.unlock();
            }
            if (lock2.isHeldByCurrentThread()) {
                lock2.unlock();
            }
            System.out.println(Thread.currentThread().getName() + ",退出。");
        }
    }
    public static void main(String[] args) throws InterruptedException {
        IntLock intLock1 = new IntLock(1);
        IntLock intLock2 = new IntLock(2);
        Thread thread1 = new Thread(intLock1, "线程1");
        Thread thread2 = new Thread(intLock2, "线程2");
        thread1.start();
        thread2.start();
        Thread.sleep(1000);
        thread2.interrupt(); // 中断线程2,且释放线程2持有的所有锁,同时放弃所有锁申请
    }
}

上述例子中,线程 thread1 和 thread2 启动后,thread1 立刻先占用 lock1,等待 500ms 后,再占用 lock2;thread2 反之,立刻先占 lock2,等待 500ms 后,后占 lock1。这便形成 thread1 和 thread2 之间的死锁。

当代码运行到 Thread.sleep(1000); 时,main 线程处于休眠(sleep)状态,两线程此时处于死锁的状态,thread2.interrupt(); 导致 thread2 被中断(interrupt),且释放 thread2 持有的所有锁,故 thread2 会放弃对 lock1 的申请。这个操作导致 thread1 最终顺利获得 lock2,从而最终解开了死锁。

可限时锁(Timed Lock)

除了可中断锁外,**可限时锁(Timed Lock)**也可以在有些情况下避免死锁的发生。

ReentrantLock 是一种可限时锁(Timed Lock),它提供了 tryLock() 方法,并传入一个超时值作为参数。当在到达超时时间前,仍然没有获得锁,则放弃对锁的持有申请。

package concurrency.in.practice;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TryLockTest extends Thread {
    public static ReentrantLock lock = new ReentrantLock();

    public TryLockTest(String name){
        super(name);
    }

    @Override
    public void run() {
        try {
            if (lock.tryLock(5, TimeUnit.SECONDS)) {
                Thread.sleep(6000);
            } else {
                System.out.println(this.getName() + " get lock failed");
            }
        } catch (Exception e) {
        } finally {
            if (lock.isHeldByCurrentThread()) {
                System.out.println("lock.isHeldByCurrentThread: " + this.getName());
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        TryLockTest t1 = new TryLockTest("TryLockTest1");
        TryLockTest t2 = new TryLockTest("TryLockTest2");

        t1.start();
        t2.start();
    }

}

两个线程来争夺一把锁,由于获得锁的线程在获得锁后会 sleep 6 秒,而未获得的线程在尝试获取锁 5 秒后,仍未获得锁,则放弃对锁的请求。

所以必定有一个线程无法获得锁。无法获得后就直接退出了。

ReentrantLock 的优缺点

优点

ReentrantLock并不是一种替代内置加锁的方法,而是作为一种可选择的高级功能。

相比于 synchronizedReentrantLock 在功能上更加丰富,它具有**可中断(interruptable)、可限时(timed)、公平/非公平(fair/unfair)**等特点。

在 JDK 1.5 里面,ReentrantLock 的性能是明显优于 synchronized 的,但是在 JDK 1.6 里面,synchronized 做了优化,他们之间的性能差别已经不明显了。但是,对于竞争激烈的情况,ReentrantLock 的性能仍然明显优于 synchronized

缺点

相比于 ReentrantLocksynchronized 的特点是使用简单,一切交给 JVM 去处理,不需要显式释放。

从用法上可以看出,与 synchronized 相比, ReentrantLock 就稍微复杂一点。因为必须在 finally 中进行解锁操作,如果不在 finally解锁,有可能代码出现异常锁没被释放,

Condition

synchronized 与 wait() 和 notify()/notifyAll() 方法相结合可以实现等待/通知模型。

ReentrantLock同样可以,但是需要借助 Condition,且 Condition 有更好的灵活性,具体体现在:

  1. 一个 Lock 里面可以创建多个 Condition 实例,实现多路通知;
  2. notify()方法进行通知时,被通知的线程时 Java 虚拟机随机选择的,但是 ReentrantLock 结合 Condition 可以实现有选择性地通知,这是非常重要的

例子

看一下利用 Condition 实现等待/通知模型的最简单用法,下面的代码注意一下,await() 和 signal() 之前,必须要先调用 lock() 以获得锁,使用完毕在finally 中 unlock() 释放锁,这和 wait()/notify()/notifyAll() 使用前必须先获得对象锁是一样的:

public class MyThread extends Thread {
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    public void run() {
        await();
    }

    public void await() {
        try {
            lock.lock();
            System.out.println("await时间为:" + System.currentTimeMillis());
            condition.await();
            System.out.println("await等待结束");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void signal() {
        try {
            lock.lock();
            System.out.println("signal时间为:" + System.currentTimeMillis());
            condition.signal();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws Exception {
        MyThread mt = new MyThread();
        mt.start();
        Thread.sleep(3000);
        mt.signal();
    }
}

结果

await时间为1551332210908
signal时间为1551332213908
await等待结束

分析

差值是 3000 毫秒也就是 3 秒,符合代码预期,成功利用 ReentrantLock 的 Condition 实现了等待/通知模型。

其实这个例子还证明了一点,调用 Condition 的 await() 方法时是释放锁的,原因也很简单,要是 await() 方法不释放锁,那么在进入 signal() 方法时,又怎么能调用到 Condition 的 signal() 方法呢?

Reference