ReentrantLock加锁阶段代码

Lock锁的公平性和非公平性

1、lock锁项目使用

在项目中的使用方式:

public class AQSTestOne {
    // 使用公平锁来进行测试
    private static final Lock LOCK = new ReentrantLock(true);

    public static void main(String[] args) {
        LOCK.lock();
        try {
            System.out.println("so something  ");
        }catch (Exception e){
            System.out.println("do Exception something............");
        }finally {
            LOCK.unlock();
        }
    }
}

因为对于对象来说,对于成员变量LOCK锁来说,会在堆内存中,任何一个线程进来的时候执行了对应的方法,都会执行到lock锁上来进行排队。

每个线程都会来使用lock锁,那么lock.lock()方法是如何保证多线程环境下,在JVM中在某一个时刻,只有一个线程占用锁呢?

2、AQS继承体系

对于ReentrantLock类中,存在AbstractQueuedSynchronizer类以及对应的子类Sync和Sync的两个子类:FairSync和NonfairSync

对应的是公平同步锁和非公平同步锁。

所以我们也认为ReentrantLock类也是实现了AQS框架中的一种实现方式,那么下面就会通过ReentrantLock类来研究一下AQS框架的实现。

3、构造函数

首先从构造函数来讲起,因为非公平锁的效率高,所以推荐使用的是非公平锁。

从构造函数中可以看到对应的结构:

可以指定当前lock锁是公平锁和非公平锁

4、加锁流程

lock锁是如何保证多线程能够保证线程安全呢?那么看一下lock锁又是如何来进行加锁的。

首先来看lock.lock();这行代码到底做了什么?那么这里看公平锁的实现方式:

final void lock() {
    acquire(1);
}

重点就来到了acquire方法,看看对应的代码实现:

通过代码可以看到,新来的线程首先会来尝试获取,如果获取得到锁失败,那么将会添加一个等待节点,然后放入到队列中来。

尝试获取锁流程

可以根据代码来画一幅公平锁是如何来进行加锁的流程:

总结起来,获取得到锁的线程只有三种情况

  • 1、当前锁状态是无锁,且队列中没有线程在排队,那么获取得到锁;

  • 2、当前锁状态是有锁,且持有锁的线程是自己,那么这个时候锁是可重入的;

  • 3、如果以上都不是,那么入队进行等待获取得到锁;

上面只是粗略的来进行的描述,具体的细节下面将会接着来进行分析。

1、队列是如何初始化的?

首先队列是一个双向链表结构,看看源码中注释描述的

其中的每个节点都是一个Node结构,那么看一下队列的初始化过程。

做一下分析:如果lock锁首次被获取得到的时候,那么Head和tail依然都是空的,而只有线程占用锁的时候,另外一个线程进来了之后,才会排队,才会来做队列的初始化操作。

因为是首次进行入队,那么当前的结构中肯定是如下所示:

那么来看下代码:

上面的if判断不会进入,而是直接来到enq方法

这里出现了一个死循环,那么肯定是有对应的目的的。

首先创建一个新的节点Node,设置为头尾节点都指向的状态。对应的图如下所示:

但是此时此刻并不会跳出来这个循环。因为这里存在了两种情况:

  • 1、只有一个线程;
  • 2、多个线程同时竞争;

如果是多个线程同时执行到了这个enq方法中(都同时跳过了上一步),总会有一个线程称为头节点,那么其他线程肯定也是要入队的。

那么其他的节点既然称为不了头节点,那么就都争抢这称为尾结点来进行排队。所以这里就是for循环的存在的必要。(必须要入队才有机会获取得到锁),最终的效果就是所有的线程都要能够在队列中等待获取得到锁。

总的来说,最终的效果图如下所示:

2、为什么同步器为0表示没有线程持有锁的时候,还要判断队列中是否有线程在排队呢?

同步器既然为0了,那么为什么做一个判断呢?

其实真正的原因在解锁流程里面:因为解锁流程并非是原子性操作。具体如下所示:

  • 1、ReentrantLock中是先将state置为了0;

  • 2、然后再去通知队列中排队的线程来获取得到锁。

