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

ReentrantLock加锁过程分析

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

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

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

伪代码①面试

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才能继续参与下一轮抢占锁。spring

NOTE:没有获取到锁的线程会一直进行while循环判断,这样作很是耗费CPU资源,全部这种方法并不可取。sql

由于不少锁的实现都是在自旋方法上的改进,因此在原伪代码的基础上加入睡眠和唤醒方法来提升代码的执行效率编程

伪代码②缓存

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,它的加锁/解锁就相似于伪代码②安全

二、ReentrantLock的提出

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

三、ReentrantLock加锁分析

3.一、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.二、ReentrantLock加锁整体分析

为了方便分析,咱们先编写一个Demo,分别以线程一、线程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.三、线程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.四、线程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底层执行流程吗?