Lock 使用范式
synchronized 有标准用法,这样的优良传统咱 Lock 也得有,相信很多人都知道使用 Lock 的一个范式
|
标准1—finally 中释放锁既然是范式(没事不要挑战更改写法的那种),肯定有其理由,我们来看一下
这个大家应该都会明白,在 finally 中释放锁,目的是保证在获取到锁之后,最终能被释放
标准2—在 try{} 外面获取锁
不知道你有没有想过,为什么会有标准 2 的存在,我们通常是“喜欢” try 住所有内容,生怕发生异常不能捕获的
在 try{}
外获取锁主要考虑两个方面:
- 如果没有获取到锁就抛出异常,最终释放锁肯定是有问题的,因为还未曾拥有锁谈何释放锁呢
- 如果在获取锁时抛出了异常,也就是当前线程并未获取到锁,但执行到 finally 代码时,如果恰巧别的线程获取到了锁,则会被释放掉(无故释放)
3、线程状态:
线程总共有5大状态,通过上面第二个知识点的介绍,理解起来就简单了。
新建状态:新建线程对象,并没有调用start()方法之前
就绪状态:调用start()方法之后线程就进入就绪状态,但是并不是说只要调用start()方法线程就马上变为当前线程,在变为当前线程之前都是为就绪状态。值得一提的是,线程在睡眠和挂起中恢复的时候也会进入就绪状态哦。
运行状态:线程被设置为当前线程,开始执行run()方法。就是线程进入运行状态
阻塞状态:线程被暂停,比如说调用sleep()方法后线程就进入阻塞状态
死亡状态:线程执行结束
1、为什么有了synchronized,还要设计新的锁?(背景)
这是由于synchronized的使用场景的局限性导致的。
虽然synchronized能够很好的控制并发访问共享资源,但是其在锁获取与释放的可操作性、可中断的获取锁以及超时获取锁上显得捉襟见肘。同时,其无法处理这样的动态锁获取的场景:如,先获取A锁,再获取B锁,当获取到B锁之后,释放A锁同时获取C锁。
鉴于synchronized以上局限性,设计者考虑Lock接口的实现,以更加灵活的进行锁的获取与释放。
什么是AQS?
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。 AQS使用一个voliate int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
AQS定义了两种资源获取方式:独占(只有一个线程能访问执行,又根据是否按队列的顺序分为公平锁和非公平锁,如ReentrantLock) 和共享(多个线程可同时访问执行,如Semaphore/CountDownLatch,Semaphore、CountDownLatCh、 CyclicBarrier )。
从 AQS 的类名称和修饰上来看,这是一个抽象类,所以从设计模式的角度来看同步器一定是基于【模版模式】来设计的,使用者需要继承同步器,实现自定义同步器,并重写指定方法,随后将同步器组合在自定义的同步组件中,并调用同步器的模版方法,而这些模版方法又回调用使用者重写的方法
我不想将上面的解释说的这么抽象,其实想理解上面这句话,我们只需要知道下面两个问题就好了
- 哪些是自定义同步器可重写的方法?
- 哪些是抽象同步器提供的模版方法?
同步器可重写的方法
同步器提供的可重写方法只有5个,这大大方便了锁的使用者:
按理说,需要重写的方法也应该有 abstract 来修饰的,为什么这里没有?原因其实很简单,上面的方法我已经用颜色区分成了两类:
独占式
共享式
自定义的同步组件或者锁不可能既是独占式又是共享式,为了避免强制重写不相干方法,所以就没有 abstract 来修饰了,但要抛出异常告知不能直接使用该方法:
|
表格方法描述中所说的同步状态
就是上文提到的有 volatile 修饰的 state,所以我们在重写
上面几个方法时,还要通过同步器提供的下面三个方法(AQS 提供的)来获取或修改同步状态:暖暖的很贴心(如果你有类似的需求也可以仿照这样的设计)
表格方法描述中所说的同步状态
就是上文提到的有 volatile 修饰的 state,所以我们在重写
上面几个方法时,还要通过同步器提供的下面三个方法(AQS 提供的)来获取或修改同步状态:
再看看获取锁失败如何加入队列。
如果队列不为空,直接加入队列。
如果队列为空,则生成新的head 和 tail 节点信息。让后在加入队列。
再看看如何阻塞
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
|
获取同步状态成功会返回可以理解了,但是如果失败就会一直陷入到“死循环”中浪费资源吗?很显然不是,shouldParkAfterFailedAcquire(p, node)
和 parkAndCheckInterrupt()
就会将线程获取同步状态失败的线程挂起,我们继续向下看
|
这个地方设置前驱节点为 SIGNAL 状态到底有什么作用?
如果前驱节点的 waitStatus 是 SIGNAL状态,即 shouldParkAfterFailedAcquire 方法会返回 true ,程序会继续向下执行 parkAndCheckInterrupt
方法,用于将当前线程挂起
|
被唤醒的程序会继续执行 acquireQueued
方法里的循环,如果获取同步状态成功,则会返回 interrupted = true
的结果
CountDownLanch
前面我们讲到过,等待队列中的线程可能有多个,而调用countDown()方法的线程只唤醒了一个处于等待状态的线程,这里剩下的等待线程是如何被唤醒的呢?其实这些线程是被当前唤醒的线程唤醒的。
具体的我们可以看看await()方法的具体执行过程。离head节点最近的线程阻塞以后, 被unparkSuccessor唤醒. 满足了计数为0, 前置节点为head的条件. 得以继续往下执行-->setHeadAndPropagate
private void setHeadAndPropagate(Node node, int propagate) {undefined Node h = head; setHead(node);// 我把自己的节点设置为head
//然后检查唤醒过程是不是要往下传递 if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) {undefined Node s = node.next; if (s == null || s.isShared()) doReleaseShared(); } } |
因为我被唤醒了, 我执行完setHeadAndPropagate之后, 就可以跳出await, 进行下面自己的逻辑了. 所以我这个节点也就用没用了, 我把自己的节点设置为head. 然后检查唤醒过程是不是要往下传递. 然后进行唤醒操作. 唤醒下一个线程. 下一个线程被唤醒后, 又可以执行
因为我被唤醒了, 我执行完setHeadAndPropagate之后, 就可以跳出await, 进行下面自己的逻辑了. 所以我这个节点也就用没用了, 我把自己的节点设置为head. 然后检查唤醒过程是不是要往下传递. 然后进行唤醒操作 |
.....重复下去就完成了所有线程await的等待激活.
5.总过程:
await内部实现流程: (1)判断state计数是否为0,是0,那么可以执行执行await后面的逻辑 (2)state大于0,则表示需要阻塞等待计数为0 (3)当前线程封装Node对象,进入阻塞队列 (4)不满足激活条件(前置节点是head, 计数为0), 会被操作系统给挂起, 等待激活 (5)被唤醒后, 执行后面的继续唤醒操作,重置头节点状态, 检查唤醒传递等待 (6)跳出await的循环, 开始自己的业务逻辑 countDown内部实现流程: (1)尝试释放锁tryReleaseShared,实现计数-1 若计数已经小于0,则直接返回false 否则执行计数(AQS的state)减一 (2)若减完之后,state==0,然后就需要唤醒被阻塞的线程了doReleaseShared 如果队列为空,即表示没有线程被阻塞(也就是说没有线程调用了 CountDownLatch#wait()方法),直接退出 (3)头结点如果为SIGNAL, 则依次唤醒头结点下个节点上关联的线程,并出队 (4)唤醒的线程会从await的第5步后开始执行 |