可重入锁和递归锁ReentrantLock

概念

可重入锁就是递归锁

指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁(前提:锁对象得是同一个对象)

也就是说:线程可以进入任何一个它已经拥有的锁所同步的代码块

ReentrantLock / Synchronized 就是一个典型的可重入锁,可重入锁的一一个优点是可一定程度避免死锁。

代码

可重入锁就是,在一个method1方法中加入一把锁,方法2也加锁了,那么他们拥有的是同一把锁

public synchronized void method1() {
	method2();
}

public synchronized void method2() {

}

也就是说我们只需要进入method1后,那么它也能直接进入method2方法,因为他们所拥有的锁,是同一把。

作用

可重入锁的最大作用就是避免死锁

可重入锁验证

证明Synchronized

/**
 * 可重入锁(也叫递归锁)
 * 指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法会自动获取锁
 *
 * 也就是说:`线程可以进入任何一个它已经拥有的锁所同步的代码块`
 */

/**
 * 资源类
 */
class Phone {

    /**
     * 发送短信
     * @throws Exception
     */
    public synchronized void sendSMS() throws Exception{
        System.out.println(Thread.currentThread().getName() + "\t invoked sendSMS()");

        // 在同步方法中,调用另外一个同步方法
        sendEmail();
    }

    /**
     * 发邮件
     * @throws Exception
     */
    public synchronized void sendEmail() throws Exception{
        System.out.println(Thread.currentThread().getId() + "\t invoked sendEmail()");
    }
}
public class ReenterLockDemo {


    public static void main(String[] args) {
        Phone phone = new Phone();

        // 两个线程操作资源列
        new Thread(() -> {
            try {
                phone.sendSMS();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "t1").start();

        new Thread(() -> {
            try {
                phone.sendSMS();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }, "t2").start();
    }
}

在这里,我们编写了一个资源类phone,拥有两个加了synchronized的同步方法,分别是sendSMS 和 sendEmail,我们在sendSMS方法中,调用sendEmail。最后在主线程同时开启了两个线程进行测试,最后得到的结果为:

t1	 invoked sendSMS()
t1	 invoked sendEmail()
t2	 invoked sendSMS()
t2	 invoked sendEmail()

这就说明当 t1 线程进入sendSMS的时候,拥有了一把锁,同时t2线程无法进入,直到t1线程拿着锁,执行了sendEmail 方法后,才释放锁,这样t2才能够进入

t1	 invoked sendSMS()      t1线程在外层方法获取锁的时候
t1	 invoked sendEmail()    t1在进入内层方法会自动获取锁

t2	 invoked sendSMS()      t2线程在外层方法获取锁的时候
t2	 invoked sendEmail()    t2在进入内层方法会自动获取锁

证明ReentrantLock

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 资源类
 */
class Phone implements Runnable{

    Lock lock = new ReentrantLock();

    /**
     * set进去的时候,就加锁,调用set方法的时候,能否访问另外一个加锁的set方法
     */
    public void getLock() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t get Lock");
            setLock();
        } finally {
            lock.unlock();
        }
    }

    public void setLock() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t set Lock");
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void run() {
        getLock();
    }
}

public class ReenterLockDemo {


    public static void main(String[] args) {
        Phone phone = new Phone();

        /**
         * 因为Phone实现了Runnable接口
         */
        Thread t3 = new Thread(phone, "t3");
        Thread t4 = new Thread(phone, "t4");
        t3.start();
        t4.start();
    }
}

现在我们使用ReentrantLock进行验证,首先资源类实现了Runnable接口,重写Run方法,里面调用get方法,get方法在进入的时候,就加了锁

public void getLock() {
  lock.lock();
  try {
    System.out.println(Thread.currentThread().getName() + "\t get Lock");
    setLock();
  } finally {
    lock.unlock();
  }
}

然后在方法里面,又调用另外一个加了锁的setLock方法

public void setLock() {
  lock.lock();
  try {
    System.out.println(Thread.currentThread().getName() + "\t set Lock");
  } finally {
    lock.unlock();
  }
}

最后输出结果我们能发现,结果和加synchronized方法是一致的,都是在外层的方法获取锁之后,线程能够直接进入里层

t3	 get Lock
t3	 set Lock
t4	 get Lock
t4	 set Lock

🤔 当我们在getLock方法加两把锁会是什么情况呢? (阿里面试)

最后得到的结果也是一样的,因为里面不管有几把锁,其它他们都是同一把锁,也就是说用同一个钥匙都能够打开

    public void getLock() {
        lock.lock();
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t get Lock");
            setLock();
        } finally {
            lock.unlock();
            lock.unlock();
        }
    }

当我们在getLock方法加两把锁,但是只解一把锁会出现什么情况呢?

