概述
本文主要讲解 ReentrantLock 的使用场景以及主要API使用方法,过程中也会讲解与synchronized 的区别。
在JDK5.0版本之前,ReentrantLock的性能远远好于synchronized关键字,JDK6.0版本之后synchronized 得到了大量的优化,二者性能也不分伯仲,但是ReentrantLock是可以完全替代synchronized关键字的。除此之外,ReentrantLock又自带一系列高逼格用法:可中断响应、锁申请等待限时、公平锁。另外可以结合Condition来使用,使得其应用越来越广泛。
ReentrantLock
ReentrantLock是一个可重入且独占式的锁,它具有与使用synchronized监视器锁相同的基本行为和语义,但与synchronized关键字相比,它更灵活、更强大,增加了轮询、超时、中断等高级功能。ReentrantLock,顾名思义,它是支持可重入锁的锁,是一种递归无阻塞的同步机制。除此之外,该锁还支持获取锁时的公平和非公平选择,简而言之,其优势主要有以下3项:
1.等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。通过lock.lockInterruptibly()来实现这个机制。
2.公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。
后面,我们也将使用程序来验证公平锁机制的利弊。
3.锁绑定多个条件,一个ReentrantLock对象可以同时绑定对个对象。ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机,唤醒一个线程要么唤醒全部线程。
ReenTrantLock实现的原理
ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
什么情况下使用ReenTrantLock
答案是,如果你需要实现ReenTrantLock的三个独有功能时。
与Synchronized的功能区别
这两种方式最大区别就是对于Synchronized来说,它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成
便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized。
公平锁与非公平锁
公平性与否是针对锁获取顺序而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合FIFO原则。
公平锁、非公平锁的创建方式:
//创建一个非公平锁,默认是非公平锁
Lock lock = new ReentrantLock();
Lock lock = new ReentrantLock(false);
//创建一个公平锁,构造传参true
Lock lock = new ReentrantLock(true);
下面我们通过编写一个测试程序来观察公平锁和非公平锁在获取锁时的区别:
public class FairAndUnfairTest {
private static CountDownLatch start;
private static class MyReentrantLock extends ReentrantLock {
public MyReentrantLock(boolean fair) {
super(fair);
}
public Collection<Thread> getQueuedThreads() {
List<Thread> arrayList = new ArrayList<Thread>(super.getQueuedThreads());
Collections.reverse(arrayList);
return arrayList;
}
}
private static class Worker extends Thread {
private Lock lock;
public Worker(Lock lock) {
this.lock = lock;
}
@Override
public void run() {
try {
start.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 连续两次打印当前的Thread和等待队列中的Thread
for (int i = 0; i < 2; i++) {
lock.lock();
try {
System.out.println("Lock by [" + getName() + "], Waiting by " + ((MyReentrantLock) lock).getQueuedThreads());
} finally {
lock.unlock();
}
}
}
public String toString() {
return getName();
}
}
public static void main(String[] args) {
Lock fairLock = new MyReentrantLock(true);
//Lock unfairLock = new MyReentrantLock(false);
testLock(fairLock);
//testLock(unfairLock);
}
private static void testLock(Lock lock) {
start = new CountDownLatch(1);
for (int i = 0; i < 5; i++) {
Thread thread = new Worker(lock);
thread.setName("" + i);
thread.start();
}
start.countDown();
}
}
testLock(fairLock)运行结果(不唯一):
Lock by [0], Waiting by [4, 1, 2, 3]
Lock by [4], Waiting by [1, 2, 3, 0]
Lock by [1], Waiting by [2, 3, 0, 4]
Lock by [2], Waiting by [3, 0, 4, 1]
Lock by [3], Waiting by [0, 4, 1, 2]
Lock by [0], Waiting by [4, 1, 2, 3]
Lock by [4], Waiting by [1, 2, 3]
Lock by [1], Waiting by [2, 3]
Lock by [2], Waiting by [3]
Lock by [3], Waiting by []
testLock(unfairLock)运行结果(不唯一):
Lock by [0], Waiting by [1]
Lock by [0], Waiting by [1, 2, 3, 4]
Lock by [1], Waiting by [2, 3, 4]
Lock by [1], Waiting by [2, 3, 4]
Lock by [2], Waiting by [3, 4]
Lock by [2], Waiting by [3, 4]
Lock by [3], Waiting by [4]
Lock by [3], Waiting by [4]
Lock by [4], Waiting by []
Lock by [4], Waiting by []
从上述结果可以看到,公平锁每次都是队列中的第一个节点获取到锁,而非公平锁出现了一个线程连续获取锁的情况。
为什么会出现连续获取锁的情况呢?因为每当一个线程请求锁时,只要获取了同步状态就成功获取了锁。在此前提下,刚刚释放锁的线程再次获取到同步状态的几率很大,而其他线程只能在同步队列中等待。
非公平锁有可能使线程饥饿,那为什么还要将它设置为默认模式呢?我们再次观察上面的运行结果,如果把每次不同线程获取到锁定义为1次切换,公平锁在测试中进行了10次切换,而非公平锁只有5次切换,这说明非公平锁的开销更小。
从结果中可以看到,公平锁与非公平锁相比,耗时更多,线程上下文切换次数更多。可以看出,公平锁保证了锁的获取按照FIFO原则,而代价则是进行大量的线程切换。非公平锁虽然可能导致线程饥饿,但却有极少的线程切换,保证了其更大的吞吐量。
讲完公平锁与非公平锁的区别,现在上简单代码看下:
import java.util.concurrent.locks.ReentrantLock;
public class FairLockTest implements Runnable{
public static ReentrantLock lock = new ReentrantLock(true);
@Override
public void run() {
while (true) {
try {
lock.lock();
System.err.println(Thread.currentThread().getName() + "获取到了锁!");
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
FairLockTest test = new FairLockTest();
Thread t1 = new Thread(test, "线程1");
Thread t2 = new Thread(test, "线程2");
t1.start();t2.start();
}
}
运行结果:
线程1获取到了锁!
线程2获取到了锁!
线程1获取到了锁!
线程2获取到了锁!
***其他结果省略
可以发现,t1和t2交替获取到锁。如果是非公平锁,会发生t1运行了许多遍后t2才开始运行的情况。
锁的可重入性
public class ReentrantLockTest implements Runnable{
public static ReentrantLock lock = new ReentrantLock();
public static int i = 0;
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
lock.lock(); // 看这里就可以
//lock.lock(); ①
try {
i++;
} finally {
lock.unlock(); // 看这里就可以
//lock.unlock();②
}
}
}
public static void main(String[] args) throws InterruptedException {
ReentrantLockTest test = new ReentrantLockTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();t2.start();
t1.join(); t2.join(); // main线程会等待t1和t2都运行完再执行以后的流程
System.err.println(i);
}
}
从上可以看出,使用重入锁进行加锁是一种显式操作,通过何时加锁与释放锁使重入锁对逻辑控制的灵活性远远大于synchronized关键字。同时,需要注意,有加锁就必须有释放锁,而且加锁与释放锁的分数要相同,这里就引出了“重”字的概念,如上边代码演示,放开①、②处的注释,与原来效果一致。
中断响应
对于synchronized块来说,要么获取到锁执行,要么持续等待。而重入锁的中断响应功能就合理地避免了这样的情况。比如,一个正在等待获取锁的线程被“告知”无须继续等待下去,就可以停止工作了。
接下来使用代码讲解这一特性。
import java.util.concurrent.locks.ReentrantLock;
public class KillDeadlock implements Runnable{
public static ReentrantLock lock1 = new ReentrantLock();
public static ReentrantLock lock2 = new ReentrantLock();
int lock;
public KillDeadlock(int lock) {
this.lock = lock;
}
@Override
public void run() {
try {
if (lock == 1) {
lock1.lockInterruptibly(); // 以可以响应中断的方式加锁
try {
Thread.sleep(500);
} catch (InterruptedException e) {}
lock2.lockInterruptibly();
} else {
lock2.lockInterruptibly(); // 以可以响应中断的方式加锁
try {
Thread.sleep(500);
} catch (InterruptedException e) {}
lock1.lockInterruptibly();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock1.isHeldByCurrentThread()) lock1.unlock(); // 注意判断方式
if (lock2.isHeldByCurrentThread()) lock2.unlock();
System.err.println(Thread.currentThread().getId() + "退出!");
}
}
public static void main(String[] args) throws InterruptedException {
KillDeadlock deadLock1 = new KillDeadlock(1);
KillDeadlock deadLock2 = new KillDeadlock(2);
Thread t1 = new Thread(deadLock1);
Thread t2 = new Thread(deadLock2);
t1.start();
t2.start();
Thread.sleep(1000);
t2.interrupt(); // ③
}
}
t1、t2线程开始运行时,会分别持有lock1和lock2而请求lock2和lock1,这样就发生了死锁。但是,在③处给t2线程状态标记为中断后,持有重入锁lock2的线程t2会响应中断,并不再继续等待lock1,同时释放了其原本持有的lock2,这样t1获取到了lock2,正常执行完成。t2也会退出,但只是释放了资源并没有完成工作。
锁申请等待限时
可以使用 tryLock()或者tryLock(long timeout, TimeUtil unit) 方法进行一次限时的锁等待。
前者不带参数,这时线程尝试获取锁,如果获取到锁则继续执行,如果锁被其他线程持有,则立即返回 false ,也就是不会使当前线程等待,所以不会产生死锁。
后者带有参数,表示在指定时长内获取到锁则继续执行,如果等待指定时长后还没有获取到锁则返回false。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockTest implements Runnable{
public static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
try {
if (lock.tryLock(1, TimeUnit.SECONDS)) { // 等待1秒
Thread.sleep(2000); //休眠2秒
} else {
System.err.println(Thread.currentThread().getName() + "获取锁失败!");
}
} catch (Exception e) {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
TryLockTest test = new TryLockTest();
Thread t1 = new Thread(test); t1.setName("线程1");
Thread t2 = new Thread(test); t1.setName("线程2");
t1.start();t2.start();
}
}
运行结果:
线程2获取锁失败!
上述示例中,t1先获取到锁,并休眠2秒,这时t2开始等待,等待1秒后依然没有获取到锁,就不再继续等待,符合预期结果。
好了,到这里重入锁ReentrantLock的基本使用方法就介绍完成了!
欢迎关注本人头条号。