正是因为这样的两个步骤并不是原子性操作,所以可能就会造成当线程A在进行释放锁的时候,另外一个线程B来进行获取锁,这个时候线程A发生了上下文切换(实际上没有唤醒到队列中的线程),如果说线程B直接将state进行了设置,那么队列中的线程如何处理?

而ReentrantLock类中的处理方式非常恰当,线程B判断队列中有线程在排队,那么就排队等待。线程A恢复之后,将会唤醒在队列中的首个线程获取得到锁来执行代码。

所以这个问题是因为解锁中的非原子性操作导致的

3、如何来判断队列中存在着线程在排队的?

首先存在着一个前提条件,那就是state=0

首先存在着一个前提条件,那就是state=0

首先存在着一个前提条件,那就是state=0

从源代码中来看,这里存在着三种情况:

  • 1、队列首次初始化阶段状态;
  • 2、队列中有线程排过队;
  • 3、队列中有线程正在排队;

那么来看具体的代码的时候来进行判断:

1、如果是第一种状态,tail和head都为null,那么在h!=t的时候,就直接为false,表示没有前驱节点,直接返回FALSE;

2、如果是第二种状态,tail和head都不是null,但是都指向一个空节点,那么也为false,表示没有前驱节点,直接返回FALSE;

3、也就只有第三种状态的时候,tail和head才不会相同,才会返回true,然后线程才会来进行排队;

4、waitstatus的状态为-1代表什么以及如何阻塞的?

这个属性被赋予了很丰富的含义,但是就当前来说,0是初始化状态,-1表示的是可被唤醒状态,也就是说在队列中进行排队的时候,等到之后是可以拿到锁的状态。

又看到了一个死循环,这个也是有意义的。

首先映入眼帘的是又有一个尝试获取得到锁的方法。为什么又会有这么一步?

因为可能存在着入队之后,可能被持有锁的线程恰恰在此时被释放掉了,而且只有是head的下一个节点才会有这个待遇,这里是为了加快效率而已。当然获取不到锁的线程都会被阻塞住,头结点也是如此。

因为初始化状态waitstatus为0,那么只会进入到最后一个判断中去,将其置为-1状态。

但是因为外层是一个死循环。第一次的时候只会将waitStatus置为-1,而第二次循环的时候,才会真正的来进行park住。

5、排队着的线程是否可以被中断以及中断后的状态?

一定一定要记住这里的排队着的线程进行中断的情况!!!!

因为使用的API不同,体现出来的效果就会不同。如下所示:

lock.lock();

