手把手带你分析ReentrantLock加锁过程

ReentrantLock加锁过程分析

1、自旋?如何实现一把自旋锁

通俗的讲,自旋就是不断的判断条件触发自己执行的功能,很多线程同步的思想都来源于自旋,我们以两个线程抢占资源来理解下自旋:

我们看到,当线程t1和线程t2共同抢占资源时,假如线程t1抢占到了资源,这时t1需要加锁并设置状态state=1,线程t2过来后会先判断状态state是否为0,如果不为0则一直循环判断state,直到线程t1解锁并设置state=0,线程t2才会继续抢占资源,线程t2不断循环判断的过程就是自旋。

伪代码①

volatile int state=0;//state标识,设置为原子操作
void lock(){
 while(!compareAndSet(0,1)){
 }
}
//逻辑代码
void unlock(){
 state=0;
}
boolean compareAndSet(int except,int newValue){
 //cas操作,修改status成功则返回true
}

我们分析下这个伪代码,这段代码存在一个原子变量state初始值为0,当线程t1拿到锁后,会先利用compareAndSet(0,1)方法进行判断,compareAndSet(0,1)的作用是比较传入的值是否为1,当传入的值为0时,则设置为1并返回true,传入的值为1时则返回false,在代码中,如果state=0,就将state设置为1并返回true,如果state=1则返回false。假设线程t1抢占锁时state=0,则!compareAndSet(0,1)就为false,则线程t1跳过while循环执行自己的逻辑代码;当线程t2想要获取锁时,因为此时state=1,则!compareAndSet(0,1)为true,线程t2就进入while循环内不断的进行循环判断,直到线程t1执行解锁方法并设置state为0,线程t2才能继续参与下一轮抢占锁。

NOTE:没有获取到锁的线程会一直进行while循环判断,这样做非常耗费CPU资源,所有这种方法并不可取。

因为很多锁的实现都是在自旋方法上的改进,所以在原伪代码的基础上加入睡眠和唤醒方法来提高代码的执行效率

伪代码②

volatile int state=0;
Queue parkQueue;//队列

void lock(){
 while(!compareAndSet(0,1)){

  park();
 }
 //逻辑代码
   unlock()
}

void unlock(){
 lock_notify();
}

void park(){
 //将当前线程加入到等待队列
 parkQueue.add(currentThread);
 //将当期线程释放cpu 
 releaseCpu();
}
void lock_notify(){
 //获取要唤醒的线程
 Thread t=parkQueue.header();
 //唤醒线程
 unpark(t);
}

伪代码②是在伪代码①的基础上加入了睡眠和唤醒操作,这样可以保证在队列中的线程不占用CPU资源,park和unpark是java.util.concurrent.locks包下的方法,用于睡眠和唤醒。这样,我们就可以手动实现锁来保证线程的同步了,事实上,很多的锁的编写都是基于这个思路的。下面,就可以引入我们要学的锁--ReentrantLock,它的加锁/解锁就类似于伪代码②

2、ReentrantLock的提出

在jdk1.6之前,我们使用锁实现同步使用的是synchronized关键字,但是synchronized的实现原理是调用操作系统函数来实现加锁/解锁,我们都知道一旦涉及操作系统的函数,那么代码执行的效率就会变低,因此,使用synchronized关键字来实现加/解锁就被称为重量级锁,为了改善这一情况,Doug Lea就写了ReentrantLock锁,这种锁分情况在jvm层面和操作系统层面完成加锁/解锁的过程,因此代码执行效率显著提高,后来sun公司在jdk1.6以后也改进了synchronized,使得synchronized的执行效率和reentrantLock差不多,甚至更好,但是由于ReentrantLock可以直接代码操作加锁/解锁,可中断获取锁等特性,因此使用的比较多。

3、ReentrantLock加锁分析

3.1、AQS简介

在学习ReentrantLock加锁之前,我们先了解下队列同步器AbstractQueueedSynchronizer的概念,简称为AQS,它是用来构建锁的基础框架,通过内置的FIFO队列来完成线程队列中的排队工作

AQS提供了一个node结点类,主要有以下属性

