一般遇到并发问题,都会想到
synchronized
,ReentrantLock
和synchronized
一样都是实现线程同步,但是像比synchronized它更加灵活、强大、增加了轮询、超时、中断等高级功能,可以更加精细化的控制线程同步,它是基于AQS实现的锁,他支持公平锁和非公平锁,同时他也是可重入锁和自旋锁。
目录
一、初识ReentrantLock
ReentrantLock
就是可重入的锁,同一个线程可以多次获得同一把锁
在使用Synchronized
,会存在以下几个问题:
-
不可中断锁,需要线程执行完才会释放锁(synchronized的获取和释放锁由jvm实现)
-
非公平锁
-
Synchronized引入了偏向锁,轻量级锁(自旋锁)后,性能有所提升
Synchronized
-
当线程尝试获取锁的时候,如果获取不到锁会一直阻塞,这个阻塞的过程,用户无法控制
-
如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待
ReentrantLock
-
可重入锁:可重入锁是指同一个线程可以多次获得同一把锁;ReentrantLock和关键字Synchronized都是可重入锁
-
可中断锁:可中断锁时子线程在获取锁的过程中,是否可以相应线程中断操作。synchronized是不可中断的,ReentrantLock是可中断的
-
公平锁和非公平锁:公平锁是指多个线程尝试获取同一把锁的时候,获取锁的顺序按照线程到达的先后顺序获取,而不是随机插队的方式获取。synchronized是非公平锁,而ReentrantLock是两种都可以实现,不过默认是非公平锁
ReentrantLock
位于java.util.concurrent.locks包下,实现了Lock接口和Serializable接口
public class ReentrantLock implements Lock, java.io.Serializable {……}
synchronized的细粒度和灵活度不够好
ReentrantLock是一把可重入锁
和互斥锁
,它具有与 Synchronized
关键字相同的含有隐式监视器锁(monitor)的基本行为和语义,但是它比 Synchronized
具有更多的方法和功能
二、简单使用
public class ReentrantLockTest {
private static final Lock lock = new ReentrantLock();
public static void test() {
try {
//获取锁
lock.lock();
System.out.println(Thread.currentThread().getName() + "获取到锁了");
//业务代码,使用部分花费100毫秒
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放锁放在finally中。
lock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了锁");
}
}
public static void main(String[] args) {
new Thread(() -> {
test();
}, "线程1").start();
new Thread(() -> {
test();
}, "线程2").start();
}
}
运行结果:
效果和Synchronized的一样,线程1获取到锁了,线程2需要等待线程1释放锁后才可以获取锁
注意:为了防止锁不被释放,从而造成死锁,强烈建议把锁的释放
lock.unlock()
放在finally
模块中
进入java.util.concurrent.locks.ReentrantLock
中,发现ReentrantLock
有三个内部类
ReentrantLock
—Sync
—NofairSync
—FairSync
更详细的图
Lock接口定义如下:
public interface Lock {
/**
* 获取锁
*/
void lock();
/**
* 获取锁-响应中断
*/
void lockInterruptibly() throws InterruptedException;
/**
* 返回获取锁是否成功状态
*/
boolean tryLock();
/**
* 返回获取锁是否成功状态-响应中断
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
/**
* 释放锁
*/
void unlock();
/**
* 创建条件变量
*/
Condition newCondition();
}
对应UML图:
看到了一个熟悉的身影java.util.concurrent.locks.AbstractQueuedSynchronizer
(AQS同步队列器)
先简单的说下AQS(AbstractQueuedSynchronizer)
流程,AQS
为加锁和解锁过程提供了统一的模板函数,加锁与解锁的模板流程是,获取锁失败的线程,会进入CLH
队列阻塞,其他线程解锁会唤醒CLH
队列线程,如下图所示(简化流程)
线程释放锁时,会唤醒CLH
队列阻塞的线程,重新竞争锁
注意:此时可能还有非
CLH
队列的线程参与竞争
非公平策略
:所以非公平就体现在这里,非
CLH
队列线程与CLH
队列线程竞争,各凭本事,不会因为你是CLH
队列的线程,排了很久的队,就把锁让给你
1、锁的过程
①Lock加锁
-非公平锁
非公平锁获取锁时不会判断阻塞队列是否有线程再等待,所以对于已经在等待的线程来说是不公平的,但如果是因为其它原因没有竞争到锁,它也会加入阻塞队列
进入阻塞队列的线程,竞争锁时都是公平的,应为队列为
先进先出(FIFO)
可以从源码中看出ReentrantLock
默认为非公平锁
,当fair参数为true为公平锁,但是系统内部肯定需要维护一个有序队列,因此公平锁的实现成本比较高,性能相对于非公平锁来说相对低一些
ReentrantLock fairLock = new ReentrantLock(); //默认非公平锁
ReentrantLock fairLock = new ReentrantLock(true); //公平锁
然后进入到NofairSync的lock()中,使用final修饰,该方法不能被重写
通过CAS操作
来修改state的状态,表示争抢锁的操作,setExclusiveOwnerThread()
设置当前获得锁状态的线程,然后acquire(1)
尝试去获取锁
CAS 方法更新的要求涉及三个变量,currentValue(当前线程的值),expectedValue(期望更新的值),updateValue(更新的值)
if(currentValue == expectedValue){
currentValue = updateValue
}
Sync
还定义了一个nonfairTryAcquire
函数,这个函数是专门给NonfairSync
使用的,FairSync
却没有这种待遇,所以说Sync
偏心
以下三个方法都是AQS中的
compareAndSetState();//通过cas操作来修改state状态,表示争抢锁的操作
setExclusiveOwnerThread();//设置当前获得锁状态的线程
acquire();//尝试去获取锁
如果当前线程是这个锁的持有者,那么持有计数将减少。如果保持计数现在为零,则释放锁
由于公平锁和非公平锁都是继承自java.util.concurrent.locks.AbstractQueuedSynchronizer
。所以我们不得不先说说AQS
,先把AQS
搞清楚了上面的ReentrantLock
方可继续
acquire
是AQS
中的方法,总共有4个方法
tryAcquire()
tryAcquire
进入非公平锁获取锁nonfairTryAcquire
方法,对锁状态进行了判断,并没有把锁加入同步队列中
如果既没有排队的线程而且使用 CAS 方法成功的把 0 -> 1 (偏向锁
),那么当前线程就会获得偏向锁,记录获取锁的线程为当前线程
acquire 方法会先查看同步状态是否获取成功,如果成功则方法结束返回,也就是 !tryAcquire == false
,若失败则先调用 addWaiter
方法再调用 acquireQueued
方法
上面的代码,逻辑大致为:
-
获取当前线程
-
获取当前的state,因为state是volatile修饰,所以不用考虑线程的可见性
-
判断state==0,表示锁没有被持有,把state设置为1,把锁持有的线程设置成当前线程,然后true则表示可以获取锁
-
state!=0,则判断当前线程是不是获取锁的线程程
-
是当前线程,则state=state+1,然后true表示获取锁成功
非公平锁加锁流程:
🔴小贴士:在
ReentrantLock
中,它对AbstractQueuedSynchronizer
的state
状态值定义为线程获取该锁的重入次数,state
状态值为0
表示当前没有被任何线程持有,state
状态值为1
表示被其他线程持有,因为支持可重入,如果是持有锁的线程,再次获取同一把锁,直接成功,并且state
状态值+1
,线程释放锁state
状态值-1
,同理重入多次锁的线程,需要释放相应的次数
addWaiter()
线程1把锁持有了,把state设置成1,这时线程2来执行nonfairTryAcquire()
就会返回false,那么这时候就会执行addWaiter()
public final void acquire(int arg) {
//如果当前线程尝试获取锁失败并且 加入把当前线程加入了等待队列
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
//先中断当前线程
selfInterrupt();
}
}
addWaiter(Node.EXCLUSIVE)
,再执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
方法。下面用一个时序图来看看当前到哪一步了
首先当把当前线程和Node的节点进行封装,Node 节点的类型有两种,EXCLUSIVE
和 SHARED
,前者为独占模式,后者为共享模式,具体的区别我们会在 AQS 源码讨论
首先会判断tail尾节点是否为null,其实头节点也相当于没有尾节点
-
如果有尾节点,就会原子性的将当前节点插入同步队列,再执行
enq入队操作
,入队操作相当于原子性的把节点插入队列中 -
如果当前同步队列尾节点为null,说明当前线程是第一个加入同步队列进行等待的线程
主要对上面刚加入队列的Node不断尝试以下两种操作之一。
-
在前驱节点就是head节点的时候,继续尝试获取锁
-
将当前线程挂起,使CPU不再调度它
该线程获取资源失败,已经被放入等待队列尾部了,线程下一步进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了
-公平锁
公平锁和非公平锁的主要区别:公平锁会考虑前面有没有线程在等待队列里,就是前面有没有线程先进来,先来先到
公平锁会获取锁时会判断阻塞队列里是否有线程再等待,若有获取锁就会失败,并且会加入阻塞队列
公平策略:
严格按照
CLH
队列顺序获取锁,线程释放锁时,会唤醒CLH
队列阻塞的线程,重新竞争锁,要注意,此时可能还有非CLH
队列的线程参与竞争
为了保证公平,一定会让CLH
队列线程竞争成功,如果非CLH
队列线程一直占用时间片,那就一直失败(构建成节点插入到CLH
队尾,由ASQ
模板流程执行),直到时间片轮到CLH
队列线程为止,所以公平策略的性能会更差
公平锁代码和nonfairTryAcquire
唯一的不同在于增加了hasQueuedPredecessors
方法的判断
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
protected final boolean tryAcquire(int acquires) {
//获取当前线程
final Thread current = Thread.currentThread();
int c = getState();
//判断当前对象是否被持有
if (c == 0) {
//如果等待队列为空 并且使用CAS获取锁成功 否则返回false然后从队列中获取节点
if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires)) {
//把当前线程持有
setExclusiveOwnerThread(current);
return true;
}
}
//若被持有 判断锁是否是当前线程 可重入锁的关键代码
else if (current == getExclusiveOwnerThread()) {
//计数加1 返回
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
//不是当前线程持有 执行
return false;
}
}
hasQueuedPredecessors
判断等待队列是都有其他节点
公平锁加锁流程图:
②UnLock释放锁
ReentrantLock
中的unlock
方法了,直接都是从AQS
里开始的
此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()
释放锁和锁的公平性就没关系了,继续在ReentrantLock
中的unlock方法
private final Sync sync;
public ReentrantLock() {
sync = new NonfairSync();
}
public void unlock() {
sync.release(1);
}
然后进入到Release()
,这里的release方法是AQS
中的release方法,此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()
空方法留给子类去执行
Syn中实现tryRelease()
//释放当前线程占用的锁 releases=1
protected final boolean tryRelease(int releases) {
//计算state=state-1
int c = getState() - releases;
//判断持有锁的线程是不是当前线程
//不是抛异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
//如果state==0证明本次锁释放成功
if (c == 0) {
free = true;
//锁持有线程设置成null
setExclusiveOwnerThread(null);
}
//把state设置成0
setState(c);
return free;
}
流程图:
小结:
可重入性实现原理:
在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功
由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功
③锁中断
tryLock()
ReentrantLock获取锁的过程是可中断的
对于Synchronized关键字来说,如果一个线程在等待锁,最终只有2种结果:
-
要么获取到锁然后继续后面的操作
-
要么一直等待,直到其他线程释放锁为止
而ReentrantLock提供了另外一种可能,就是在等的获取锁的过程中(发起获取锁请求到还未获取到锁这段时间内)是可以被中断的,也就是说在等待锁的过程中,程序可以根据需要取消获取锁的请求
tryLock()会尝试获取锁
,会立即返回,返回值表示是否获取成功
在ReetrantLock
的tryLock(long timeout, TimeUnit unit)
提供了超时获取锁的功能。它的语义是在指定的时间内如果获取到锁就返回true,获取不到则返回false。这种机制避免了线程无限期的等待锁释放,该方法会响应线程的中断
方法tryAcquireNanos
是AQS
中的方法
总结
如果超时时间设置小于等于0,则直接返回获取失败。线程先入等待队列,然后开始自旋,尝试获取锁,获取成功就返回,失败则在队列里找一个安全点把自己挂起直到超时时间过期。
很多人可能会问:这里为什么还需要循环呢?
因为当前线程节点的前驱状态可能不是SIGNAL,那么在当前这一轮循环中线程不会被挂起,然后更新超时时间,开始新一轮的尝试
三、总结
-
ReentrantLock可以实现公平锁和非公平锁
-
ReentrantLock默认实现的是非公平锁
-
ReentrantLock的获取锁和释放锁必须成对出现,锁了几次,也要释放几次
-
释放锁的操作必须放在finally中执行
-
实例方法tryLock()会尝试获取锁,会立即返回,返回值表示是否获取成功
-
实例方法tryLock(long timeout, TimeUnit unit)会在指定的时间内尝试获取锁,指定的时间内是否能够获取锁,都会返回,返回值表示是否获取锁成功,该方法会响应线程的中断