public void getLock() {
    lock.lock();
    lock.lock();
    try {
        System.out.println(Thread.currentThread().getName() + "\t get Lock");
        setLock();
    } finally {
        lock.unlock();
    }
}

得到结果:也就是说程序直接卡死,线程不能出来,也就说明我们申请几把锁,最后需要解除几把锁

t3	 get Lock
t3	 set Lock

当我们只加一把锁,但是用两把锁来解锁的时候,又会出现什么情况呢?

public void getLock() {
  lock.lock();
  try {
    System.out.println(Thread.currentThread().getName() + "\t get Lock");
    setLock();
  } finally {
    lock.unlock();
    lock.unlock();
  }
}

这个时候,运行程序会直接报错

t3	 get Lock
t3	 set Lock
t4	 get Lock
t4	 set Lock
Exception in thread "t3" Exception in thread "t4" java.lang.IllegalMonitorStateException
	at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
	at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
	at com.moxi.interview.study.thread.Phone.getLock(ReenterLockDemo.java:52)
	at com.moxi.interview.study.thread.Phone.run(ReenterLockDemo.java:67)
	at java.lang.Thread.run(Thread.java:745)
java.lang.IllegalMonitorStateException
	at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
	at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
	at com.moxi.interview.study.thread.Phone.getLock(ReenterLockDemo.java:52)
	at com.moxi.interview.study.thread.Phone.run(ReenterLockDemo.java:67)
	at java.lang.Thread.run(Thread.java:745)

Synchronized的重入实现机理

每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。

当执行monitorenter时, 如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。

在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待直至持有线程释放该锁。

当执行monitorexit时, Java虛拟机则需将锁对象的计数器减1。计数器为零代表锁己被释放。

LockSupport

概述

Java提供了一个较为底层的并发工具类:LockSupport,可以让线程停止下来(阻塞),还可以唤醒线程。

LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。

三种让线程等待和唤醒的方法

传统的synchronized和Lock实现等待唤醒通知的约束

1.使用Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程

  • wait()notify()方法不可以脱离Synchronized内部执行
  • 先执行notify后wait可能会导致程序无法执行,无法唤醒

2.使用JUC包中Conditionawait()方法让线程等待,使用signal()方法唤醒线程

  • 和第 1 种方式一样

  • 线程先要获得并持有锁,必须在锁块(s ynchronized或lock)中
  • 必须要先等待后唤醒,线程才能被唤醒

LockSupport类中的park等待和unpark唤醒

1)概述

LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。

LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(permit),permit只有两个值1和0,默认是零。

可以把许可看成是一种(0,1)信号量(Semaphore),但与Semaphore不同的是,许可的累加上限是1。

2)主要方法

// 底层实现是UNSAFE.park()
// permit默认是0,所以一开始 调用park()方法,当前线程就会阻塞,直到别的线程将当前线程的permit设置为1时,park方法会被唤醒,然后会将permit再次设置为0并返回。
LockSupport.park(Object blocker) 
// 底层实现是UNSAFE.unpark(Thread thread)  
// 调用unpark(thread)方法后,就会将thread线程的许可permit设置成1(注意多次调用unpark方法,不会累加,permit值还是1)会 自动唤醒thread线程,即之前阻塞中的LockSuppot.park()方法会立即返回。
LockSupport.unpark(Thread thread)

3)代码

// ⚠️ 之前错误的先唤醒后等待,LockSupport照样支持
public class LockSupportDemo {
    
    public static void main(String[] args) {
        Thread a = new Thread(() -> {
            try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}
            System.out.println(Thread.currentThread().getName()+"\t---come in");
            LockSupport.park(); // 被阻塞...等待通知等待放行,他要通过的话需要许可证
            System.out.println(Thread.currentThread().getName()+"\t---被唤醒");
        }, "a");
        a.start();
      
        Thread b = new Thread(() -> {
            LockSupport.unpark(a); // 给线程a发放许可证
            System.out.println(Thread.currentThread().getName()+"\t---已通知");
        }, "b");
        b.start();
    }
}

4)重点说明 🤔

  1. LockSupport是用来创建锁和其他同步类的基本线程阻塞原语

    LockSupport是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,阻塞之后也有对应的唤醒方法。归根结底,LockSupport调用的Unsafe中的native代码。

  2. LockSupport提供park()和unpark()方法实现阻塞线程和解除线程阻塞的过程

    LockSupport和每个使用它的线程都有一个许可(permit)关联。permit相当于1,0的开关,默认是0,调用一次unpark就加1变成1,调用一次park会消费permit,也就是将1变成0,同时park立即返回。

    如再次调用park会变成阻塞(因为permit为零了会阻塞在这里,一直到permit变为1),这时调用unpark会把permit置为1。

    每个线程都有一个相关的permit, permit最多只有一个,重复调用unpark也不会积累凭证。

  3. 形象的理解

    线程阻塞需要消耗凭证(permit),这个凭证最多只有1个。

    当调用park方法时:

    • 如果有凭证,则会直接消耗掉这个凭证然后正常退出;
    • 如果无凭证,就必须阻塞等待凭证可用;

    而unpark则相反,它会增加一个凭证,但凭证最多只能有1个,累加无效。