volatile Node prev;//执行前一个线程
volatile Node next;//执行下一个线程
volatile Thread thread;//结点中的当前线程

除此之外,AQS为了维护好线程队列,它还定义了两个结点用于指向队列头部和队列尾部,定义了了state用于修饰锁的状态

private transient volatile Node head;//指向队列头
private transient volatile Node tail;//指向队列尾
private volatile int state;//锁状态,默认为0,加锁成功则为1,重入+1 解锁则为0
private transient Thread exclusiveOwnerThread;//独占锁的线程

队列线程图示

AQS中有很多操作锁的方法,我们会以ReentrantLock的加锁过程来讲解这些方法,在这里就不单独讲解。

3.2、ReentrantLock加锁总体分析

为了方便分析,我们先编写一个Demo,分别以线程1、线程2抢占锁的步骤来学习ReentrantLock

/**
 * @Author: Simon Lang
 * @Date: 2020/5/8 16:19
 */
public class TestReentrantLock {

    public static void main(String[] args){
        final ReentrantLock lock=new ReentrantLock(true);
        Thread t1=new Thread("t1"){
            @Override
            public void run() {
                lock.lock();
                lockTest();
                lock.unlock();
            }
        };
        Thread t2=new Thread("t2"){
            @Override
            public void run() {
                lock.lock();
                lockTest();
                lock.unlock();
            }
        };
        t1.start();
        t2.start();
    }

