多线程第五章
Lock锁
Lock锁可以保证无论在多少个线程涌进来的时候,都能够保证线程是安全的,从而不会造成线程安全问题。
在很多源码中,没有使用syncronized关键字来保证同步,而是使用了lock锁来保证同步。那么原理又是什么?下来来分析一波。
为什么利用
lock.lock();
xxxxx;
lock.unlock();
就可以实现同步了呢?下面用伪代码来分析一下AQS中的思想过程:
在lock.lock()要是我自己来做的话:
while(true){
// 对于进来的每个线程来说,首先尝试去获取得到锁对象。
if(获取锁){ // 如果抢锁?CAS,无论多少线程都是可以支持的!
// 获取得到了之后直接退出循环,然后去拿锁;
break;
}
// 没有获取得到锁的线程全部阻塞掉
LockSupport.park();
// 但是进来的线程不能杂乱无章的存放,什么时候这些个线程才能够执行?到时候需要唤醒的时候,又该怎么来进行唤醒?所以需要用一个容器来进行保存起来;
list.add(thread); // 每一个新来的线程都放入到容器中去
}
然后执行unlock方法:
// 先将处理完业务逻辑的线程释放掉;让其被GC回收掉;
// 然后着手唤醒下一个线程;
Thread t = list.get(index);// 从容器中获取得到线程;
lockSupport.unpark(t);// 唤醒线程
然后进入下一轮抢锁。
在自己来模拟的多线程抢锁的条件中:多线程(必要条件)、容器、支持阻塞和唤醒线程、CAS来抢锁。
那么在多线程环境下,这种方式仍然是可以来保证同步的。为什么?先主观上分析一波,多线程走到了lock.lock()这个地方的时候,那么多线程应该在这个地方“卡住”,而只放一个线程进来执行任务,执行完任务之后,走到lock.unlock之后,通知在lock.lock()中卡住的线程;然后依次循环,再找一个出来,继续执行逻辑,执行完成之后,继续走lock.unlock()。最终多线程依次抢锁执行完成!
这是一个比较正常的思路实现方式。
那么接下来看下源码中是怎么来进行实现的:
首先看下ReentrantLock实现的接口:
接口中定义了一些方法来约束子类来继承实现。可以自己根据去看看:
看一下ReentrantLock中的重要属性:
可以看到属性Sync这个类继承了两个抽象类,两个子类公平和非公平的。在Sync这个类中,定义了一个抽象方法:
abstract void lock();
这里定义了个抽象方法,利用了模板模式,让子类来进行重写这里的方法:
接下来先分析一个公平锁的源码,先看下FairSync类中的实现方式:
final void lock() {
// 携带了参数为1。因为状态同步器是int类型
acquire(1);
}
跟着看:
// 尝试来进行获取,没有获取成功的添加到队列中去
public final void acquire(int arg) {
// tryAcquire:尝试获取锁
// acquireQueued:尝试获取得到锁失败,排队
// addWaiter:没有抢到锁的线程来进行排队,创建节点来排队
if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg)){
// 自我中断
selfInterrupt();
}
}
所以,这个判断大概意义就是:来了一个新线程,排队等待尝试获取。所以有了这样的一个大概的概念即可。
看看tryAcquire这个方法:
protected final boolean tryAcquire(int acquires) {
// 获取得到当前线程
final Thread current = Thread.currentThread();
// 获取得到一个状态!这个状态很重要。预先提醒一下:如果是0的时候,那么表示‘锁’是没有线程占用的,如果是大于0,有两种情况:
// 第一种:只有一个线程重复占用;第二种:当前线程眼看着别的线程占用;
int c = getState();
if (c == 0) {
// hasQueuedPredecessors:获取得到队列中的前驱
// compareAndSetState:原子操作,修改状态
if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires)) {
// 设置独占'锁'的线程是哪个线程。也就是说当前多线程中哪个线程在运行
setExclusiveOwnerThread(current);
return true;
}
}
// 获取得到独占'锁'的线程,如果是和当前线程相等的,那么就会将状态相加。
// 这段没有加任何锁操作,也就是,多线程环境下的单线程操作。why?因为就只有一个线程占用着'锁'
else if (current == getExclusiveOwnerThread()) {
// 如果一个线程已经占用过,又占用了一次,那么将状态加一个。这里也就说明了为什么lock和unlock的次数要一致了。
// 可重用锁
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
这段代码的意思是:尝试获取得到锁,如果获取得到锁了,那么修改节点的同步器的值;这里可以看到Lock锁是一个可重入的锁;
那么沿着代码执行顺序接着走:hasQueuedPredecessors
// 根据方法名字来理解:当前线程在队列中有前驱节点。
// 返回true的条件:队列中有结点(线程),第一个结点是null或者结点中的线程不是当前线程,都会返回true;那么对于第一个进来的,肯定是没有队列的,那么直接返回
// false的,那么就直接获取得到锁了
public final boolean hasQueuedPredecessors() {
// tail,head分别是尾指针和头指针
Node t = tail;
Node h = head;
Node s;
// 第一个条件:h != t,也就是说想要保证当前返回的是true,就得保证头指针和尾指针指向的不是一个结点,那么就说明了必要条件是队列中是有元素的
// 第二个条件:h.next是为null;
// 第三个条件:h.next不是null,但是h.next线程和当前线程不是同一个
return h != t &&((s = h.next) == null || s.thread != Thread.currentThread());
}
将关注点转移到执行hasQueuedPredecessors的if判断中去
// 在队列中没有前驱,那么就可以尝试去获取得到锁
if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires))
也就是说hasQueuedPredecessors为false的时候,也就是没有获取得到锁的线程到了这个if判断中来,才为true,才会继续执行后面条件的进入;为true的时候,就不会走到后面的compareAndSetState条件中来,也就是说,直接获取得到锁了,直接执行获取得到锁的线程的逻辑了。
那么经过这里的时候,肯定有一个是获取得到锁的,后面来的线程都是没有获取得到锁的,那么没有获取得到锁的走到了下面:
// Node.EXCLUSIVE:表示是独占式的锁,因为还有一种锁是共享的
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
首先看下addWaiter,添加等待者这里做的事情:
private Node addWaiter(Node mode) {
// 创建新的节点,当前线程和mode(null)作为参数放入进去。也就是说每个结点中持有了当前线程的引用,mode为null,表示独占锁
Node node = new Node(Thread.currentThread(), mode);
// 尾指针
Node pred = tail;
// 如果队列中有结点,那么走到了这里
if (pred != null) {
node.prev = pred;
// tail指向新添加进来的节点
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 队列为空,那么走到这里来,对队列做初始化
enq(node);
return node;
}
看下添加结点的方法:
private Node enq(final Node node) {
// 结束循环的条件是return
for (;;) {
Node t = tail;
// 最开始进来的必定为null,因为从头到尾,没有看到对head和tail的操作
if (t == null) {
// 原子操作创建出来一个新节点。也就是说head = new Node();可以看下Node的构造,属性都是默认值
if (compareAndSetHead(new Node()))
// head和tail都指向了这个node
tail = head;
} else {
// 因为是for(;;),那么会接着走。走到了这里head和tail都指向了node,但是node的前驱指向了head
node.prev = t;
// 原子操作:tail指向了先来的线程结点node
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
也就是,每一个新添加的线程都将会添加到队尾去;每一个结点中多有当前线程的引用;
图解:
执行完成之后,就将没有获取得到锁的线程结点放入到了队列中来,后面的线程依次添加到结点中去;
注意一个小小的事项:head结点指向的是new Node,而tail指向的是最新添加进来的结点。那么head结点在后面是有需求作用的。
// 尝试获取队列。这个时候队列已经形成了
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
// 中断状态
boolean interrupted = false;
// 终止条件只有return
for (;;) {
// 新创建的前一个结点,那么在队头中结点的前驱就是一个new Node(),全空的。这就是上面说的为什么要指向head结点;
final Node p = node.predecessor();
// 如果是头结点,然后尝试获取获取得到。对于公平锁来说,那么这里是可以获取得到的。
if (p == head && tryAcquire(arg)) {
// 将当前节点设置为头结点。也就是说将head的下一个节点设置成为头结点了,原来的头结点释放掉,被GC回收掉了
setHead(node);
p.next = null; // help GC
failed = false;
// 返回的是中断状态。如果没有被中断过,那么返回的是false,如果被中断过,那么返回的是true;
// 这里的true是由下面的方法导致的!那么这里就会将线程的中断状态暴露出去!可以看到Lock锁在使用的时候,最终可以将
// 线程状态设置设置为中断!
return interrupted;
}
// 不是头结点的,后续的走到了这里
// 第二轮循环进来的时候,那么就直接走后面的步骤parkAndCheckInterrupt
if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
在尝试获取锁失败后做的事情:shouldParkAfterFailedAcquire
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱结点的状态,这里要用前驱结点来获取得到当前结点的排队状态。但是对于每一个初始的节点来说,可以看到都没有对其等待状态进行设置
// 也就是说默认值是0,那么走到下面的来;
int ws = pred.waitStatus;
// -1,表示的是在公平锁条件下是可以获取得到锁对象的状态
if (ws == Node.SIGNAL)
return true;
// 1,表示的是cancel。那么一直将该结点调整到不是>0的结点后;
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
// pred = pred.prev;
// node.prev = pred
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
// 不是-1,而且还不是大于0的结点,都到这里来。
} else {
// 将waitStatus的状态进行修改!修改成-1
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
方法退出之后,又重新进入到循环中来;因为返回的是false,那么parkAndCheckInterrupt将不会进入;
第一轮循环结束,那么紧接着走第二轮循环:
for (;;) {
// 再次获取前驱节点
final Node p = node.predecessor();
// 可以看到对于队列中的第一个结点来说,并非是立马就中断的,而是再次尝试获取尝试一次
if (p == head && tryAcquire(arg)) {
// 将当前节点设置为头结点。也就是说将head的下一个节点设置成为头结点了,原来的头结点释放掉,被GC回收掉了
setHead(node);
p.next = null; // help GC
failed = false;
// 返回的是中断状态。如果没有被中断过,那么返回的是false
return interrupted;
}
// 第二轮循环进来的时候,会再次执行shouldParkAfterFailedAcquire,那么就会将node结点的waitStatus设置为-1,那么就会进入到后面的方法中去
// parkAndCheckInterrupt方法中的逻辑看下面
if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
interrupted = true;
}
那么接下来看下:parkAndCheckInterrupt
private final boolean parkAndCheckInterrupt() {
// 阻塞了当前线程。那么肯定有一个唤醒这个线程的,那么就是LockSupport.unpark(this);
// 所有的线程都将会阻塞到这里来!那么还有一种情况可以导致这种阻塞失效。那么就是设置当前线程为中断状态,这里就不再阻塞了。直接进行下面的方法;
LockSupport.park(this);
// 给当前的线程清除掉中断标记!但是清除了中断状态之后,那么外界如何得知整个状态?
return Thread.interrupted();
}
if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
// 这里会将其进行标注中断状态为true。那么这里想要干嘛呢?为什么要这么做?为了lock.lockInterruptly服务的。下面介绍:
interrupted = true;
那么对于获取了锁的线程来说,就可以按照下面的代码来执行了,执行线程所要做的业务逻辑。
lock.lock();
// 执行逻辑
lock.unlock();
那么业务逻辑执行完成之后,将会执行unlock方法;
再看下unlock方法中的操作
public void unlock() {
sync.release(1);
}
那么继续跟进去:
public final boolean release(int arg) {
// 尝试去释放。看下源码
if (tryRelease(arg)) {
Node h = head;
// 可以看到这里的waitStatus又为0了!那么对于一个线程来说,首先从0---->-1----->0
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
tryRelease源码:
protected final boolean tryRelease(int releases) {
// 因为是可重入锁,所以加锁多少次,就要释放多少次
int c = getState() - releases;
// 检查独占锁的线程是否是当前的线程;如果不是,那么抛出异常;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
// 释放状态
boolean free = false;
if (c == 0) {
free = true;
// 将独占线程置为null,也就是释放掉;
setExclusiveOwnerThread(null);
}
// 然后设置下同步器为0,让其他的线程来抢
setState(c);
return free;
}
释放成功后接着做的事情:
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
// 将结点的值设置为0。所以在公平锁条件下,将waitStatus从0设置为了-1之后,表示的是等待获取得到锁
// 在同步器状态修改成了0,之后,又将waitStatus设置为了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;
}
// 唤醒队列中的下一个线程
if (s != null)
// 对于公平锁来说,这里是可以成功的!那么唤醒的肯定就是队列中排队的第一个结点;
LockSupport.unpark(s.thread);
}
总结一下公平锁的执行流程:
1、大量线程过来,抢到锁的线程先进行执行,那么其他的线程首先尝试获取得到锁之后失败;
2、没有获取得到锁之后,那么开始进入到排队状态中去;添加新结点,然后添加进去;
3、经过两层for循环之后,将队列中的结点的waitStatus设置成-1,表示的可以获取得到锁,
4、设置完成之后,那么进入到阻塞状态中去,然后等待获取得到线程;
5、在获取得到锁的线程释放之后,那么立即将其释放,并激活队列中的第一个结点;
非公平锁执行流程:
可以看到区别就在于,对于每一个new出来的Node,那么waitStatus都是0,经过循环之后修改成-1,在释放锁的时候,又会将下一个结点的waitStatus设置为0,目的就是为了非公平锁中获取得到锁的不一定是队列中的第一个元素,如果没有抢到,那么将继续进去排队。
再接着说一下上面的lock.lockInterruptibly方法:
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public final void acquireInterruptibly(int arg) throws InterruptedException {
// 判断是否中断过?如果中断过,那么立即抛出中断异常!那么异常最终抛到了调用lock.lockInterruptibly这个地方来进行处理;
if (Thread.interrupted())
throw new InterruptedException();
// 没有获取得到锁!再次走下面的逻辑!
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}
private void doAcquireInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
// 这里和上面唯一的区别就在于这里!这里不再是返回true了,而是直接抛出了异常处理
throw new InterruptedException();
}
// 在抛异常之前,先执行这里的代码!true,因为不是从for(;;)循环里面出来的;
} finally {
if (failed)
cancelAcquire(node);
}
}
将状态设置为Node.SIGNAL取消状态:
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
// 线程置为null
node.thread = null;
// Skip cancelled predecessors
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
Node predNext = pred.next;
// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
node.waitStatus = Node.CANCELLED;
// If we are the tail, remove ourselves.
// 如果是tail的处理
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
// 如果不是tail的处理
} else {
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&((ws = pred.waitStatus) == Node.SIGNAL ||(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
unparkSuccessor(node);
}
// 最终将结点移动,移动到可以运行的结点上。中断的线程交给GC处理
node.next = node; // help GC
}
}
所以接下来我们要做的事情就是针对于抛出来的异常来进行处理了:
try{
// 这就是为什么习惯上将lock.lock和下面的代码都放在try代码里面了!
lock.lockInterruptibly();
xxxx;
}catch(InterruptedException e){
// 针对出现异常的线程进行针对性处理;
// 其实走到了这里,做一些处理。这个时候线程已经没有指向了,即将被GC回收了。
}finnaly{
lock.unlock();
}
我也去查了一下其他中断线程的时候:
比如说IO操作超出了我们预期的时间,那么我们就可以来终止掉线程,然后针对捕获的异常来进行处理;比较的灵活;
但是我们不会使用Thread.stop来做处理,太过于粗暴,采用了比较柔性的处理方式,Thread.interrupt()来进行处理,然后在业务逻辑中进行判断;
但是在使用的时候,需要注意多线程是在哪里启动的!这个是个关键的地方,如果说把握不住这个,那么即使使用了锁,也不一定能够保证线程是安全的。
@Slf4j
public class MultiplyDemo {
static int j = 0;
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
// 下面才是多线程的起始的地方,因为下面使用到了lock,所以不会进行指令重排,也不需要进行volatile关键字修饰
// 在这里使用lock锁之后,大量的线程都会进入到lock方法中去,改变不了里面的线程,就算是拷贝,拷贝的也是同一个地址值进去而已
for (int i = 0; i < 1000; i++) {
new Thread(()->{
try {
lock.lock();
j++;
log.info("hello,{}",Thread.currentThread().getName());
log.info("lock的地址值是:{}",lock.hashCode());
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}).start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("最终变量j的值是:{}",MultiplyDemo.j);
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?