5)面试题

为什么可以先唤醒线程后阻塞线程?

ANSWER:因为unpark获得了一个凭证,之后再调用park方法,就可以名正言顺的凭证消费,故不会阻塞。

为什么唤醒两次后阻塞两次,但最终结果还会阻塞线程?

ANSWER:因为凭证的数量最多为1,连续调用两次unpark和调用一次unpark效果一样,只会增加一个凭证;而调用两次park却需要消费两个凭证,证不够,不能放行。

LockSupport实现自旋锁

public class SpinLock {
    // 锁状态
    volatile int status=0;
    // 阻塞线程队列
    Queue<Thread> parkQueue = new LinkedBlockingQueue<>();

    public void mylock() {
        // 自旋获取锁
        while (!compareAndSet(0,1)) { // 比较交换原子操作:当前为0则与1交换;当前为1则交换失败
            park();
        }
      lock()
      // 业务代码   
      ...
      // 解锁
      unlock()
    }

    public void myUnlock() {
        // CAS解锁
        status = 0;
        lock_notify();
    }
    
    public void park() {
      	// 把当前线程加入到等待队列
        parkQueue.add(Thread.currentThread());
        // 将当前线程释放CPU 阻塞
        LockSupport.park(Thread.currentThread());
    }
    
    public void lock_notify() {
        // 得到要唤醒的线程 头部线程
      	Thread t = parkQueue.header();
      	// 唤醒等待线程
      	unpack(t);
    }
}

AbstractQueuedSynchronizer之AQS

从ReentrantLock的实现看AQS的原理

AbstractQueuedSynchronizer是用来构建锁或者其它同步器组件的重量级基础框架及整个JUC体系的基石, 通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量表示持有锁的状态

image-20201230221813687

CLH: Craig、Landin and Hagersten队列,是一个单向链表,AQS中的队列是CLH变体的虚拟双向队列FIFO

AQS为什么是JUC内容中最重要的基石

和AQS有关的类:

  • ReentrantLock
  • CountDownLatch
  • ReentrantReadWriteLock
  • Semaphore

进一步理解锁和同步器的关系:

  • 锁,面向锁的使用者:定义了程序员和锁交互的使用层API,隐藏了实现细节,你调用即可。
  • 同步器,面向锁的实现者:比如Java并发大神Douglee,提出统一规范并简化了锁的实现,屏蔽了同步状态管理、阻塞线程排队和通知、唤醒机制等。

能干嘛

加锁会导致阻塞:有阻塞就需要排队,实现排队必然需要有某种形式的队列来进行管理

抢到资源的线程直接使用办理业务,抢占不到资源的线程的必然涉及一种排队等候机制,抢占资源失败的线程继续去等待(类似办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。

既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?

如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node) ,通过CAS、自旋以及LockSuport.park()的方式,维护state变量的状态,使并发达到同步的效果。

AQS初步

官网解释

image-20210101221640837

有阻塞就需要排队,实现排队必然需要队列。

AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的 FIFO队列来完成资源获取的排队工作将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对State值的修改。

image-20210101221859513

AQS内部体系架构

image-20210101222408408

AQS自身:

1)AQS的int变量

  • AQS的同步状态State成员变量
  • 类似于银行办理业务的受理窗口状态:
    • 零就是没人,自由状态可以办理
    • 大于等于1,有人占用窗口,需要等待

2)AQS的CLH队列

  • CLH队列(三个大牛的名字组成),为一个双向队列
  • 类似于银行侯客区的等待顾客

image-20210101222806940

3)小总结

  • 有阻塞就需要排队,实现排队必然需要队列
  • AQS就可以看作是:state变量+CLH双端Node队列

内部类Node(Node类在AQS类内部):

1)Node的int变量

  • Node的等待状态waitState成员变量
  • 说人话
    • 等候区其它顾客(其它线程)的等待状态
    • 队列中每个排队的个体就是一个Node.

2)Node此类的讲解

  • 内部结构

image-20210101223510639

  • 属性说明

image-20210101223236203

image-20210101223312149

AQS同步队列的基本结构

image-20210101223349077

从ReentrantLock开始解读AQS

Lock接口的实现类,基本都是通过【聚合】了一个【队列同步器】的子类完成线程访问控制的。

