本着重新学习(看到什么复习什么)的原则,这一篇讲的是JAVA的ReentrantLock。看了诸位大神的解释后详细的查了一些东西,记录下来,也感谢各位在网络上的分享!!!
就像上一篇说的,这一块知识薄弱,终究还是要攻克的。看了视频,也看了文章,学习到了ReentrantLock相关的真的很好的东西,我不知道b站上的那位up是不是本家,就不放视频课的链接了,毕竟还是要在b站学编程的。。。不过博客地址我放在下边了,有功夫真的要多重新看几遍。
https://blog.youkuaiyun.com/java_lyvee/article/details/98966684
我跟着课程和博客也写了一些伪代码,因为我觉得这位老师的例子非常好,希望不会介意我引荐过来。整体的讲述过程也清晰了很多,思路也清晰了,所以我争取用老师的路自己的走一遍理解一遍。
1.什么是用户态和内核态?
用户态和内核态最直观的理解就是我们的程序一般运行在用户态,而内核态一般是我们触及不到的核心地带。然而在一些场合时会出现从用户态转为内核态的访问情况,如系统调用了内核部分,CPU在执行用户态程序触发不可知异常,外部设备的中断。在JDK以前的版本中,synchronized关键字是调用了操作系统函数,也就是说会经历用户态和内核态的互相转换。而这个转换过程是CPU消耗很大的。现在,synchronized关键字和ReentrantLock都尽可能的在JVM级别解决这个问题,尽量不去访问内核态的内容,也就将原本较大消耗的重量级锁发展成了消耗小的轻量级锁。
2.什么是CAS和AQS?
简单来说,CAS就是比较并交换,在条件比对后符合的情况下进行赋值。是解决多线程并行问题中的一种原子性操作,也是一种较为消耗CPU的操作系统操作。在CAS操作中包括三块,即内存位置(V)、预期原值(A)和新值(B)。通过比对内存位置(V)的值和预期原值(A),决定是否需要将该位置更新为新值(B)。
而AQS主要提供了一种通过阻塞实现的锁机制和依赖FIFO的线程等待队列。其思想就在于实现,无竞争则获取,有竞争则入队等待的机制,并且在AQS中会经常使用自旋的方式去尝试获取锁,相当严谨。
3.如何自己实现同步?
自己如何实现同步可以有以下几种方式,各有不同与优劣。
(1).自旋——>同步:在仅有一个线程的加锁过程中,由于status初始值设定为0,则可以进行CAS操作将status置为1,方法返回true,while循环判断条件返回false,所以直接返回。相当于是加锁成功。因为代码运行成功,才可以继续执行lock方法以后的代码。而在多线程加锁过程中,若上一个线程仍持有锁(所以status=1),下一个线程会在(!compareAndSet(0,1))处不断死循环。所以没有获取到所得线程不会休眠,而是会一直占用CPU进行CAS操作。
volatile int status = 0;
void lock(){
while(!compareAndSet(0,1)){
// while死循环消耗CPU资源,没有竞争到锁的线程会一直占用资源进行CAS
// 但是也通过死循环达到了自旋的效果
// 要解决自旋锁的性能问题就必须让竞争锁失败的线程不空转,而是在获取不到的时候让出CPU,yield方法能让出CPU
}
}
void unlock(){
status = 0;
}
boolean compareAndSet(int expect,int newValue){
//CAS操作
}
(2).yield()+自旋——>同步:通过yield方式确实可以跳出循环,但是也由于yield是CPU调度而变得不可控,在多线程的情况下很有可能还是会调用同一个线程进来加锁。
void lock(){
while(!compareAndSet(0,1)){
//yield
//但是自旋锁+yield的方式并不能完全解决问题,当系统只有两个线程竞争锁时有效,但yield只是让出CPU并不能保证操作系统下一次运行那个线程,有可能还是同一个线程
yield();
}
}
(3).sleep()+自旋——>同步:使用sleep方式的弊端就在于休眠时间的不可控,我们看似是可以对数字进行改变,但实质上对于不同的线程调用,等待时间可能是不同的,这个数字的值应该为多少就变得非常重要和考究。
sleep()+自旋锁
void lock(){
while(!compareAndSet(0,1)){
//sleep问题就在于休眠多长时间不确定
sleep(10);
}
}
(4).park()+自旋——>同步:使用等待队列和park的方式就变得比较科学,在不能获取锁的时候可以通过入队保持线程顺序,也可以通过lock和unlock更加直观的更改线程状态。
volatile int status = 0;
Queue parkQueue;// 队列
void lock(){
while(!compareAndSet(0,1)){
park();
}
unlock();
}
void unlock(){
status = 0;
lock_notify();
}
void park(){
// 将当前线程加入等待队列
parkQueue.add(currentThread);
// 释放当前线程CPU 阻塞
releaseCPU();
}
void lock_notify(){
// 得到要唤醒的线程头部线程
Thread thread = parkQueue.header();
// 唤醒当前线程
unpack(thread);
}
在队列中保持线程关系,在出队时保持FIFO的就是公平锁,而非公平锁可能会被任一在程序中运行到加锁部分代码的线程获取。这就是公平锁与非公平锁。而在ReentrantLock公平锁/非公平锁的判断是看如何创建对象,若是调用默认构造方法实例化的就是非公平锁。通过传递参数也可以选择使用哪种锁。如下图所示:
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
4.ReentrantLock如何加锁?
使用ReentrantLock进行加锁,我看到的有这几种情况:无竞争加锁,有竞争入队等待,有竞争但是过程中可加锁。听起来比较绕,看过视频课和源码分析后会明确很多,我的代码粘贴部分标志着调用的部分方法,图中表示的就是判断过程等。
(1).无竞争加锁
// 调用公平锁
static ReentrantLock reentrantLock = new ReentrantLock(true);
// 在执行了下述部分源码后顺利返回,即可以继续运行加锁后逻辑
reentrantLock.lock();
// 传入参数的ReentrantLock调用new FairSync()
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
// 调用公平锁中的lock()
final void lock() {
acquire(1);
}
// 调用acquire()
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
//获取当前线程
final Thread current = Thread.currentThread();
//获取当前线程状态(是一个赋值操作,原子操作)
int c = getState();
//判断锁的状态
if (c == 0) {
//若直接进入CAS则为非公平锁,因为当前等待队列中仍有很多等待线程,因为一次问询直接跳过所有的等待是不公平的,所以这里不是直接进行CAS
//hasQueuedPredecessors为判断自己是否需要排队
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;
}
public final boolean hasQueuedPredecessors() {
// 若队头等于队尾(都为null)则不需要排队
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
// h!=t判断头部是否等于尾部
// (s=h.next)==null将头部的后继赋给s,并判断s是否为null
// s.thread!=Thread.currentThread()将头部的后继s的线程与当前线程对比,即比对当前要申请排队的线程是否处在AQS中的第二个位置,若不相等则继续休眠(适用场景是在第二次自旋时判断当前自旋的线程是否是排队中的下一个正确的位置)
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
// 非公平锁部分,做对比用
final void lock() {
//非公平锁,直接进行CAS
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
(2).有竞争入队/有竞争过程中加锁
// 模拟有竞争情况下一线程占用锁时,另一线程进入,即进行竞争入队过程(仍是公平锁)
public final void acquire(int arg) {
// !tryAcquire(arg)为true(注意“非”)
// 前者返回必然是true或者false的布尔值,后者addWaiter中返回同样是true或者false的布尔值,而要想加锁成功,必然需要一个park,否则总会有方法执行结束的时候,并一路向上返回直至lock()处继续执行后续代码,这样会使得加锁没有意义
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
// tryAcquire(arg)返回false
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 在有竞争情况下,说明已经有线程对c进行过修改了,所以此时c=1进入else分支
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 首先判断进来的线程是不是当前持有锁的线程(重入)
else if (current == getExclusiveOwnerThread()) {
// 如果是当前线程,就在原值上加1,相当于计数器加1重入+1次
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 因为不是当前线程,直接返回false
return false;
}
private Node addWaiter(Node mode) {
// 实例化一个node,维护链表(上下节点是哪一个)
Node node = new Node(Thread.currentThread(), mode);
// 首次出现竞争时,队尾为null,直接跳过判断进入enq(node)
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 完成入队和确定队列间前驱和后继的关系
enq(node);
return node;
}
// 在AQS队列里的队头所对应的Node中的Thread永远为null
private Node enq(final Node node) {
for (;;) {
// 因为仍是首次出现竞争,所以队首也为null(要注意是死循环)
// 1.首次循环时队首为null
// 2.次回循环时队首为t
Node t = tail;
if (t == null) { // Must initialize
// CAS操作AQS队头,此时AQS队头是一个Thread为null的空Node对象,而后让尾结点也指向头结点所在的空node,而后退出判断
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 2.让传入的node的前驱指向t
node.prev = t;
// 若AQS队列的尾部是t,则进行CAS操作,将其更改为node
if (compareAndSetTail(t, node)) {
// 而后将t的后继指向传入的node并返回
t.next = node;
return t;
}
}
}
}
// 阻塞竞争中以入队的线程
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 判断p的上一个节点是不是头部,如果是头部说明自己是第一个排队的线程,并且使用上述的tryAcquire()方法去尝试获取一次锁(自旋)
// 1.首次循环时ws = 0
// 2.次回循环时ws = -1
final Node p = node.predecessor();
// 若前一个线程仍未结束,则无法获取锁,则跳过该if判断
// 若此时可以获取锁则tryAcquire返回true
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 由于ws返回false不进入if判断
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
// 判断自己是否需要park,并且不是第一次就直接在等于0的时候就返回true是为了多自旋一次(多自旋一次是为了避免使用park,若park则为重量锁)
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 1.首次进入判断当前线程的上一个线程状态(因为在整个过程中都未对ws状态进行赋值,所以为0)
// 2.次回进入判断当前线程的上一个线程状态(由于外部方法acquireQueued死循环,故为上次进入时赋值-1),状态为-1,进入判断,方法结束,返回true
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
//ws==-1======>false
return true;
if (ws > 0) {
//ws>0======>false
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//ws的状态值进行CAS操作由0变为-1 方法返回false
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
private final boolean parkAndCheckInterrupt() {
// 线程2阻塞在当前位置,若被唤醒则从该位置继续执行代码
LockSupport.park(this);
return Thread.interrupted();
}
所以在ReentrantLock中的处理流程大致可以总结为下图:
5.ReentrantLock如何释放锁?
使用了lock进行加锁,自然是使用unlock进行释放锁的过程。持有锁的线程永远不在队列中
// 进行解锁操作
reentrantLock.unlock();
// 调用relaease方法
public void unlock() {
sync.release(1);
}
// arg = 1 仅仅是释放锁而没有唤醒
public final boolean release(int arg) {
if (tryRelease(arg)) {
// h是头部,也就是空Node对象
Node h = head;
// 一旦有线程入队列,则头部的ws变为-1&&为空,所以可以进入判断
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
// arg = 1 释放锁
protected final boolean tryRelease(int releases) {
// 使用当前线程的状态-1
int c = getState() - releases;
// 判断当前线程是不是持有锁的线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果锁是被释放的,则设置持有该锁的线程为null
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
// 设置当前线程状态等于0
setState(c);
return free;
}
// 传入的是头部Node
private void unparkSuccessor(Node node) {
// 取出当前Node对象的状态,并由于ws=-1所有CAS操作该Node节点状态变为0,因为要出队
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
//
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 由于头结点Node的下一个不为空,所以唤醒,唤醒后在休眠的位置继续执行代码(休眠位置:AbstractQueuedSynchronizer—>parkAndCheckInterrupt—>LockSupport.park(this);)
if (s != null)
LockSupport.unpark(s.thread);
}
// 唤醒后继续执行
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
// 线程没有被打断,返回false
return Thread.interrupted();
}
// 返回上一层acquireQueued
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
// 2.此时再尝试加锁会加锁成功,在setHead中将唤醒的线程作为头部,Thread置空并将前驱删除,而后将p(获取到的原来的头部的后继删除),则现在AQS中仍为第一个Node是Thread为空的节点
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 1.此时由于parkAndCheckInterrupt返回false,所以跳过判断,继续循环
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
6.总结
在ReentrantLock中,由于所有的操作都在JDK级别解决,只有CAS操作是在操作系统中完成的,所以如果是交替执行会非常快捷。并且要牢记,在队列中被初始化后始终拥有一个空Node节点的原因是,持有锁的线程永远不在队列中,而初次入队为了同样保持这句话需要new一个空Node节点,而后维护其队列关系。并且由于JDK版本的不断更迭,现在实质上可能还是更倾向于使用synchronized关键字,因为在很多层面是两者有很多相似之处了,如都是加锁等待机制,都是可重入锁等。但是两者相比较也有很多的区别,如synchronized关键字不需要关注锁的释放,而ReentrantLock必须手动释放锁,ReentrantLock有可以按照执行顺序的公平锁也可以通过很多直观的方法返回判断当前的锁状态等。毕竟有了比较才会有场景的区分。
这一块这位老师真的讲的很好,我记住了很多知识,有时间得多回顾回顾,相信这一块会有被攻克的一天。感谢!