而使用lockInterruptibly方法的时候,如下所示:

        try {
            lock.lockInterruptibly();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

首先所有的线程在入队之后,都需要修改状态之后进行阻塞

都是调用park方法来进行阻塞

lock方法

对于调用Lock方法进入队列中排队的线程来说,当被打断的时候,会将interrupted置为 true;除此之外,没有另外的操作了。

然而当调用其来进行实现的时候,才会将状态暴露出来:

    static void selfInterrupt() {
        Thread.currentThread().interrupt();
    }

所以要想捕捉的话,在临界区中感知到。

然而线程中断唤醒之后,并不是说立马就来执行临界区代码,而是又会尝试获取得到锁,如果尝试获取得到锁成功,那么执行临界区代码。此时此刻,应该来做了一个判断,判断能够来进行执行。因为是线程中断引起的。

自定义实现代码!

public class ReentranLockTestOne {
    static final Logger logger = LoggerFactory.getLogger(ReentranLockTestOne.class);
    static ReentrantLock lock  = new ReentrantLock(true);
    public static void main(String[] args) {
        Thread t0 = new Thread(() -> {
            try {
                lock.lock();
                TimeUnit.SECONDS.sleep(5L);
            } catch (InterruptedException e) {

            }finally {
                lock.unlock();
            }
            logger.info("开始执行t0代码");
        }, "t0");
        Thread t1 = new Thread(() -> {

            lock.lock();
            try {
                if (Thread.currentThread().isInterrupted()){
                    logger.info("不会执行临界区代码");
                    logger.info("线程中断之后的线程标记为:{}",Thread.currentThread().isInterrupted());
                    Thread.interrupted();
                    logger.info("线程中断之后的线程标记为:{}",Thread.currentThread().isInterrupted());
                    return;
                }
                logger.info("线程执行临界区代码");
            }finally {
                logger.info("进行解锁");
                lock.unlock();
            }
        }, "t1");
        t0.start();
        try {
            TimeUnit.MICROSECONDS.sleep(200);
            t1.start();
            t1.interrupt();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

控制台输出:

2022-10-16 14:42:40.344 [t0] INFO  com.guang.concurrent.ReentranLockTestOne - 开始执行t0代码
2022-10-16 14:42:40.344 [t1] INFO  com.guang.concurrent.ReentranLockTestOne - 不会执行临界区代码
2022-10-16 14:42:40.347 [t1] INFO  com.guang.concurrent.ReentranLockTestOne - 线程中断之后的线程标记为:true
2022-10-16 14:42:40.348 [t1] INFO  com.guang.concurrent.ReentranLockTestOne - 线程中断之后的线程标记为:false
2022-10-16 14:42:40.348 [t1] INFO  com.guang.concurrent.ReentranLockTestOne - 进行解锁
lockInterruptibly方法

这里是直接将异常抛出。JVM这里利用了一个原理,对于被中断了的线程来说,JVM会将中断状态置为原始状态。

所以这里只需要处理线程被中断了的之后的代码。

线程中断之后的代码,线程立即消失了。但是在队列中会存在一个空节点。

等到调用过来的时候,会从尾部来找到这个空节点,过滤掉这个空节点即可。

具体分析看下面即可

小结
  • Lock方法是在临界区来进行处理异常情况;让外部感知到排队中的线程是因为线程中断引起的;
  • lockInterruptibly方法是在加锁异常阶段来处理对应的代码,JVM会自动恢复线程中断标记;
6、线程中断之后,为什么从尾结点来进行遍历?

也就是说,将waitStatus置为了CANCELLED的情况。那么这种情况,也就会在lockInterruptibly、tryLock(long timeout, TimeUnit unit)方法中出现,下面以lockInterruptibly方法为例来进行说明:

然后会来到unparkSuccessor方法中来

首先将当前节点的waitStatus从-1置为0,然后获取得到下一个节点,然后将s置为了null。

这里也有会有两种情况:

1、最后一个节点为null;最后一个节点为null,根本就没有必要唤醒下一个节点了;

2、最后一个节点不为null;会唤醒下一个节点;

            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;

所以会判断t!=null,省得再去判断一下。

同时,如果最后一个恰好是需要被唤醒的,还需要加上一个waitStatus=0的情况。

唤醒节点开始来进行操作。

那么只需要看这里的for循环为什么要从尾部来进行遍历的原理。

因为需要避免线程安全问题,这又和释放锁的流程有关。

因为释放锁的时候,首先会将exclusiveOwnerThread置为null,然后state置为0,然后返回TRUE之后,才会唤醒下一个线程。

而在加锁的时候,是先将当前节点的前置节点指向尾部节点,然后在将尾部节点指向前置节点,但是这并不是原子性操作

如果要是从头部来进行查找的话,可能在查找被取消节点的时候,又新来了一个线程

此时此刻发生了上下文切换,导致了将尾部节点指向前置节点这一步骤,没有执行。

那么被取消了的线程开始执行,会认为没有线程在尾部,将会导致,最后的一个线程始终无法被调用到。造成了内存泄漏。

具体的分析图如下所示:

而通过尾结点来进行查找的时候,为什么就没有线程安全问题呢?

重点就在于这个循环!!!因为当线程上下文切换回来的时候,tail又会执行队尾。来进行继续for循环

避免出现了线程安全问题。

posted @ 2022-10-16 15:12  雩娄的木子  阅读(34)  评论(0编辑  收藏  举报