1)ReentrantLock原理

image-20210102202308862

2)从最简单的lock方法开始看看公平和非公平

通过ReentrantLock的源码来讲解公平锁和非公平锁

可以明显看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:

  • hasQueuedPredecessors() 是公平锁加锁时判断等待队列中是否存在有效节点的方法

image-20210102202516446

3)非公平锁走起,方法lock()

对比公平锁和非公平锁的tryAcquire()方法的实现代码,其实差别就在于非公平锁获取锁时比公平锁中少了一个判断!hasQueuedPredecessors()

hasQueuedPredecessors()中判断了是否需要排队,导致公平锁和非公平锁的差异如下:

  • 公平锁:公平锁讲究先来先到,线程在获取锁时,如果这个锁的等待队列中已经有线程在等待,那么当前线程就会进入等待队列中;
  • 非公平锁:不管是否有等待队列,如果可以获取锁,则立刻占有锁对象。也就是说队列的第一 个排队线程在unpark(),之后还是需要竞争锁(存在线程竞争的情况下)

image-20210102212048083

image-20210102212226208

4)AQS源码深度分析走起

业务逻辑

本次讲解我们走最常用的,lock/unlock作为案例突破口

我相信你应该看过源码了,那么AQS里面有个变量叫State,它的值有几种?3个状态:没占用是0,占用了是1,大于1是可重入锁。

如果AB两个线程进来了以后,请问这个总共有多少个Node节点?答案是3个,其中队列的第一个是傀儡节点(哨兵节点)

业务图:

image-20210102220540651

代码:

public class AQSDemo {
    public static void main(String[] args) {
        ReentrantLock lock=new ReentrantLock();
        // 带入一个银行办理业务的案例来模拟AQS如何进行线程的管理和通知唤醒机制
        // 3个线程模拟3个银行网点,受理窗口办理业务的顾客

        // A顾客就是第一个顾客,此时受理窗口的没有任何人,A可以直接去办理
        new Thread(() -> {
            // method
            lock.lock();
            try {
                System.out.println("---Thread A come in---");
                try {
                    TimeUnit.MINUTES.sleep(20);} catch (InterruptedException e) {e.printStackTrace();}
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "A").start();

        // 第二个顾客B,第二个线程---> 由于受理业务的窗口只有一个(只能一个线程持有锁),此时B只能等待
        // 进入候客区
        new Thread(() -> {
            // method
            lock.lock();
            try {
                System.out.println("---Thread B come in---");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "B").start();

        // 第二个顾客C,第二个线程---> 由于受理业务的窗口只有一个(只能一个线程持有锁),此时C只能等待
        // 进入候客区
        new Thread(() -> {
            // method
            lock.lock();
            try {
                System.out.println("---Thread C come in---");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }, "C").start();
    }
}

lock()方法

image-20210102214153923

acquire()方法

源码和3大流程走向:

image-20210102220848619

image-20210102221123211

tryAcquire(arg)

本次走非公平锁方向

image-20210102221247406

nonfairTryAcquire(acquires):

  • return false(继续推进条件,走下一步方法addWaiter)
  • return true(结束)

image-20210102221350257


addWaiter(Node mode)

双向链表中,第一个节点为虚节点(也叫哨兵节点),其实并不存储任何信息,只是占位。 真正的第一个有数据的节点,是从第二个节点开始的。

image-20210102221632665

enq(node):

image-20210102221722321

B、C线程都排好队了效果图如下:

image-20210102220540651


acquireQueued(addWaiter(Node.EXCLUSIVE), arg)

1)acquireQueued()

(会调用如下方法:shouldParkAterFailedAcquire和parkAndCheckInterrupt setHead(node) )

2)shouldParkAfterFailedAcquire()

如果前驱节点的waitStatus是SIGNAL状态,即shouldParkAfterFailedAcquire方法会返回true程序会继续向下执行parkAndCheckInterrupt方法,用于将当前线程挂起

image-20210102222624234

3)parkAndCheckInterrupt()

image-20210102222638535

4)当我们执行下图中的③表示线程B或者C已经获取了permit(许可证)了

image-20210102222658869

5)setHead( )

代码执行完毕后,会出现如下图所示

image-20210102222721269

image-20210102222742038

unlock( )获取permit

1)release tryRelease unparkSuccessor(h)

image-20210102224107432

2)tryRelease()

image-20210102224115659

3)unparkSuccessor( )

image-20210102224128752

源码总结

AQS流程图

AQS的考点

我相信你应该看过源码了,那么AQS里面有个变量叫State,它的值有几种?

ANSWER:3个状态:没占用是0,占用了是1,大于1是可重入锁

如果AB两个线程进来了以后,请问这个总共有多少个Node节点?

ANSWER:3个