    public static void lockTest(){

        System.out.println(Thread.currentThread().getName());
        try {
            Thread.sleep(2000);
            System.out.println(" -------end");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个测试Demo里,锁对象就是ReentrantLock对象,加锁过程就是调用lock对象里的lock方法,即lock.lock()

lock方法是reentrantLock类中提供的方法,Sync是同步器(AQS)提供的实施加锁的方法,AQS提供了两种加锁方法,分别为公平锁和非公平锁。

//同步器提供的加锁方法
private final Sync sync;
 public void lock() {
        sync.lock();
    }

为了方便后序加锁流程的分析,我们先简要说明下的公平锁与非公平锁的区别。

公平锁的源码

final void lock() {
    acquire(1);//1------标识加锁成功之后改变的值
}

非公平锁的源码

final void lock() {
 if (compareAndSetState(0, 1))//cas判断
  setExclusiveOwnerThread(Thread.currentThread());//设置当前前线程抢占
 else
   acquire(1);
} 

我们看到非公平锁比公平锁多了个判断,非公平锁在在执行lock方法时,会先进行cas判断,如果为0直接抢占锁成功,如果state=1,则进行acquire(1)方法判断,而公平锁是直接进行acquire(1)判断,事实上,公平锁公平的原因是因为它考虑队列中线程的排队顺序,保证的依次进行加锁执行,而非公平锁则是直接判断状态state的值进行抢占。

为了使得分析代码的时候不容易绕晕,我们先从逻辑层面上分析ReentrantLock的加锁的流程,具体的细节放在每个线程执行的流程上讲解。

结合上面的伪代码②,大家可能会对这个流程图会有疑问:state=0不应该直接加锁么?为什还要判断是否加入队列呢?

其实这和线程间的并发执行有关,释放锁的过程也是并发执行的,释放锁执行顺序可能是①设置state=0②unpark③唤醒下一个线程。如果获取当前锁的线程进行步骤②操作时,另一个线程就进来判断了,如果这个线程不进行是否需要排队判断则会引发线程安全问题。

我们以公平锁为例学习reentrantLock的加锁过程

3.3、线程1执行流程

当线程1执行公平锁的过程中,会首先执行acquire(1)方法,我们来分析下线程1的执行步骤

acquire(int arg)方法是独占式的获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则将会进入同步队列等待。

 public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

线程1会首先执行tryAcquire(arg)方法,

tryAcquire(int arg)是独占式获取锁,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后在进行cas设置同步状态

 protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
     //获取锁的状态
            int c = getState();
     //如果c=0,则判断是否需要排队
            if (c == 0) {
                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;
            }
     //否则,返回false
            return false;
        }

线程1会先获取当前的锁的状态,假设忽略主线程,线程t1是第一个进来的,所以state=0,继续判断是否需要排队(调用hasQueuedPredecessors)

  public final boolean hasQueuedPredecessors() {
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

因为线程t1是第一个,所以线程队列为空,tail和head均为null,所以条件h!=t不成立,hasQueuedPredecessors方法返回为false,所以在tryAcquire方法中第一个判断条件成立,又因为此时的state=0,所以执行compareAndSetState返回为true,第二个判断条件成立。执行setExclusiveOwnerThread(current)将线程1上锁成功并返回true,acquire()也正常返回,一直返回到我们编写的逻辑代码内。

线程1执行流程图

3.4、线程2执行流程

在线程t1执行的过程中,假设线程2来试图获取锁,它首先还是会先执行acquire方法

 public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

在acquire方法中先执行tryAcquire方法进行条件判断

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
     //获取锁的状态
            int c = getState();
     //如果c=0,则判断是否需要排队
            if (c == 0) {
                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;
            }
     //否则,返回false
            return false;
        } 

因为此时的state=1且当前持有锁的线程为线程t1,所以线程t2执行tryAcquire()方法直接返回false给acquire方法。

在acquire()方法内,!tryAcquire()为true,所以要进行第二个判断acquireQueued(addWaiter(Node.EXCLUSIVE),arg)

我们先分析addWaiter(Node.EXCLUSIVE)方法

将新加入的线程结点加入到队列尾部

 private Node addWaiter(Node mode) {
     //将当前线程设置为线程结点
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
     //将队列的尾节点赋给pred
        Node pred = tail;
     //判断pred是否为空结点
        if (pred != null) {
            //将当前线程(t2线程结点)结点的前驱结点设为pred
            node.prev = pred;
            //将node结点cas操作
            if (compareAndSetTail(pred, node)) {
                //建立连接关系
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

这段代码首先会将线程t2设置成线程结点,判断队列中是否存在线程结点,如果不存在,则执行enq(node)先构造一个空的线程结点

    private Node enq(final Node node) {
        //死循环
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                //构造一个空节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {//将线程t2结点加入队列
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

图解构造线程结点

  • 第一次循环构造空线程结点


  • 第二次循环将线程t2结点加入队列

将t2结点加入到队列中并返回addWaiter方法,addWaiter返回t2线程结点到acquire方法中执行acquireQueued方法

 final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            //死循环
            for (;;) {
                //获取当前线程结点的上一个结点p
                final Node p = node.predecessor();
                //判断p是否为头结点,并尝试这再次获取锁
                if (p == head && tryAcquire(arg)) {
                    //将当前结点设置为头结点
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                //否则,让线程t2结点睡眠
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

这段代码主要是判断线程t2结点的前驱结点是否为头结点,如果为头结点就尝试再次获取锁,否则就直接睡眠,如果不能获取锁就一直睡眠停留在这里,否则就会返回执行用户编写的代码

线程2执行流程图


前面提到,ReentrantLock可以分情况在jvm层面和操作系统层面执行,我们将线程执行分为以下几种情况

  • 只有一个线程:直接进行CAS操作,不需要队列(jvm层面)

  • 线程交替执行:直接进行CAS操作,不需要队列(jvm层面)

  • 资源竞争

竞争激励:调用park方法(操作系统层面)

竞争不激烈:多一次自旋 ,如果能获取到锁,则在jvm层面执行;不能获取到锁,执行park方法(在操作系统层面执行)

参考文献

[1]https://blog.csdn.net/java_lyvee/article/details/98966684

[2]方腾飞.java并发编程的艺术

往期推荐

Intellij idea 2020永久破解,亲测可用!!!

spring大厂高频面试题及答案看完这篇缓存穿透的文章,保证你能和面试官互扯!!!Redis高频面试题及答案大白话布隆过滤器,又能和面试官扯皮了~【吊打面试官】Mysql大厂高频面试题!!!天天用Redis,持久化方案有哪些你知道吗?面试官:你知道哪几种事务失效的场景?天天写 order by,你知道Mysql底层执行流程吗?
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 书香水墨 设计师: CSDN官方博客
应支付0元
点击重新获取
扫码支付

支付成功即可阅读